首页 / C# 入门教程 / 排序/分组/去重

C# 入门教程

排序/分组/去重

本教程共 100 篇 · 第 70 篇 · 更新于 2026-07-31 · 约 11 分钟阅读

C#C# 入门教程编程语言LINQ排序分组去重

70. 排序/分组/去重

本节目标:学完你能用 LINQ 对集合排序、按键分组、并去除重复元素,且知道它们和手写排序/字典/HashSet 相比好在哪儿。

筛选和投影解决「挑哪些、变啥样」。这一章解决「怎么排、怎么归类、怎么去重」,三个操作覆盖日常绝大多数整理数据的需求。

排序:OrderBy 与 OrderByDescending

OrderBy 按某个键升序排,OrderByDescending 降序。键由 lambda 指定:

List<Student> students =
[
    new Student(1, "小明", 90),
    new Student(2, "小红", 85),
    new Student(3, "小刚", 90),
    new Student(4, "小美", 78),
];

foreach (var s in students.OrderBy(s => s.Score))
{
    Console.WriteLine($"{s.Name}: {s.Score}");
}

class Student
{
    public int Id { get; }
    public string Name { get; }
    public double Score { get; }
    public Student(int id, string name, double score)
    {
        Id = id; Name = name; Score = score;
    }
}

输出:

小美: 78
小红: 85
小明: 90
小刚: 90

分数从小到大。换成 OrderByDescending(s => s.Score) 则是从大到小。

Tip

排序也是延迟执行,记得遍历或 ToList() 才真正出结果。排序不会修改原集合,而是返回新序列。另外 .NET 的 OrderBy稳定排序:相等元素会保持原相对顺序——所以上面两个 90 分仍是「小明在前、小刚在后」,符合原始次序。

按字符串长度等非数值键排序

排序键不限于数字,任何能比较的类型都行。比如按名字长度排:

foreach (var s in students.OrderBy(s => s.Name.Length))
{
    Console.WriteLine($"{s.Name}{s.Name.Length} 字)");
}

输出:小明(2 字)小刚(2 字)小红(2 字)小美(2 字) 之类(这里名字长度都相同时保持原序)。换成 s.Name 则按字符编码字典序排——注意中文按 Unicode 码点比大小,未必符合拼音顺序,需要按拼音请务必用 CultureInfo 比较器(进阶话题)。

多键排序:ThenBy

当第一排序键相同(如两人同分),用 ThenBy 指定「次要键」:

var ordered = students
    .OrderByDescending(s => s.Score)
    .ThenBy(s => s.Name);

foreach (var s in ordered)
{
    Console.WriteLine($"{s.Name}: {s.Score}");
}

输出:

小刚: 90
小明: 90
小红: 85
小美: 78

同分的两人都按名字升序:小刚小明 前。降序的次要键用 ThenByDescending

Warning

次要键必须接在 OrderBy/OrderByDescending 之后用 ThenBy。如果你在同分后再调一次 OrderBy,前面的排序会被「推翻」——OrderBy 是重新开始一套全新排序,不是叠加。要叠加多键,永远用 ThenBy

分组:GroupBy

GroupBy 按某个键把元素分成一组一组。每组是个 IGrouping<TKey,TElement>,有 Key 和组内元素:

var groups = students.GroupBy(s => s.Score);

foreach (var group in groups)
{
    Console.WriteLine($"分数 {group.Key}:");
    foreach (var s in group)
    {
        Console.WriteLine($"  {s.Name}");
    }
}

输出:

分数 90:
  小明
  小刚
分数 85:
  小红
分数 78:
  小美

每个 group.Key 是分组的依据(这里是分数),组内遍历拿到原元素。分组常用于「按类别统计」。

分组时只投影想要的部分

GroupBy 还可以带第二个 lambda,决定「组内留哪些字段」,避免整对象都带出来:

var groups = students.GroupBy(
    s => s.Score,
    s => s.Name);          // 组内只保留名字

foreach (var group in groups)
{
    Console.WriteLine($"分数 {group.Key}{string.Join("、", group)}");
}

输出:

分数 90:小明、小刚
分数 85:小红
分数 78:小美

s => s.Name 让每个组里只装名字字符串,再用 string.Join 拼起来。这种「分组 + 投影」在生成报表文本时很顺手。

分组后做聚合

分组完常配合计数、求和。比如看每个分数各有多少人:

var summary = students
    .GroupBy(s => s.Score)
    .Select(g => new { Score = g.Key, Count = g.Count() });

foreach (var item in summary)
{
    Console.WriteLine($"分数 {item.Score}{item.Count} 人");
}

输出:

分数 90 有 2 人
分数 85 有 1 人
分数 78 有 1 人

g.Count() 是下一章要讲的聚合操作,这里先感受「分组 + 投影」的组合威力。

去重:Distinct

Distinct 去掉重复元素,只保留首次出现的:

