简介:这是面向数据结构课程初学者的集合交并差实验资源包,基于Visual Studio环境实现,便于快速建立工程并调试。资源围绕集合唯一性展开,重点演示交集、并集、差集的算法流程与复杂度分析,同时贴合软件工程实践场景,适合高校学生完成实验或复习相关考点。压缩包共8个文件,包含3个C++源文件与3个头文件,构成可运行的工程代码;另有1份PPTX与1份DOCX,分别用于概念讲解、伪代码展示和实验报告撰写说明,整体大小约8.74MB。该资源已有314人学习。从源码中可以学习数组、链表、哈希表等数据结构在集合运算中的实际应用,结合文档中的复杂度对比,能快速掌握从集合初始化、运算执行到结果验证的完整流程,并理解不同存储方式对执行效率的影响,为后续算法与数据结构学习打下基础。
1. 实验一集合交并差.zip:这只是一个压缩包,还是你需要的集合运算方案?
收到一个名叫“实验一集合交并差.zip”的压缩包时,你大概率正在赶数据结构课程的集合运算实验,或者接手同事转过来的一份集合处理任务。压缩包里面可能是实验要求、模板代码,甚至只是一个交作业的入口,但真正要交付的东西从来不是解压出来的文件,而是一个能正确完成集合交、并、差运算的程序。集合运算听起来简单到可以“两分钟写完”,真动手时却会遇到重复元素、空集合、元素类型混用、差集方向搞反等一系列边界陷阱。这篇文章从数据结构选型讲到代码实现、参数调整和排查清单,新手能照着跑通,熟手可以对照边界条件自查。
2. 集合交并差为什么不能直接用数组硬算:数据结构的选型与复杂度对比
第一次做这个实验的人,看到“交并差”三个字就想到用两个数组嵌套循环。小数据量似乎能跑通,但数据量一上千,程序开始肉眼可见地卡顿,结果里还偶尔混进重复元素。问题通常不在算法写得差,而在存储结构选错了。先花几分钟把几种存储形态和复杂度摸清楚,再动手写代码,后面会省很多排错时间。
2.1 集合的三种常见存储形态:位图、哈希表、有序数组
集合运算本质上是“元素是否属于某个集合”的判断,再加上批量合并、剔除操作。常见的存储形态有三类,各有边界。
位图适用于非负整数的稠密集合。比如要表示 1 到 8 这些数,就开 8 个二进制位,第 i 位为 1 代表 i 存在。查找和修改都是 O(1),交并差还能用位运算一次处理一批。缺点也很明显:如果集合里只有 0 和 1000000 两个元素,你仍需为 100 万个 bit 分配空间,内存浪费巨大。所以位图只在元素取值范围有限且稠密的时候划算。
哈希表是 Python 内置set的底层结构,插入、删除、查找平均 O(1),内存占用与元素个数成线性关系。它天然支持交并差,而且实现极快。唯一缺点是元素没有顺序,如果最终要按顺序输出,还得额外排序。生产环境里的集合运算几乎都用哈希表完成,但对于数据结构实验来说,直接用内置set往往等于把题目做成了“调包侠”,考察点全丢了。
有序数组就是把元素按升序放进数组或列表。查找可以二分,O(log n);插入因为要移动元素,O(n)。交并差用双指针同时扫描两个数组,一次就能跑完,复杂度 O(n+m)。这个结构是课程实验里最常见的考法,因为排序、二分、双指针都是必考技能。
| 存储形态 | 查找 | 插入 | 交并差复杂度 | 顺序性 | 适用场景 |
|---|---|---|---|---|---|
| 无序数组 | O(n) | O(1) | O(n*m) 或更差 | 无序 | 几乎不推荐 |
| 有序数组 | O(log n) | O(n) | O(n+m) 双指针 | 有序 | 教学、嵌入式 |
| 哈希表 | O(1) 平均 | O(1) 平均 | O(n+m) 平均 | 无序 | 生产环境 |
| 位图 | O(1) | O(1) | O(范围/字长) | 无直接顺序 | 稠密非负整数集合 |
2.2 交并差运算的复杂度推演:为什么有序数组适合做实验,哈希表适合做生产
用无序数组做交集,最直观的是两重循环:
def intersection_naive(a, b): res = [] for x in a: for y in b: if x == y and x not in res: res.append(x) return res这串代码有两重循环,最坏 O(n*m),还额外用x not in res做去重,又叠加一层 O(len(res))。三个集合操作都是这个量级,数据量到几千时就会出现明显卡顿。
换成有序数组后,双指针的思路非常固定:两个指针各自从数组头部出发,比较当前元素大小。交集只在两数相等时记录一次,并集则把所有出现过的大小按序记录,差集只记录第一个数组中不在第二个数组里的元素。最坏情况下两个指针各自走完整个数组,比较次数是 n+m,而不是 n*m。哪怕加上一开始排序的 O(n log n),整体仍然划算。
我在实际项目里也遵循同样的权衡:如果业务代码里要对两个大集合做运算,首选 Python 内置set,因为它是用 C 实现的哈希表,常量和复杂度都优秀。但如果是写底层算法模块、嵌入式环境,或者课程要求手写数据结构,有序数组加双指针是最稳妥的方案。理解这一点,你就不会在实验课上对着set纠结“为什么老师不让我直接用它”。
2.3 本实验推荐的最小实现:基于有序数组的集合类设计
既然选了有序数组,设计上就必须守住两个不变式:列表始终升序,且每个元素唯一。任何插入和运算都不能破坏这两个不变式,否则 contains 的二分和交并差的双指针都会失效。
围绕这两个不变式,设计一个MySet类,对外暴露 5 个方法:
| 方法 | 行为与返回 |
|---|---|
| add(value) | 插入元素,保持有序且去重 |
| contains(value) | 返回元素是否存在 |
| intersection(other) | 返回新集合,包含公共元素 |
| union(other) | 返回新集合,包含两集合所有元素 |
| difference(other) | 返回新集合,包含属于自身但不属于 other 的元素 |
三个运算方法都返回新集合,不修改原集合。这是我做这个实验时特别注意的约定:不可变性让代码更好推理,也避免在数据集共享时互相污染。第 3 章就按这个设计给出完整代码。
3. 用 Python 手写集合交并差:从空集合到完整运算的代码与参数
这套代码我拆成三部分讲:存储与去重、三个运算函数、验证脚本。每个代码块后面会解释参数怎么改、失败时看哪里。你可以在本地直接建一个my_set.py文件,把代码粘贴进去运行。
3.1 定义集合存储与去重插入
class MySet: def __init__(self, elements=None): self._data = [] if elements: for e in elements: self.add(e) def add(self, value): i = 0 n = len(self._data) while i < n and self._data[i] < value: i += 1 if i < n and self._data[i] == value: return # 已存在,忽略 self._data.insert(i, value)构造函数接收一个可迭代对象elements,逐个调用add完成去重插入。这里特意用while而不是for扫描,因为要在找到插入位置的同时保留那个下标。
add里用<而不是<=是关键。当当前位置元素等于value时,我们希望停在它前面而不是后面,这样检查到self._data[i] == value就可以直接返回。如果写成<=,等值元素会被插到后面,下次再插入重复值就会在另一个位置插入,最终出现两个相同元素。
这个版本用线性插入,时间复杂度 O(n),数据量小的时候足够清晰。实验数据达到十万级时,add会成为瓶颈,第 5 章会给出优化方案。
3.2 实现交集、并集、差集的核心函数
def intersection(self, other): result = MySet() i = j = 0 di, dj = self._data, other._data while i < len(di) and j < len(dj): if di[i] == dj[j]: result._data.append(di[i]) i += 1 j += 1 elif di[i] < dj[j]: i += 1 else: j += 1 return result def union(self, other): result = MySet() i = j = 0 di, dj = self._data, other._data while i < len(di) and j < len(dj): if di[i] == dj[j]: result.add(di[i]) i += 1 j += 1 elif di[i] < dj[j]: result.add(di[i]) i += 1 else: result.add(dj[j]) j += 1 while i < len(di): result.add(di[i]) i += 1 while j < len(dj): result.add(dj[j]) j += 1 return result def difference(self, other): result = MySet() i = j = 0 di, dj = self._data, other._data while i < len(di) and j < len(dj): if di[i] == dj[j]: i += 1 j += 1 elif di[i] < dj[j]: result.add(di[i]) i += 1 else: j += 1 while i < len(di): result.add(di[i]) i += 1 return result交集里直接result._data.append(di[i]),是因为双指针扫描产生的序列天然有序且无重复,直接追加比调add再做线性插入快得多。这里越过add修改了私有字段,但在类内部操作是安全的,前提是你心里清楚它没有破坏不变式。
并集没有直接用append,因为它要在遇到两个集合相等元素时只取一次,这个去重逻辑和add的语义一致。用add作为统一入口,代码更安全,代价是每次插入都要从头扫描,但在实验场景下完全可接受。
差集只追加self中不在other里的元素。当di[i] > dj[j]时,说明dj[j]更小,它在self里不可能出现匹配,所以只移动j,不追加。等j走完后,self剩余的元素全部都要追加。
这三个函数都传入两个MySet实例,返回新MySet,不修改原集合。参数要求两个集合的元素类型一致,否则在<比较时会抛TypeError,这也是第 4 章要重点排查的坑。
3.3 验证数据:用边界用例检查你的运算逻辑
if __name__ == "__main__": a = MySet([1, 2, 2, 5]) b = MySet([2, 3, 5]) print(a._data) # [1, 2, 5] print(a.intersection(b)._data) # [2, 5] print(a.union(b)._data) # [1, 2, 3, 5] print(a.difference(b)._data) # [1] empty = MySet([]) print(a.intersection(empty)._data) # [] print(empty.union(b)._data) # [2, 3, 5] print(a.difference(empty)._data) # [1, 2, 5]第一组用例里的MySet([1, 2, 2, 5])故意重复传入2,就是为了验证去重在构造阶段就生效。后面三行是空集合用例,很多翻车都发生在空集分支,这里必须跑通。
再补一个随机测试,跟 Python 内置set的结果对照:
import random random.seed(17) for _ in range(1000): d1 = [random.randint(-10, 10) for _ in range(20)] d2 = [random.randint(-10, 10) for _ in range(20)] a = MySet(d1) b = MySet(d2) expected = sorted(set(d1) & set(d2)) actual = a.intersection(b)._data if expected != actual: print("mismatch", d1, d2, expected, actual) break固定随机种子让测试结果可复现。这一轮跑下来,如果没有任何mismatch输出,至少说明常规范围内的交集逻辑是对的。练习时可以把&换成|和-,用同样的方式检查并集和差集。
4. 集合交并差常见问题排查:空集、乱序与类型混用
我见过很多份实验代码栽在同样的地方,而且这些错误往往不是算法思路错,而是边界条件没守住。下面这几条是我自己反复踩过的坑,按“现象 → 原因 → 解决”整理,你可以直接对照查找。
4.1 空集参与运算时返回了 None,代码直接崩溃
现象:调用a.intersection(MySet([]))得到None,后续代码一访问结果就抛AttributeError。
原因:实现里判断到某个集合为空,直接return None;或者result只在某个分支里创建,空集分支没走到。
解决:所有运算函数的第一行就创建result = MySet(),任何分支都不返回None,只在最后return result。调试时先确认empty._data == [],再检查方法里是否有提前返回。
4.2 并集结果里出现了两个相同的元素
现象:union返回[1, 2, 2, 5],或者[1, 1, 2]。
原因:add方法里插入条件写成了<=,导致等值元素被插到原元素后面。下次再插入相同值,会因为前面的等值元素已经被跳过,又插到新的位置,于是出现重复项。
解决:把while i < n and self._data[i] < value中的<严格保留,不用<=。检查union中相等分支只调一次add,不要同时添加两个集合里的相同值。最直接的定位方法是在union开头打印self._data和other._data,看是不是原集合就已经有重复了。
4.3 整数和字符串混在一个集合里,比较时直接 TypeError
现象:执行MySet([1, "a"])时抛出TypeError: '<' not supported between instances of 'str' and 'int'。
原因:有序数组要求任意两个元素能比较大小,整数和字符串之间没有自然序,Python 不允许这样的<比较。
解决:在构造阶段就做类型一致性校验。可以在__init__里记录第一个元素的类型,后续元素如果不是同一类型就直接抛异常。注意bool是int的子类,如果实验数据只包含True和False,可以单独约定,否则严谨的类型检查要先把bool排除掉。
4.4 差集方向搞反,把 B 里多余的算进去了
现象:a = MySet([1, 2]),b = MySet([2, 3]),执行a.difference(b)返回[2, 3],而正确结果应该是[1]。
原因:双指针那层逻辑里,在di[i] > dj[j]的分支错误地add(dj[j]),把other中独有的元素也加进来了。还有一种情况是忘记在j走完后追加self的剩余元素。
解决:记住差集只保留“属于 A 但不属于 B”的元素。双指针里只有两种情况追加di[i]:di[i] < dj[j],以及dj已经走完而di还有剩余。写一个a=[1,2],b=[2,3]的测试用例锁死这个行为。
4.5 运算之后原集合被改动,后续计算全乱
现象:执行完a.difference(b),再打印a._data,发现a里的元素减少了。
原因:实现里直接在self._data上调用remove或pop,而不是构造新列表。
解决:三个运算方法都创建新MySet,原集合的_data只读不写。调试时可以比较运算前后id(a._data)是否变化,如果变化了说明a._data被重新赋值,大概率是代码里写了self._data = ...之类的操作。
5. 从一次性实验到可复用工具:运算符重载、单元测试与规模验证
实验题目的要求通常到“能算出正确结果”就结束了,但如果你想把这个集合类用在后续实验或小工具里,下面三件事值得做:运算符重载、单元测试、构造性能优化。
5.1 用运算符重载让调用更像数学表达式
def __and__(self, other): return self.intersection(other) def __or__(self, other): return self.union(other) def __sub__(self, other): return self.difference(other)重载之后可以写a & b、a | b、a - b,读起来比a.intersection(b)更直观。Python 里&和|都存在优先级,a & b | c会先算a & b,和数学里的交集先于并集保持一致。需要注意这三个方法必须返回新的MySet实例,而不是返回内部列表,否则链式调用会失效。
5.2 用单元测试锁定行为
import unittest class TestMySet(unittest.TestCase): def test_add_deduplicates(self): s = MySet([1, 1, 2]) self.assertEqual(s._data, [1, 2]) def test_intersection_with_empty(self): s = MySet([1, 2]) e = MySet() self.assertEqual(s.intersection(e)._data, []) def test_difference_direction(self): a = MySet([1, 2]) b = MySet([2, 3]) self.assertEqual((a - b)._data, [1]) def test_union_no_duplicate(self): a = MySet([1, 2]) b = MySet([2, 3]) self.assertEqual((a | b)._data, [1, 2, 3]) if __name__ == "__main__": unittest.main()每个测试用例对应第 4 章里的一类坑:构造去重、空集、差集方向、并集去重。断言里还可以加msg参数,比如self.assertEqual(actual, expected, "交集结果不对"),这样失败时能直接看到是哪一步错了。有了这套测试,后面改add或双指针逻辑时就不怕把旧功能改坏。
5.3 性能验证:从 10 个到 10 万个元素
当前版本的add是线性插入,构造n个元素时最坏是 O(n²)。所以做性能对比时,我一般额外提供一个类方法,跳过逐个add,直接接收一个已经排序且去重的列表:
@classmethod def from_sorted_unique(cls, sorted_unique): obj = cls.__new__(cls) obj._data = sorted_unique return obj这个类方法绕开了__init__里的逐个插入,适合数据量大的场景。注意它要求调用方保证参数确实是“排序且无重复”,一旦破坏不变式,后面的二分和双指针都会出问题。
性能测试用随机数据:
import time import random for n in [10, 100, 1000, 10000, 100000]: data1 = [random.randint(0, n) for _ in range(n)] data2 = [random.randint(0, n) for _ in range(n)] t0 = time.perf_counter() a = MySet.from_sorted_unique(sorted(set(data1))) b = MySet.from_sorted_unique(sorted(set(data2))) c = a & b dt = time.perf_counter() - t0 print(f"n={n:6d}, time={dt:.6f}s, inter_size={len(c._data)}")这段代码统计的是“排序+去重+构造+交集”的整体时间。数据量从 10 到 10 万时,时间增长基本接近线性,因为最重的部分在sorted(set(...))。如果你在实验里必须用add逐个插入,可以把这一行的耗时单独测出来,它会呈现出明显的二次增长,那就是add线性插入的代价。
6. 位图法:当集合元素是稠密整数时,用位运算把交并差推进到纳秒级
最后一个进阶技巧,是给那些元素范围不太大、且都是非负整数的场景准备的。把集合存储换成二进制位图,交并差就变成了位与、位或、位与非,一次 64 位运算同时处理 64 个元素,速度非常快。
class BitSet: def __init__(self, max_value, elements=None): self.max_value = max_value self.bits = [0] * ((max_value + 63) // 64) if elements: for x in elements: self.add(x) def add(self, x): self.bits[x // 64] |= 1 << (x % 64) def intersection(self, other): size = min(len(self.bits), len(other.bits)) res = BitSet(min(self.max_value, other.max_value)) res.bits = [a & b for a, b in zip(self.bits, other.bits)] return resmax_value必须严格大于所有元素。每个元素x用x // 64定位到第几个 64 位整数,用1 << (x % 64)定位到那个整数里的第几位。内存上,max_value为 1000 万时需要约 156250 个 64 位整数,换算下来不到 2 MB,可以表示 1000 万个整数是否存在。
位图只适合非负整数,并且元素分布要相对稠密。如果集合里只有 {1, 5000000} 两个元素,位图会浪费大量空间,此时哈希表或有序数组更合理。我上次在日志分析场景里用这个技巧,把两百万整数的集合求交从几百毫秒压到一两毫秒,代价是初始化时要遍历一遍数据建立位图。这个方向我踩过最痛的坑就是max_value设置太小,add时数组越界,所以现在所有位图构造函数里都会强制要求max_value参数,而不是自动推断。希望帮到你。
本文还有配套的精品资源,点击获取