Skip to Content
Week 02Day 3 - LINQ 分组与聚合

Day 3 - LINQ 分组与聚合

建议用时:180-210 分钟

你将学会什么

  • GroupBy 是什么,为什么它不是直接求和
  • group.Key 表示什么
  • CountSumAverageMaxMin 分别怎么用
  • 匿名对象 new { ... } 是什么
  • 为什么分组统计通常是 GroupBy -> Select -> ToList
  • 如何按分类、客户、日期等维度做汇总
  • 如何先过滤,再分组,再统计
  • 如何写一个完整的订单分类统计程序

分组统计的核心顺序是:先分组,再对每组统计。不要一上来就想 Sum,先问“按什么分组”。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“LINQ 基础”,今天继续学习“LINQ 分组与聚合”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“LINQ 分组与聚合”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分先解决“这是什么、为什么要这样写”。每看到一个短例子就亲手敲一遍并运行,不要先背完整程序。

今天先抓住什么

报表通常不是看单条数据,而是看汇总:

食品一共有多少条?总金额多少? 数码一共有多少条?平均金额多少? 哪个分类金额最高?

这类问题的固定顺序是:

原始数据 -> 按某个字段分组 -> 每组分别统计 -> 输出汇总结果

名词先讲清楚

名词直接解释例子
GroupBy按某个规则把数据分成多组按分类分组
分组键每组的名字或标识"食品""数码"
group.Key当前组的分组键分类名称
聚合把一组数据算成一个结果求和、平均
Count数量每组几条订单
Sum求和每组总金额
Average平均值每组平均金额
Max最大值每组最高金额
Min最小值每组最低金额
匿名对象临时拼出来的结果对象new { Category, Total }

GroupBy 不是直接统计

GroupBy 的第一步只是分组。

假设原始数据是:

食品 - 面包 - 12 食品 - 牛奶 - 8 数码 - 鼠标 - 59 数码 - 键盘 - 199

按分类分组后,结构像这样:

食品: 面包 12 牛奶 8 数码: 鼠标 59 键盘 199

分完组以后,才能对每一组做 CountSumAverage

group.Key 是当前组的名字

var orders = new List<Order> { new("食品", "面包", 12m), new("食品", "牛奶", 8m), new("数码", "鼠标", 59m) }; var groups = orders.GroupBy(order => order.Category); foreach (var group in groups) { Console.WriteLine(group.Key); } record Order(string Category, string Name, decimal Amount);

输出:

食品 数码

这里 group.Key 就是 Category 的值。

聚合方法怎么用

var amounts = new List<decimal> { 12m, 8m, 59m }; Console.WriteLine(amounts.Count); Console.WriteLine(amounts.Sum()); Console.WriteLine(amounts.Average()); Console.WriteLine(amounts.Max()); Console.WriteLine(amounts.Min());

输出:

3 79 26.333333333333333333333333333 59 8

对简单数字集合,可以直接调用。对对象集合,要告诉 C# 用哪个字段统计。

var orders = new List<Order> { new("食品", 12m), new("食品", 8m) }; Console.WriteLine(orders.Sum(order => order.Amount)); record Order(string Category, decimal Amount);

输出:

20

分组和聚合常用操作速查

需求写法说明
按一个字段分组.GroupBy(x => x.Category)最常见
按多个字段分组.GroupBy(x => new { x.Category, x.Status })分类加状态
每组数量group.Count()当前组几条
每组求和group.Sum(x => x.Amount)金额合计
每组平均group.Average(x => x.Amount)平均金额
每组最大group.Max(x => x.Amount)最高金额
每组最小group.Min(x => x.Amount)最低金额
每组取第一条group.First()需要代表数据时
分组后转换.Select(group => new { ... })生成汇总结果
汇总后排序.OrderByDescending(x => x.Total)报表常用
固定结果.ToList()得到可遍历列表

多字段分组例子

var orders = new List<Order> { new("食品", "已支付", 12m), new("食品", "已支付", 8m), new("食品", "已退款", 5m), new("数码", "已支付", 199m) }; var summary = orders .GroupBy(order => new { order.Category, order.Status }) .Select(group => new { group.Key.Category, group.Key.Status, Count = group.Count(), Total = group.Sum(order => order.Amount) }) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Category} / {item.Status}: {item.Count} 条,{item.Total:F2}"); } record Order(string Category, string Status, decimal Amount);

