说实话,C语言学到指针这一章,很多人的第一反应不是兴奋,而是“完蛋了”。指针和数组纠缠在一起,再加上一个快速排序,这门课不知道劝退了多少人。但我想说的是,快速排序和指针操作一维字符型数组这两个知识点,恰恰是把C语言从“会写”推向“懂写”的临界点。这篇是系列学习笔记的第十四篇,我尽量用一个写过不少实际项目的老码农视角,把这俩硬骨头拆开揉碎讲清楚——核心讲快排的实现思路、边界问题,以及指针操作字符数组时那些文档里不会写的坑。
这篇内容适合刚学到数组和指针、正在做C语言练习题的读者,也适合工作中偶尔需要手写排序、被字符串指针搞到头疼的嵌入式或后端开发者。你会看到可以直接“抄作业”的完整代码,也会看到我在调试过程中真实踩过的坑。别指望看一遍就全会,但看完你至少知道,快排为什么快,指针为什么“指”,以及什么时候必须用char **。
1. 快速排序:吃透分治思想其实只需三步
1.1 选定基准、左右夹逼、递归处理
快排的核心思想用一句话总结就是:选一个基准,把比它小的放左边,比它大的放右边,然后对左右两边重复这个过程。这就是典型的分治思想,把一个大的排序问题,拆成两个规模更小的子问题,再继续拆,直到区间只剩一个元素或者空区间。
具体到一趟排序里,最常用的操作是“挖坑法”。我以数组[5, 3, 8, 1, 9, 2]为例,如果你把第一个元素5当作基准pivot,那么位置0就相当于被挖掉了一个“坑”。接下来,先让右指针j从右往左找比5小的数,找到2,把2填到坑里,位置5变成新坑;然后让左指针i从左往右找比5大的数,找到8,把8填到位置5的坑,位置2变成新坑。这样反复交替,最后i和j相遇时,把基准5填进相遇的位置。这时你会发现,5前面的元素都比它小,后面的都比它大,但整体还没有完全有序,因为左半部分里3和1还需要再排,右半部分里9和8也还需要再排——于是递归。
这个过程最妙的地方在于:每一趟排序,一定能把基准元素放到它最终应该在的位置上。而且一趟排序完成后,问题规模就被切成两半,后续排序互不干扰。
你可能听说过还有“Lomuto分区法”或者“Hoare分区法”。这俩在算法教材里也常见,但挖坑法在C语言里实现最直白,尤其适合初学者一步步跟踪。我给初学者讲课的时候,一贯推荐先用挖坑法把流程跑通,等彻底理解了,再去对比其他分区策略。
1.2 手写快排:一份可以直接抄的 C 语言实现
下面这份代码是我在平时开发中反复使用的基础版本。它针对整数数组,注释写得很详细,方便你对照上面描述的挖坑过程:
#include <stdio.h> // 挖坑法分区:返回基准最终所在的下标 int partition(int arr[], int left, int right) { int pivot = arr[left]; // 先取最左边元素作为基准,这个位置就是初始的“坑” int i = left, j = right; while (i < j) { // 从右向左找第一个小于 pivot 的元素 while (i < j && arr[j] >= pivot) j--; // 把这个元素填到左边的坑里,j 位置变成新坑 arr[i] = arr[j]; // 从左向右找第一个大于 pivot 的元素 while (i < j && arr[i] <= pivot) i++; // 把这个元素填到右边的坑里,i 位置变成新坑 arr[j] = arr[i]; } // i 和 j 相遇,把基准放回坑里 arr[i] = pivot; return i; } void quick_sort(int arr[], int left, int right) { if (left >= right) // 空区间或单元素区间,天然有序 return; int pos = partition(arr, left, right); quick_sort(arr, left, pos - 1); // 排序左半部分 quick_sort(arr, pos + 1, right); // 排序右半部分 } int main(void) { int a[] = {5, 3, 8, 1, 9, 2}; int n = sizeof(a) / sizeof(a[0]); quick_sort(a, 0, n - 1); for (int i = 0; i < n; i++) printf("%d ", a[i]); printf("\n"); return 0; }这里有一个关键点:每次从右边填坑时,arr[i] = arr[j]这一步,旧值其实已经被之前填坑时覆盖了,所以不会丢数据;但从左边往右边填的时候,arr[j] = arr[i]也同理。整趟排序的过程就像在玩“挪坑”游戏,最后基准归位,一轮结束。
我建议你把上述代码复制到本地编译器里,在partition函数的循环体里加一行打印,每次移动后输出整个数组,你会看到数据“流动”的过程,这对理解快排的帮助比看十遍原理都大。
1.3 基准选择背后的性能博弈
上面代码里,基准直接取了最左边的元素。这在数组完全随机的情况下没什么问题,平均时间复杂度是O(n log n)。但如果数组本身已经有序,比如[1, 2, 3, 4, 5],你取最左边元素做基准,每一趟只能把区间切成“一个元素”和“剩下所有元素”两段,递归深度会变成n,时间复杂度退化到O(n^2),而且容易栈溢出。
解决这个问题,业界最常用的办法是“三数取中法”:取区间的左端、中间、右端三个元素,把它们之中大小居中的那个作为基准。代码上并不复杂:
int mid = left + (right - left) / 2; // 简单的取中逻辑:确保 arr[mid] 是三者中大小居中的 if ((arr[left] - arr[mid]) * (arr[left] - arr[right]) <= 0) pivot = arr[left]; // left 居中 else if ((arr[mid] - arr[left]) * (arr[mid] - arr[right]) <= 0) pivot = arr[mid]; else pivot = arr[right];记住,不需要精确地计算哪个是“中位数”,只需要避免选到最大或最小元素。这样在大多数情况下都能有效避免快排退化。你还要注意,选完基准后,通常还要把它交换到边界位置,再套用之前的挖坑法代码,逻辑才不会被边界条件绕晕。
2. 从“能用”到“好用”:快排的边界问题和优化
2.1 最坏情况、递归深度与栈溢出
很多初学者写完快排,跑几个用例没问题就以为完事了。实际上,快排在工程中最怕两件事:递归深度过大和重复元素过多。
先说递归深度。在数组已经有序或近乎有序时,如果基准选得不好,递归深度会从log2(n)恶化为n。假设数组有一百万个元素,递归调用栈里每层都占用若干字节的局部变量和返回地址,栈空间很容易被耗尽,程序直接段错误。这也是为什么我在 1.3 里强调基准选取的重要性。——那是在源头上减少退化概率。
再说重复元素。如果一个数组里有大量相同的值,比如[2, 2, 2, 2, 2, 1, 2],挖坑法配合arr[j] >= pivot这样的判断,每次扫描时会把相等的值不断来回移动,一趟结束了,区间却没被有效切开,性能同样很差。
一个朴素有效的处理思路是:在分区时把与基准相等的元素尽量均匀地分到两侧,这需要用到“三路快排”的思想。三路快排把数据分成小于基准、等于基准、大于基准三段,中间等于基准的部分不再参与递归。实现起来比普通快排长不少,但对付重复数据非常实用。如果只是学习阶段,可以先忽略;如果是工作中遇到海量重复数据,我建议直接上三路快排。
2.2 非递归快排:用栈模拟调用过程
递归是快排最自然的表达方式,但工程代码里,你可能会因为栈空间受限,或者单纯想避开递归的不可控性,而改用非递归实现。思路很简单:递归的本质是“把待排序的区间压入系统调用栈”。那我们自己维护一个显式的栈,保存每次需要处理的left, right区间即可。用数组模拟栈,代码如下:
typedef struct { int left; int right; } Range; void quick_sort_nonrecursive(int arr[], int n) { if (n <= 1) return; Range stack[1000]; // 根据数据规模调整 int top = 0; stack[top++] = (Range){0, n - 1}; while (top > 0) { Range range = stack[--top]; int left = range.left; int right = range.right; if (left >= right) continue; // 这里复用挖坑法分区逻辑 int pivot = arr[left]; int i = left, j = right; while (i < j) { while (i < j && arr[j] >= pivot) j--; arr[i] = arr[j]; while (i < j && arr[i] <= pivot) i++; arr[j] = arr[i]; } arr[i] = pivot; // 注意:先压入区间大的一边,可以减少栈的空间使用 if (i - left > right - i) { stack[top++] = (Range){left, i - 1}; stack[top++] = (Range){i + 1, right}; } else { stack[top++] = (Range){i + 1, right}; stack[top++] = (Range){left, i - 1}; } } }这里有个小技巧:把区间更大的那一半先压栈,后压栈的小区间先被弹出处理。这样栈的深度可以控制在大约O(log n)级别。我在实际工程中就是这么做的,效果不错。
2.3 调试快排的实战三板斧
很多读者拿着快排代码跑崩溃了,第一反应是去网上复制一份新的。但我建议你先学会自己调试,尤其是初学者,这是练内功的好机会。
第一板斧,打印数组。在递归函数入口打印当前处理的left和right,以及数组当前状态。你会发现,快排过程中数组局部有序的规律非常明显,一旦某个区间没有按照预期“左边都比基准小”,你马上就能定位问题。
第二板斧,缩小数据量。把测试数组从十个元素减到三四个,手算一遍预期结果,再和程序输出对比。很多人调试大数组出错却找不到原因,就是因为一次处理太多变量,脑容量不够。
第三板斧,查越界。快排最常见的段错误,往往出现在left == 0时执行quick_sort(arr, left, pos - 1)导致pos - 1为负数;或者right写成n而不是n - 1,导致数组越界访问。这时候用printf在递归函数入口打印left和right的值,作弊地解决一半问题。如果是 Linux 环境,配合gdb可以在崩溃时直接查看调用栈,定位到具体是哪一行越界了。这个习惯值得养成。
3. 指针操作一维字符型数组的底层逻辑
3.1 数组名、指针变量、字符串常量:三个经常被搞混的东西
离开快速排序,我们切换到今天第二个重头戏:指针操作一维字符型数组。很多人在这里的第一道坎,就是分不清char str[] = "hello";和const char *str = "hello";有什么区别。这俩在大多数场景下都能“读”到相同的内容,但一旦涉及修改,就天差地别。
char str[] = "hello";:在栈上分配了一个长度为6的字符数组(别忘了结尾的\0),内容可修改。const char *str = "hello";:str 是一个指针变量,指向位于只读数据区的一块字符串常量。通过指针修改这个字符串,在部分编译器上会直接崩溃,在另一些编译器上行为不可预期。在C标准里,修改字符串字面量本身就是未定义行为。
画个简单的类比:数组名是你家房子的固定门牌号,指针变量是一个可以随时改写地址的名片。名片上你可以随便写上“某小区某栋某号”,但你拿着名片去改别人家的墙,那就是找事了。这个例子虽然俗,但真切。
另一个容易混淆的概念是:数组名到底是不是指针?答案是否定的。数组名在大多数表达式中会隐式转换为指向首元素的指针,但它是“常量”,不能自增自减,不能重新赋值。比如str++在数组名身上是编译错误,在指针变量上却是合法操作。这个区别在阅读代码时尤其重要,很多人报错找半天才发现是数组名被当指针用了。
3.2 指针遍历、修改字符数组的惯用写法
用指针操作字符数组,最经典的就是遍历和修改。我举一个最常见的写法:
#include <stdio.h> int main(void) { char str[] = "hello world"; // 方式一:下标访问 for (int i = 0; str[i] != '\0'; i++) putchar(str[i]); putchar('\n'); // 方式二:指针遍历,判断 *p 是否为结束符 char *p = str; while (*p != '\0') { putchar(*p); p++; } putchar('\n'); return 0; }这里的while (*p)其实等价于while (*p != '\0'),因为字符'\0'的整数值就是0,直接判断真假即可,这是C语言里一个非常顺手的技巧。
再比如把字符串中的小写字母转成大写:
char str[] = "hello"; char *p = str; while (*p) { if (*p >= 'a' && *p <= 'z') *p -= 32; p++; }注意这里必须使用数组char str[],而不能用const char *str = "hello"。否则*p -= 32就是在修改只读数据区,大概率崩溃。每碰到一次这种崩溃,你对“字符串字面量不可修改”的印象就会加深一层。
还有一个细节:当用scanf给字符数组输入时,通常会写scanf("%s", str),而不是scanf("%s", &str)。原因就是str这个数组名在表达式中会自动转换为首元素的地址,类型是char *,和%s期望的参数类型一致。如果你加了&,传进去的则是指向整个数组的指针,虽然数值上可能相等,但类型不匹配,编译器会警告,运行时也可能因为处理方式不一致而出问题。这一点不少教材没讲透,我看着不少人在这里栽跟头。
3.3 指针作为函数参数:什么时候能改,什么时候改不了
你需要知道一个基本事实:C语言函数传参是按值传递。当你把数组名作为参数传给函数时,实际传入的是首地址的值,也就是一个指针的拷贝。函数内部修改p本身,不会影响外部指针变量;但通过p去修改它指向的内容,外部是能看到的。
举个例子,下面这个函数想给字符串填充'a',是完全正确的:
void fill(char *s) { while (*s) { *s = 'a'; s++; } }调用fill(str);后,str 的内容确实都变成了a。因为s拷贝了str的首地址,它和str指向的是同一块内存。
但如果你这样写,就无效了:
void set_null(char *s) { s = NULL; // 只修改了形参的指向,外部 str 不受影响 }如果你想让函数内部把一个指针变量本身“指到别处”,例如希望函数能把str指向一个新字符串,就必须传入指针的指针,也就是char **:
void redirect(char **s) { *s = "new string"; }调用时写成redirect(&str);。这里的&str是一个char **类型的表达式。很多初学者看到char **就头皮发麻,其实展开来看就是“指向指针的指针”——外层指针指向的是那个装着地址的变量本身。这个模式在处理字符串数组、链表等场景里非常常见,后面综合实战部分你会再次见到它。
4. 综合实战:用快速排序给字符数组和字符串数组排序
4.1 看清对象:排序一维字符型数组,到底排的是什么
很多时候题目说“对一维字符型数组排序”,但你要先搞清楚到底排什么。通常有两种理解:
- 把一个
char arr[N]里的每个字符按 ASCII 码排序,比如"hello"排序成"ehllo"。 - 把多个字符串排字典序,这时数据结构通常是
char *strs[],也就是一个指针数组,数组中每个元素都是指向一个字符串首字符的指针。
这两种场景使用的快排代码几乎一模一样,唯一的区别是:第一种情况交换的是字符(char),第二种情况交换的是指针(char *)。这个区别很关键,因为交换指针并不会移动字符串本身,只是改变字符串在数组中的排列顺序,开销小得多。
4.2 手写快排对 char 数组排序
先看第一种。对单个字符数组排序,直接套用前面整数快排的代码,把int换成char即可:
void quick_sort_char(char arr[], int left, int right) { if (left >= right) return; char pivot = arr[left]; int i = left, j = right; while (i < j) { while (i < j && arr[j] >= pivot) j--; arr[i] = arr[j]; while (i < j && arr[i] <= pivot) i++; arr[j] = arr[i]; } arr[i] = pivot; quick_sort_char(arr, left, i - 1); quick_sort_char(arr, i + 1, right); }调用前用strlen拿到长度,然后对arr排[0, len - 1]区间。注意字符比较是按 ASCII 码进行的,所以大写字母会排在所有小写字母前面。如果你期望的是忽略大小写的字典序,还得先转成统一大小写再比较。
这种排序在做什么?比如键盘录入一串字符之后把乱序的字母整理好,或者给一个字符串去重前先排序让相同的字符相邻,都有实际应用价值。
4.3 用指针数组排字符串字典序
第二种情况就更需要指针功底了。假设你有三个字符串"banana"、"apple"、"cherry",如果把它们分别存进普通的二维字符数组,比如char arr[3][16],那么交换字符串时只能用strcpy把整个内容拷来拷去,效率低且容易越界。更合理的方式是定义一个指针数组:
char *strs[3]; strs[0] = "banana"; strs[1] = "apple"; strs[2] = "cherry";这样每个元素就是一个char *,排序交换时只需要交换指针本身。手写快排字符串版本的代码如下:
#include <stdio.h> #include <string.h> void quick_sort_str(char *arr[], int left, int right) { if (left >= right) return; char *pivot = arr[left]; int i = left, j = right; while (i < j) { // 找右边第一个“小于 pivot”的字符串(按 strcmp 结果) while (i < j && strcmp(arr[j], pivot) >= 0) j--; arr[i] = arr[j]; while (i < j && strcmp(arr[i], pivot) <= 0) i++; arr[j] = arr[i]; } arr[i] = pivot; quick_sort_str(arr, left, i - 1); quick_sort_str(arr, i + 1, right); }理解了挖坑法,这个版本的逻辑并不难,唯一要适应的是用strcmp代替关系运算符。注意strcmp返回的是整数:负数表示第一个参数排序在前,正数反之,0表示相等。所以>= 0的含义就对应着“不小于基准”。
用的时候:
char *strs[] = {"banana", "apple", "cherry", "date"}; int n = sizeof(strs) / sizeof(strs[0]); quick_sort_str(strs, 0, n - 1); for (int i = 0; i < n; i++) puts(strs[i]);这就是利用指针数组避免大量字符串拷贝的典型手法。在真实项目里,尤其是从文件里读入一堆单词然后排序统计的场景,这种写法是标配。
4.4 对标 qsort:库函数为什么不香
C标准库提供了qsort,它是一段泛型排序函数,可以通过传入自定义比较函数来排序任意类型。上面的字符串数组,用库函数写起来是这样:
#include <stdlib.h> #include <string.h> int cmp_str(const void *a, const void *b) { const char **pa = (const char **)a; const char **pb = (const char **)b; return strcmp(*pa, *pb); } // 调用 qsort(strs, n, sizeof(char *), cmp_str);得益于void *和函数指针,qsort堪称“万能排序器”。那为什么我还要反复强调手写快排?两个原因。
第一,面试和考试里,算法题往往不允许你用qsort,你必须能自己写出来。手写一遍不是为了炫技,而是为了真正理解分治、递归、指针这些底层概念。
第二,qsort的通用性换来的是性能上的一些妥协,而且它不清楚元素的语义,只能通过函数指针间接比较,调用开销略大。当你面对海量数据、特定的优化需求(比如三路快排)时,定制的手写快排反而更合适。
所以我给你的建议是:平时能用 qsort 就用 qsort,但你必须具备随时手写快排的能力。这和会开车也要会换备胎是一个道理。
5. 常见问题排查与避坑实录
5.1 八个高频问题排查速查表
这一节直接把我在教学和开发中见过的高频问题列成一张表,每一条背后都有至少十个人问过我:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 快排结果部分有序但局部错乱 | 分区边界写错,递归时范围重复或遗漏 | 检查quick_sort(arr, left, pos-1)和quick_sort(arr, pos+1, right),base后返回位置必须排除pos本身 |
| 字符串没排序,原样输出 | 比较时用了arr[j] > pivot而不是strcmp | 字符串比较必须用strcmp,不可用关系运算符 |
| 程序崩溃(段错误) | 修改了字符串常量 | 把const char *str="hello"换成char str[]="hello" |
| 输出乱码或多了奇怪字符 | 字符串缺少\0结尾 | 手动初始化时记得留一位给'\0',用memset清空数组是良习惯 |
scanf输入后程序报错或行为异常 | 用了&str作为%s参数 | 直接写scanf("%s", str),数组名本身就是地址 |
| 快排在数据量大时爆栈 | 递归过深,基准选取不当 | 改用三数取中,或使用非递归实现 |
| 指针变量指向了局部数组,函数返回后用崩溃 | 返回的地址已失效(悬空指针) | 返回动态分配内存,或把数组声明为static |
| 修改指针形参后外部无变化 | 试图在函数里改变指针本身的指向 | 参数改成char **,通过*s修改外部指针 |
这八个问题几乎覆盖了初学者在半年的C语言学习中会踩到的最经典的坑。尤其是“字符串常量 VS 字符数组”和“指针形参的无效修改”,几乎每届学生都会碰到。
5.2 比避坑更重要的:三个编码习惯
排查问题是被动的,我更希望你养成三个主动习惯,从源头上减少麻烦。
第一,编码时总是初始化指针和数组。声明char *p之后如果立刻让它指向某个已知地址,或者初始化为NULL,就不会出现“野指针”乱指导致崩溃还找不到源头的情况。数组同理,用char buf[64] = {0}清空后,后续使用才有安全感。
第二,边界计算统一用“左闭右闭”区间。快排函数签名如果是quick_sort(arr, left, right),那递归时就传left, pos-1和pos+1, right,与 C 语言数组下标风格保持一致,千万不要混用半开区间写法,否则出错率高得惊人。
第三,区分“修改内容”和“修改指向”。写函数前先自问一句:这个函数要不要改变实参指针的指向?如果要,就必须用指针的指针或返回新指针。这个判断只需要几秒钟,却能帮你避免掉一半以上的指针逻辑错误。
5.3 环境与调试小提示
最后补充一点环境相关的经验。Windows 上如果你用 Visual Studio,默认的栈空间比较大,但嵌入式开发用 Keil MDK 时栈空间往往很可怜,递归快排在几千个元素时就可能爆栈。这时候优先考虑非递归版本,并且注意把局部变量控制在较小范围。
我在用 VSCode 配置C语言环境时,通常建议装一个C/C++扩展,配合单步调试。单步调试对理解指针和快排的作用是碾压级的——你亲眼看到i和j的移动,看到指针指向的地址变化,比看任何教材都直观。调试的过程中不要怕打断点,尤其在你怀疑“区间边界”和“字符串常量”时。
6. 写在最后:我踩过快排和指针的几个坑
快排和指针放在一起,几乎是C语言学习曲线中最陡峭的一段。我记得当年第一次手写快排,递归边界写成了quick_sort(arr, left, pos),结果死循环到程序卡死。后来学会在函数入口打印区间,一眼就发现左递归区间里永远包含一个已经排好的基准元素,导致问题规模没有缩小。这个教训让我从此养成“打印调试”的习惯。
指针操作字符数组的坑就更经典了。有次写一个字符串反转函数,用char *s做参数,找末尾字符时写成了while (*s) s++;,结果忘了s已经指到结尾的\0,回退偏移又搞错,最后返回的指针指向了错误位置。还是用 gdb 看地址才发现问题。事后总结就一句话:指针操作一定要在纸上画出地址走向,先在草稿纸上走一遍,再上机敲代码。这个习惯救了我很多次,尤其当你面对char *和char **混在一起的时候。
写这篇笔记已经是这个系列的第十四篇了,快排和指针的碰撞远不止这些内容,后续我可能还会把链表、动态内存、函数指针这些题目继续拆解。C语言的学习本来就不是一件“看一遍就会”的事情,它是一个反复踩坑、反复修正的过程。如果你把每一道指针题都在草稿纸上完整推演一遍,把快排的每个递归边界都亲自打出来验证一遍,你会发现,这两个当初让你头疼的东西,慢慢就变成了手上最趁手的工具。