☰
集合交并差实验全攻略:从数学定义到C语言数组、位图、链表实现
2026/9/25 15:19:01 网站建设 项目流程

简介:实验一集合交并差.zip 是一份数据结构课程的集合操作实验资源,面向正在学习数据结构与算法、需要完成集合交集并集差集编程实践的学生。资源结合软件工程(SE)课程思路,在 VS 环境下用 C++ 实现了集合的初始化、遍历、比较与结果验证等操作,并附有复杂度分析,适合作为实验报告撰写或复习参考。压缩包共 8 个文件,以 3 个 cpp 源文件和 3 个 h 头文件为主,涵盖可运行的集合运算代码,另有 1 份 pptx 用于概念讲解与伪代码展示,1 份 docx 为实验报告模板,整体大小约 8.74MB。目前已有 314 人学习或下载。通过这份资料,读者可以快速掌握数组、链表或哈希表实现集合运算的思路,理解 O(1) 与 O(n) 不同实现的效率差异,并获得可直接修改运行的代码与配套说明,为后续算法学习打下基础。

1. 实验一集合交并差.zip:先认识再动手

从课程平台下载的「实验一集合交并差.zip」,是大多数数据结构或离散数学课的第一次上机作业。解压后你会看到模板代码、实验指导书和样例数据,任务是实现两个集合的交、并、差三个运算,再按指定格式输出。说它坑,不在算法本身——三个运算的逻辑初中生都能讲明白——而在实验包的文件结构、输入输出协议和评分脚本的隐性要求上:去重做没做、空集怎么输出、差集方向对不对、压缩时是不是多套了一层文件夹,都可能让一次本来写得对的程序被判零分。这份笔记带你一次走通:从读懂压缩包里每份文件的用途,到写出数组、位图、链表三种实现,最后聊判分时最容易丢分的细节。

2. 交并差先算清楚数学再写代码:三个运算符的本义与程序翻译

2.1 交、并、差的定义:用一个例子把符号变成集合

集合这个词在数学里的定义不用背:它就是一个“确定且互不相同的对象群体”。交、并、差是作用在两个集合 A 和 B 上的三个二元运算:

  • 交集 A ∩ B:所有既属于 A 又属于 B 的元素。
  • 并集 A ∪ B:所有属于 A 或属于 B 的元素,重复的只算一次。
  • 差集 A \ B:所有属于 A 但不属于 B 的元素。

用具体数字走一遍:A = {1, 2, 3, 5, 5}(注意有重复),B = {2, 3, 4}。先把 A 去重成 {1, 2, 3, 5},再算:

  • A ∩ B = {2, 3}
  • A ∪ B = {1, 2, 3, 4, 5}
  • A \ B = {1, 5}
  • B \ A = {4}

最后这个 B \ A 是实验里最常见的分叉点:题目写了“求两个集合的差集”但没有说明方向时,默认要做 A \ B;如果指导书写的是“差集”而不是“A 减 B”,建议两个方向都实现并在输出里给出标签。实际判题一般是固定方向,看清它。

后面写代码时你会发现,交并差函数的参数顺序在并集和交集上无所谓,但差集 A \ B 的参数顺序至关重要。我一般会把do_difference(a, n, b, m)和do_difference(b, m, a, n)都写出来,输出时按题目要求调换实参——这个习惯能让你快速验证两个方向,不会在答辩时被问倒。

2.2 程序里怎么表示集合:数组、链表、位图的选型

C 语言没有内置集合,实验第一件事就是选容器。三种主流做法:

表示方式适用数据规模主要缺点适合练习点
动态数组顺序表几千以内插入删除要挪元素排序和双指针
单链表任意,主要练指针边界条件多指针操作
位图数组元素是有界非负整数范围不定时浪费空间位运算