List<int> numbers = [1, 2, 2, 3, 3, 3];

foreach (var n in numbers.Distinct())
{
    Console.WriteLine(n);
}

输出:

1
2
3

对基本类型(数字、字符串)直接可用。对自定义对象,默认按引用比较,需要重写相等规则(如用 record)或传入比较器才能正确去重。

Note

想按对象的某个字段去重,可先 Select 出该字段再 Distinct,例如 students.Select(s => s.Score).Distinct() 得到所有出现过的分数。这是「先投影后去重」的常用套路。

字符串去重示例

Distinct 对字符串按值比较(因为 string 重写了相等规则),直接能用:

List<string> tags = ["C#", "Java", "C#", "Python", "Java"];

foreach (var t in tags.Distinct())
{
    Console.WriteLine(t);
}

输出:

C#
Java
Python

重复的 C#Java 各只剩一份。可见 Distinct 是否「去得掉」,取决于该类型怎么定义相等——值类型/重写了相等的类型(如 stringrecord)按值去重,普通 class 按引用去重。

三个操作组合实战

把它们串成一条管道,处理一份成绩单:

var result = students
    .Where(s => s.Score >= 80)             // 先筛
    .GroupBy(s => s.Score)                 // 再分组
    .OrderBy(g => g.Key);                  // 按分数升序

foreach (var g in result)
{
    Console.WriteLine($"分数 {g.Key}{g.Count()} 人");
}

输出:

分数 85:1 人
分数 90:2 人

链式写法让数据像流水一样经过各道工序,读起来就是业务逻辑本身。

适用场景:何时用哪个

  • OrderBy/ThenBy:要给列表排个顺序——成绩单排名、商品按价格排、日志按时间排。
  • GroupBy:要「按某属性归类统计」——按城市分组用户、按分数段分组、按月份汇总。
  • Distinct:要「去掉重复值」——提取所有出现过的标签、去重后的 ID 列表。
Tip

管道顺序有讲究:通常先 Where 缩小范围,再 OrderBy/GroupBy/Distinct 整理。先过滤能省掉后续操作的运算量。最后若要多处复用结果,记得 ToList() 落地。

与其它写法对比

操作LINQ 写法手写等价LINQ 优势
排序OrderBy(...)list.Sort(...) / Array.Sort不改原集合、可链式、稳定排序
分组GroupBy(...)Dictionary<K, List<T>> 手撸一行完成、直接可枚举
去重Distinct()new HashSet<T>() 手撸链式集成、惰性计算

LINQ 版的好处是「不破坏原数据、能直接接进管道」。手写 Sort/HashSet 会改状态或要额外容器,代码更碎。

Warning

OrderBy 返回的是新序列,不会动原集合——这和 List<T>.Sort()(原地排序、返回 void)完全不同。别以为调了 students.OrderBy(...) 之后 students 自己就排好了,它还是乱的,排序结果只在返回的那条序列里。

新手容易踩的坑

第一,忘记 ThenBy 要接在 OrderBy 之后。直接再调一次 OrderBy 会「推翻」前面的排序,只保留最后一次。

第二,GroupBy 返回的组需要再次遍历才能拿到元素。对组调用 Console.WriteLine(group) 只会打印类型名,要遍历 group 内部。

第三,Distinct 对自定义类默认按引用去重,常常「去不掉」。优先对基本类型或 record 使用,或显式传比较器。

第四,排序、分组、去重都不会改原集合,它们返回新序列。想保留结果务必遍历或 ToList()

第五,字符串排序的中文顺序。默认按 Unicode 码点比较,不等于拼音序。需要拼音/文化相关排序时,给 OrderBy 传一个 IComparer<string>(如基于 CultureInfo),别依赖默认顺序。

常见疑问解答

问:OrderBy 是稳定排序吗?去重会打乱顺序吗? OrderBy 是稳定的:相等元素维持原相对次序。Distinct 保留「首次出现」的次序,不会乱排。这点比自己写排序省心。

问:GroupBy 之后还能再 Where 吗? 能。比如先 GroupBy 再用 Where(g => g.Count() > 1) 筛出「人数多于 1 的组」。分组结果本身也是可枚举序列,继续接 LINQ 操作即可。

问:Distinct 和 GroupBy 去重有啥区别? Distinct 只保留唯一值本身;GroupBy 把同键元素归到一组,你还能拿到「每组有哪些、共几个」。若只要唯一清单用 Distinct,若要「归类 + 统计」用 GroupBy

小结

OrderBy/OrderByDescending 排序,ThenBy 补次要键(稳定排序、不改原集合);GroupBy 按键分组便于归类统计,可配合投影与聚合;Distinct 去除重复,对值类型/record/string 按值生效。三者配合 WhereSelect 能拼出强大的数据整理管道。聚合操作(Count/Sum 等)下一章继续。