输出:

食品 / 已支付: 2 条,20.00 食品 / 已退款: 1 条,5.00 数码 / 已支付: 1 条,199.00

空集合聚合要注意

var amounts = new List<decimal>(); Console.WriteLine(amounts.Count); Console.WriteLine(amounts.Sum()); // Console.WriteLine(amounts.Average()); // 空集合会报错 // Console.WriteLine(amounts.Max()); // 空集合会报错 // Console.WriteLine(amounts.Min()); // 空集合会报错

如果可能没有数据,就先判断:

if (amounts.Count == 0) { Console.WriteLine("没有可统计的数据"); return; }

匿名对象 new { ... }

分组统计后,经常需要临时做一个汇总结果。

var item = new { Category = "食品", Count = 2, Total = 20m }; Console.WriteLine($"{item.Category}: {item.Count} 条,{item.Total:F2}");

输出:

食品: 2 条,20.00

这个对象没有专门起类名,所以叫匿名对象。它适合 LINQ 里临时组织结果。

标准写法:GroupBy -> Select -> ToList

var orders = new List<Order> { new("食品", 12m), new("食品", 8m), new("数码", 59m) }; var summary = orders .GroupBy(order => order.Category) .Select(group => new { Category = group.Key, Count = group.Count(), Total = group.Sum(order => order.Amount) }) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Category}: {item.Count} 条,{item.Total:F2}"); } record Order(string Category, decimal Amount);

这段从上往下读:

从 orders 开始 -> 按 Category 分组 -> 每组生成一个汇总对象 -> 固定成 List

先过滤再分组

如果只统计有效订单,先用 Where 过滤,再 GroupBy

var orders = new List<Order> { new("食品", 12m), new("食品", -3m), new("数码", 59m) }; var summary = orders .Where(order => order.Amount > 0) .GroupBy(order => order.Category) .Select(group => new { Category = group.Key, Total = group.Sum(order => order.Amount) }) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Category}: {item.Total:F2}"); } record Order(string Category, decimal Amount);

输出:

食品: 12.00 数码: 59.00

负数金额被过滤掉了。

空集合要注意

CountSum 对空集合通常没问题,但 AverageMaxMin 对空集合可能报错。

所以正式统计前可以先判断:

var orders = new List<Order>(); if (orders.Count == 0) { Console.WriteLine("没有可统计的数据"); return; } record Order(string Category, decimal Amount);

今天写分组统计时按这个顺序

  1. 先准备一小批能手算的数据。
  2. 确定按哪个字段分组。
  3. GroupBy
  4. Select 里写 group.Key
  5. 再写 CountSumAverage 等统计。
  6. ToList() 固定结果。
  7. 遍历输出,并和手算结果对照。

第二部分:把知识组合成完整例子

前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要写一个订单分类统计程序。

最终你要写出这个程序

var orders = new List<Order> { new("食品", "面包", 12m), new("食品", "牛奶", 8m), new("数码", "鼠标", 59m), new("数码", "键盘", 199m), new("办公", "笔记本", 15m) }; var summary = orders .GroupBy(order => order.Category) .Select(group => new { Category = group.Key, Count = group.Count(), Total = group.Sum(order => order.Amount), Average = group.Average(order => order.Amount), Max = group.Max(order => order.Amount), Min = group.Min(order => order.Amount) }) .OrderByDescending(item => item.Total) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Category}: {item.Count} 条,总额 {item.Total:F2},平均 {item.Average:F2},最高 {item.Max:F2},最低 {item.Min:F2}"); } record Order(string Category, string Name, decimal Amount);

输出:

数码: 2 条,总额 258.00,平均 129.00,最高 199.00,最低 59.00 食品: 2 条,总额 20.00,平均 10.00,最高 12.00,最低 8.00 办公: 1 条,总额 15.00,平均 15.00,最高 15.00,最低 15.00

这段代码做了三件事:按分类分组、每组统计、按总额排序。

第三部分:跟着敲代码

从这里开始动手。每个例子都可以直接放进 Program.cs 运行。

例子 1:按分类分组,只看每组数量

