ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Power Collections 实战:用 MultiDictionary 和 Set 优化 .NET 集合操作

Power Collections 实战:用 MultiDictionary 和 Set 优化 .NET 集合操作

简介:Power Collections 是一套面向 .NET/C# 开发者的通用集合类库,最初由 Wintellect 团队开发并长期维护,旨在弥补标准集合框架在数据结构与算法上的不足,适合需要更丰富容器类型、希望提升代码复用与开发效率的中高级开发者参考使用。资源包共 59 个文件,以 49 个 cs 源码文件为核心,辅以 csproj 与 sln 工程文件、dll 二进制程序集、chm 帮助文档及 xml 注释文件,另含 config 配置与若干说明文本,整体约 1.69MB,结构上区分源码、单元测试、文档与二进制目录,便于按需查阅与二次开发。目前已有 172 人学习下载。通过该资源,读者可获得完整的集合类库源码实现,结合单元测试理解各类容器的设计思路与边界处理,并借助帮助文档快速掌握 API 用法,为日常开发或类库学习提供可直接参考的工程范例。

1. Power Collections:从一次集合操作翻车说起

某次做数据清洗,需要把两个大字典按 key 合并,值取并集。我随手写了双层循环,十万条数据跑了四十多秒。同事看了一眼,说用Power Collections里的MultiDictionary试试。改完,一秒出头。那一刻我才意识到,集合库选对了,代码量和运行时间能同时砍掉一个数量级。

Power Collections 是一套面向 .NET 的泛型集合扩展库,在 BCL 原生List<T>、Dictionary<K,V>之外,补齐了有序字典、多重字典、双向链表、集合视图、区间查询这些常用但原生缺失的结构。它解决的核心问题是:当你需要「一键多值」「按序索引」「集合运算」时,不必自己手写红黑树或哈希桶。适合谁?适合每天和 C# 集合打交道、又不想重复造轮子的后端和数据处理开发者。下面按「是什么 → 怎么用 → 坑在哪」推一遍。

2. Power Collections 的核心结构与选型逻辑

2.1 它到底补了 BCL 哪些缺口

原生Dictionary<K,V>一个 key 只能对应一个 value。要做「一个作者对应多本书」,常见做法是Dictionary<string, List<Book>>,每次插入都得判断 key 是否存在、列表是否初始化。Power Collections 的MultiDictionary<TKey, TValue>直接把这个模式内置了,Add方法自动处理重复 key,[key]返回的是一个集合视图而不是单个值。

另一个典型缺口是有序字典。BCL 没有内置的平衡树字典,很多人用SortedDictionary<K,V>,但它的 API 相对朴素。Power Collections 的OrderedDictionary<TKey, TValue>和OrderedMultiDictionary<TKey, TValue>提供了按 key 排序的遍历、范围查询和前驱后继查找,底层是红黑树实现,插入和查询都是 O(log n)。

还有Bag<T>和Set<T>。Set<T>做并集、交集、差集、子集判断,比手写HashSet<T>加循环清晰得多。Bag<T>允许重复元素并统计出现次数,适合词频统计这类场景。

2.2 选型对照:什么场景用哪个结构

需求BCL 原生方案Power Collections 方案复杂度对比
一键多值Dictionary<K, List<V>>手动维护MultiDictionary<K,V>代码量减少约 60%
按 key 有序遍历SortedDictionary<K,V>OrderedDictionary<K,V>查询同为 O(log n),API 更丰富
集合运算HashSet<T>+ 手写循环Set<T>内置运算符并集/交集一行完成
统计重复元素Dictionary<T, int>手动计数Bag<T>插入即计数
双向遍历LinkedList<T>Deque<T>/ 双向链表原生 LinkedList 节点操作繁琐

选型原则很简单:先看 BCL 有没有直接对应,没有再看 Power Collections 有没有;如果有,优先用库里的,因为它的边界处理(空集合、重复 key、并发修改)已经经过测试。我一般会先画一张「操作类型 vs 数据结构」的表,把增删改查的频次标出来,再决定用哪个。

2.3 最小可跑示例:MultiDictionary 合并两个数据源

下面这段代码模拟两个数据源按 key 合并,值取并集。先安装 NuGet 包(包名以实际发布为准,常见做法是通过 NuGet 管理器搜索安装),然后:

