刷PTA的C语言习题时,我估计不少人都在“找出不是两个数组共有的元素”这道题上交过一血——辛辛苦苦写完,一提交,红色大字:段错误。我第一次也是这样,当时程序在本地跑得好好的,放进PTA评测机就崩了,连个像样的提示都不给。后面把代码翻来覆去看了好几遍,才发现问题出在一个特别不起眼的下标越界上。这篇文章就以这道题为引子,把段错误的常见成因、排查思路和这类数组题的写法一起聊透,帮你少走弯路。
这道题本身不算难,但它特别适合用来暴露C语言初学者对数组边界的模糊理解。题目要你从两个数组中找出“不是两者共有”的元素,输出时还要去掉重复项,保持原顺序。听起来很直接,真正动手写的时候,“用哪个数组的长度”“下标从哪开始”“结果数组会不会越界”这些问题一个接一个冒出来。文章后面会给出可AC的完整代码,也会把每一步为什么要这么写讲清楚。
1. 先看懂题目在问什么
1.1 题面到底让你干什么
这道题的标准描述是这样的:输入两行数据,第一行第一个整数是n,后面跟着n个整数,构成第一个数组;第二行第一个整数是m,后面跟着m个整数,构成第二个数组。要求输出所有“不是两个数组共有”的元素。
“不是共有”的含义要拆开看:如果一个元素只出现在第一个数组里,或者只出现在第二个数组里,那它就是要输出的;如果一个元素两个数组里都有,那就不输出。注意,这里不是简单的“取差集”或者“取并集再减交集”那种数学集合运算,因为题目要求输出顺序是:先按第一个数组中的出现顺序输出独有元素,再按第二个数组中的出现顺序输出独有元素,而且同一个值只能输出一次。
举个例子:第一个数组是 1 3 5 7,第二个数组是 3 5 8。两个数组共有的是3和5,单独属于第一个数组的是1和7,单独属于第二个数组的是8,所以最终输出是 1 7 8。
这种问题本质上是一种模式匹配:把一个数组中的每个元素拿去另一个数组里查一遍,判断“在不在”。说白了就是个嵌套循环查重的问题,但真正让新手翻车的不是“查不查得到”,而是“数组下标写到哪里去了”。
1.2 为什么这道题容易写出越界代码
C语言不像Java、Python那样会在数组越界时抛出异常,它连个警告都不给你。数组名在底层就是一块连续内存的首地址,a[i]会被编译器翻译成*(a + i),也就是说,你写a[100]时只要那块内存还在进程的地址空间里,程序就能“正常”执行,并不会当场报错。但如果你越界越得足够狠,踩到了栈的保护区或者不存在的内存页,操作系统就会直接终止进程,表现出来就是“段错误”。
放到这道题里,越界机会简直遍地都是。两个数组一个长n一个长m,很多同学写着写着就把n和m搞混:遍历第一个数组时用了m当边界,或者嵌套循环里内层i和j用错数组。还有一种情况是定义一个“结果数组”来存最终要输出的元素,结果用来控制下标的变量没初始化,或者没想清楚最多会存多少个元素,导致往结果数组里写的时候下标失控。这些行为在本地可能撞不上敏感内存,但在评测机特定的编译参数和内存布局下,就变成段错误了。
注意:PTA上常见的“Runtime Error”或者直接显示“段错误”,绝大多数不是算法思路错,而是内存访问越界、空指针解引用、递归栈溢出这类底层问题。这道题里,越界是头号嫌疑。
2. 段错误到底是怎么发生的
2.1 段错误的本质
段错误(Segmentation Fault)听起来很吓人,实际上原理并不复杂。操作系统会给每个进程分配一块虚拟地址空间,你的程序只能访问自己那部分内存区域。栈、堆、全局变量区、代码区,各有各的地址范围。当你试图读写一块不属于当前进程的地址,或者往只读区域里写数据时,CPU会触发一个内存保护异常,操作系统收到异常后就会给进程发SIGSEGV信号,默认处理方式是直接杀掉进程。
C语言本身不检查数组边界,这是“信任程序员”的设计,但也意味着所有边界责任都在写代码的人身上。数组下标从0开始,长度为n的数组,合法下标范围是0到n-1。a[n]在语法上是合法的表达式,它指向数组最后一个元素之后的位置,但如果你去读它或者写它,就是越界访问。很多时候越界访问没有立刻崩溃,是因为那块地址刚好还在进程栈里,比如踩到了另一个局部变量,结果就是“本地运行正常,PTA上莫名其妙崩”。
2.2 这道题里最常见的三种越界姿势
第一种,循环边界用错数组长度。伪代码大概是这样的:外层循环遍历第一个数组,内层循环遍历第二个数组,结果外层循环条件写成了i < m,内层写成了j < n。如果n和m恰好相等,程序能跑;一旦n和m差距拉大,数组下标就会访问到不属于该数组的位置。这种错误最容易出现在“复制粘贴”代码时,上头写着for(i=0; i<n; i++),下个循环复制过来改成遍历第二个数组,只改了数组名,没改长度。
第二种,结果数组下标失控。这题的输出需要去重,很多人会单独开一个数组res[]来装最终结果,然后有一个计数器cnt。如果cnt的初始值漏写,或者去重逻辑里没有正确维护cnt,就会出现往res[负数]或res[巨大值]写数据的操作。负数下标尤其可怕,res[-1]实际上是访问数组起始地址之前的内存,这种越界在栈上几乎必崩。
第三种,数组开小了。有人看到题目说n不超过20,就开int a[20],但数组下标是0到19,如果你用a[20]去读,就是越界。还有人在读入时先读n再读数组,结果循环条件写成了i <= n,多读了一个数。评测数据不跟你开玩笑,边界值就在那里等着你踩。稳妥的做法是把数组开得比上限大一些,比如上限是20就开25,上限是100就开105,多开几个元素不值钱,但能救你一命。
这里还顺带提一下“指针数组”和“数组指针”的问题。有些同学看到数组相关题目就条件反射想用指针,结果写出int *p[n]或者int (*p)[n]。这两种写法在C语言里含义完全不同:int *p[n]是“指针数组”,数组里存了n个指针;int (*p)[n]是“数组指针”,指向一个含n个int的数组。如果用它们来存普通整数数组的数据,很容易操作失误导致段错误。这道题用普通的一维数组就够了,不建议折腾指针。
3. 正确解法:从算法思路到可直接AC的代码
3.1 常规双数组标记思路
这道题最常见的思路是双重循环检查。对第一个数组里的每个元素a[i],拿它去第二个数组里找一遍,如果在第二个数组里找不到,说明它是“只属于第一个数组”的独有元素,可以放入结果;相反地,对第二个数组里的每个元素b[j],拿它去第一个数组里找一遍,找不到就放入结果。
但这里有个坑:去重。假设第一个数组是 1 2 1 3,第二个数组是 4 5。遍历第一个数组时,第一个1是独有元素,要输出;第二个1也是独有元素,但题目要求同一个值只能输出一次。所以每准备把元素放入结果数组之前,都要先检查它是不是已经在结果数组里出现过。如果出现过,就跳过。这本质上是在用一个小型的“线性查重”来保证输出不重复。
有人会想:为什么不先把数组排序,然后用集合差运算一次搞定?排序确实方便去重,但题目要求输出顺序必须保持元素在原始数组中的出现顺序。一排序,顺序全乱了,即使能算出正确的元素集合,输出顺序也不合法。所以在时间和空间都没压力的情况下,双循环加查重就是最贴合题目要求的解法。
3.2 完整可运行代码
下面这段代码在PTA上实测可以通过,注释写得比较细,可以直接对照理解。
#include <stdio.h> // 判断 value 是否在数组 arr 的前 len 个元素中出现 int inArray(int value, int arr[], int len) { for (int i = 0; i < len; i++) { if (arr[i] == value) { return 1; } } return 0; } int main() { int n, m; int a[25], b[25]; // 题目没给明确上限时,多开一点空间 int res[50]; // 结果数组最多存放 n+m 个元素,开50足够 int cnt = 0; // 结果数组中当前元素个数 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]); } // 找第一个数组中独有的元素 for (int i = 0; i < n; i++) { // 如果 a[i] 不在第二个数组中 if (!inArray(a[i], b, m)) { // 再检查 a[i] 是否已经在结果数组里 int duplicated = 0; for (int j = 0; j < cnt; j++) { if (res[j] == a[i]) { duplicated = 1; break; } } if (!duplicated) { res[cnt++] = a[i]; } } } // 找第二个数组中独有的元素 for (int i = 0; i < m; i++) { // 如果 b[i] 不在第一个数组中 if (!inArray(b[i], a, n)) { int duplicated = 0; for (int j = 0; j < cnt; j++) { if (res[j] == b[i]) { duplicated = 1; break; } } if (!duplicated) { res[cnt++] = b[i]; } } } // 输出结果,元素之间用空格隔开,最后一个元素后面没有空格 for (int i = 0; i < cnt; i++) { if (i > 0) { printf(" "); } printf("%d", res[i]); } printf("\n"); return 0; }3.3 代码中的关键细节:为什么这样写不会段错误
先看函数inArray。它接收参数时明确拿到了数组名和长度,循环内部只用i < len作为边界,这样无论外面传入的是a还是b,只要调用时长度参数写对,就不会越界。
这道题最容易出错的点是:找第一个数组独有元素时,内层查重要把res数组已有的cnt个元素都过一遍。注意res数组的下标范围是0到cnt-1,所以第二个查重循环的边界条件是j < cnt。如果你写成了j < n或者j < m,在cnt比n或m小的场景里不会出问题,但一旦cnt超过了n或m,就会访问到res数组还没有写入数据的“空位”甚至越界。
接下来是结果数组的长度问题。两个数组长度分别为n和m,理论上最极端的情况是两个数组完全没有交集,独有元素最多就是n+m个。所以res数组开成n+m大小完全够用。但题目没明确给数组长度上限时,我建议别开“刚好够”的大小,而是直接开一个看起来“浪费”的容量。这段代码里结果数组开50,对应的是n和m各不超过20的常见场景。如果实际题目上限更大,把数组大小相应调大即可。
输出部分也有个小细节:PTA对输出格式卡得很严,两个数之间要有一个空格,但最后一个数后面不能有多余空格。用if (i > 0) printf(" ")这种写法,可以保证空格只出现在两个元素之间,不会在末尾多出一个空格。很多答案错误的提交并不是计算结果错,而是输出格式差了一个空格,这个点要注意。
4. 段错误排查四步法
4.1 从上到下检查数组定义和输入
遇到段错误,第一步不是去翻算法,而是把代码从头到尾读一遍,重点看数组定义和scanf读取部分。数组定义时,长度是否足够覆盖题目可能出现的最大值?读入时,scanf的格式串和变量地址是否匹配?scanf("%d", &a[i])里有没有漏掉&?漏掉地址符的话,程序会把a[i]的值当成地址去写内存,轻则数据错乱,重则直接段错误。
这里还要回到题目的数据输入格式。第一行先读n,再读n个数;第二行先读m,再读m个数。不要拿着第一行的循环条件去读第二行的数据,否则数组里会混入错误的值。虽然这不一定直接导致段错误,但会让后续判断逻辑紊乱,甚至让查重循环访问到错误的位置。
4.2 检查循环边界
这是排查段错误的重中之重。把代码里所有的for循环条件都列出来,逐条确认边界变量的含义。比如for (int i = 0; i < n; i++)就意味着数组访问范围是0到n-1,循环体里出现的所有数组下标都必须落在这个范围里。
特别要关注嵌套循环里的i和j。外层循环控制第一个数组时,内层循环极容易误用外层变量作下标。比如内层循环遍历b数组,但写成了b[i]而不是b[j],当i超过m-1时,b[i]就越界了。这种错误在数据量小时不容易暴露,原因前面说过,越界后可能踩到不敏感的栈内存,程序还会“顽强”地跑完。
4.3 用printf定点排查
如果肉眼检查没看出问题,就在关键位置加打印语句。比如在每个循环入口打印当前遍历到的下标和被访问的数组元素值:
printf("i=%d, a[i]=%d, j=%d, b[j]=%d\n", i, a[i], j, b[j]);这样程序崩溃前最后一次打印的内容,会直接告诉你程序死在哪一次循环访问上。如果打印出来的下标值已经远超数组长度,那问题就很明显了。这招虽然原始,但在做题场景下非常高效,因为PTA的判题环境不会给你调试器,你只能靠打印语句“盲调”。
4.4 本地用gdb快速定位
如果在本地环境复现了段错误,可以用gdb直接看崩溃位置。编译时加上调试信息:
gcc -g -o test test.c gdb ./test进入gdb后输入run,程序崩溃时gdb会显示崩溃所在的行号和函数调用栈。比如它可能告诉你“test.c:45”,那一行的数组访问就是问题所在。看到行号后,回代码里检查那一行涉及的所有数组下标是否超过合法范围,基本上就能锁定问题。这个方法比printf更精准,不用反复加打印再删除,效率很高。
5. 常见问题速查表
5.1 PTA提交中的高频报错对照
在PTA上刷题,提交结果不是只有“段错误”一种。我把这类数组题常见的评测结果、典型原因和解决办法整理成了一张表,方便你对照排查。
| 评测结果 | 典型的代码问题 | 排查方向 |
|---|---|---|
| 段错误 / Runtime Error | 数组越界、空指针解引用、递归栈溢出 | 检查所有数组下标范围,重点看循环边界和结果数组计数变量 |
| 答案错误(Wrong Answer) | 去重逻辑缺失、判断共有元素的条件反了、输出顺序不对 | 用题目给的样例手动演算一遍,确认逻辑边界条件 |
| 格式错误(Presentation Error) | 行尾多了空格、缺少换行、用中文标点输出 | 输出时用flag控制空格,最后一个元素后不输出多余空格 |
| 运行超时(Time Limit Exceeded) | 循环写得过于低效,或存在死循环 | 检查while循环的条件是否有可能永远为真;确认数据规模下算法复杂度是否合理 |
| 编译错误(Compile Error) | 缺少头文件、变量名冲突、C89/C99标准不兼容 | 先确认本地编译通过,再检查数组定义方式是否兼容PTA的编译器 |
这个表格里,段错误和运行时错误排在最前面,因为它们在初学者遇到的报错里占比最高。如果你看到的结果不是“段错误”,而是满屏的“wrong answer”,那问题往往不在于内存访问,而是算法逻辑有偏差。
5.2 一个提高数组题目通过率的习惯
做了这么多数组题,我想跟你分享一个我自己的习惯:永远把数组长度写成“上限 + 5”,而不是“刚好够”。题目说n最大是20,我写int a[25],结果数组写int res[50],宁可浪费十几个int的空间,也不去赌自己不会多走一步。这个习惯帮我躲过了很多次“本地能跑,提交就崩”的尴尬。
再有就是,写for循环时强迫自己看一眼“这个下标是不是这个数组的长度”。养成肌肉记忆之后,很多线段错误会在写代码的时候就暴露,而不是等提交后被打回。最后提醒一句:调试数组题,先看边界,再看逻辑,最后才考虑换算法。沿着这个顺序走,段错误基本都藏不住。