前两天群里有人问:学排序到底先学哪个?我说如果只选两个入手,那一定是交换排序里的冒泡排序和快速排序。这两个算法都用“交换元素”来消除逆序,但一个稳扎稳打,一个快刀斩乱麻,正好把 C 语言里数组、指针、递归、分治这些基本功全串起来了。这篇文章我准备用 C 语言把两块讲透:从最朴素的代码,到优化版本,再到 debug 和边界处理,适合刚啃数据结构的人照抄,也适合调试一半卡住的人回来翻。
1. 交换排序到底在解决什么问题
1.1 逆序对是排序的“病灶”
排序目标说白了就是让序列变成升序。那在交换排序的思路里,问题的根源是“逆序对”:一对元素 i < j,但 arr[i] > arr[j],这就叫一个逆序。排序的过程,就是在不断消灭逆序对。冒泡排序是相邻两个元素慢慢消除逆序,像气泡一样把大的或者小的顶到一头;快速排序则是跳着交换,一次操作就能把一个元素放到最终位置,同时把剩下的序列切成两半。两者的共同点是都依赖交换操作,区别在于交换的“跨度”和“策略”。
为什么要先理解逆序对?因为无论是分析稳定,还是设计优化,都离不开它。比如冒泡排序只在相邻元素间交换,一旦交换就把一个逆序对消掉,还不会跨越相同元素的相对位置,所以它是稳定的。快速排序交换经常跨很远,同值元素可能被换到另一边,所以通常情况下它不稳定。理解了这层关系,后面很多现象就不用死记。
从工程角度看,交换排序属于“原地排序”,不需要开辟额外的大块辅助数组,代码里只借助一个临时变量。这非常适合嵌入式、系统编程等内存受限的场景,也正因为它不申请额外存储,C 语言课程和面试题里才会反复考。
1.2 C语言里实现交换的关键点
C 语言实现交换排序,第一个基本功就是写对 swap 函数。很多人一上来写成的样子是这样的:
c复制void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
}
这样写并不会改变实参。因为 C 语言函数参数是值传递,a 和 b 只是实参的副本,函数执行完,外部变量还是老样子。正确做法需要传地址:
c复制void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
调用时写 swap(&arr[i], &arr[j])。这里的 & 就是取地址,让函数内部能直接修改数组元素。如果你看到代码“排序完了数组一点没变”,十有八九就是把 swap 写成了值传递,或者调用时忘了加 &。
另外一个容易忽略的点是数组传参。在 C 语言里,函数参数里写 int arr[] 和 int *arr 几乎等价,数组名传入函数后会退化成指向首元素的指针,所以函数内无法通过 sizeof(arr) / sizeof(arr[0]) 拿到元素个数。这就是为什么排序函数必须额外传一个长度 n。很多新手在主函数里 sizeof 算得挺开心,到了子函数发现长度变成 1,原因就是这个退化机制。
1.3 冒泡和快排的分工
就实际使用来说,冒泡排序更像“教学排序”:代码直观、逻辑简单,适合用来理解排序最朴素的执行流程。快速排序则是“工程级排序”:平均时间复杂度 O(n log n),相比冒泡的 O(n^2) 有数量级优势,在很多标准库里被作为默认排序方案之一。C 语言标准库里的 qsort,名字就来自 quick sort,虽然具体实现可能还会混合插入排序或堆排序,但核心思路仍然是快速排序。
所以这篇文章的主角虽然是两个算法,但读者心里可以先有个谱:冒泡是“地基”,快速排序是“目标”。前者帮你建立循环和交换的感觉,后者帮你建立分治和递归的思维。把这两套吃透,再去看别的排序,比如归并、堆排序,会发现很多概念都是相通的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 冒泡排序:先看懂最土的版本
2.1 外层和内层循环的分工
冒泡排序最经典的版本是这样:
c复制void bubble_sort(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
外层循环的 i 控制“已经放好多少个元素到最终位置”。第一轮比较结束后,最大值一定被交换到了数组末尾;第二轮结束后,次大值一定到了倒数第二的位置。所以内层循环的右边界可以不断收缩,写成 n - 1 - i,就不需要再去碰后面已经排好的区域。
内层循环里比较的是 arr[j] 和 arr[j + 1],也就是相邻元素。如果前一个大于后一个,就交换。这轮结束之后,当前未排序区间内的最大值就像气泡一样浮到最右边。有人喜欢让小的往前浮,其实只要比较符号对称就行,核心机制完全一样。
这里有个书写细节:内层循环如果写成 j < n - 1,代码也能运行,但第一轮之后每一轮都会重复比较已经就位的尾部元素,白白浪费时间。养成 n - 1 - i 的习惯,是理解冒泡优化过程的第一步。
2.2 加flag提前结束
最原始的冒泡就算遇到一个已经有序的数组,例如 {1, 2, 3, 4, 5},依然会执行两层循环,做大量无意义的比较。解决办法是加一个“是否有交换”的标记:
c复制void bubble_sort_flag(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
int swapped = 0;
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = 1;
}
}
if (!swapped) {
break;
}
}
}
swapped 每轮开始时置 0,一旦发生交换就置 1。如果一整轮下来没有任何交换,说明所有相邻元素都已经满足升序,整个数组必然有序,这时候直接跳出外层循环。这个优化让冒泡排序在“几乎有序”的数据上能达到 O(n) 的最好情况,例如数据 {1, 2, 3, 5, 4},第一轮把 5 和 4 交换后,第二轮发现没有交换,直接结束。
我一开始对“没有交换就说明有序”这句话不太敢信,后来想明白了:数组有序等价于所有相邻元素都满足 arr[i] <= arr[i + 1]。如果一轮扫描里所有相邻元素都不需要交换,那这个条件就全部成立,自然不用再比下去。
2.3 记录最后交换位置继续收紧
比加 flag 更进一步,是记录“最后一次发生交换的位置”。因为一轮扫描下来,最后一次交换之后的位置,理论上已经全部有序,下一轮没有必要再扫到那么远。实现如下:
c复制void bubble_sort_last(int arr[], int n) {
int unsorted_len = n;
while (unsorted_len > 1) {
int last_swap = 0;
for (int i = 0; i < unsorted_len - 1; i++) {
if (arr[i] > arr[i + 1]) {
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
last_swap = i + 1;
}
}
unsorted_len = last_swap;
}
}
这里是拿 unsorted_len 代替原来的 n - 1 - i。当最后一次交换发生在 i 与 i + 1 之间时,last_swap = i + 1 表示下一轮只需要扫描到这个位置之前的区域。这个版本对“前半段乱序、后半段已经有序”的数据效果特别明显,比如 {3, 1, 2, 4, 5, 6, 7, 8},第一轮之后 last_swap 可能停留在很靠前的位置,后续循环规模被压缩得很小。
在实际编码时,要注意 while 循环和 for 循环的边界别搞混。如果直接拿 n 在外层递减,同时内层又用 n - 1,就会出现数组越界或者漏排。建议这种优化版本单独封装一个函数,不要和基础版本混在一起改。
2.4 稳定性与复杂度的账
冒泡排序是稳定的。原因在于它只在 arr[j] > arr[j + 1] 时交换,如果两个相等,即使相邻也不会交换,所以相同元素的相对顺序不会被破坏。这一点在按多个字段排序时有用,例如先按年龄排、再按姓名排,稳定的算法能保留上一次排序结果。
时间复杂度方面,最坏和平均都是 O(n^2),最好情况是加了提前结束标记后的 O(n)。空间复杂度是 O(1),属于原地排序。
冒泡排序适合教学和极少量数据,但工程上不太会用,因为它数据一旦到一万甚至十万量级,比较次数会呈平方增长。我见过有人拿冒泡排五万元素,跑了十几秒才出结果,换成快排基本是毫秒级。所以先学会冒泡,更要尽快过渡到快速排序。
3. 快速排序:把一个元素放到正确位置
3.1 快速排序的“一趟定一个位置”
快速排序的核心思想是分治:从待排序区间里挑一个“枢轴”元素,然后通过交换把比枢轴小的元素放到它左边,比枢轴大的放到它右边,这一步叫分区。分区完成后,枢轴元素已经落在最终位置上,因为它左边的元素都小于等于它,右边的元素都大于等于它。接下来只需要对左半区和右半区递归执行同样的过程。
这里最关键的并不在“分治”这两个字,而在“一次分区一定能让至少一个元素就位”。哪怕每次都挑到最差的枢轴,这个特质也保证了排序能收敛,不会无限递归。快排能不能快,主要看分区是否平衡。理想情况下,每次分区都把区间大致对半分,递归深度是 log2(n),每一层总的比较次数是 O(n),整体就是 O(n log n)。
如果枢轴每次恰好是当前区间的最小值或最大值,分区后一边为空,另一边是 n - 1 个元素,递归深度退化成 n,时间复杂度变成 O(n^2)。这个退化不是理论吓唬人,对一个已经有序的数组,如果每次都取第一个元素当枢轴,跑出来就是赤裸裸的平方级。
3.2 Lomuto分区:代码最直白
Lomuto 分区是最好写的一种实现,尤其适合第一次接触快速排序的人。它的思路是从左到右扫描,用一个指针 i 维护“已经知道小于枢轴的元素区域”。我习惯把枢轴取在区间最右侧:
c复制int lomuto_partition(int arr[], int left, int right) {
int pivot = arr[right];
int i = left - 1;
for (int j = left; j < right; j++) {
if (arr[j] < pivot) {
i++;
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[right]);
return i + 1;
}
void quick_sort_lomuto(int arr[], int left, int right) {
if (left >= right) {
return;
}
int p = lomuto_partition(arr, left, right);
quick_sort_lomuto(arr, left, p - 1);
quick_sort_lomuto(arr, p + 1, right);
}
这段代码的每一步我拆开讲。初始 i = left - 1,表示目前还没有发现比枢轴小的元素。j 从 left 扫到 right - 1,遇到比枢轴小的元素,就把 i 往前挪一格,再交换 arr[i] 和 arr[j]。这样,[left, i] 区间内全都是比枢轴小的元素,[i + 1, j - 1] 区间内全都是不小于枢轴的元素。扫描完成后,把枢轴从 right 换到 i + 1,返回的 p 就是枢轴的最终位置。
递归调用时,左右区间分别是 [left, p - 1] 和 [p + 1, right],因为枢轴已经就位,不能再把它放进子区间里继续排序。这个递归出口条件 left >= right 很关键,子区间为空或只有一个元素时就不需要再处理了。
用等号或者不等号要小心。我在上面用的是 arr[j] < pivot,也就是说遇到相等元素不往左边换,这样相等元素会偏向右侧。实际效果和用 <= 不太一样,但对正确性没有影响。不过大量重复元素时,Lomuto 分区容易退化,后面我会说怎么处理。
3.3 Hoare分区:双端扫描更快
Lomuto 的缺点在于额外交换次数较多,而且对重复元素不友好。Hoare 分区是原始论文里的双端扫描方案,从左右两头同时往中间找,一次交换能同时把一个“过大的左元素”和一个“过小的右元素”处理掉。下面是一份常见的实现:
c复制int hoare_partition(int arr[], int left, int right) {
int pivot = arr[left];
int i = left;
int j = right + 1;
while (1) {
do {
i++;
} while (i <= right && arr[i] < pivot);
do {
j--;
} while (j >= left && arr[j] > pivot);
if (i >= j) {
break;
}
swap(&arr[i], &arr[j]);
}
swap(&arr[left], &arr[j]);
return j;
}
void quick_sort_hoare(int arr[], int left, int right) {
if (left >= right) {
return;
}
int boundary = hoare_partition(arr, left, right);
quick_sort_hoare(arr, left, boundary - 1);
quick_sort_hoare(arr, boundary + 1, right);
}
这里我做了额外边界保护,防止 i 跑出 right 造成野指针访问。需要特别注意的是,Hoare 分区和 Lomuto 分区在返回值的语义上并不同。上面这个实现先把枢轴 arr[left] 换到 boundary 位置,返回的 boundary 就是枢轴最终下标,所以递归区间写成 [left, boundary - 1] 和 [boundary + 1, right]。
网上还流行另一种 Hoare 写法,partition 返回的是“右半区的左边界减一”,递归时会写 quick_sort(arr, left, p) 和 quick_sort(arr, p + 1, right)。两种写法都能用,但如果你把两种混在一起,比如 Hoare 分区返回 j,递归却按 Lomuto 写 p - 1,就很容易排完序后发现枢轴不是最终位置,甚至出现死循环。最好固定一套,不要来回切换。
Hoare 分区之所以快,是因为它不做稳定的“小块归堆”,而是直接让两个不符合条件的元素互相交换,减少了交换次数。但代价是代码边界更难理解,需要单独练几遍才能形成肌肉记忆。
3.4 如何应对最坏情况
快速排序最怕两件事:一是输入已经有序或接近有序,二是枢轴总是选在最值上。解决办法常见的有两个方向。
第一个是随机化。在 [left, right] 区间内随机选一个下标当枢轴,这能让“恶意构造的有序数据”不再每次都命中第一个元素。代码很简单:
c复制srand((unsigned)time(NULL));
int random_pivot_index(int left, int right) {
return left + rand() % (right - left + 1);
}
在 partition 之前把随机位置的元素和 arr[left](或 arr[right])交换,后续逻辑不用改。需要包含 <stdlib.h> 和 <time.h>。
第二个是三数取中。取 left、mid、right 三个位置的元素,选择值居中的那个作为枢轴。实现上有技巧,可以用三次比较交换把三个数排好序,然后直接取中间位置:
c复制int median_of_three(int arr[], int left, int right) {
int mid = left + (right - left) / 2;
if (arr[left] > arr[mid]) {
swap(&arr[left], &arr[mid]);
}
if (arr[left] > arr[right]) {
swap(&arr[left], &arr[right]);
}
if (arr[mid] > arr[right]) {
swap(&arr[mid], &arr[right]);
}
return mid;
}
调用时把 mid 位置的值交换到 partition 的枢轴位置,例如 swap(&arr[mid], &arr[right]) 再走 Lomuto,或者 swap(&arr[mid], &arr[left]) 再走 Hoare。三数取中的实际效果通常优于简单的随机化,因为它能过滤掉部分已经有序的数据。工程上更激进的做法是选样本更多求中位数,但三数取中已经足够大多数场景。
大量重复元素是另一个更难缠的问题。无论 Lomuto 还是 Hoare,重复元素都可能导致分区极不均衡。标准库级实现会采用“三分区”思路,把相等的元素集中放在中间,这样左右两侧只处理真正小于和大于的部分。完整的双指针三分区比前面两个版本复杂,但常用思路是:维护三个区域 小于、等于、大于,扫描时把等于枢轴的元素直接交换到中间。如果只是应付课程作业和普通面试,先掌握三数取中即可,重复数据不是入门阶段的第一优先级。
3.5 边界的记忆方法(避坑重点)
关于快速排序的边界,网上问得最多的就是“几种边界怎么记”。我根据自己的经验总结成四条线。
先看递归出口。只要 left >= right,区间长度为 0 或 1,直接返回。记住“一个元素不排序”,等于号不要漏,否则某些情况下会死循环。
再看 Lomuto 递归范围。Lomuto 返回的 p 是枢轴最终下标,因此左侧 [left, p - 1]、右侧 [p + 1, right]。可以这样记:枢轴已经被“定死”,不能再出现在任何子问题里,所以要写成 p - 1 和 p + 1,不是 p。
Hoare 分区要看实现版本。如果是先交换 arr[left] 和 arr[j] 的版本,返回的 j 就是枢轴位置,子区间同样排除枢轴;如果是另一种返回未分类边界、递归到 [left, p] 和 [p + 1, right] 的写法,p 是右半区开头左边一位,不是枢轴位置。建议初学只认一种,并写下注释,例如“返回 pivot 位置,递归时不要包含 boundary”。
扫描边界也容易错。Lomuto 的 j 只扫到 right - 1,因为 right 上面放着枢轴,不能提前参与交换;Hoare 双端扫描时,i 从 left 开始找大于等于枢轴的元素,j 从 right 开始找小于等于枢轴的元素,遇到等于枢轴的元素时可以停但不能继续越过已经确定的位置,否则重复元素会导致某一边为空。
最后想强调一点:肉眼检查代码时把数值代入一个小数组,比如 [3, 1, 2],用手走一遍分区过程。我不止一次因为偷懒没手推,结果把 p - 1 写成 p,排完序后发现总有一个元素在错误位置。
4. 完整C语言实操:从零拼一个测试程序
4.1 代码骨架与函数职责划分
实际写项目时,我习惯把排序代码和测试主函数分开,这样每部分逻辑清晰,后期也方便改成算法库。下面是一个可直接编译的完整例子,融合了冒泡、Lomuto 快排和一个通用打印函数。
c复制#include <stdio.h>
#include <stdlib.h>
#include <time.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
void print_array(int arr[], int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
void bubble_sort(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
int swapped = 0;
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
swap(&arr[j], &arr[j + 1]);
swapped = 1;
}
}
if (!swapped) {
break;
}
}
}
int lomuto_partition(int arr[], int left, int right) {
int pivot = arr[right];
int i = left - 1;
for (int j = left; j < right; j++) {
if (arr[j] < pivot) {
i++;
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[right]);
return i + 1;
}
void quick_sort(int arr[], int left, int right) {
if (left >= right) {
return;
}
int p = lomuto_partition(arr, left, right);
quick_sort(arr, left, p - 1);
quick_sort(arr, p + 1, right);
}
int is_sorted(int arr[], int n) {
for (int i = 1; i < n; i++) {
if (arr[i] < arr[i - 1]) {
return 0;
}
}
return 1;
}
int main(void) {
int a[] = {34, 7, 23, 32, 5, 62, 31, 3, 12, 1};
int n = sizeof(a) / sizeof(a[0]);
printf("origin: ");
print_array(a, n);
bubble_sort(a, n);
printf("bubble: ");
print_array(a, n);
int b[] = {34, 7, 23, 32, 5, 62, 31, 3, 12, 1};
int m = sizeof(b) / sizeof(b[0]);
quick_sort(b, 0, m - 1);
printf("quick: ");
print_array(b, m);
return 0;
}
这一段代码在主流 C 编译器上都能直接跑。需要注意的是主函数里的 b 数组重新初始化了一份,因为冒泡排序已经修改了 a 的内容。如果你只想验证快排,直接从 b 开始也行。
is_sorted 函数是我调试时必写的校验工具。排序算法本身有可能“看起来正常”,但输出里有某个元素错位,利用这个函数在排序前后各调用一次,能快速确认结果。
4.2 排序正确性验证方法
单凭一两个小数组验证排序并不足够。常见的做法是构造几类数据:随机数据、完全有序数据、完全逆序数据、大量重复数据。每一种都分别跑冒泡和快速排序,然后用 is_sorted 判断结果。也建议加上最大最小值检查,确保没有越界。
随机数据可以直接用 rand() 生成:
c复制int data[10000];
srand((unsigned)time(NULL));
for (int i = 0; i < 10000; i++) {
data[i] = rand() % 100000;
}
生成之后先备份一份。由于排序是原地操作,想对比两个算法,必须用相同的原始数组,最简单的方法是额外声明一个数组,或者先排序一次,再恢复原数据。恢复原数据最稳妥的做法是保留原始副本,而不是依赖逆操作。
测试有序和逆序数据更要注意。如果快排采用固定首元素或尾元素作枢轴,遇到已经有序的数据会退化成 O(n^2),递归层数可能很大,数据量稍微一大会栈溢出。用三数取中或随机化能大幅缓解这个问题。这也是为什么我建议代码里直接集成三数取中,而不是等测出问题了再加。
我自己的测试流程是这样:先拿十来个元素的手写数组跑通逻辑,再用一万随机数据对比两个算法时间,最后专门用十万个相等元素做压力测试。这个流程能暴露绝大多数排序实现问题。
4.3 冒泡和快速排序性能对比观察
在数据量大的场景下,两种算法的差异肉眼可见。我用一万个随机整数做了简单测试,冒泡排序在优化后约耗几十到一百多毫秒,快速排序则通常只有几毫秒。如果数据规模到十万,冒泡排序会慢到让人怀疑程序死循环,快速排序依然能在几十毫秒内完成。
下面是一个大概的对比表,具体耗时取决于编译器和硬件,但数量级关系是明确的:
| 数据规模 | 冒泡排序(优化版) | 快速排序(Lomuto) |
|---|---|---|
| 1,000 | 不明显 | 不明显 |
| 10,000 | 明显卡顿 | 毫秒级 |
| 100,000 | 数秒甚至更长 | 依旧很快 |
| 1,000,000 | 不推荐测试 | 能跑但递归深度需注意 |
这个差距来自算法复杂度本身。冒泡需要反复扫描整个未排序区间,快速排序每次都用分区把问题规模减半。看到这组数据,就会明白为什么工程上排序必须用更聪明的算法。
测试耗时的 C 代码很简单,用 clock() 包住排序前后即可。注意 clock() 返回 CPU 时钟数,需要转成秒或毫秒:
c复制clock_t start = clock();
bubble_sort(data, n);
clock_t end = clock();
double ms = (double)(end - start) * 1000.0 / CLOCKS_PER_SEC;
printf("cost: %.3f ms\n", ms);
5. 常见问题与调试经验
5.1 排序结果不对,问题多半出在哪
先说一个我踩过很多次的坑:swap 值传递。很多代码“看起来完全对”,但排序后数组原封不动,就是因为有人写 swap(int a, int b) 没加指针。这个问题一出现在冒泡和快排里都会有同样表现,排查时先检查 swap 函数开头是不是 int *a 而不是 int a。
第二个常见问题是递归边界写错。快速排序里,如果 left >= right 写成了 left == right,当子区间反转后可能越界;如果漏掉大于号,某些合法输入会直接出问题。建议一律写成 left >= right,这是最安全的写法。
第三个问题是分区函数的返回值理解错误。Lomuto 返回的 p 是枢轴最终位置,递归时不能包含它;有人把 p - 1 写成 p,或者把右区间从 p 开始,这样枢轴被反复排序,可能导致同一个元素一直参与递归。调试时可以在 partition 返回后打印一下 arr[p],看它是否已经满足左右条件。
第四个问题是数组越界,尤其在 Hoare 版本里。Hoare 的循环用 do...while,如果 i 或 j 的边界条件没控制住,可能访问到 arr[n] 这种未定义内存。如果你在 Linux 下用 gcc -fsanitize=address 编译,越界会立刻报错,这个方法强烈推荐:
bash复制gcc -g -fsanitize=address -o test test.c
第五个问题我放在快排专属场景里:大文件或大数组导致栈溢出。递归函数每一次调用都会占用栈空间,如果数据量几百万且分区不平衡,递归深度可能接近数据量,进程栈很容易爆掉。解决办法是保证分区足够平衡,比如三数取中;更进一步可以改成尾递归优化,只对较短的子区间递归,较长的那一侧用循环继续处理。但入门阶段先把基础版本写对,再谈这种优化。
5.2 快速排序常见错误速查表
我把高频问题整理成一张表,方便你排查时直接对号入座:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 数组完全没变 | swap 参数没传地址 | swap 用 int *,调用用 &arr[i] |
| 数组部分有序但有一两个错位 | 递归子区间包含了枢轴 | Lomuto 写成 left, p-1 和 p+1, right |
| 程序栈溢出 | 有序数据 + 固定选首元素 | 换三数取中或随机化枢轴 |
| 死循环 | partition 条件对等号处理不当 | 检查 arr[j] < pivot 和 arr[j] > pivot 是否在不同方向等号上矛盾 |
| 越界访问 | 内层循环边界多写了一个元素 | 检查 j < n - 1 - i 或 j < right |
| 排序后稳定性不符合预期 | 用快速排序 | 快排不稳定,需要稳定场景换归并 |
| 大量重复元素时非常慢 | 二路分区对重复值不友好 | 数量级很大时用三分区或调用标准库 qsort 对比 |
这张表不可能覆盖所有奇怪问题,但排序的 bug 往往集中在“边界条件”和“指针传递”两处。遇到离奇现象,先不要靠肉眼硬找,加打印、加 sanitizer 是最快的两条路。
5.3 我对两个排序的实际感受
最后再说说我自己的经验。数据量很小时,比如几十个元素,冒泡排序和快速排序的耗时差距完全可以忽略,甚至冒泡因为代码简单,反而不容易出错。但一旦数据量上万,我绝对不会在正式代码里写冒泡。快速排序也不是万能的,它的最坏情况很隐蔽,必须做好枢轴选择,否则可能被一份看似无害的有序数据打回 O(n^2)。
实际做课程设计或小工具时,我的习惯是:如果允许使用标准库,直接用 qsort;如果是为了教学或面试,则手写带三数取中的快速排序。冒泡排序并非没用,它非常适合解释“交换排序”的基本思想,也能帮你建立分析时间复杂度的感觉。看完这篇文章,建议你亲手把两版代码敲一遍,再把边界错一错,看看程序会怎么崩溃。只有在 debug 中踩过坑,才真正算是学会了交换排序。
