前两天一个学弟在群里发了张截图,SDUT 在线评测系统的判题界面,题号是 "SDUT-实验九-25 A-B",状态 WA。他说自己在本机上测试所有样例都没问题,一交上去就是红的,整个人已经快崩溃了。我看了一眼题名,瞬间就回忆起当年自己在这道题上磨到深夜的经历,所以这篇想把这个题彻底讲清楚。
这个题号看起来简单,就是集合 A 减集合 B,但它在实验九这个时间点出现,意味着你已经被默认掌握了数组、排序和基本的输入输出,而这些恰恰是 OJ 上最容易翻车的地方。题目本身不复杂,真正的分水岭在于:你有没有把"读入、判断、去重、排序、输出格式"这五件事全部处理干净。这篇文章适合刚学完数组排序、正在做实验九的同学,也适合准备期末机试、想拿这套题当复习清单的人。我会把三种解法、WA 的完整排查链路、以及题目数据范围变化后的升级方向都捋一遍。
1. 上 OJ 之前先读题:这道"集合差"到底在问什么
1.1 从题号和实验进度反推题目隐藏约定
"实验九"这个编号本身就是一个信息。到了第九次实验,课程大概率已经讲完了一维数组、排序算法、函数的封装调用,所以老师不会只考"你会不会用 if 判断两个数相等",而是会把这几个知识点的基本功串在一起考。在 SDUT OJ 上,这类带题号的小题通常描述都很精简,一般长这样:
- 输入:第一行两个整数 n 和 m,表示集合 A 有 n 个元素,集合 B 有 m 个元素;
- 第二行输入 n 个整数,表示集合 A;
- 第三行输入 m 个整数,表示集合 B;
- 输出:将 A-B 中的所有元素按升序输出,每个元素之间用一个空格隔开;
- 如果结果为空,输出 "NULL"。
这里有几个不会写进标题、但会在测试数据里体现的隐含约定。第一,元素是整数,而且题目会给一个取值范围,这个范围决定你能不能开数组做哈希。第二,A 和 B 内部可能包含重复值,也可能默认不含,稳妥做法是当成"可能重复"来处理。第三,输出必须升序,哪怕你的结果集合元素完全正确,只要顺序不对,Judge 一样判 WA。
我个人的建议是,从拿到题目的第一分钟起,就先把输出格式抄在草稿纸上,比如"升序、空格分隔、空集输出 NULL、行尾不能有多余空格"。别高估自己的记忆力,OJ 判题非常死板,一个多余空格就是一次 WA,这种分丢得最冤。
1.2 集合差运算的数学定义和 OJ 考法差异
A-B 的数学定义很干净:{x | x 属于 A 且 x 不属于 B},也就是 A 里有而 B 里没有的元素。但 OJ 上的考法会在数学定义上叠两层东西:去重和排序。严格说,集合本身是"无序且不重复"的,但计算机的线性存储没法天然体现集合,所以题目要求用排序后的数组来表示集合,这其实是很多初学者没有意识到的问题。
所以这道题真正考的是三件事:
- 读入:存在多组测试数据,必须用 while 循环读到文件末尾(EOF);
- 判断:对 A 的每一个元素,判断它是否存在于 B 中;
- 整理:对结果去重、排序,并按照规定格式输出。
把这三件事拆开,A-B 就不是一个难想的算法题,而是一个流程题。后面三种解法本质上只是在"判断"这一步做了不同的取舍,其余步骤大同小异。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种求解路径与选型逻辑:暴力、排序归并、哈希标记
2.1 暴力双重循环:先保证正确,再谈效率
拿到这道题,你脑子里闪过的第一个方案往往是暴力。对 A 中的每个元素,到 B 里从头到尾查一遍,没找到就放进结果数组。这个思路非常直白,伪代码写出来就是两层 for 循环加一个标志变量。
暴力解法的成本非常透明:
- 时间复杂度 O(n×m),最坏情况下如果 n 和 m 都接近 10^4,运算量就是 10^8 量级,在 OJ 上大概率会超时;
- 空间复杂度 O(n),只需要一个数组存结果;
- 代码量最小,核心逻辑几乎不可能写错,调试时可以用 printf 随便打印中间状态。
我的建议是,如果你对题目整体流程还不熟,先别想着用什么高级方法,直接暴力写一版,交上去拿一个 AC 建立信心,或者至少拿到一个"输出答案正确但超时"的反馈,这样能帮助你确认前面读入、去重、输出这三步是没问题的。把复杂度和格式问题分开排查,效率要高得多。
当然,暴力不是终点。如果数据范围稍微大一点,比如 n、m 到 5000,双重循环就跑不动了,这时候就需要第二种思路。
2.2 排序+线性归并:综合最优的扎实做法
优化"判断某个 A 元素是否在 B 中"这一步,最自然的想法是:如果 B 是有序的,查找就可以用二分,但更聪明的做法是让 A 也有序,然后两个有序序列像拉链一样合并比较。这就是双指针线性归并。
完整流程分三步:
- 分别对 A、B 做升序排序;
- 去重:因为排完序后重复元素必然相邻,输出时跳过重复即可,不需要额外开数组;
- 双指针从两个数组头部开始移动,比较当前 A[i] 和 B[j] 的大小关系,分三种情况处理:
- A[i] < B[j]:A[i] 不可能等于 B[j] 和后面任何元素,直接放入结果,i 前进;
- A[i] == B[j]:A[i] 在 B 里出现了,跳过它,i 前进;
- A[i] > B[j]:B[j] 太小了,不可能匹配上当前 A 元素,j 前进。
这个算法的时间复杂度是排序的 O(nlogn + mlogm) 加上线性扫描的 O(n+m),总共 O(nlogn + mlogm)。相比暴力,它不要求数据分布有什么特殊性质,任何整数都适用,而且当 n、m 到 10^5 时依然能跑得非常稳,所以这是最值得掌握的做法。
这里有个容易被问住的问题:为什么不排序 A,只排序 B,然后对每个 A 元素做二分查找?二分单次是 O(logm),n 个元素就是 O(nlogm),再加上排序 B 的 O(mlogm),理论上也可以。但双指针做法的优势在于,一次线性扫描就把所有判断做完了,不需要反复在 B 上跳跃寻址,缓存友好性更好,而且逻辑一旦习惯了,比二分更不容易写错。实验九这个阶段,双指针也是为后面归并排序打基础,老师是很愿意看到这种思路的。
2.3 布尔数组哈希:值域受限时的最快方案
第三种方案依赖一个前提:题目给出的元素值域足够小且连续。比如题干说元素范围在 -1000 到 1000,或者 0 到 10000,那就可以开一个足够大的 bool 数组当哈希表用。
做法分两步:
- 遍历 B,把所有出现在 B 中的值对应的标记位设为 true;
- 遍历 A,如果某个元素对应的标记位是 false,说明它不在 B 中,收集起来。
时间复杂度只有 O(n+m),空间复杂度 O(值域),这是三种方案里最快的。但它有两个非常隐蔽的坑。
第一个坑是负数下标。C 语言数组下标只能是非负整数,如果值域包含负数,必须统一平移。比如值域是 [-1000, 1000],就开一个长度为 2001 的数组,把 a[i]+1000 作为存储下标。平移这一步必须对 A 和 B 都做,漏掉任何一个都会导致结果错乱。我见过不少同学问"为什么用哈希反而 WA",排查到最后都是这里出了漏子。
第二个坑是值域太大。如果题目说元素范围是 [-10^9, 10^9],你不可能开一个 2×10^9 长度的数组,内存直接爆掉。所以哈希标记法只能用在值域受限的情况,一旦值域失去控制,就得退回到排序归并。
三套方案的选型逻辑总结起来很简单:数据范围小而连续,优先哈希标记;范围大或未知,用排序归并;只想快速验证思路,暴力兜底。下面这张表可以把成本看得更清楚。
| 方案 | 时间复杂度 | 空间复杂度 | 适用场景 | 主要风险 |
|---|---|---|---|---|
| 暴力双重循环 | O(n×m) | O(n) | n、m 很小,快速验证 | 数据一大就超时 |
| 排序+双指针 | O(nlogn + mlogm) | O(n + m) | 通用场景,范围大 | 排序比较器易写错 |
| 布尔数组哈希 | O(n + m) | O(值域) | 值域小且连续 | 负数下标,值域爆炸 |
3. C 语言实现的分水岭:排序、去重、输出格式三件套
3.1 为什么排序之后去重更简单
大部分同学第一次写这道题的代码,都会下意识准备一个新数组,把符合条件的元素"拷贝"过去。这个思路没错,但拷贝的过程中还得先查一遍结果数组里有没有重复,反而把问题搞复杂了。
其实在排序之后,去重这件事就变得非常廉价。因为所有重复元素必然相邻,你只要在收集结果或者直接输出的时候判断"当前元素和上一个元素是否相等"就行了。具体来说,双指针归并的过程中,如果 A[i] 满足条件要放入结果,先看看结果数组的最后一个元素是不是和 A[i] 相等,相等就跳过。这种去重既不需要额外数组,也不需要嵌套循环,是效率最高的做法。
很多题解不会专门讲这一笔,但它恰恰是代码能不能过"集合中可能含重复元素"这类测试数据的关键。
3.2 输出格式三件套:升序、空格、NULL
输出这一关是 OJ 题重灾区,SDUT 的严格模式尤其喜欢抓这种问题。要点可以归纳成三句话:
- 升序输出,所以你要么先排序再输出,要么收集完结果手动排序;
- 元素之间用一个空格隔开,行尾不能有空格;
- 结果为空时输出 NULL,注意大小写,换行不要丢。
行尾空格这个问题,本地终端上几乎看不出区别,但 OJ 的判题器是逐字符比对,多一个空格就是 WA。如果你用 for 循环写成"先输出元素,再输出空格",最后一个元素后面就会多出一个空格,这种提交基本必死。
一个我非常推荐的写法是:用变量 flag(或者 count)记录已经输出了几个元素,只有 flag 不为 0 时才先输出一个空格,再输出当前元素。这样能保证第一个元素前没有空格,最后一个元素后也没有空格,从根本上杜绝行尾空格问题。
3.3 参考实现:排序+双指针,逻辑完整可提交
下面这份是我当年最终 AC 的写法,核心是排序加双指针。注释里标了容易翻车的几个点,建议照着敲一遍再改成自己的风格。
c复制#include <stdio.h>
#include <stdlib.h>
int cmp(const void *a, const void *b) {
// 注意:这里绝不能写 return *(int*)a - *(int*)b;
// 当两个 int 差值超过 int 范围时会溢出,排序结果不稳定
int x = *(const int *)a;
int y = *(const int *)b;
if (x < y) return -1;
if (x > y) return 1;
return 0;
}
int main() {
int n, m;
// 多组输入,读到 EOF 为止。漏掉这行,只处理一组数据,必错
while (scanf("%d %d", &n, &m) != EOF) {
int a[1005], b[1005];
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
for (int i = 0; i < m; i++) scanf("%d", &b[i]);
qsort(a, n, sizeof(int), cmp);
qsort(b, m, sizeof(int), cmp);
int res[1005], cnt = 0;
int i = 0, j = 0;
while (i < n) {
// 跳过 A 中重复元素(排序后相邻重复)
if (i > 0 && a[i] == a[i - 1]) {
i++;
continue;
}
// 让 j 移动到不小于 a[i] 的位置
while (j < m && b[j] < a[i]) j++;
// 如果 j 越界,说明 a[i] 不在 B 中
if (j >= m || b[j] != a[i]) {
// 再看结果数组末尾有没有重复,没有才加入
if (cnt == 0 || res[cnt - 1] != a[i]) {
res[cnt++] = a[i];
}
}
i++;
}
if (cnt == 0) {
printf("NULL\n");
} else {
for (int k = 0; k < cnt; k++) {
if (k > 0) printf(" ");
printf("%d", res[k]);
}
printf("\n");
}
}
return 0;
}
这段代码有几个值得注意的设计。排序用 qsort 而不是手写冒泡,一是代码短,二是 qsort 是经过优化的快排,数据量大了也比手写冒泡稳。比较器函数用了逐分支比较,规避了整型溢出问题。结果数组 res 的大小我开了和 a 一样大,这是上限,因为最极端的情况下 A 的所有元素都不在 B 里,结果最多就是 n 个元素,不会越界。
如果题目值域小,想用哈希标记法,核心代码更短:
c复制bool mark[2005] = {false};
// 假设元素范围是 [-1000, 1000],平移 1000 作为下标
for (int i = 0; i < m; i++) {
int x;
scanf("%d", &x);
mark[x + 1000] = true;
}
for (int i = 0; i < n; i++) {
int x;
scanf("%d", &x);
if (!mark[x + 1000]) {
printf("%d ", x);
}
}
哈希版的速度优势确实明显,但前提是你必须从严验证值域,而且别忘了平移负数下标。这两条少了任何一条,代码跑出来的结果都是错的。
4. 在 OJ 上连续 WA 的排查链路:我把每次提交都当成一次实验
4.1 第一次 WA:没有排序,直接按 A 原顺序输出
我第一次提交换来的就是第一份红色判题结果。当时想着,A-B 就是把 A 里存在于 B 的元素删掉,剩下的不还是 A 的顺序吗,直接按输入顺序输出有什么问题?样例确实过了,因为样例的 A 恰好是升序给出的。但判题数据里 A 的顺序是随机的,比如 A = {5, 2, 8},B = {3},正确输出是升序的 2 5 8,我输出的却是 5 2 8。
那次 WA 给我上了一课:OJ 的样例只是示意,不是全部测试数据。你必须在头脑里自己构造几个特殊用例,而不是依赖题目给的例子。从那以后我养成一个习惯:任何涉及排序输出的题,一定先问自己一句"我依赖的这个人肉眼可见的顺序,是不是题目要求的顺序"。
4.2 第二次 WA:qsort 比较器写成 return a-b
加上排序之后我以为稳了,结果提交又是 WA。这次问题更隐蔽,出在比较器函数里。我第一次写的是 return (int)a - (int)b,这在多数情况下没问题,但当两个元素差值超过 int 的表示范围时,减法会溢出,排序结果就不是真正的升序,而是一个莫名其妙但看起来很像升序的顺序。判题数据里如果恰好有一组大整数,直接就 WA。
这个问题非常恶心,因为本地测试基本不会触发溢出,只有遇到极端数据才冒出来。我的建议是切掉所有依赖减法的写法,老老实实写分支比较:
c复制int cmp(const void *a, const void *b) {
int x = *(const int *)a;
int y = *(const int *)b;
return (x > y) - (x < y);
}
这行代码等价于三分支,但更紧凑。不要用减法那一套,一劳永逸。
4.3 第三次 WA:行尾空格与多组输入的连环坑
在我把排序和比较器都修好后,本地已经感觉"绝对没问题了",再交还是 WA。这让我十分崩溃,开始从头逐行查代码。最后锁定在输出循环上,当时我写的是:
c复制for (int k = 0; k < cnt; k++) {
printf("%d ", res[k]);
}
printf("\n");
这种写法最后一个元素后面会跟着一个空格。OJ 判题器比对时,期望输出是 "2 5 8",我提交的是 "2 5 8 ",肉眼几乎看不出来,但判题器不认。改成"先判断是否是第一个元素,不是则在前面加空格"的写法后,问题彻底解决。
另外还有一个多组输入引发的连带问题。有些同学的 while (scanf(...) != EOF) 确实写了,但 scanf 的返回值用的不对,比如写成 != 2,导致读入逻辑在某些空行场景下提前跳出,整体结果自然就乱了。多组读入的标准做法就是那句 != EOF,不要自己发明别的判断方式。
4.4 排查过程中我总结出的调试方法论
这里分享一套适用于 OJ 题 WA 排查的通用链路,不管是这道 A-B 还是以后的题目都能用到。
第一步,造特殊数据。至少覆盖这六类:空集合(n=0 或 m=0)、A 全部包含于 B、A 与 B 完全一样、A 和 B 都有重复元素、A 包含负数、B 包含 A 中没有的较大数。每一类都手动算出预期输出,再与实际输出对比。
第二步,隔离变量。如果你不确定是排序错了还是输出错了,就先写一段临时代码,把排序后的 A 和 B 全部 printf 出来,检查排序是否满足升序。排序确认无误后,再把结果数组的全部内容打印出来逐位核对。这个过程有点像做实验时控制变量,能帮你快速圈定错误属于哪一个环节。
第三步,检查格式。把程序输出重定向到一个文本文件里,用编辑器开启"显示空白字符"功能,看看行尾有没有多余空格,换行符是否统一。这个检查 30 秒就能完成,却能在提交前拦截一大批 WA。
调试 OJ 题最忌讳的是"看着样例过了就交"。样例过只不过说明你的程序在演示数据上表现良好,和所有合法输入下表现正确是两码事。养成自己构造测试数据的习惯,比任何调试技巧都重要。
5. 从题号 25 往后看:当集合不再"简单"时的解法升级路线
5.1 数据范围放大后,哈希优先让位于排序归并
把这道题的约束条件改一改,解题策略就会明显变化。如果题目说元素范围扩大到了 10^9,哈希标记法直接作废,你不可能开一个长度为 10^9 的 bool 数组去标记,内存超出了 OJ 的限制。此时双指针归并的优势就体现出来,它不依赖值域,只依赖元素个数,即使 n、m 到 10^5 也毫不吃力。
如果 n、m 进一步放大到 10^6,排序本身成了瓶颈,这时候还得在排序算法上做文章。C 标准库的 qsort 在绝大多数场景下够用,但如果平台对时间限制非常苛刻,可以考虑手写一个更快且稳定的排序,或者在数据特性允许的情况下尝试基数排序的思想。这里不展开,但你需要意识到,同一个 A-B 逻辑,在不同数据规模下对排序实现的要求完全不同。
5.2 元素类型从整数换成字符串和对象
假设题目要求计算两个字符串集合的差集,比如文件列表、关键词列表,那排序比较器就不能再用整数减法,而是要用 strcmp。字符串数组的排序本质上比较的是指针,必须写一个解引用后调用 strcmp 的比较器:
c复制int cmp_str(const void *a, const void *b) {
return strcmp(*(const char **)a, *(const char **)b);
}
如果集合元素是浮点数,比较时还得考虑精度问题,不能直接用等号判断是否相等,而是要看差的绝对值是否小于某个阈值。如果集合元素是结构体,比如你需要按某个键去重、另一个键输出,那排序比较器要同时处理这两个键的优先级。所有这些变化都在考验你对"排序规则"的理解深度,而不是题目名字从 A-B 变成了 C-D。
5.3 去重逻辑的复用:交集、并集、对称差集
做完 A-B 这道题之后,你会发现交集、并集、对称差集在代码结构上高度相似。核心都是"两个有序序列双指针扫描",只是每个分支的处理动作不同:
- 交集:当 A[i] == B[j] 时记录元素,两边同时前进;
- 并集:谁小先输出谁,相等时只输出一次,两边同时前进;
- 对称差集:交集的对立面,只要两边不相等,就更小的那个。
实验九的后续题目里有很大概率会冒出来一个"求交集"或者"求并集",到时候只要你把 A-B 的双指针框架吃透了,改两三行就能提交通过。这也是为什么我不建议用暴力蒙过 A-B 就算完事,双指针这套思维方式在后面的课程里会反复用到,现在多花一晚上把它弄明白,后面能省出好几个晚上。
聊到这儿,回到最开头学弟的那个问题:"到底还有什么边界我没考虑到。"其实每个 WA 的背后,都是题目里某个约定没有被识别出来:顺序、去重、格式、多组输入,每一个都在判题数据里等着你。这道题我做完之后最大的体会是,OJ 上任何一个看似简单的题号,都不该被当成"随便提交一下"的存在。把每个小实验题当作一次完整的工程演练来对待,这种习惯带给你的收益,会远超这道题本身那 5 分经验值。