var orders = new List<Order> { new("食品", "面包", 12m), new("食品", "牛奶", 8m), new("数码", "鼠标", 59m) }; var groups = orders.GroupBy(order => order.Category); foreach (var group in groups) { Console.WriteLine($"{group.Key}: {group.Count()} 条"); } record Order(string Category, string Name, decimal Amount);

预期输出:

食品: 2 条 数码: 1 条

例子 2:每组求总金额

var orders = new List<Order> { new("食品", "面包", 12m), new("食品", "牛奶", 8m), new("数码", "鼠标", 59m), new("数码", "键盘", 199m) }; var summary = orders .GroupBy(order => order.Category) .Select(group => new { Category = group.Key, Total = group.Sum(order => order.Amount) }) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Category}: {item.Total:F2}"); } record Order(string Category, string Name, decimal Amount);

预期输出:

食品: 20.00 数码: 258.00

例子 3:每组数量、总额、平均值

var orders = new List<Order> { new("食品", "面包", 12m), new("食品", "牛奶", 8m), new("数码", "鼠标", 59m), new("数码", "键盘", 199m) }; var summary = orders .GroupBy(order => order.Category) .Select(group => new { Category = group.Key, Count = group.Count(), Total = group.Sum(order => order.Amount), Average = group.Average(order => order.Amount) }) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Category}: {item.Count} 条,总额 {item.Total:F2},平均 {item.Average:F2}"); } record Order(string Category, string Name, decimal Amount);

例子 4:先过滤再分组

var orders = new List<Order> { new("食品", "面包", 12m), new("食品", "错误退款", -5m), new("数码", "鼠标", 59m), new("数码", "键盘", 199m) }; var summary = orders .Where(order => order.Amount > 0) .GroupBy(order => order.Category) .Select(group => new { Category = group.Key, Total = group.Sum(order => order.Amount) }) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Category}: {item.Total:F2}"); } record Order(string Category, string Name, decimal Amount);

负数记录不会进入统计。

例子 5:按客户统计订单

var orders = new List<Order> { new("Tom", 120m), new("Alice", 80m), new("Tom", 30m), new("Alice", 20m) }; var summary = orders .GroupBy(order => order.Customer) .Select(group => new { Customer = group.Key, Count = group.Count(), Total = group.Sum(order => order.Amount) }) .OrderByDescending(item => item.Total) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Customer}: {item.Count} 单,{item.Total:F2}"); } record Order(string Customer, decimal Amount);

这个例子说明:分组字段不一定是分类,也可以是客户、日期、状态。

例子 6:最高值和最低值

var orders = new List<Order> { new("食品", 12m), new("食品", 8m), new("食品", 30m) }; var summary = orders .GroupBy(order => order.Category) .Select(group => new { Category = group.Key, Max = group.Max(order => order.Amount), Min = group.Min(order => order.Amount) }) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Category}: 最高 {item.Max:F2},最低 {item.Min:F2}"); } record Order(string Category, decimal Amount);

常见错误和修法

错误为什么错修法
分组后不知道 group.Key 是什么Key 是分组依据先明确按分类、日期还是状态分组
空集合上求平均值没有数据时无法计算平均先判断 Any()Count()
聚合字段选错统计结果会不对金额用 Sum(price * count),数量用 Sum(count)
多字段分组写乱匿名对象字段不清楚写成 new { product.Category, product.Status }
只输出分组名不输出数量结果看不出价值同时输出 CountSumAverage

小白重复敲写训练

先敲短例子,再回到页面里的完整分组案例。

训练 1:按奇偶分组

var numbers = new List<int> { 1, 2, 3, 4, 5, 6 }; var groups = numbers.GroupBy(number => number % 2 == 0 ? "偶数" : "奇数"); foreach (var group in groups) { Console.WriteLine(group.Key); Console.WriteLine(string.Join(", ", group)); }

改动任务:加入数字 7 和 8,预测两个组分别多出什么。

训练 2:统计订单总额

var amounts = new List<decimal> { 20m, 35m, 15m }; Console.WriteLine(amounts.Count); Console.WriteLine(amounts.Sum()); Console.WriteLine(amounts.Average());

改动任务:再加入 30m,先手算总额和平均值,再运行验证。

训练 3:按首字母分组并计数

var names = new List<string> { "Apple", "Alice", "Bob", "Ben" }; var groups = names.GroupBy(name => name[0]); foreach (var group in groups) { Console.WriteLine($"{group.Key}: {group.Count()}"); }

