九、连接与分组
用于关联多个序列(如按键匹配两个集合)或按键分组(将同一键的元素归类),是处理多源数据关系和聚合分析的核心工具,类似数据库中的 “连接” 和 “分组” 操作。
1. Zip(second, resultSelector)
:按索引配对序列
将两个序列按索引一一配对(如第一个序列的第 n 个元素与第二个序列的第 n 个元素配对),通过resultSelector
生成新元素。若序列长度不同,以较短序列为准(忽略较长序列的多余元素)。
性能特性:O (n) 复杂度(n 为较短序列的长度),按索引顺序遍历配对。
典型场景:合并两个长度相同的序列(如 “姓名” 与 “成绩” 配对生成 “学生信息”)、对应元素计算(如两列数值相加)。
示例 1:基础数值配对
var numbers1 = new List<int> { 1, 2, 3 };
var numbers2 = new List<int> { 10, 20, 30 };
// 对应索引元素相加(1+10, 2+20, 3+30)
IEnumerable<int> sums = numbers1.Zip(numbers2, (x, y) => x + y); // {11, 22, 33}
示例 2:对象序列配对
var names = new List<string> { "张三", "李四", "王五" };
var ages = new List<int> { 25, 30, 28 };
// 配对生成“姓名-年龄”字符串
IEnumerable<string> userInfo = names.Zip(ages, (name, age) => $"{name}({age}岁)");
// 结果:{"张三(25岁)", "李四(30岁)", "王五(28岁)"}
示例 3:处理长度不匹配的序列
var listA = new List<int> { 1, 2 };
var listB = new List<int> { 10, 20, 30 }; // 更长的序列
// 仅配对前2个元素(以较短序列为准)
IEnumerable<int> zipped = listA.Zip(listB, (a, b) => a * b); // {10, 40}(忽略30)
2. Join(inner, outerKeySelector, innerKeySelector, resultSelector)
:内连接
类似 SQL 的 INNER JOIN
,根据键匹配两个序列(outer
为外部序列,inner
为内部序列),返回匹配的元素组合。仅保留两边都有匹配键的元素。
核心参数:
outerKeySelector
:从外部序列元素中提取用于匹配的键。innerKeySelector
:从内部序列元素中提取用于匹配的键。resultSelector
:定义匹配后生成的结果(如合并两个元素的属性)。
性能特性:内部通过哈希表匹配键(O (n + m) 复杂度,n 和 m 为两个序列的长度),比嵌套循环更高效。
典型场景:关联两个集合中 “键相同” 的数据(如 “订单” 与 “用户” 通过 “用户 ID” 关联)。
示例:订单与用户内连接
// 外部序列:订单
public class Order {
public int Id { get; set; }
public int UserId { get; set; } // 关联用户的键
public decimal Amount { get; set; }
}
// 内部序列:用户
public class User {
public int Id { get; set; } // 被关联的键
public string Name { get; set; }
}
var orders = new List<Order> {
new Order { Id = 1, UserId = 101, Amount = 199 },
new Order { Id = 2, UserId = 102, Amount = 299 },
new Order { Id = 3, UserId = 103, Amount = 99 } // UserId=103在用户表中无匹配
};
var users = new List<User> {
new User { Id = 101, Name = "张三" },
new User { Id = 102, Name = "李四" }
};
// 内连接:按UserId匹配订单和用户(仅保留两边都有匹配的记录)
var joined = orders.Join(
inner: users,
outerKeySelector: o => o.UserId, // 订单的匹配键
innerKeySelector: u => u.Id, // 用户的匹配键
resultSelector: (order, user) => new { // 合并结果
OrderId = order.Id,
UserName = user.Name,
Amount = order.Amount
}
);
// 结果:仅包含UserId=101和102的订单(排除103)
// { {OrderId=1, UserName="张三", Amount=199}, {OrderId=2, UserName="李四", Amount=299} }
3. GroupJoin(inner, outerKeySelector, innerKeySelector, resultSelector)
:左外连接
类似 SQL 的 LEFT JOIN
,返回外部序列的所有元素,以及内部序列中匹配的元素集合(若无匹配,返回空集合)。
与Join
的区别:
Join
返回 “一对一” 的匹配组合(每个匹配生成一个结果)。GroupJoin
返回 “一对多” 的匹配组合(外部元素对应一组内部匹配元素)。
典型场景:获取 “外部元素及其所有关联的内部元素”(如 “用户及其所有订单”,包括无订单的用户)。
示例:用户与订单左外连接
// 复用上面的Order和User类
var users = new List<User> {
new User { Id = 101, Name = "张三" },
new User { Id = 102, Name = "李四" },
new User { Id = 104, Name = "赵六" } // 无匹配订单
};
var orders = new List<Order> {
new Order { Id = 1, UserId = 101, Amount = 199 },
new Order { Id = 2, UserId = 101, Amount = 299 }, // 张三的第二个订单
new Order { Id = 3, UserId = 102, Amount = 99 }
};
// 左外连接:用户及其所有订单(包括无订单的用户)
var groupJoined = users.GroupJoin(
inner: orders,
outerKeySelector: u => u.Id, // 用户的匹配键
innerKeySelector: o => o.UserId, // 订单的匹配键
resultSelector: (user, userOrders) => new { // 合并结果
UserName = user.Name,
Orders = userOrders.Select(o => new { o.Id, o.Amount }), // 该用户的所有订单
TotalAmount = userOrders.Sum(o => o.Amount) // 订单总金额(无订单则为0)
}
);
// 结果:
// 1. 张三:2个订单,总金额498
// 2. 李四:1个订单,总金额99
// 3. 赵六:0个订单,总金额0
4. GroupBy(keySelector)
:按键分组
将序列中的元素按指定键分组,返回 IGrouping<TKey, TElement>
集合(每个分组包含一个键和该键对应的所有元素)。支持多级分组(嵌套GroupBy
)和分组后聚合(如计数、求和)。
性能特性:O (n) 复杂度(通过哈希表分组),分组后可直接对组内元素进行聚合操作。
典型场景:数据分类统计(如 “按部门分组统计员工数量”“按月份分组统计订单金额”)。
示例 1:基础分组与聚合
var scores = new List<int> { 80, 90, 75, 85, 95, 70 };
// 按“是否及格”分组(键为bool类型)
var groupedByPass = scores.GroupBy(
keySelector: s => s >= 60, // 分组键:是否及格
elementSelector: s => s // 组内元素(默认为原元素,可省略)
);
foreach (var group in groupedByPass) {
Console.WriteLine($"键:{group.Key}({group.Count()}个元素)");
Console.WriteLine($"元素:{string.Join(", ", group)}");
}
// 输出:
// 键:True(6个元素)
// 元素:80, 90, 75, 85, 95, 70
示例 2:对象分组与多级统计
public class Sales {
public string Region { get; set; } // 地区
public string Product { get; set; } // 产品
public decimal Amount { get; set; } // 销售额
}
var sales = new List<Sales> {
new Sales { Region = "华东", Product = "手机", Amount = 10000 },
new Sales { Region = "华东", Product = "电脑", Amount = 15000 },
new Sales { Region = "华北", Product = "手机", Amount = 8000 },
new Sales { Region = "华北", Product = "手机", Amount = 5000 }
};
// 1. 先按地区分组,再按产品分组(多级分组)
var regionProductGroups = sales
.GroupBy(s => s.Region) // 一级键:地区
.Select(regionGroup => new {
Region = regionGroup.Key,
Products = regionGroup.GroupBy(p => p.Product) // 二级键:产品
.Select(productGroup => new {
Product = productGroup.Key,
Total = productGroup.Sum(s => s.Amount) // 该产品在该地区的总销售额
})
});
// 2. 直接按地区分组并计算总销售额
var regionTotal = sales
.GroupBy(s => s.Region)
.ToDictionary(
group => group.Key,
group => group.Sum(s => s.Amount) // 地区总销售额
);
// regionTotal 结果:{ "华东": 25000, "华北": 13000 }
核心对比与注意事项
方法 | 核心逻辑 | 输出类型 | 典型场景 |
---|---|---|---|
Zip | 按索引配对两个序列 | 合并后的新序列 | 对应元素计算、数据合并 |
Join | 内连接(仅保留匹配元素) | 一对一的匹配组合 | 关联两个集合的匹配数据 |
GroupJoin | 左外连接(保留外部所有元素 + 匹配的内部元素组) | 一对多的匹配组合 | 外部元素及其关联的内部元素集合 |
GroupBy | 按键分组元素 | IGrouping<TKey, TElement> 集合 | 分类统计(如按部门 / 月份分组) |
关键注意事项
- 键的相等性:
Join
/GroupJoin
/GroupBy
依赖键的相等性,自定义键需确保正确实现GetHashCode
和Equals
,或通过IEqualityComparer<T>
指定比较规则。- 示例:按字符串键分组时忽略大小写,可传入
StringComparer.OrdinalIgnoreCase
。
Join
与GroupJoin
的选择:- 需 “一对一” 结果(如 “订单 + 用户” 的扁平结构):用
Join
。 - 需 “一对多” 结果(如 “用户 + 其所有订单” 的嵌套结构):用
GroupJoin
。
- 需 “一对一” 结果(如 “订单 + 用户” 的扁平结构):用
GroupBy
的灵活性:- 可通过
elementSelector
筛选组内元素(如仅保留需要的属性)。 - 可通过
resultSelector
直接生成聚合结果(如GroupBy(key, (k, g) => new { Key = k, Count = g.Count() })
)。
- 可通过
- 性能优化:
- 大集合连接 / 分组时,确保键的哈希计算高效(避免复杂对象作为键)。
- 分组后如需多次访问组内元素,建议转为
List
缓存(如group.ToList()
)。