很多院系的实验指导书会直接指定“必须用带头结点的单链表实现”,这时别顶着要求写数组——评分里有一项会抽查代码结构,用错了不一定零分,但讲评时比较难看。如果没有任何限制,优先用数组:代码短、逻辑直白、调试容易。C++ 用户如果想用 STL 的 set 一步到位,注意指导书末尾那句“不得使用 STL”,一旦出现,就当这条捷径不存在。

选型时还有一条实际经验:实验平台如果是 PTA 这类自动判题系统,编译时不会关心你用的是数组还是链表,只看输出;但如果是人工提交实验报告并要求答辩,老师会对照指导书检查数据结构。对自己负责的角度,优先选择指导书点名的那种结构;没有点名,数组。

2.3 去重:集合定义里最容易被忽略的第一关

集合的元素互不相同,但输入数据不一定守规矩。比如样例输入是:

3 1 1 2

表示集合里有三个元素,分别是 1、1、2,真实含义是 {1, 2}。如果在读入后不处理重复,交并差三个结果都会出现重复值:交集不用双指针去重的话,A 和 B 里同时有两个 1,输出就成了1 1。判分脚本按参考答案逐元素比对,直接给你判错。

去重的两种做法。第一种是插入时就检查:每来一个元素,扫一遍已有数组,如果存在就丢弃。这种写法直观但插入复杂度变成 O(n²)。第二种是先全部存入,再 qsort 排序,最后原地压缩。第二种效率更高,也是我一般推荐的做法——排序顺便解决了输出顺序问题。用数组实现的去重核心只有三行:

qsort(a, n, sizeof(int), cmp); int idx = 1; for (int i = 1; i < n; i++) if (a[i] != a[idx - 1]) a[idx++] = a[i]; n = idx;

逻辑说明:qsort 先把所有元素排成非递减序列,这样重复值一定相邻。idx 指向“下一个可写入的去重后位置”,循环里把与上一个保留值不同的元素搬运到前面。最终 n 被更新为去重后的长度。

参数说明:cmp 是 qsort 的比较函数,必须返回两个元素的大小关系;数组 a 只要保证在调用前已排序,这段代码就不会出错。如果输入规模小于 100,也可以在插入时用两层循环手工去重,代码更短但效率低,实验报告里写复杂度时不太好看。

2.4 空集和字符串集合:让边界情况不拖后腿

空集参与运算是必须处理的边界,也是最容易被扣分的地方:

  • A = ∅,B = {1, 2}:交、差均为空,并集是 B。
  • A = B:交、并都是 A,差为空。
  • 交或差为空时,那一行要输出一个空行。注意有的指导书要求输出空集时打印none或EMPTY,这属于“额外命题”,以指导书为准。

元素类型也要确认是整数还是字符串。字符串集合的排序用strcmp,去重逻辑不变但相等判断要换成strcmp(s1, s2) == 0;双指针遍历时,用strcmp的返回值决定谁小谁大,不要直接比较字符串地址。实验里极少出现字符串版本,但出现过一次“输入若干个英文单词,求两个集合交”的题目,提前知道没坏处。

3. 解压实验包先别写代码:文件结构与输入输出协议摸清再动手

3.1 典型实验包里那四类文件,分别干什么

一个标准的「实验一集合交并差.zip」解压后大概长这样:

实验一集合交并差/ ├── 实验指导书.pdf ├── main.c ├── input_sample.txt ├── output_sample.txt └── README.txt

实验指导书:写的字最多,是你唯一要照着做的需求文档。里面的数据规模(比如“集合元素个数不超过 1000”)、算法要求(是否必须链表)、输出格式(元素间空格还是逗号)都要逐句确认。模板 main.c:通常已经把 main 函数和读入部分写好,留三个函数给你填。input_sample.txt / output_sample.txt:给你对拍用的小样例,程序跑完和 output_sample.txt 逐字符比对,这是最简单也是最有用的验证手段。README.txt:写编译命令和提交说明,多数时候还写清楚了“压缩包必须包含哪些文件”。

