Day 3 - LINQ 分组与聚合
建议用时:180-210 分钟
你将学会什么
GroupBy是什么,为什么它不是直接求和group.Key表示什么Count、Sum、Average、Max、Min分别怎么用- 匿名对象
new { ... }是什么 - 为什么分组统计通常是
GroupBy -> Select -> ToList - 如何按分类、客户、日期等维度做汇总
- 如何先过滤,再分组,再统计
- 如何写一个完整的订单分类统计程序
分组统计的核心顺序是:先分组,再对每组统计。不要一上来就想 Sum,先问“按什么分组”。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“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分完组以后,才能对每一组做 Count、Sum、Average。
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负数金额被过滤掉了。
空集合要注意
Count 和 Sum 对空集合通常没问题,但 Average、Max、Min 对空集合可能报错。
所以正式统计前可以先判断:
var orders = new List<Order>();
if (orders.Count == 0)
{
Console.WriteLine("没有可统计的数据");
return;
}
record Order(string Category, decimal Amount);今天写分组统计时按这个顺序
- 先准备一小批能手算的数据。
- 确定按哪个字段分组。
- 写
GroupBy。 - 在
Select里写group.Key。 - 再写
Count、Sum、Average等统计。 - 用
ToList()固定结果。 - 遍历输出,并和手算结果对照。
第二部分:把知识组合成完整例子
前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要写一个订单分类统计程序。
最终你要写出这个程序
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 } |
| 只输出分组名不输出数量 | 结果看不出价值 | 同时输出 Count、Sum、Average |
小白重复敲写训练
先敲短例子,再回到页面里的完整分组案例。
训练 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()}");
}改动任务:加入 Cindy 和 Bill。
每日小测
做完本页后,用这 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 增加一条正常数据,确认报警数不变。
第四部分:作业完整答案
这一部分给出当天作业的完整答案。先按第三部分做一遍,再用这里检查。
作业要求
写一个订单分类统计程序,要求:
- 至少准备 6 条订单。
- 每条订单包含分类、商品名、金额。
- 金额小于等于 0 的订单不参与统计。
- 按分类分组。
- 每组统计数量、总金额、平均金额、最高金额、最低金额。
- 按总金额从高到低输出。
作业拆解
按这个顺序写:
- 定义
Order记录类型。 - 准备订单列表。
- 用
Where过滤金额。 - 用
GroupBy按分类分组。 - 用
Select生成匿名汇总对象。 - 在匿名对象里写
Count/Sum/Average/Max/Min。 - 用
OrderByDescending按总额排序。 - 遍历输出结果。
完整代码
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求平均值。- 匿名对象适合临时保存汇总结果。