改动任务:加入 CindyBill

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:LINQ 分组与聚合。今天至少要掌握的 3 个点是:

1. `GroupBy` 是什么,为什么它不是直接求和 2. `group.Key` 表示什么 3. `Count`、`Sum`、`Average`、`Max`、`Min` 分别怎么用

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:GroupBy 是什么,为什么它不是直接求和。

上位机专项练习

分组和聚合适合按设备统计平均值、最大值和报警次数,这是趋势分析和报表的基础。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:按设备分组

var readings = new[] { new { Device = "A", Value = 20.1 }, new { Device = "A", Value = 21.5 }, new { Device = "B", Value = 30.0 } }; foreach (var group in readings.GroupBy(item => item.Device)) { Console.WriteLine($"{group.Key}: {group.Count()} 条"); }

运行结果或界面效果:

A: 2 条 B: 1 条

改动任务: 给设备 B 再增加一条数据。

专项例子 2:计算每台设备平均值

var readings = new[] { new { Device = "A", Value = 20.0 }, new { Device = "A", Value = 22.0 }, new { Device = "B", Value = 30.0 } }; var summaries = readings .GroupBy(item => item.Device) .Select(group => new { Device = group.Key, Average = group.Average(x => x.Value) }); foreach (var item in summaries) { Console.WriteLine($"{item.Device}: {item.Average:F1}"); }

运行结果或界面效果:

A: 21.0 B: 30.0

改动任务: 再计算每组最大值。

专项例子 3:统计每台设备报警数

var readings = new[] { new { Device = "A", IsAlarm = true }, new { Device = "A", IsAlarm = false }, new { Device = "B", IsAlarm = true } }; foreach (var group in readings.GroupBy(item => item.Device)) { int count = group.Count(item => item.IsAlarm); Console.WriteLine($"{group.Key}: {count} 个报警"); }

运行结果或界面效果:

A: 1 个报警 B: 1 个报警

改动任务: 给设备 B 增加一条正常数据,确认报警数不变。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。先按第三部分做一遍,再用这里检查。

作业要求

写一个订单分类统计程序,要求:

  1. 至少准备 6 条订单。
  2. 每条订单包含分类、商品名、金额。
  3. 金额小于等于 0 的订单不参与统计。
  4. 按分类分组。
  5. 每组统计数量、总金额、平均金额、最高金额、最低金额。
  6. 按总金额从高到低输出。

作业拆解

按这个顺序写:

  1. 定义 Order 记录类型。
  2. 准备订单列表。
  3. Where 过滤金额。
  4. GroupBy 按分类分组。
  5. Select 生成匿名汇总对象。
  6. 在匿名对象里写 Count/Sum/Average/Max/Min
  7. OrderByDescending 按总额排序。
  8. 遍历输出结果。

完整代码

var orders = new List<Order> { new("食品", "面包", 12m), new("食品", "牛奶", 8m), new("食品", "退款错误", -3m), new("数码", "鼠标", 59m), new("数码", "键盘", 199m), new("办公", "笔记本", 15m), new("办公", "笔", 5m) }; var summary = orders .Where(order => order.Amount > 0) .GroupBy(order => order.Category) .Select(group => new { Category = group.Key, Count = group.Count(), Total = group.Sum(order => order.Amount), Average = group.Average(order => order.Amount), Max = group.Max(order => order.Amount), Min = group.Min(order => order.Amount) }) .OrderByDescending(item => item.Total) .ToList(); foreach (var item in summary) { Console.WriteLine($"{item.Category}: {item.Count} 条,总额 {item.Total:F2},平均 {item.Average:F2},最高 {item.Max:F2},最低 {item.Min:F2}"); } record Order(string Category, string Name, decimal Amount);

正确运行结果

你应该能看到:

数码: 2 条,总额 258.00,平均 129.00,最高 199.00,最低 59.00 食品: 2 条,总额 20.00,平均 10.00,最高 12.00,最低 8.00 办公: 2 条,总额 20.00,平均 10.00,最高 15.00,最低 5.00

你要能说清楚这 6 件事

  • GroupBy 是先分组,不是直接统计。
  • group.Key 是当前组的分组值。
  • Count 统计数量。
  • Sum 求总和。
  • Average 求平均值。
  • 匿名对象适合临时保存汇总结果。