简介:这份资源面向数据结构与算法课程的初学者及软件工程相关专业学生,围绕集合的交集、并集、差集三种基本运算,提供一套可在 Visual Studio 环境下直接运行的实验工程,帮助读者把课堂上的集合概念落到实际编码中。压缩包共 8 个文件,约 8.74MB,以 3 个 cpp 源文件、3 个 h 头文件为核心实现代码,另配 1 份 pptx 实验讲解和 1 份 docx 实验报告,覆盖从概念梳理、伪代码展示到程序验证的完整流程。资源已有 314 人学习下载,说明其在同类实验资料中具备一定参考价值。读者可据此理解集合去重、元素比较与逻辑运算的实现思路,对比数组、链表、哈希表等不同存储方式对交并差操作的影响,并借助报告与课件完成实验过程记录和复杂度分析,为后续算法设计与效率评估打下基础。
1. 集合交并差实验包:一份能直接跑通的离散数学实操素材
刚接触离散数学或者数据结构入门课的人,几乎都会在“集合的交、并、差”这一关卡一下。概念看着简单,真让你用代码把两个集合的交集、并集、差集算出来,还要处理重复元素、空集、元素顺序这些细节,很多人就开始翻车了。这份名为“实验一集合交并差.zip”的资源包,就是围绕这个最基础也最容易踩坑的知识点整理的一套实验素材,里面通常包含可运行的源码、实验说明和测试用例,适合正在做课程实验的学生、需要快速补齐基础的数据从业者,以及想拿它当教学演示模板的讲师。它解决的不是“集合论是什么”这种理论问题,而是“怎么用代码把交并差正确实现出来、怎么验证结果对不对”这种落地问题。下面我按自己拆包复现的顺序,把这份资源怎么用、参数怎么设、坑在哪,一条条讲清楚。
2. 拆开压缩包先看什么:文件结构与运行环境确认
拿到一个实验包,最忌讳的就是双击压缩包看一眼文件名就开始写代码。我一般会先把目录结构摸清楚,确认里面到底是源码、文档还是混合内容,再决定用哪种方式跑起来。集合交并差这类实验包,常见的组织方式有两种:一种是纯源码加一个说明文档,另一种是源码、测试数据、实验报告模板三件套。不同组织方式对应的复现路径完全不一样,先看清楚再动手,能省掉后面大量返工。
2.1 目录结构与文件类型识别
解压之后先别急着打开 IDE,用命令行把目录树列出来,重点看文件扩展名和层级深度。常见的集合实验包大致长这样:一个 src 或 code 目录放源码,一个 data 或 test 目录放测试输入,根目录下可能有一个 readme 或者实验指导书。如果看到 .java、.py、.cpp 混在一起,说明这个包可能是多语言版本,你只需要挑自己课程要求的那一种,不用全跑。
# 列出解压后目录的完整结构,只看两层,避免输出过长 find ./实验一集合交并差 -maxdepth 2 -type f | sort # 统计各类型文件数量,快速判断包的性质 find ./实验一集合交并差 -type f | sed 's/.*\.//' | sort | uniq -c | sort -rn第一段命令的作用是把两层以内的所有文件按路径排序输出,你能一眼看出源码集中在哪个目录、测试数据放在哪里。第二段命令按扩展名统计数量,如果 .py 或 .java 占大头,说明这是源码为主的包;如果 .txt、.csv 很多,说明测试数据丰富,重点应该放在用例覆盖上。参数上,-maxdepth 2 是控制递归深度,包特别大的时候可以改成 1 先看顶层;sort -rn 是按数量倒序,方便你判断哪类文件最多。
识别完文件类型,接下来要确认运行环境。集合交并差实验对环境的依赖通常很轻,Python 版本一般 3.6 以上就够,Java 看源码里有没有用 var 或者 record 这类新语法,C++ 则要注意是否用了 C++11 以上的容器特性。我一般会先看源码头部的 import 或 include,再决定装什么。
2.2 运行环境与依赖确认
环境确认这一步,新手最容易犯的错是直接拿系统自带的旧版本解释器去跑,结果语法报错,还以为是代码有问题。稳妥的做法是先看源码里的语法特征,再对照本机版本。比如 Python 源码里如果出现了 f-string,那至少需要 3.6;如果用了 match 语句,那得 3.10 以上。Java 源码里如果用了 List.of(),那是 Java 9 之后的写法。
# 查看本机 Python 版本,确认是否满足源码要求 python3 --version # 查看 Java 版本 java -version 2>&1 | head -n 1 # 查看 g++ 版本,C++ 实验包常用 g++ --version | head -n 1这三条命令分别对应三种常见语言环境。python3 --version 输出的是主版本号,如果低于源码要求,要么升级解释器,要么找包里的兼容版本。java -version 的输出会写到标准错误,所以用 2>&1 重定向再取第一行。g++ 那条同理,取第一行看版本号即可。这里没有复杂参数,关键是养成“先看版本再跑代码”的习惯,很多所谓的“代码跑不通”,根子都在版本不匹配上。
环境确认完之后,如果包里带了 requirements.txt 或者 pom.xml 这类依赖清单,按清单装依赖就行。集合交并差实验一般不需要第三方库,标准库里的 set、HashSet、std::set 足够用,所以依赖这一步通常可以跳过。真正需要花时间的是读懂源码里集合的表示方式,这决定了你后面怎么改、怎么测。
3. 交并差的核心实现:三种语言下的集合操作对照
集合交并差看着是三个独立操作,实际上底层都依赖同一套数据结构。用 Python 的 set、Java 的 HashSet、C++ 的 std::set,写法差别不小,但思路是一致的:先把元素装进一个不允许重复的容器,再调用容器自带的交并差方法,或者手写循环实现。这一章我把三种常见实现方式拆开讲,每种都给可抄的代码,并说明参数和边界怎么处理。
3.1 Python 版:用 set 内置方法实现交并差
Python 是这类实验最常用的语言,因为 set 类型天生就支持 &、|、- 三个运算符,分别对应交、并、差。但直接用运算符有个前提:两个操作数都必须是 set 类型,如果传进来的是 list,得先转换。另外,差集是有方向的,A - B 和 B - A 结果完全不同,这一点在实验报告里经常被忽略。
def set_operations(a, b): """计算两个集合的交、并、差,输入可以是 list 或 set""" # 统一转成 set,去掉重复元素,避免 list 里有重复导致结果偏差 sa = set(a) sb = set(b) # 交集:同时存在于两个集合中的元素 intersection = sa & sb # 并集:出现在任意一个集合中的元素,自动去重 union = sa | sb # 差集:只在 sa 中、不在 sb 中的元素,注意方向 difference_ab = sa - sb difference_ba = sb - sa return { "intersection": intersection, "union": union, "difference_ab": difference_ab, "difference_ba": difference_ba, } if __name__ == "__main__": A = [1, 2, 2, 3, 4] B = [3, 4, 5, 6] result = set_operations(A, B) for key, value in result.items(): print(f"{key}: {sorted(value)}")这段代码的关键点在开头两行:set(a) 和 set(b) 把输入统一成集合类型,顺带完成了去重。如果跳过这一步直接对 list 用 & 运算符,Python 会直接抛 TypeError。交集用 &,并集用 |,差集用 -,三个运算符的语义和数学定义完全一致。差集我特意算了两个方向,因为实验里经常要求同时给出 A-B 和 B-A,只算一个方向是不完整的。最后的 sorted(value) 是为了输出稳定,set 本身无序,直接打印每次顺序可能不同,排序之后方便对照实验报告。
参数上,这个函数接受任意可迭代对象,list、tuple、set 都能传。返回值用字典包起来,是为了让调用方按名字取结果,比返回一个元组更不容易搞混顺序。如果你的实验要求输出成特定格式,改 print 那一段就行,核心逻辑不用动。
3.2 Java 版:HashSet 与 retainAll 的配合
Java 里没有运算符重载,交并差得靠集合类的方法组合实现。交集用 retainAll,并集用 addAll,差集用 removeAll,这三个方法都是原地修改调用方集合,所以每次操作前必须先复制一份,否则算完交集原集合就被改掉了,后面的并集和差集全错。这是 Java 版实验里最高频的翻车点。
import java.util.*; public class SetOps { public static void main(String[] args) { Set<Integer> a = new HashSet<>(Arrays.asList(1, 2, 2, 3, 4)); Set<Integer> b = new HashSet<>(Arrays.asList(3, 4, 5, 6)); // 交集:复制 a 之后调用 retainAll,避免修改原集合 Set<Integer> intersection = new HashSet<>(a); intersection.retainAll(b); // 并集:复制 a 之后 addAll Set<Integer> union = new HashSet<>(a); union.addAll(b); // 差集 A-B:复制 a 之后 removeAll Set<Integer> diffAB = new HashSet<>(a); diffAB.removeAll(b); System.out.println("交集: " + new TreeSet<>(intersection)); System.out.println("并集: " + new TreeSet<>(union)); System.out.println("差集A-B: " + new TreeSet<>(diffAB)); } }代码里每个操作都以 new HashSet<>(a) 开头,这一步是保命的。retainAll、addAll、removeAll 都是原地修改,如果直接对 a 调用,第一次 retainAll 之后 a 就只剩交集了,后面再算并集结果必然错。输出时套一层 TreeSet 是为了排序,HashSet 的遍历顺序不保证,实验报告里顺序乱跳不好核对。Arrays.asList 那一步允许你直接写元素列表,但要注意它返回的是固定长度列表,外面再包一层 HashSet 才能增删。
参数说明:HashSet 的构造器接受任何 Collection,所以从 list 转过来很自然。如果你的实验要求保留元素插入顺序,把 HashSet 换成 LinkedHashSet 即可,方法名完全一样。元素类型上,Integer 是最常见的,如果实验涉及字符串集合,把泛型改成 String 就行,逻辑不变。
3.3 C++ 版:std::set 与算法库的组合
C++ 标准库没有直接的交并差成员方法,得用 std::set_intersection、std::set_union、std::set_difference 这三个算法,配合插入迭代器把结果写进新容器。这三个算法要求输入区间有序,std::set 本身是有序的,所以直接传就行;如果你用的是 vector,必须先排序,否则结果不对还不报错,属于典型的静默失败。
#include <iostream> #include <set> #include <algorithm> #include <iterator> int main() { std::set<int> a = {1, 2, 2, 3, 4}; std::set<int> b = {3, 4, 5, 6}; std::set<int> intersection, unionSet, diffAB; // 交集:结果写入 intersection std::set_intersection(a.begin(), a.end(), b.begin(), b.end(), std::inserter(intersection, intersection.begin())); // 并集 std::set_union(a.begin(), a.end(), b.begin(), b.end(), std::inserter(unionSet, unionSet.begin())); // 差集 A-B std::set_difference(a.begin(), a.end(), b.begin(), b.end(), std::inserter(diffAB, diffAB.begin())); std::cout << "交集: "; for (int x : intersection) std::cout << x << " "; std::cout << "\n并集: "; for (int x : unionSet) std::cout << x << " "; std::cout << "\n差集A-B: "; for (int x : diffAB) std::cout << x << " "; std::cout << std::endl; return 0; }三个算法的参数模式一致:前两个迭代器对是第一个集合的区间,中间两个是第二个集合的区间,最后一个是输出迭代器。std::inserter 的作用是把结果以插入方式写进目标容器,避免目标容器提前分配大小。std::set 初始化时写了重复的 2,但 set 会自动去重,所以实际存进去的只有一个 2,这一点和 Python 的 set 行为一致。差集同样只算了 A-B,需要 B-A 的话把两个区间顺序对调再调一次即可。
编译时记得加 -std=c++11 或更高,因为初始化列表和 range-for 都是 C++11 的特性。如果实验环境是老版本的编译器,把 {1,2,3} 改成逐个 insert 也能跑,只是代码会长一些。
4. 测试用例怎么设计:把空集、重复元素和类型混用都覆盖到
代码能跑通只是第一步,实验报告里真正拉开差距的是测试用例的设计。集合交并差看起来简单,但边界情况不少:空集参与运算、两个集合完全不相交、元素有重复、元素类型不一致。这一章我按“先构造用例、再跑、最后核对”的顺序,把一套能覆盖主要边界的测试方案讲清楚。
4.1 边界用例清单与预期结果
设计用例之前,先把要覆盖的场景列出来,避免漏测。我一般按下面这张表来组织,每个场景对应一组输入和预期输出,跑完逐条核对。
| 场景 | 集合 A | 集合 B | 交集预期 | 并集预期 | 差集 A-B 预期 |
|---|---|---|---|---|---|
| 常规重叠 | {1,2,3,4} | {3,4,5,6} | {3,4} | {1,2,3,4,5,6} | {1,2} |
| 完全不相交 | {1,2} | {3,4} | 空 | {1,2,3,4} | {1,2} |
| 一方为空 | {1,2} | 空 | 空 | {1,2} | {1,2} |
| 完全包含 | {1,2,3} | {1,2} | {1,2} | {1,2,3} | {3} |
| 含重复元素 | {1,1,2} | {2,2,3} | {2} | {1,2,3} | {1} |
这张表的价值在于,它把“预期结果”提前写死了,跑代码的时候不是看输出“像不像对的”,而是逐格对照。完全不相交和一方为空这两种情况,很多人的代码会抛异常或者返回 None,提前列出来就能第一时间发现。含重复元素那一行专门用来验证去重逻辑,如果交集里出现了两个 2,说明输入没有先去重。
4.2 自动化跑用例的脚本写法
手工一条条跑太慢,也不利于复现。我一般会写一个小脚本,把用例表转成数据,批量调用前面的实现函数,自动比对预期结果。下面以 Python 版为例,其他语言思路一样。
# 用例列表:(名称, A, B, 预期交集, 预期并集, 预期差集A-B) cases = [ ("常规重叠", [1, 2, 3, 4], [3, 4, 5, 6], {3, 4}, {1, 2, 3, 4, 5, 6}, {1, 2}), ("完全不相交", [1, 2], [3, 4], set(), {1, 2, 3, 4}, {1, 2}), ("一方为空", [1, 2], [], set(), {1, 2}, {1, 2}), ("完全包含", [1, 2, 3], [1, 2], {1, 2}, {1, 2, 3}, {3}), ("含重复元素", [1, 1, 2], [2, 2, 3], {2}, {1, 2, 3}, {1}), ] def run_cases(): passed = 0 for name, a, b, exp_i, exp_u, exp_d in cases: r = set_operations(a, b) ok = (r["intersection"] == exp_i and r["union"] == exp_u and r["difference_ab"] == exp_d) status = "PASS" if ok else "FAIL" print(f"[{status}] {name}") if not ok: print(f" 实际: 交={r['intersection']} 并={r['union']} 差={r['difference_ab']}") passed += ok print(f"通过 {passed}/{len(cases)}") if __name__ == "__main__": run_cases()这段脚本把用例表直接写成列表,每条用例包含名称、两个输入和三个预期结果。run_cases 里逐条调用 set_operations,用 == 比对集合是否相等,集合比较不看顺序,正好符合集合语义。失败时打印实际结果,方便定位是哪一项对不上。最后的通过计数让你一眼看出整体覆盖情况。参数上,预期结果必须写成 set 字面量,如果写成 list,== 比较会因为类型不同直接判失败,这是新手常犯的错。
跑完这套用例,如果全部通过,说明核心逻辑没问题;如果有失败,按失败项回去查对应场景,通常能快速定位到是去重没做、还是差集方向搞反了。
5. 避坑与排查:集合实验里最容易翻车的五个地方
这套实验包我前后拆过几次,每次都能看到相似的错误反复出现。下面五条是我整理出来的高频踩坑记录,每条按“现象、原因、解决”写,你跑代码的时候对照着看,能省掉不少调试时间。
5.1 差集方向搞反,结果看着“对”其实是错的
现象:A-B 和 B-A 输出一样,或者实验报告里差集结果和预期对不上,但代码没报错。原因:差集不满足交换律,A-B 是“在 A 中但不在 B 中”,B-A 正好相反,很多人在写循环时把两个集合的位置写反了,或者只算了一个方向就当成两个方向的结果。解决:在函数里显式区分两个方向,命名上就用 difference_ab 和 difference_ba,不要用笼统的 diff;测试用例里专门加一条 A 和 B 元素数量不同的场景,方向错了立刻暴露。
5.2 输入没去重,交集里出现重复元素
现象:输入 A 是 [1,1,2],跑出来的交集里有两个 1,和数学定义不符。原因:直接把 list 当集合用,没有先转成 set 或 HashSet,导致重复元素被带进运算。解决:所有运算入口第一步统一做类型转换,Python 用 set(),Java 用 new HashSet<>(),C++ 用 std::set 构造;转换之后再参与交并差,重复元素自然被吃掉。
5.3 Java 里原地修改导致后续结果全错
现象:先算交集正常,再算并集结果只剩交集那部分,差集干脆变成空。原因:retainAll、addAll、removeAll 都是原地修改调用方集合,算完交集原集合已经被裁剪,后面的运算基于被改过的数据,自然全错。解决:每次运算前用 new HashSet<>(原集合) 复制一份,在副本上操作;或者把三个运算封装成独立函数,每个函数内部自己复制,调用方不用关心。
5.4 C++ 算法库对无序输入静默失败
现象:用 vector 存元素,调 std::set_intersection 结果少了几个元素,程序不报错。原因:这三个算法要求输入区间有序,vector 默认无序,算法按有序前提做归并式比较,遇到乱序就漏元素,而且不会抛异常。解决:要么直接用 std::set 存元素,要么在调用算法前对 vector 做 std::sort;如果实验允许,优先用 std::set,省掉排序这一步,也避免忘记排序的风险。
5.5 空集参与运算时抛异常或返回 None
现象:B 为空列表时,代码直接崩,或者返回 None 而不是空集。原因:部分实现里对空输入没做判断,比如取 b[0] 或者调用某些库函数时传了空序列。解决:在函数入口对空输入做显式处理,空集和任何集合的交集是空集,并集是另一个集合本身,差集也是另一个集合本身;把这三种情况写进测试用例,跑通再交付。
6. 从实验包到可复用工具:把交并差封装成命令行小工具
实验做完,代码扔在文件夹里吃灰是常态。我的习惯是把它再往前推一步,封装成一个能接受命令行参数的小工具,这样以后遇到临时要算两个集合交并差的场景,不用再翻实验代码。下面以 Python 版为例,用 argparse 接收两个逗号分隔的集合,输出三个结果,顺便把前面讲的去重和方向问题都固化进去。
import argparse def parse_set(text): """把 '1,2,3' 这样的字符串解析成 set,自动去重""" if not text.strip(): return set() return set(item.strip() for item in text.split(",") if item.strip()) def main(): parser = argparse.ArgumentParser(description="集合交并差计算器") parser.add_argument("--a", required=True, help="集合A,逗号分隔,如 1,2,3") parser.add_argument("--b", required=True, help="集合B,逗号分隔,如 3,4,5") args = parser.parse_args() a = parse_set(args.a) b = parse_set(args.b) print("交集:", sorted(a & b)) print("并集:", sorted(a | b)) print("差集A-B:", sorted(a - b)) print("差集B-A:", sorted(b - a)) if __name__ == "__main__": main()parse_set 里做了两件事:按逗号切分,以及过滤空字符串。过滤空字符串是为了处理“1,2,”这种末尾多一个逗号的输入,不处理的话会混进一个空元素。required=True 保证两个参数都必须传,缺一个 argparse 会直接报错并给出用法提示,比手动判断更省事。输出四个结果,两个方向的差集都给出来,避免使用者自己搞混方向。
用法很直接:
python set_tool.py --a "1,2,2,3" --b "3,4,5"跑出来交集是 [3],并集是 [1,2,3,4,5],差集 A-B 是 [1,2],B-A 是 [4,5]。输入里故意放了重复的 2,输出里只出现一次,说明去重生效了。这个工具可以直接放进自己的脚本目录,以后需要的时候一条命令搞定。
再往深一层,如果你想让工具支持从文件读集合,可以在 parse_set 外面加一个分支:参数以 @ 开头就当成文件路径,逐行读取再合并。这个扩展不难,但要注意文件编码统一用 utf-8,否则中文元素会乱码。我一般会在读文件那一步加一个 encoding 参数,显式指定,不依赖系统默认。
最后说个我自己的习惯。每次封装完这种小工具,我都会拿前面那张边界用例表再跑一遍,确认命令行入口和函数入口的行为一致。有一次我改了解析逻辑,函数测试全过,但命令行传空字符串时崩了,原因就是 parse_set 里没处理空输入。从那以后我每次加新入口,都强制把空集、单元素、含重复这三类用例走一遍,确认没有回归才收工。希望这份拆解能帮到你,把实验包真正用起来,而不是解压完就放着。
本文还有配套的精品资源,点击获取