先读指导书、再看模板 main.c、最后对着样例验证,这个顺序不要颠倒。我见过不少同学把 main.c 里已经写好的读入代码整体删掉重写,结果读入格式和评分脚本不一致,白忙一场。

3.2 输入输出协议:判分脚本到底期望什么格式

判题系统统一用“标准输入 + 标准输出”,命令行对比是./main < input.txt > output.txt这样的重定向。最常见的输入协议是:

4 1 2 3 5 3 2 3 4

含义:A 有 4 个元素,分别是 1 2 3 5;B 有 3 个元素,分别是 2 3 4。有的模板会在第一行先读集合 A 的个数,再读元素;也有的设计成“读到 EOF 为止,前 n 个属于 A,后 m 个属于 B”,后者少见但存在。实验指导书里一定写,模板 main.c 里一定有对应的 scanf 序列——照着模板读就行。

输出格式一般是三行:第一行交集,第二行并集,第三行差集(方向为 A \ B)。元素之间用一个空格分隔,行尾没有多余空格,最后有换行。空集输出空行。这就是绝大多数评分的全部要求。

还要注意一个细节:判断输入结束的方式。模板 main.c 若是用scanf("%d", &n) == 1判断读取成功,那么输入文件末尾没有多余空白时不会误读;如果你自己改成while (!feof(stdin)),会因最后一行读取后再进循环而多读一次 EOF,导致集合里多出一个未定义值。所以读入逻辑尽量沿用模板,不要从零重写。

3.3 解压与编译:Windows 和 Linux 下的常用命令

拿到 zip 先在 Windows 资源管理器里右键解压,最省事,编码也基本不会出问题。如果实验平台是 Linux,解压命令是:

unzip 实验一集合交并差.zip unzip -O GBK 实验一集合交并差.zip # 文件名是中文且解出乱码时用

如果没装 unzip,先sudo apt update && sudo apt install unzip。第一行命令解开 zip;第二行-O GBK是处理 Windows 下用 GBK 压缩的中文文件名,不加的话文件名解出来是乱码——这就是热词榜上“zip 乱码”的实际场景。

编译用 gcc 或 g++:

gcc main.c -o lab1 ./lab1 < input_sample.txt

编译参数-o lab1指定输出文件名,不写的话产出a.out。命令行./lab1 < input_sample.txt把文件作为标准输入重定向,省去手工敲数。Windows 下是lab1.exe < input_sample.txt,注意别在源文件里写死freopen("input.txt", "r", stdin)这类代码——评分系统直接重定向输入,文件路径是否一致是另一门玄学,少给自己加戏。

如果你在 Windows cmd 里编译后运行./lab1,系统会提示找不到路径,正确写法是lab1.exe或.\lab1.exe。这个细节看似初级,每年都有人因为没跑通样例就放弃,挺可惜。

4. 动手实现交并差:数组、位图、链表三份代码与细节

4.1 数组法:排序去重后一次双指针遍历通吃三个运算

数组法是目前最稳妥、也是代码量最小的实现。整体流程分三步:读入、qsort 排序、unique 去重;之后三个运算全部基于有序数组用双指针扫描。直接给一个可以编译运行的完整 main.c:

#include <stdio.h> #include <stdlib.h> int cmp(const void *a, const void *b) { return *(const int *)a - *(const int *)b; } int unique(int *a, int n) { if (n <= 1) return n; int idx = 1; for (int i = 1; i < n; i++) { if (a[i] != a[idx - 1]) { a[idx++] = a[i]; } } return idx; } void do_intersection(int *a, int na, int *b, int nb) { int i = 0, j = 0, first = 1; while (i < na && j < nb) { if (a[i] < b[j]) i++; else if (a[i] > b[j]) j++; else { if (!first) printf(" "); printf("%d", a[i]); first = 0; i++; j++; } } printf("\n"); } void do_union(int *a, int na, int *b, int nb) { int i = 0, j = 0, first = 1; while (i < na || j < nb) { int v; if (i >= na) v = b[j++]; else if (j >= nb) v = a[i++]; else if (a[i] < b[j]) v = a[i++]; else if (a[i] > b[j]) v = b[j++]; else { v = a[i]; i++; j++; } if (!first) printf(" "); printf("%d", v); first = 0; } printf("\n"); } void do_difference(int *a, int na, int *b, int nb) { int i = 0, j = 0, first = 1; while (i < na && j < nb) { if (a[i] < b[j]) { if (!first) printf(" "); printf("%d", a[i]); first = 0; i++; } else if (a[i] > b[j]) { j++; } else { i++; j++; } } while (i < na) { if (!first) printf(" "); printf("%d", a[i]); first = 0; i++; } printf("\n"); } int main() { int n, m, a[2000], b[2000]; scanf("%d", &n); for (int i = 0; i < n; i++) scanf("%d", &a[i]); scanf("%d", &m); for (int i = 0; i < m; i++) scanf("%d", &b[i]); qsort(a, n, sizeof(int), cmp); qsort(b, m, sizeof(int), cmp); n = unique(a, n); m = unique(b, m); do_intersection(a, n, b, m); do_union(a, n, b, m); do_difference(a, n, b, m); return 0; }

逻辑说明:unique 依赖 qsort,因为只有对有序数组做相邻比较才能一次性去重。qsort 的比较函数 cmp 必须返回差值,别写成恒真表达式。do_intersection 在 a[i] 与 b[j] 相等时输出并同时推进两个指针,避免重复匹配;do_union 使用“谁小谁输出、相等只输出一次”的策略;do_difference 只在 a[i] < b[j] 时输出,因为此刻 b[j] 一定大于 a[i] 且不会与后续任何一个 b 相等——有序性是这个判断的前提。

参数说明:数组容量 2000 只是图省事给的一个量级;实验若写明“元素个数不超过 100000”,请在 main 里用malloc动态分配,避免栈溢出。cmp 里*(const int*)a - *(const int*)b在元素接近 INT_MAX 时可能溢出,但实验通常限制在 int 正数范围;如果元素可能是 unsigned 或 long long,把比较函数改成两段条件判断更安全。

4.2 位图法:元素范围确定时三行逻辑做三个运算

如果指导书给出“集合元素是不超过 1000 的正整数”这类约束,位图法是个像黑匣子一样简洁的解法——读入时打标记,输出时扫一遍标记:

#include <stdio.h> #include <string.h> #define MAXV 1005 int inA[MAXV], inB[MAXV]; int main() { int n, m, v; memset(inA, 0, sizeof(inA)); memset(inB, 0, sizeof(inB)); scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &v); inA[v] = 1; } scanf("%d", &m); for (int i = 0; i < m; i++) { scanf("%d", &v); inB[v] = 1; } int first = 1; for (v = 1; v < MAXV; v++) if (inA[v] && inB[v]) { if (!first) printf(" "); printf("%d", v); first = 0; } printf("\n"); first = 1; for (v = 1; v < MAXV; v++) if (inA[v] || inB[v]) { if (!first) printf(" "); printf("%d", v); first = 0; } printf("\n"); first = 1; for (v = 1; v < MAXV; v++) if (inA[v] && !inB[v]) { if (!first) printf(" "); printf("%d", v); first = 0; } printf("\n"); return 0; }

逻辑说明:位图把“集合是否包含某元素”编码成数组下标,inA[v] = 1等价于 v ∈ A。交集判断inA[v] && inB[v],并集判断inA[v] || inB[v],差集判断inA[v] && !inB[v],三个循环完全对称。数组是全局变量,自动初始化为 0,memset 其实可以不写,但写上能让读代码的人一眼明白意图。

