做到某高校SSE课程平台的C语言编程练习第28题时,很多人都是先愣一下:这不就是数组去重吗?两层循环一扫不就完事了。结果提交上去,要么超时,要么段错误,要么报一个看起来莫名其妙的Presentation Error。这道题本身并不复杂,但它真正卡人的地方从来不在语法,而在于你对数据规模、边界条件和输出格式的判断。这篇文章就把这道题拆开讲透,给出能直接用的代码,也记录我在这题上连续翻车的全过程。
1. 先看清楚第28题到底在考什么
1.1 题目原型与常见变体
SSE平台的这套C语言练习是一题一题递进式的,第28题一般落在"一维数组"章节的中后段,属于那种"看着容易,想拿满分不容易"的题目。我在本地把它的原型归纳成下面这个描述:
输入一个正整数 n,随后输入 n 个整数。请删除数组中重复出现的元素,只保留每个元素第一次出现的那一份,最后输出剩余元素的个数,以及按原顺序排列的去重后数组。
输入和输出通常长这样:
输入: 6 1 2 3 2 4 1 输出: 4 1 2 3 4样例里,2 出现了两次,1 也出现了两次,去掉重复后剩下四个数字,顺序保持不变。当然,不同版本的平台会把题目包装成"成绩统计""名单过滤""整理书架"这类场景,但核心逻辑完全相同:判断某个数字在之前是否出现过,没出现过就保留,出现过就跳过。
这道题之所以被安排在数组章节的靠后位置,是因为它不是单纯考你会不会写for循环,而是考你会不会根据数据规模选择合理算法。很多同学在这里第一次意识到"我写的代码能跑,但不代表能通过评测",这是后面所有练习都会遇到的坎。
1.2 这道题隐藏的两个考点
第一个考点是"去重逻辑"本身。你要记录"哪些数已经见过了",C语言里没有现成的集合容器,不像其他语言能直接往 HashSet 里塞,必须自己想办法。常见的做法有三种:用数组记录标志、用哈希表模拟集合、先排序再相邻比较。每种做法有各自的适用条件。
第二个考点是"边界条件的稳定输出"。题目数据范围通常会给到 n 比较大,比如 1≤n≤100000,甚至更大。隐藏测试点里会出现 n=1、所有数字全部相同、所有数字全部不同、数字含负数、数字含0、数字非常大等极端情况。你如果只在样例上跑通就提交,很容易在这些测试点上翻车。
这里先给一个最基础的输入输出骨架,后面所有方案都建立在这个结构上:
#include <stdio.h> int main(void) { int n; scanf("%d", &n); // 读取 n 个整数到数组 // 去重处理 // 输出去重后的个数和内容 return 0; }题目数据范围不同,你填进这三个步骤的方式就完全不同。
2. 为什么说"能跑对"和"能过评测"是两回事
2.1 双循环解法:5分钟写完,但别急着提交
初学者最容易想到的解法是:开一个结果数组,遍历原始数组时,拿着当前元素去结果数组里从头到尾查一遍,找不到就加入。
#include <stdio.h> #define MAXN 100005 int main(void) { int n; int a[MAXN]; int res[MAXN]; int cnt = 0; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &a[i]); } for (int i = 0; i < n; i++) { int flag = 0; for (int j = 0; j < cnt; j++) { if (res[j] == a[i]) { flag = 1; break; } } if (!flag) { res[cnt] = a[i]; cnt++; } } printf("%d\n", cnt); for (int i = 0; i < cnt; i++) { printf("%d", res[i]); if (i < cnt - 1) { printf(" "); } } printf("\n"); return 0; }这段代码逻辑没错,样例也能过。但它的内层循环会随着结果数组变大而变长。最坏情况是 n 个元素全部不重复,那么比较次数是:
0 + 1 + 2 + ... + (n-1) = n * (n-1) / 2当 n=100000 时,这个值大约是 50 亿次比较。普通评测机一秒钟大概能执行 1 亿到 10 亿次简单操作,50 亿次比较意味着可能要跑几十秒。评测平台的单点时间限制通常是 1 秒或者 2 秒,所以这个写法在数据量大的时候必然超时。
2.2 数据规模决定了你会不会超时
很多初学者不明白,为什么题目明明一句话就能说清,非要限制 n 的范围。其实数据范围是出题人留给你的最重要提示。看到 n≤1000,两层循环没问题;看到 n≤100000,你就要警惕 O(n^2) 这个量级了。
我用一个表格来对比三种方案的适用性:
| 数据规模 | 双循环 O(n^2) | 标志数组 O(n) | 哈希表 O(n) |
|---|---|---|---|
| n≤100 | 可行 | 可行 | 可行 |
| n≤10000 | 勉强可行 | 推荐 | 推荐 |
| n≤100000 | 大概率超时 | 推荐 | 推荐 |
| 值域巨大且未知 | 不可行 | 内存不足 | 推荐 |
这里的重点不是背下这张表,而是养成一个习惯:拿到题先看数据范围,再决定算法。第28题如果给你 n≤100000,那它就是在暗示你,请用空间换时间,或者用哈希表。
2.3 一个简单的估算方法
我教初学者一个很实用的估算办法:拿到一道题,先看你算法的循环嵌套层数,再估算最坏情况下的操作次数,最后除以 1 亿,看看超过 1 秒没有。
比如双循环去重,操作量级是 n^2/2。n=100000,除以 1 亿,大约是 25 秒,肯定超时。如果换用哈希表,每次插入和查找的平均操作次数接近常数,总操作量是 n 级别,即 100000 次左右,除以 1 亿,连 0.01 秒都不到,稳稳通过。
这个估算方法不需要很精确,只要数量级不对,你就知道该换思路了。用仓库管理来类比:双循环去重就像每来一件新货就把整个仓库翻一遍;哈希表则像给每件货都贴好标签,来一件看一眼标签就知道有没有入库。货少的时候翻仓库也无所谓,货一多,差别就出来了。
3. 三道能直接抄的代码,和它们的适用范围
3.1 方案一:暴力双重循环,适合 n≤1000
上面的双循环代码已经可以直接使用。它适合数据量小、比赛时间紧张的时候快速拿分。还有一个额外的作用:它可以作为"正确性基准",用来和高效方案对拍。
对拍是什么意思?你写好一个高效方案后,用小数据量随机测,把高效方案和暴力方案的输出逐行比对。如果一致,说明高效方案大概率正确。这个技巧在后面的复杂题目里非常有用,第28题正好是练习对拍思路的好起点。
使用双循环时有一个容易被忽略的点:输出格式。很多平台严格要求行末没有多余空格,所以输出循环要写成:
for (int i = 0; i < cnt; i++) { printf("%d", res[i]); if (i < cnt - 1) { printf(" "); } }而不是在每个数字后都跟一个空格。
3.2 方案二:标志数组,适合值域已知且有限
如果题目告诉你数值在一个有限范围内,比如绝对值不超过500000,那么可以直接用一个数组记录"某个数是否出现过"。数组下标天然是"数本身",值是0或1。这里要注意负数下标问题,需要加偏移量。
#include <stdio.h> #include <string.h> #define MAXV 1000000 #define OFFSET 500000 #define MAXN 100005 int seen[MAXV + 5]; int res[MAXN]; int main(void) { int n, x, cnt = 0; scanf("%d", &n); memset(seen, 0, sizeof(seen)); for (int i = 0; i < n; i++) { scanf("%d", &x); if (x >= -OFFSET && x <= OFFSET) { int idx = x + OFFSET; if (!seen[idx]) { seen[idx] = 1; res[cnt++] = x; } } } printf("%d\n", cnt); for (int i = 0; i < cnt; i++) { printf("%d", res[i]); if (i < cnt - 1) { printf(" "); } } printf("\n"); return 0; }这个方案的时间复杂度是 O(n),非常快。代价是空间取决于值域大小。如果值域达到 10^9,数组就爆了。所以适用场景很明确:题目给了明确且可接受的数值范围时,标志数组是最简单的优化手段。
3.3 方案三:开放寻址哈希表,适合没有范围限制的大数据
很多题目不会给你一个温和的值域,它可能说"每个整数的绝对值不超过 10^9",这时标志数组就不可行了。C语言没有现成的 HashMap,需要自己实现一个简单版本。我推荐用开放寻址法,代码量不大,理解起来也不难。
#include <stdio.h> #define TABLE_SIZE 200003 #define MAXN 100005 typedef struct { int used; int val; } Bucket; Bucket table[TABLE_SIZE]; int res[MAXN]; unsigned int hash(int x) { return (unsigned int)x * 2654435761u % TABLE_SIZE; } // 返回 1 表示成功加入集合,返回 0 表示重复 int tryAdd(int x) { int h = hash(x); int start = h; while (table[h].used) { if (table[h].val == x) { return 0; } h = (h + 1) % TABLE_SIZE; if (h == start) { // 表已满,正常情况下不会发生 return -1; } } table[h].used = 1; table[h].val = x; return 1; } int main(void) { int n, x, cnt = 0; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &x); int ok = tryAdd(x); if (ok == 1) { res[cnt++] = x; } } printf("%d\n", cnt); for (int i = 0; i < cnt; i++) { printf("%d", res[i]); if (i < cnt - 1) { printf(" "); } } printf("\n"); return 0; }这里有两个关键设计。第一,表长取 200003,它是大于 200000 的一个质数,尽可能减少哈希冲突。第二,哈希函数里用了乘法取模,让分布更均匀。当某个桶被占用时,线性探测往后找空位,这就是"开放寻址"这个名字的由来。
这个方案的优点是通用性强,不依赖值域大小,平均时间复杂度 O(n)。缺点是代码量比前两个方案多,而且需要你理解哈希冲突的处理。平时练习时建议把三个方案都写一遍,真正用到时才能根据场景选对。
三种方案的核心差异我用一个表总结:
| 方案 | 时间复杂度 | 空间复杂度 | 适用场景 | 代码量 |
|---|---|---|---|---|
| 双循环 | O(n^2) | O(n) | n 小,快速拿分 | 少 |
| 标志数组 | O(n) | O(值域) | 值域明确且有限 | 中 |
| 开放寻址哈希 | 平均 O(n) | O(n) | 通用,值域巨大 | 多 |
4. 我在这题上提交了四次,三次都是边界条件
4.1 第一次提交:越界崩溃
我最初写的是双循环版本,数组开的是a[1000]。样例数据很小,跑起来没问题。我心想这题大概就这样了,直接提交,结果评测系统返回了一个运行时错误,本地复现时才发现,隐藏测试数据里有 n=10000,我的数组根本装不下。
这个问题特别典型:数组大小卡着样例数据来,而不是卡着题目的上限来。正确做法是看题目的 n 范围,然后用一个足够大的常量,比如:
#define MAXN 100005宁可多开一点,也不要卡着上限开。遇到 n=100000 时,a[100005]够用,a[100000]勉强,a[99999]就是越界。
4.2 第二次提交:格式错误
修好数组越界后,我再次提交,这次换成了格式错误。原因是我的输出写成:
printf("%d ", res[i]);每个数字后面都带一个空格,输出的最后也多了一个空格。平台是把你的输出和标准答案逐字符比较的,多一个空格、少一个换行都算错。
这个错误在本地几乎无感,因为人眼不会去在意末尾空格,但评测机会非常严格。从那以后我写输出循环都会记住这个模式:前 m-1 个数字后面加空格,最后一个数字后面只换行。
4.3 第三次提交:超时
修复格式后我以为稳了,结果超时了。我一开始怀疑是不是自己写了死循环,又检查了好几遍逻辑,完全没问题。后来在本地随机生成了 100000 个不重复数字跑了一下,发现程序要跑十几秒才结束,这才意识到问题不是逻辑,而是算法复杂度。
那一刻我才真正理解数据范围的意义:n=100000 时,双循环的 50 亿次比较就是致命的。于是我转向了标志数组,代码改成 O(n) 方案,本地跑大数据瞬间出结果。提交后通过了,但还没结束。
4.4 第四次:负数下标把标记数组搞崩了
标志数组第一次提交时,我把输入数字直接当成数组下标使用。样例里全是正数,跑得很欢。但隐藏数据里一旦出现负数,比如 -5,程序就会访问seen[-5],直接越界,严重一点会段错误。
解决方法是加偏移量。如果数字范围是 [-500000, 500000],就用seen[x + 500000]来记录。同时要加一个范围判断,防止 x 超出预期区间时访问到数组外。
这里也暴露了标志数组的另一个限制:一旦值域范围不清楚,偏移量你就不知道设多少。所以后来遇到数值范围宽泛的题目,我直接改用哈希表方案,不再纠结偏移量。
这个提交过程让我养成了一个习惯:提交前先在本地把边界数据全部测一遍。下面是我那段时间常用的测试用例清单:
| 测试场景 | 输入示例 | 期望结果 |
|---|---|---|
| n=1 | 1 / -7 | 输出 1 和 -7 |
| 全部相同 | 5 / 3 3 3 3 3 | 输出 1 和 3 |
| 全部不同 | 5 / 5 4 3 2 1 | 输出 5 和原序列 |
| 包含0 | 3 / 0 0 1 | 输出 2 和 0 1 |
| 包含负数 | 4 / -2 -2 1 -3 | 输出 3 和 -2 1 -3 |
| 大数据 | 100000 个随机数 | 快速结束,无越界 |
5. 把第28题的思路迁移到后面的题
5.1 去重逻辑是很多算法题的"地基"
第28题做完后,你会发现在后续的练习里,"判断某个元素之前是否出现过"这个需求反复出现。比如统计一段文本里有多少个不同单词,本质是字符串去重;比如判断一个有向图是否有环,需要标记哪些节点已经访问过;再比如二分查找题目里常用的"排序后去重",底层道理和这题一模一样。
这些题目的公共内核都是:设计一个高效的"存在性查询"结构。你在第28题里学的标志数组、哈希表,就是为了解决这个问题而准备的。不要只把这道题当成一次作业,它其实是后续很多复杂题目的小型模型。
5.2 给新手的三个练习建议
第一个建议:拿到任何题目,先看数据范围,再去想循环嵌套层数。如果 n 达到 10^5 量级,基本就要放弃 O(n^2) 思路,改为 O(n) 或 O(n log n)。
第二个建议:每道题先写暴力版本,再写优化版本,最后用小规模随机数据对拍。这个流程能帮你同时验证正确性和性能,比盯着代码找逻辑错误高效得多。
第三个建议:把边界测试用例整理成自己的模板。n=1、全部重复、全部不同、含负数、含最大值,这些都是高频隐藏测试点,每次提交前都跑一遍,能省下不少不必要的提交次数。
最后再分享一个小技巧:我后来做这类去重题,都会在本地写一个小 C 程序,随机生成各种极端数据,把暴力版和高效版同时跑一遍,对比输出是否一致。这个方法帮我避掉了后面至少五六次无意义的提交。第28题不是终点,但它确实是一个很好的起点,把它吃透,你后面做题的底气会完全不一样。