老实说,我自己第一次把何钦铭、颜晖《C语言程序设计(第四版)》第七章完整刷完时,最大的感受不是“数组原来这么简单”,而是“前面几章学到的东西,到这里终于开始变成真正的算法了”。第七章表面上只讲了一维数组、二维数组和字符数组,但从课后题和各类考试反馈来看,真正的分水岭就是“数组元素查找和交换”:顺序查找、二分法查找、交换变量、选择排序……每一个都是后面指针、字符串、文件读写等章节的基础。这篇文章我想以一个把第七章翻过很多遍、也踩过不少坑的人的身份,把数组元素查找和交换这部分讲透:不光讲代码怎么写,还讲为什么这么写、哪些地方最容易错、平时练习和考试喜欢怎么出题。如果你正在自学C语言,或者期末复习到数组这一章,这篇文章应该能帮你省下不少试错时间。
1. 第七章的位置:为什么“查找”和“交换”被放在一起讲
1.1 数组是C语言从“单点操作”转向“批量处理”的第一道门
在第七章之前,你写程序基本都是围绕一个变量、两个变量打转:输入一个数,判断奇偶;输入两个数,交换一下;输入三个数,找最大值。虽然也能跑,但你心里清楚,这种方式一碰到“批量数据”就会彻底崩掉。
举一个很现实的例子:要处理全班40个人的成绩,找出最高分。如果你还停留在前面的变量思维,那就得定义40个变量score1、score2、score3……然后写40次比较。先不说代码量大得吓人,光是把40个成绩读进去,就要写40条scanf,这根本不是正常程序员该干的事。
数组的出现,把这个问题一下子解开了:int scores[40] 定义一块连续的内存,配合循环,一次就能把40个数读进来,再配合循环,一次就能遍历完。C语言从这里开始从“单点操作”转向“批量处理”,而查找和交换,正是批量处理中最基础、最高频的两个动作。
这也是为什么第七章在整本教材里位置很关键。前面章节你学的是语法零件,到了数组这里,你开始用零件组装小工具,再往后学字符串、指针、链表,本质上都是在数组这套“连续存储”和“下标访问”的逻辑上做加法。第七章没学好,后面很容易越学越虚。
1.2 查找与交换:算法世界里最基本的两个“原子操作”
如果再往深处看,查找和交换这两个操作被放在同一章,完全是有意为之。它们就像算法世界里的两个原子操作:查找负责定位,交换负责重排,两者组合起来,就能构造出排序、去重、移动元素等一大堆更复杂的算法。
你可以用生活里的场景来理解:查字典,你要先定位到某个字在哪一页,这是查找;整理书架,你想把某本书放到更合适的位置,先抽出来再塞进去,这是交换。前者解决“东西在哪”的问题,后者解决“让顺序变得更好”的问题。第七章在讲完数组基本用法之后,专门用例题和习题反复训练这两个操作,就是为了让你形成一种条件反射:拿到数组,先想怎么找;找到之后,可能就是顺手一换。
所以刷第七章的时候,千万不要只背代码。搞懂“查找”如何通过循环和下标去实现,搞懂“交换”为什么必须借助中间变量,才算真正拿下了这一章的核心。后面学习选择排序、冒泡排序时,你会发现它们的骨架都是“比较 + 查找 + 交换”,只是具体策略不同而已。
2. 初始化数组:做查找和交换前,先解决“地基”问题
2.1 三种初始化写法与“懒于初始化”的后果
数组的初始化看似基础,但我见过太多人栽在这里,而且往往是栽在“不初始化”上。先理清三种常见写法。
int a[5] = {1, 2, 3, 4, 5}; // 完全初始化 int b[5] = {1, 2}; // 部分初始化,剩余补0 int c[] = {1, 2, 3}; // 由初始化列表推断长度为3第一种没什么好说的,直接把5个元素全给了。第二种是很多人容易忽略的:b[2]、b[3]、b[4]会自动补成0,不会是一堆随机数。第三种写法的好处是省得自己数元素个数,编译器会根据后面的花括号决定数组长度。字符串数组是另一种常见场景,char name[] = "hello" 会自动分配6个字节,把结尾的 '\0' 也算进去,这个细节到第七章后面学字符串时也会用到。
真正的问题出在局部数组不初始化。你在函数里写 int arr[10]; 然后想都不想就开始查找或交换,这个数组里装的是上一次函数调用留下的“垃圾值”,也叫不确定值。查找时你发现目标明明“应该存在”,却总是找不到;有时候又发现某个位置莫名其妙出现了一个无关数字。这不是查找算法写错了,而是数组里根本就不是你想象的那堆数据。
我平时调试这种“幽灵错误”时,第一步一定是把数组完整打印出来,看看里面的值到底是什么。如果是垃圾值,优先回去补初始化。
2.2 数组作为函数参数会“退化”成指针
第七章从数组引入开始,就不断强调函数调用,但很多初学者会对一个问题感到莫名:明明在main里定义的是int a[10],为什么传到自定义函数里,感觉怪怪的?
关键在于:数组作为函数参数时,并不会真的把整个数组复制一份进去。void search(int a[], int n) 里的 int a[] 只是语法糖,它和 int *a 完全等价。也就是说,数组传参的本质是传首地址,函数内部拿到的只是一个指向数组首元素的指针。
这个“退化”带来一个著名陷阱:很多人在函数里写 sizeof(a) / sizeof(a[0]),想算出数组长度。结果呢?在主函数里这样写是对的,因为编译器知道a是一个长度为10的数组;但在函数里,a已经退化成指针了,sizeof(a) 是8(64位平台指针大小)或者4(32位平台),除以 sizeof(int),得到2或者1,完全不是你想要的10。
所以,凡是写数组查找、排序这类函数,都要老老实实把长度作为一个参数传进去。这也是教材里反复出现 void search(int a[], int n, int key) 这种签名设计的原因。n 看似多余,实际是函数的生命线。
2.3 越界访问:C语言数组“不查下标”的代价
C语言的数组和Python、Java里的数组有一个巨大区别:它不检查下标是否越界。你写 a[10] 去访问一个长度是10的数组(合法下标是0到9),编译器不会报错,运行时大概率也不会立刻崩溃,但你会读到数组后面那块内存里的内容,或者把一个值写到别人的地盘上。
第七章做查找时,最常见的越界场景是循环条件写错。比如长度为 n 的数组,合法下标是 0 到 n-1,一不留神写成 for (i = 0; i <= n; i++),最后一个下标就是 a[n],越界读到了数组外面的数据。这种错误在打印数组时很隐蔽,因为输出往往只是多了一个奇怪的值,程序还继续跑。
我自己的经验是:遇到查找和交换相关的问题,先检查循环边界,再想算法。养成大脑中时刻有一根“数组下标从0开始,到n-1结束”的弦,越界错误能减少一半以上。特别是二分查找里 right 到底等于 n 还是 n-1,这个细节会在后面章节专门讲。
3. 顺序查找:最简单的算法里其实藏着三个设计决策
3.1 返回值用“下标”而不是“元素值”
顺序查找也叫线性查找,思路就是从头到尾一个元素一个元素地比,直到找到目标。代码写出来非常好懂,但有一个细节很多人没意识到:查找函数到底应该返回什么?
很多人第一反应是“返回找到的那个元素值”。这个设计其实很糟糕。假如数组里存的是成绩,你查找的目标是 key = 75,找到后你返回75,那调用者怎么知道这个75到底是从数组哪个位置找到的呢?更极端的情况是,你要找的 key 本身就是 0,返回0还会被误认为查找失败,因为很多函数用0表示“没找到”。
正确做法是返回下标。找到目标,返回下标;找不到,返回一个不可能出现的下标,比如 -1。这样客户端代码写起来就非常自然:
int pos = search(score, n, 75); if (pos >= 0) { printf("找到,位置是第%d个\n", pos + 1); } else { printf("不存在\n"); }这看起来是小事,但它是一种接口设计意识。第七章只是查找一个整数,后面学字符串查找、结构体查找时,你能不能设计出清晰易用的函数接口,就是从这里开始的。
3.2 用 break 还是直接 return
顺序查找的第二处设计决策,是循环里找到目标之后怎么处理。两种主流写法都值得掌握。
第一种是直接 return,找到就立刻返回下标,函数结束:
int search(int a[], int n, int key) { for (int i = 0; i < n; i++) { if (a[i] == key) { return i; } } return -1; }第二种是 break 跳出循环,用一个标志变量记录是否找到:
int search(int a[], int n, int key) { int pos = -1; for (int i = 0; i < n; i++) { if (a[i] == key) { pos = i; break; } } return pos; }两种写法结果一样,但思路不同。直接 return 更符合“找到了就收工”的直觉,代码更短。break 写法把查找逻辑和返回值拆得更开,适合后面还想在函数里做更多收尾工作的情况。教材习题里两种都有,我的建议是先把直接 return 练熟,再去理解标志位模式,因为标志位在复杂程序里是一个非常重要的通用技巧。
3.3 哨兵查找:省一次比较的小技巧
顺序查找里有个挺有意思的优化,叫哨兵法。常规写法每循环一次都要判断 i < n 和 a[i] == key,两个条件。如果想少判断一个条件,可以用一个“哨兵”放在数组末尾。
int search_sentinel(int a[], int n, int key) { int i = 0; a[n] = key; // 把key放在哨兵位置 while (a[i] != key) { i++; } if (i == n) { return -1; } return i; }这里有个前提:调用方要保证 a[n] 这个位置是合法可写的,也就是说数组实际长度要比逻辑长度多至少1。循环里只判断元素值,遇到 key 就停下来,如果停在下标 n,说明前面都没找到,key 是哨兵自己。这样每轮少一次下标越界判断,在数据量很大时能省下不少时间。
这个技巧看起来只是个小优化,但它体现了一个很重要的思想:用“必然成立的条件”去替换“需要额外检查的条件”。第七章没必要死磕所有优化技巧,但了解哨兵法,再回头看教材里那些查找模板,会有一种“原来还可以这样写”的开窍感。
4. 二分查找:第七章里卡住最多人的边界问题
4.1 使用前提:先排序,再二分
如果说顺序查找是“挨家挨户敲门”,那二分查找就是“按目录翻书”。它的大前提只有一个:数组必须有序,通常是升序。这个前提太容易被忽略,我见过不少同学拿到数组直接跑二分查找,查了半天查不到,回头一看数组是乱序的。二分查找的核心逻辑是每次和中间元素比较,判断目标在左半边还是右半边,如果数组无序,这个判断就没有意义。
二分查找的时间复杂度是 O(log n),这和顺序查找的 O(n) 有本质差别。用个直观例子:一个长度为1000的数组,顺序查找最坏要比较1000次,二分查找最多10次。因为每比一次,搜索范围就缩小一半。这个增长关系以后在算法分析里会反复遇到,第七章先把人肉模拟搞懂,后面就能轻松很多。
4.2 左闭右闭与左闭右开:选定区间后再写循环
二分查找为什么容易写崩?因为边界处理的选择太多了。网上随便一搜就有一堆版本,看起来都一样,细节却互相打架。我这里只聊两个主流流派:左闭右闭 [left, right] 和左闭右开 [left, right)。
左闭右闭的写法是:
int binary_search(int a[], int n, int key) { int left = 0, right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; if (a[mid] == key) { return mid; } else if (a[mid] < key) { left = mid + 1; } else { right = mid - 1; } } return -1; }这里 while 条件是 left <= right,因为左边和右边都可以指向同一个有效元素,当 left == right 时,这个位置还没有被检查过,所以循环必须继续。
左闭右开写法则是:
int binary_search(int a[], int n, int key) { int left = 0, right = n; // [left, right) while (left < right) { int mid = left + (right - left) / 2; if (a[mid] == key) { return mid; } else if (a[mid] < key) { left = mid + 1; } else { right = mid; } } return -1; }开区间把 right 初始化为 n,表示 right 这个位置本身不参与搜索。所以循环条件是 left < right,一旦 left == right,区间就空了。关键是 right 更新成 mid,而不是 mid - 1,因为 mid 已经比过了,但右边界本来就是开区间,把 mid 之后的区间设置为 [left, mid),mid 不会被包含。
我的建议是:选定一种写法后固定用它,不要混。混用开闭区间的更新规则,是二分查找死循环和漏查的常见根源。
4.3 mid 的计算:小心加法溢出
二分查找里 mid 的常规写法是 (left + right) / 2,但更稳妥的写法是 left + (right - left) / 2。两者在普通小数组上结果一样,但 left + right 在极端情况下可能超过 int 能表示的范围。比如数组很长,left 接近 2147483647,right 也接近这个值,两个数一加就溢出了,变成负数,mid 就算错了。
这个细节在教材的小数组练习里根本测不出来,但在面试题、竞赛题和大数据处理里是个经典考点。第七章就当培养好习惯,直接写成 left + (right - left) / 2,既避免了溢出,也不影响可读性。
4.4 查找不到和“第一个等于目标”的变体
二分查找返回 -1 表示没找到,这是最基础的约定。但实际应用中经常有更具体的要求:找“第一个等于目标”的位置,或者“最后一个等于目标”的位置。
顺序查找里,第一个和最后一个的区别只是从头找还是从尾找;但在二分里,因为范围是跳跃式缩小的,问题就变得微妙。找第一个等于 key 的元素,可以让二分不断向左收缩:当 a[mid] >= key 时,right = mid,继续向左找;当 a[mid] < key 时,left = mid + 1。循环结束后,left 指向第一个不小于 key 的位置,再检查它是否等于 key,就能确定是否存在以及在哪。
// 返回第一个 >= key 的位置 int lower_bound(int a[], int n, int key) { int left = 0, right = n; // 左闭右开 while (left < right) { int mid = left + (right - left) / 2; if (a[mid] >= key) { right = mid; } else { left = mid + 1; } } return left; }这个 lower_bound 变体在算法题里太常见了,第七章不一定考,但如果你能把基础二分和这个变体一起理解,后面学数据结构时会轻松很多。
5. 元素交换:看似三行代码,实际是个大考点
5.1 中间变量法的本质与异或交换的坑
交换两个变量的值,是第六章就出现过的操作,到第七章变成了交换数组中的两个元素,本质一样,靠的是中间变量:
int tmp = a[i]; a[i] = a[j]; a[j] = tmp;这个片段看起来简单,但你有没有想过,为什么必须借助 tmp?如果不借助,直接写 a[i] = a[j]; a[j] = a[i];,那第二步的时候 a[i] 已经被覆盖成 a[j] 的值了,原来 a[i] 的值彻底丢失,a[j] 再也变不回原来的 a[i]。中间变量本质上是给“被覆盖的值”找了个临时避难所。
网上还流传一种不借助中间变量的异或交换写法:
a[i] = a[i] ^ a[j]; a[j] = a[i] ^ a[j]; a[i] = a[i] ^ a[j];说实话,这种写法能跑,但我不推荐学生在第七章用。第一,它可读性差,别人读代码要想半天;第二,如果 a[i] 和 a[j] 指向同一个位置,比如 i == j,第一次异或就把元素变成0了,后面全错。交换的本质是“两个不同位置的值的互换”,为了省一个变量引入这么多限制,不值得。
5.2 交换两个数组元素与交换两个数组的区别
第七章题目里经常出现“交换数组中的两个元素”,这说的是 a[i] 和 a[j] 交换,用上面的 tmp 方法。但有时候题目会把要求说成“交换两个数组”,那就完全是另一回事了。
比如有两个数组 x 和 y,长度都是 n。要让它们整体互换,不能写 x = y,因为数组名不是可赋值的变量。C语言里数组名可以理解成固定的地址,不能让整个数组在赋值语句里左值身份出现。要交换两个数组,只能一个元素一个元素地换:
for (int k = 0; k < n; k++) { int tmp = x[k]; x[k] = y[k]; y[k] = tmp; }还有另一种思路:如果是通过指针操作的,可以交换两个指向数组首元素的指针,让外界看起来“数组互换了”。但第七章通常还没到那一步,先老老实实写循环就好。记住一点:数组名不是变量,不能整体赋值,这个认知能帮你避开很多莫名其妙的编译报错。
5.3 选择排序:第七章里查找+交换的集大成者
选择排序是第七章习题里最经典的组合应用:每轮在待排序区间里找一个最小元素的下标,再把这个最小元素交换到区间最前面。查找负责定位最小值,交换负责把它送到正确位置。
void selection_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { int min = i; for (int j = i + 1; j < n; j++) { if (a[j] < a[min]) { min = j; } } if (min != i) { int tmp = a[i]; a[i] = a[min]; a[min] = tmp; } } }这个代码里有几个细节值得琢磨。第一,内层循环从 i + 1 开始,因为 a[i] 自己不用和自己比。第二,min 记录的是下标,不是元素值,因为交换时需要知道位置。第三,每轮只交换一次,如果 min 没有变化,说明 a[i] 本来就已经是最小值,不需要交换。
如果找不到最小位置就直接交换,而是在内层循环中一遇到更小的元素就交换,那整个排序也能排出来,但交换次数会大幅增加,性能差很多。所以“先查找,再交换”并不是一句废话,而是一种让算法更高效的设计策略。
6. 二维数组:按行查找、按列查找与行列交换
6.1 二维数组的存储本质
第七章后半部分会引入二维数组,比如 int matrix[3][4],表示3行4列。很多初学者会把它想象成一个“表格”,这没错,但要想写出高效代码,还得知道它在内存里是连续存储的:按行优先依次排开,先是第0行的4个元素,再是第1行的4个元素,最后是第2行的4个元素。
这个存储本质带来一个公式:元素 matrix[i][j] 的地址,等于整个二维数组的首地址加上 (i * 列数 + j) 个元素大小的偏移。有了这个概念,你就不难理解为什么二维数组作为函数参数时,第二维的长度必须写出来、不能省略。因为编译器需要知道每行有多少列,才能计算 matrix[i][j] 对应的地址。第一维则可以省略,编译器不关心一共有几行,只关心每行有多长。
6.2 一个完整的二维数组查找示例
二维数组的查找,本质是两重循环嵌套。外层遍历行,内层遍历列,把每个元素都访问一遍:
int find_in_2d(int b[M][N], int key) { for (int i = 0; i < M; i++) { for (int j = 0; j < N; j++) { if (b[i][j] == key) { printf("找到:第%d行第%d列\n", i + 1, j + 1); return 1; } } } return 0; }M 和 N 这里假设是宏定义好的常量。如果 M、N 是变量,二维数组作为函数参数时会有更多限制,比如数组第二维必须是编译期常量,这也是很多初学者卡住的地方。习题里如果要求“输入行数列数”,很多人会下意识写出 int b[m][n] = ... ,这在某些编译器下可以(变长数组),但在标准 C 的很多场合并不支持,稳妥做法是定义一个足够大的常量二维数组,再传入实际行数和列数。
6.3 行交换和列交换是完全不同的代码
二维数组的交换,比一维数组又多了一个维度,容易被绕晕。
交换两行,意思是第 r1 行的所有元素和第 r2 行的所有元素互换。实现时要用一个内层循环,把列下标 j 从 0 变到 N-1,逐列交换:
void swap_rows(int b[][N], int r1, int r2) { for (int j = 0; j < N; j++) { int tmp = b[r1][j]; b[r1][j] = b[r2][j]; b[r2][j] = tmp; } }交换两列,则是第 c1 列的所有元素和第 c2 列的所有元素互换。外部循环是行下标 i,内层固定列号:
void swap_cols(int b[][N], int c1, int c2) { for (int i = 0; i < M; i++) { int tmp = b[i][c1]; b[i][c1] = b[i][c2]; b[i][c2] = tmp; } }为什么行交换和列交换的循环方向不一样?因为二维数组按行存储,相邻元素是同一行里的不同列。交换行时,同一行的元素是连续操作,逻辑上是“整块搬移”;交换列时,每一行只动一个元素,必须把所有行都过一遍。这个例子很能训练多维数组的下标思维,第七章学到这里,建议自己在纸上画一个 3×3 的方格,把行列交换的访问顺序走一遍,比死记代码管用得多。
7. 用指针视角再看查找与交换
7.1 下标和指针在访问数组时的等价关系
C语言里,a[i] 本质上就是 *(a + i)。编译器把下标表达式转换成指针运算,再取指向位置的值。也就是说,你可以用指针遍历一个数组,效果和用下标一模一样:
int search_ptr(int *p, int n, int key) { for (int i = 0; i < n; i++) { if (*(p + i) == key) { return i; } } return -1; }这里的 p 指向数组首元素,*(p + i) 就是 p[i]。理解了这一点,你会发现数组作为函数参数退化成指针这件事一点也不神秘:函数里的 a 就是个指针变量,用 a[i] 访问,本质是先算 a+i 这个地址,再解引用。
7.2 为什么交换函数必须“传数组名”
第七章讲到交换两个数组元素时,很多人会自然而然地想写一个通用交换函数,比如 swap(int x, int y)。如果你真的这样写,然后调用 swap(a[i], a[j]),你会发现交换根本没生效。
原因还是值传递:x 和 y 只是实参的副本,函数内部交换的是副本,实参 a[i]、a[j] 毫无变化。所以交换数组元素时,要么把交换逻辑直接写在主调函数里,靠数组名传递首地址,要么写一个接收指针参数的函数,传 &a[i] 和 &a[j]:
void swap(int *x, int *y) { int tmp = *x; *x = *y; *y = tmp; } // 调用 swap(&a[i], &a[j]);这里传进去的是 a[i] 的地址,函数通过指针修改了这块内存里的值,效果才能传回主调函数。第七章很多学生就是在这里被指针这个概念第一次“绊倒”的。我的建议是不要跳过,必须想明白“值传递与地址传递”的区别,否则后面学链表、学文件操作时会更吃力。
7.3 从数组到指针数组:存放字符串时的查找逻辑
第七章如果往后多翻一点,还会遇到指针数组。比如 char *strs[] = {"hello", "world", "c语言"},这本质上是一个数组,数组里的每个元素都是一个 char * 指针,指向某个字符串的首字符。
在指针数组中做查找,比较的是字符串的内容,而不是指针本身。这时候要用 strcmp 函数:
#include <string.h> int find_str(char *strs[], int n, char *key) { for (int i = 0; i < n; i++) { if (strcmp(strs[i], key) == 0) { return i; } } return -1; }如果想把字符串数组排序,交换的是指针变量,不是字符串本身。这样做的好处是速度快:字符串可能很长,直接交换字符串内容需要逐字符复制,而交换指针只需交换4或8字节的地址。这个思路到了后面学排序、学结构体时会经常碰到,“交换地址”比“交换内容”更高效。
8. 我刷第七章习题时踩过的几个坑:完整排查过程
8.1 在函数里用 sizeof 求数组长度
有一次我在做二分查找题,明明把数组定义成 int a[10],传到函数里以后,发现二分查找只能处理前两个元素。排查半天,终于看到自己写了 int len = sizeof(a) / sizeof(a[0]) 放在函数里。当时 len 算出来是2,所以查找范围一下被缩没了。
这个错误的排查链路其实很清晰:先打印 len,发现不是10;再在 main 里打印 sizeof(a),发现是40;在函数里打印 sizeof(a),发现是8。同一个 a,在不同位置 sizeof 结果不一样,就是因为函数参数退化成指针。从那以后,我写所有数组函数,都强制自己检查是不是把长度参数传进来了,绝不在函数内反推长度。
8.2 二分查找死循环的完整复盘
还有一次写二分查找,输入一个很大的数组后程序卡死。我当时用的左闭右闭写法,但 right 更新那里偷懒写成了 right = mid,而不是 right = mid - 1。用一个小数组模拟:假设 left=0、right=1,mid = 0,a[0] 大于 key,此时应该把 right 变成 -1 或0,结果我写成 right = 0。
问题来了:left 还是0,right 也是0,循环条件 left <= right 依然成立,mid 永远算成0,right 又被更新成0,就死循环了。这个问题的排查方法也很笨但很有效:在循环开头打印 left、right、mid,很快就能看到数字卡在同一个值上不再变化。二分查找的边界更新在刷题时就是“改了这处忘了那处”,所以我后来固定用开区间版本,虽然代码多几行,但 left 和 right 的更新规则更不容易混。
8.3 数组数据没初始化的“幽灵错误”
有一次课后题要求“输入10个数,查找某个值是否出现”。我拿着代码反复看,算法逻辑没有问题,但运行起来第一次查不到,第二次又能查到。后来我把数组打印出来,发现里面有大量不是输入的数字。原因很简单:我定义的数组没有初始化,输入时又只往里读了几个数,剩下的位置全是垃圾值,查找当然会得出随机结果。
这个问题在一维数组里已经够隐蔽了,在二维数组里更常见。比如只初始化了循环读入的有效行,后面的行没管,查找时一旦遍历到垃圾行就会出错。排查方法仍然是先打印数组,看看“你以为的样子”和“实际的样子”差多远。数组相关的 bug,打印辅助函数永远是最先上的工具。
8.4 一个能救命的调试习惯
所谓打印辅助函数,其实就是一个简单到不值一提的函数:
void print_array(int a[], int n) { for (int i = 0; i < n; i++) { printf("%d ", a[i]); } printf("\n"); }在查找前打印一次,在交换后打印一次,在排序的每一轮结束再打印一次,你会看到数据是逐步变化的,bug基本能在两步之内暴露。这个方法我到现在都在用,不管处理的是数组还是链表,只要涉及连续修改状态,打印中间结果都是最直观的排查方式。
我自己在备这一章的时候,有个习惯:不急着写代码,先在纸上画一个数组,标好下标,用笔手动运行一遍查找或交换过程。这个动作看起来笨,但特别能训练“下标从0开始”“交换需要中间变量”“二分边界怎么移动”这几件事。等到纸上流程完全理顺了,再上机写代码,你会发现错误率直线下降,而且遇到问题也更清楚应该去哪一行查。第七章刷完不是终点,之后学指针、学字符串、学文件读写时,查找、交换、数组这套基本功还会一次次找你“复习”。