
刷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(i0; in; 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]; // 结果数组最多存放 nm 个元素开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理论上最极端的情况是两个数组完全没有交集独有元素最多就是nm个。所以res数组开成nm大小完全够用。但题目没明确给数组长度上限时我建议别开“刚好够”的大小而是直接开一个看起来“浪费”的容量。这段代码里结果数组开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循环时强迫自己看一眼“这个下标是不是这个数组的长度”。养成肌肉记忆之后很多线段错误会在写代码的时候就暴露而不是等提交后被打回。最后提醒一句调试数组题先看边界再看逻辑最后才考虑换算法。沿着这个顺序走段错误基本都藏不住。