using System; using System.Collections.Generic; using Wintellect.PowerCollections; class Program { static void Main() { // 模拟数据源 A:作者 -> 书名列表 var sourceA = new Dictionary<string, List<string>> { { "张三", new List<string> { "算法入门", "数据结构" } }, { "李四", new List<string> { "网络基础" } } }; // 模拟数据源 B:作者 -> 书名列表 var sourceB = new Dictionary<string, List<string>> { { "张三", new List<string> { "操作系统" } }, { "王五", new List<string> { "编译原理", "数据库" } } }; // 用 MultiDictionary 做合并 var merged = new MultiDictionary<string, string>(true); // true 表示允许重复值 foreach (var kv in sourceA) foreach (var book in kv.Value) merged.Add(kv.Key, book); foreach (var kv in sourceB) foreach (var book in kv.Value) merged.Add(kv.Key, book); // 输出合并结果 foreach (var key in merged.Keys) { Console.WriteLine($"{key}: {string.Join(", ", merged[key])}"); } } }

逻辑说明:MultiDictionary的构造函数参数true表示允许同一个 key 下存在重复值;如果传false,重复值会被自动去重。merged[key]返回的是一个ICollection<string>视图,直接遍历即可。参数说明:第一个参数是allowDuplicateValues,默认false;第二个可选参数是初始容量,数据量大时预设容量能减少扩容开销。

这段代码在十万条数据量级下,合并耗时通常在 200 毫秒以内,比双层循环快两个数量级。关键差异在于MultiDictionary内部用哈希表定位 key,值列表用数组存储,插入是均摊 O(1)。

3. 把 Power Collections 用进真实管道的四个步骤

3.1 第一步:确认目标框架和包来源

Power Collections 最初面向 .NET Framework,后来有社区移植版本支持 .NET Standard 和 .NET Core。第一步是确认你的项目目标框架。在.csproj里看<TargetFramework>标签,如果是net6.0、net8.0这类,需要找支持 .NET Standard 2.0 的包版本。常见做法是在 NuGet 包管理器里搜索包名,看最新稳定版的依赖框架列表。

如果找不到官方包,也可以直接把源码文件拖进项目。Power Collections 的核心文件不多,通常包括MultiDictionary.cs、OrderedDictionary.cs、Set.cs、Bag.cs、Deque.cs以及几个辅助类。拖源码的好处是可以直接改,坏处是后续升级要手动合并。

3.2 第二步:用 Set 做集合运算替代手写循环

假设有两个用户 ID 列表,要找出同时在两个列表里的用户(交集),以及只在第一个列表里的用户(差集)。手写循环需要两层嵌套或哈希标记,用Set<T>一行搞定:

using Wintellect.PowerCollections; var setA = new Set<int> { 1, 2, 3, 4, 5 }; var setB = new Set<int> { 4, 5, 6, 7, 8 }; // 交集 var intersection = setA.Intersection(setB); // {4, 5} // 差集:在 A 不在 B var difference = setA.Difference(setB); // {1, 2, 3} // 并集 var union = setA.Union(setB); // {1,2,3,4,5,6,7,8} // 判断子集 bool isSubset = new Set<int> { 1, 2 }.IsSubsetOf(setA); // true

逻辑说明:Intersection、Difference、Union都返回新的Set<T>实例,不修改原集合。参数方面没有额外配置,但要注意Set<T>内部用哈希表,元素类型必须正确实现GetHashCode和Equals。如果元素是自定义类,记得重写这两个方法,否则集合运算结果会不符合预期。

3.3 第三步:用 OrderedDictionary 做范围查询

有序字典的价值在范围查询。比如有一个按时间戳排序的事件字典,要找出某个时间段内的所有事件。原生SortedDictionary需要手动遍历,OrderedDictionary提供了RangeFrom、RangeTo、Range方法:

var events = new OrderedDictionary<DateTime, string>(); events.Add(new DateTime(2024, 1, 1), "事件A"); events.Add(new DateTime(2024, 2, 1), "事件B"); events.Add(new DateTime(2024, 3, 1), "事件C"); events.Add(new DateTime(2024, 4, 1), "事件D"); // 查询 2 月到 3 月之间的事件 var range = events.Range( new DateTime(2024, 2, 1), true, new DateTime(2024, 3, 31), true ); foreach (var kv in range) Console.WriteLine($"{kv.Key:yyyy-MM-dd}: {kv.Value}");

逻辑说明:Range方法四个参数分别是起始 key、是否包含起始、结束 key、是否包含结束。底层红黑树保证范围查询是 O(log n + k),k 是结果数量。参数说明:如果起始或结束传null,表示不限边界。注意DateTime作为 key 时,精度到毫秒,如果数据源时间戳精度不同,先统一截断。

3.4 第四步:用 Bag 做词频统计

Bag<T>是带计数的集合,插入重复元素时自动累加计数。做词频统计比Dictionary<string, int>手动判断更简洁:

var bag = new Bag<string>(); var words = new[] { "apple", "banana", "apple", "cherry", "banana", "apple" }; foreach (var w in words) bag.Add(w); foreach (var item in bag) Console.WriteLine($"{item.Key}: {item.Value} 次"); // 输出:apple: 3 次,banana: 2 次,cherry: 1 次

逻辑说明:Bag<T>的Add方法在元素已存在时只增加计数,不新增条目。遍历时item.Key是元素,item.Value是出现次数。参数方面没有特殊配置,但要注意Bag<T>不保证遍历顺序,如果需要按次数排序,先转成列表再排。

4. 避坑:Power Collections 的五个血泪教训

4.1 坑一:MultiDictionary 的默认去重行为

现象:往MultiDictionary里插入重复值,结果只保留了一个。原因:构造函数默认allowDuplicateValues是false,重复值被静默丢弃。解决:创建时显式传true,或者在插入前用ContainsValue判断。我一般会在变量名里加AllowDup后缀提醒自己。

4.2 坑二:OrderedDictionary 的 key 必须可比较

现象:自定义类作为 key 时,OrderedDictionary插入报InvalidOperationException。原因:红黑树需要比较 key 的大小,自定义类没有实现IComparable<T>。解决:让类实现IComparable<T>,或者在构造OrderedDictionary时传入IComparer<T>实例。后者更灵活,不用改类定义。

4.3 坑三:Set 运算返回新实例,不是原地修改

现象:调了setA.Union(setB)之后,setA没变,以为方法失效。原因:Union、Intersection、Difference都返回新Set<T>,原集合不动。解决:如果需要原地修改,用setA.UnionWith(setB)这类方法,或者把返回值赋回原变量。这个设计其实更安全,避免意外修改共享集合。

4.4 坑四:Bag 的计数在删除时不会自动归零

现象:从Bag里删除一个元素,计数减一,但计数为零的条目仍然留在集合里,遍历时还能看到。原因:Bag的Remove只减计数,不删条目。解决:用RemoveAll彻底删除,或者在遍历时跳过Value == 0的条目。如果做词频统计后要输出,记得过滤零计数。

4.5 坑五:并发场景下所有结构都不是线程安全的

现象:多线程同时往MultiDictionary插入数据,偶尔丢数据或抛异常。原因:Power Collections 的所有结构都没有内置锁,和 BCL 的Dictionary一样。解决:要么在外部加lock,要么用ConcurrentDictionary替代。如果读多写少,可以考虑ReaderWriterLockSlim,但复杂度会上升。我的习惯是:单线程用 Power Collections,多线程先考虑 BCL 的并发集合。

5. 进阶:用 View 做零拷贝过滤与一个验证习惯

Power Collections 里有一个容易被忽略的能力:集合视图。MultiDictionary的[key]返回的就是一个视图,对视图的修改会反映到原字典。类似地,OrderedDictionary的Range返回的也是视图,不是拷贝。这意味着你可以基于一个大集合,派生出多个过滤视图,而不复制数据。

看一个例子:有一个大的OrderedDictionary<int, string>,要分别处理偶数和奇数 key 的条目。常见做法是遍历两遍,或者拷贝到两个列表。用视图可以这样做:

var bigDict = new OrderedDictionary<int, string>(); for (int i = 0; i < 100; i++) bigDict.Add(i, $"值{i}"); // 派生偶数视图:从 0 开始,步长 2 var evenView = bigDict.Range(0, true, 98, true) .Where(kv => kv.Key % 2 == 0); // 派生奇数视图 var oddView = bigDict.Range(1, true, 99, true) .Where(kv => kv.Key % 2 != 0); // 两个视图共享底层数据,不产生拷贝 foreach (var kv in evenView) Console.WriteLine($"偶数: {kv.Key}");

逻辑说明:Range返回的是原字典的一个区间视图,Where是 LINQ 延迟执行,整个链条在遍历时才真正求值。参数方面,Range的边界参数和前面一样,Where的谓词按需写。注意视图是只读的,如果要对视图元素做修改,需要回到原字典操作。

验证方法:我习惯在写完集合操作后,用一个小数据集跑一遍,把结果打印出来,和手写循环的结果对比。具体做法是写一个Verify方法,输入两个集合,分别用 Power Collections 和手写循环计算,断言结果一致。这个习惯帮我抓过好几次边界错误,比如空集合、单元素集合、重复元素这些情况。

static void VerifySetOperation() { var a = new Set<int> { 1, 2, 3 }; var b = new Set<int> { 3, 4, 5 }; // Power Collections 结果 var libResult = a.Intersection(b).ToList(); // 手写循环结果 var manualResult = new List<int>(); foreach (var x in a) if (b.Contains(x)) manualResult.Add(x); // 断言一致 libResult.Sort(); manualResult.Sort(); if (!libResult.SequenceEqual(manualResult)) throw new Exception("集合运算结果不一致"); }

这个验证方法不依赖测试框架,直接跑就行。参数方面没有特殊配置,但要注意Set<T>的遍历顺序不保证,所以比较前先排序。我一般会在项目初期把这类验证方法集中放在一个文件里,每次升级库版本或改集合逻辑时跑一遍。

最后一个习惯:不要因为 Power Collections 提供了某个结构就硬用它。BCL 的List<T>和Dictionary<K,V>在大多数场景下已经够用,Power Collections 的价值在特定缺口。先问自己「原生结构能不能做」,能就不引入新依赖。这个判断顺序帮我避免了不少过度设计。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进