参数说明:MAXV 要覆盖元素上界,题目说“不超过 1000”时设 1005 留余量。这个做法的复杂度是 O(MAXV),跟集合实际大小无关。缺点是元素范围到 10^9 就没法开数组,此时回到排序数组。还有一点,术语上别把位图叫成“哈希表”——位图直接用下标映射值域,哈希表要处理冲突,实验报告里写错术语容易被答辩老师追问。

4.3 链表法:模板强制要求时,指针边界盯紧这三处

如果实验提纲明确规定用单链表实现,你必须处理指针操作。核心套路是“有序合并”:先把两个链表各自排序,或边插入边保持有序,然后用两个遍历指针扫描。下面是插入式去重的骨架:

struct Node { int val; struct Node *next; }; struct Node* insert_sorted(struct Node *head, int v) { struct Node *node = (struct Node*)malloc(sizeof(struct Node)); node->val = v; if (head == NULL || v < head->val) { node->next = head; return node; } struct Node *cur = head; while (cur->next && cur->next->val < v) cur = cur->next; if (cur->next && cur->next->val == v) { // 已存在,去重 free(node); return head; } node->next = cur->next; cur->next = node; return head; }

逻辑说明:插入式去重是最稳妥的链表写法——每来一个新元素,按值查找到插入位置;如果下一个节点的值恰好等于新值,说明重复,释放节点直接返回。while 循环的条件顺序是先判断cur->next再访问cur->next->val,短路顺序反了就会对空指针取字段,这是链表题最常见的段错误来源。

参数说明:链表的三个关键指针——头指针 head、遍历指针 cur、临时节点 node——命名统一,提交实验报告时自己能看懂。链表版交并差的三个运算其实可以归并式扫描:交集条件是元素同时出现在两条链中;并集和差集套路与数组双指针完全一致,只是把下标推进换成cur = cur->next。

4.4 main 函数的读写细节:个数前缀、空集行与行尾空格

三个实现的 main 有几处细节直接影响判分,放在一起说。

读入:严格按“先个数、再元素”的顺序调用 scanf。个数那行可能和其他数据在同一行,scanf 以空白符为分隔,不区分空格和换行,所以不用关心具体在第几行。

空集输出:交集或差集没有元素时,循环一次都不进,printf("\n")仍然执行,保证输出是空行而不是缺行。判分脚本按行号对答案,少一行也会挂。

行尾空格:代码用first标志控制“元素之间才输出空格”,整行不会以空格结尾。很多判分系统用 token 比较不受影响,但万一用逐字符 diff,行尾空格就是零票否决。

差集方向:main 里调用do_difference(a, n, b, m)输出的是 A\B;如果同时要 B\A,就改成do_difference(b, m, a, n)再打一次,别让函数内部的 i/j 搞乱顺序。

5. 避坑:实验一集合交并差.zip 最常翻车的 5 个现场

5.1 解压后源文件注释乱码,模板代码不敢动

现象:main.c 打开后中文注释全是乱码,结构看懂了但不敢确认哪几行是题目写好的、哪几行要自己补。

原因:zip 打包时按 GBK 编码存储了文件名和文件内容,某些解压工具默认用 UTF-8 解出内容,编码不匹配。

解决:Windows 下直接右键解压,或改用 7-Zip 的“用系统默认编码解压”;Linux 下执行unzip -O GBK 实验一集合交并差.zip。VS Code 打开乱码文件后,点右下角编码、选“GBK 重新加载”。注意文件内容用 GBK 存储对编译器没有影响,gcc 按本地编码读源文件,Windows 默认 GBK 是对的,不用强转 UTF-8。

5.2 忘记去重,输出多出重复数字

现象:输入4 / 1 2 2 3,交集输出1 2 2。

原因:数组存的是原始输入,没有去重;双指针在有序数组里把重复的 2 当成两个元素。

解决:在 qsort 之后统一跑一遍 unique,或者在链表的 insert 函数里发现相等就 free。无论哪种,输出前集合必须严格互异。这个错是评分脚本里扣分重点,通常比排序错误还冤。

5.3 在线测评平台无输出,本地却跑得好好的

现象:本地./main < input.txt一切都正常,交到测评系统直接编译通过但 0 分、提示无输出。

原因:代码里写了system("pause")、getchar()这类调试停驻语句。测评环境把输入重定向成文件后,getchar 读不到交互按键,system("pause") 调用失败,程序异常退出。

解决:提交前全局搜索 system、pause、getch、sleep 这几个字眼,全删。也不要自己定义一个阻塞读入的循环去“等输入”。评测系统对返回值没有硬性要求,关键是程序不能在读输出前卡住。

5.4 把外层文件夹整个压缩,提交后找不到 main.c

现象:测评日志显示编译失败,提示不能打开源文件 main.c;或者本地解压后路径里多了一层同名文件夹。

原因:压缩时对着「实验一集合交并差」文件夹右键,生成的是包含文件夹本身的 zip,解压后结构是实验一集合交并差/main.c,而评分脚本期望在解压根目录直接看到main.c。

解决:压缩前双击进入该文件夹,全选内部所有文件和目录再压缩;把 zip 解压到临时目录验证,如果第一层不是 main.c 而是文件夹,说明压错了。

5.5 差集方向和输出格式的隐藏规则

现象:程序逻辑正确,手算也对,就是六七十分不知差在哪。

原因:很可能是行尾空格、空集该输出什么、还是差集方向。指导书没写空集输出什么、没写清是 A\B 还是 B\A 时,很多同学直接按自己的习惯写。

解决:把指导书里“输出”段落截图出来一行行对照,尤其注意样例输出里的空白行。判分脚本的参考答案是固定字符串,空集如果需要输出none而你输出空行,直接整题 mismatch。遇到题目描述含糊,最稳的一招是从模板 main.c 的注释里找答案——模板作者会把约定的输出格式写在注释里。

6. 交并差实验的进阶验证:从跑通到有说服力

6.1 用随机测试生成器构造大样本

写一个 gen_test.c,生成 0~7 个元素(随机个数能自然覆盖空集场景),元素范围 0~9,故意允许重复。把生成器输出重定向到 input.txt,你的程序和暴力程序分别读它并产生两份输出,用diff -w忽略空白差异做比较。暴力程序直接按集合定义写:每个 A 元素判断是否在 B 中,对 10 以内的小数据一定是正确答案。这个动作跑 200 次,比任何手算都让人安心。

#include <stdio.h> #include <stdlib.h> #include <time.h> int main() { srand(time(NULL)); int n = rand() % 8; // 0 到 7,有意构造空集 int m = rand() % 8; printf("%d\n", n); for (int i = 0; i < n; i++) printf("%d ", rand() % 10); printf("\n%d\n", m); for (int i = 0; i < m; i++) printf("%d ", rand() % 10); printf("\n"); return 0; }

逻辑说明:srand(time(NULL))让每次运行的随机序列不同,rand() % 8产生 0~7 的个数,rand() % 10产生 0~9 的元素值。故意不避免重复,就是为了测去重逻辑。

6.2 快速验证脚本与报告里的复杂度分析

检查输出前再想一下方向:A \ B 和 B \ A 都打印出来,和手算结果比对。实验报告里如果能写“数组法时间复杂度 O(n log n),空间 O(n);位图法时间复杂度 O(V)、空间 O(V),其中 V 是元素值域”,会比只贴代码更有说服力。

最后一步善后:把 main.c、报告 PDF 和一个 README(说明编译方式)一同放进压缩包,解压后第一层没有文件夹层级,用unzip -t或右键“测试压缩文件”验证完整性再提交。我第一次带实验课改作业时,有三分之一同学的压缩包在测试环节就解不出正确目录,从那以后我养成了每次提交前都重新解压一次、然后立刻运行样例的习惯。希望这份笔记能帮你省下这半个晚上的折腾。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询