☰
C语言常见排序算法全解析:从冒泡到快排的核心原理与避坑指南
2026/10/13 10:45:50 网站建设 项目流程

我在学C语言算法基础那段时间,排序几乎是绕不过去的第一座山。当时教材从冒泡排序讲起,我照着代码抄了三遍才觉得“会了”,可一到自己动手写,哪怕是交换两个数的顺序都要停下来想半天。后来我才明白,问题的根源不在于排序难,而在于我没有先把学习路线和目标想清楚。

这篇文章把我自己学习C语言常见排序算法的心得整理一遍,内容包括:为什么先从简单算法入手、五大经典排序的原理和实现拆解、写C语言排序代码时最容易踩的坑、复杂度与实际性能的关系,以及从排序延伸到工程思维的一些体会。如果你正在学C语言或者刚接触算法,希望我的这些经验能帮你少走弯路。

1. 学习路线的选择:冒泡先行还是快排直上?

1.1 我最初的纠结:先会写代码还是先懂原理

很多初学者和我一样,一开始最大的困惑不是“排序怎么写”,而是“到底先学哪个排序”。有人告诉我说冒泡简单,先从冒泡入手;也有人直接甩给我快速排序的模板,说背下来面试就能用。我两个都试过,结果都很别扭。

背快排模板的时候,我虽然能把 partition 那段代码默写下来,但完全不理解为什么要那样交换,遇到稍微变形的题目立刻傻眼。后来回到冒泡和插入排序,老老实实从“比较两个数、交换两个数”这种最基础的动作开始,才慢慢找到感觉。我的体会是:先会写代码和先懂原理不是二选一,而是得先有最简单的代码做支点,原理才能立起来。

简单排序就像学游泳时的换气动作,看起来慢,但它是后面所有复杂动作的地基。我建议至少前三个排序都保持“先手写一遍 → 对比教材 → 再默写一遍”的节奏,不要只停留在看懂的层面。

1.2 不同目标下的学习路线建议

如果你的目标是把基础打牢、应付课程和考试,我的推荐顺序是:冒泡 → 插入 → 选择 → 归并 → 快速。这个顺序的好处是从O(n²)逐步过渡到O(n log n),思维跨度小,每一步都能吃透再往下走。

如果你的目标是面试和实际工程,也不必跳过简单排序直接刷快排。因为面试里经常问“这个排序稳定吗”“最好的情况是什么”,这些问题的答案藏在简单排序的细节里。举个例子,插入排序在几乎有序的数组上能跑到O(n),这个特性在后续混合排序算法中会被反复用到,不了解插入排序就很难听懂那些优化方案。

还有一条路线上的提醒:学排序的时候要多角度验证,不要只对着同一个随机样例跑。把数组改成逆序、改成全部重复、改成几乎有序,每个算法在这种输入下表现差异极大,这些对比才会让你真正理解算法特性。

2. 五大经典排序的逐层拆解:从搬砖到分治

2.1 冒泡排序:最容易理解的“搬砖法”

冒泡排序的思路一句话就能讲完:从头到尾两两比较,把大的往后挪,就像气泡往上浮一样,经过一趟排序,最大的元素就沉到末尾。它的代码也很直白:

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]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = 1; } } if (!swapped) break; } }

我学冒泡的时候最容易犯的两个错误,一是外层循环写成i < n导致最后多比较一趟,二是忽略了 swapped 标记。这个标记很重要,它能让数组在已经有序的时候提前退出,达到最好的O(n)复杂度,不加这个优化的话冒泡在任何情况下都是O(n²)。

冒泡排序真正的教学意义在于,它把“比较—交换”这两个最基础的原子操作,变成了两层循环的结构化表达。不要因为它在实际中性能最差就轻视它,我见过不少刚接触指针的同学,光是交换arr[j]和arr[j+1]三个语句的顺序都会写错。

2.2 插入排序:整理扑克牌的自然操作

插入排序是我自己觉得最“像人”的排序。你想一下打牌时候理牌的动作:拿起一张新牌,从右往左找到合适的位置插进去。数组前面部分是已经排好的,后面是没处理的,每轮把当前元素插入前面的有序区。

void insert_sort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }

这段代码看起来简单,但细节非常多。首先,key必须提前保存,因为arr[i]的位置后面会被覆盖;其次,while 循环里移动的是比 key 大的元素,不是简单的逐个交换;最后,arr[j + 1] = key这一步一定不能忘。

插入排序有两个难得的好品质:一是稳定,因为遇到相等元素时循环条件用>而不是>=,相对顺序不会被破坏;二是当数据几乎有序时,内层循环几乎不移动,复杂度接近O(n)。这两个特性让插入排序成为很多工程级混合排序里“最后收尾”的常用选择,比如数据规模小于某个阈值时直接切到插入排序。这个点我当时没在意,后来看高级排序实现才发现它的价值。

2.3 选择排序:最“本分”的最小值策略

选择排序的思路也简单:每次从未排序部分选出最小的元素,和当前位置交换。它比冒泡的好处是交换次数少,最多只交换 n-1 次,适合那种交换特别昂贵的场景。

void select_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[min_idx]) min_idx = j; } if (min_idx != i) { int tmp = arr[i]; arr[i] = arr[min_idx]; arr[min_idx] = tmp; } } }

很多人以为选择排序“比较稳定”,其实它是不稳定的。举个例子,数组[5a, 5b, 1],第一轮找到最小值1,把它和第一个位置的5a交换,得到[1, 5b, 5a],两个5的相对顺序就颠倒了。这个反直觉的点在面试里经常被当作考察细节的陷阱,我当时就是因为没仔细想清楚,回答错了才留下深刻印象。

选择排序的另一个特点是,无论数据原本是什么顺序,它的比较次数始终是固定的 n(n-1)/2。也就是说它没有最好情况、最坏情况之分,天然稳定在O(n²)。这种“没有惊喜也没有意外”的特性,让它很适合在一些对耗时要求绝对稳定的嵌入式场景中使用。

2.4 归并排序:分治思想的第一课

归并排序是我第一次真正感受到“算法设计”威力的排序。它把数组从中间一切两半,分别排序,再把两个有序数组合并成一个。这种“先拆到底,再逐层合起来”的策略叫分治。

void merge(int arr[], int left, int mid, int right) { int len = right - left + 1; int *temp = (int *)malloc(len * sizeof(int)); int i = left, j = mid + 1, k = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) temp[k++] = arr[i++]; else temp[k++] = arr[j++]; } while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; for (int p = 0; p < len; p++) arr[left + p] = temp[p]; free(temp); } void merge_sort(int arr[], int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; merge_sort(arr, left, mid); merge_sort(arr, mid + 1, right); merge(arr, left, mid, right); }

学习归并排序时,最重要的收获不是背代码,而是理解“合并两个有序数组”这个子问题。两个有序数组合并只需要一次线性扫描:两个指针分别指向两个数组的开头,谁小取谁,剩余的直接拼上去。这个操作在后面的链表合并、求逆序对等问题里反复出现。

我在写 merge 函数时踩到的第一个坑是没有写free(temp),导致内存泄漏。第二个坑是mid = left + (right - left) / 2这个写法,一开始写的是(left + right) / 2,当时数组小没出问题,但老师提醒我当 left 和 right 都很大时可能溢出。第三个坑是要记住往原数组回填时下标从left + p开始,而不是从0开始。这三个坑加起来让我明白了:算法思路可以画图说明,但 C 语言的实现必须落实到指针和边界。

归并排序时间稳定在O(n log n),代价是需要额外O(n)的辅助空间。它是稳定的排序,前提是合并时遇到相同元素优先取左边,也就是代码里的<=而不是<。

2.5 快速排序:平均性能最好的通用排法

快排的平均性能非常好,实际应用也最广泛,但初学时也是最容易“听懂但写不对”的排序。它的思想是选一个基准值 pivot,把数组分成左边都比 pivot 小、右边都比 pivot 大的状态,然后递归处理左右两部分。

int partition(int arr[], int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] < pivot) { i++; int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } } int tmp = arr[i + 1]; arr[i + 1] = arr[high]; arr[high] = tmp; return i + 1; } void quick_sort(int arr[], int low, int high) { if (low < high) { int p = partition(arr, low, high); quick_sort(arr, low, p - 1); quick_sort(arr, p + 1, high); } }

快排里最核心的 partition 实现需要理解一个“循环不变式”:i是小于 pivot 区间的最后一个位置,j是扫描指针。每轮如果arr[j]小于 pivot,就先把i前进一格,再交换arr[i]和arr[j]。这个思想理解了,后面很多数组分区的题目都能套用。

快排是不稳定的,因为 partition 的交换会跳过一些元素,破坏相等元素的相对顺序。它的平均复杂度是O(n log n),但最坏情况会退化到O(n²),比如数组已经有序、而每次 pivot 又正好选到最大或最小值时。我刚学的时候没有意识到这个最坏情况的危害,直到有一次拿有序数组去测试,发现快排跑得比冒泡还慢,才真正理解了“数据特征决定算法命运”这句话。

3. C语言实现中的五个经典坑:指针、下标与边界

3.1 数组下标和循环边界:从 n-1 说起

C语言的数组下标从0开始,这可以说是所有排序代码写错的第一大来源。数组长度为 n 时,最后一个有效下标是 n-1。冒泡排序的外层循环需要 n-1 趟,因为最后一个元素在倒数第二趟后就已经排好了;内层每一趟只需要跑到n - 1 - i的位置,因为最后 i 个元素已经是最大的。

我至少三次把冒泡的内层循环写成j < n或j + 1 < n的变体然后越界访问。这种错误不会每次都能被检查出来,有时候在刚好分配了内存的邻接区域时程序甚至不崩溃,但它是彻头彻尾的未定义行为。一个很好的自查方法是:在二层循环里涉及arr[j]和arr[j+1]同时访问时,循环条件的上限一定是 n-1,而不是 n。

选定排序和插入排序的循环边界也是考点。插入排序从 i=1 开始,因为 arr[0] 已经可以视为长度为1的有序区;选择排序的外层到 n-1 结束,因为最后一个元素在只剩它一个时天然有序。

3.2 函数传参与 sizeof 陷阱

新手最常见的翻车操作是把“计算数组长度”写进排序函数里,比如在bubble_sort内部写int n = sizeof(arr) / sizeof(arr[0]);。表面上看没什么问题,但数组作为函数参数传入后,会退化成指针,sizeof(arr)得到的只是一个指针的大小(通常是8字节),而不是整个数组的大小。这导致算出来的 n 是 2 之类的错误值,排序代码直接乱套。

正确的做法是:在主调函数里用数组定义处计算好长度,再把长度作为参数传入。比如:

int arr[] = {5, 2, 9, 1, 5, 6}; int n = sizeof(arr) / sizeof(arr[0]); bubble_sort(arr, n);

这个坑特别隐蔽,因为在小数组上它可能只是“部分排序成功”,看上去像逻辑问题而不是长度问题。我当时排查了很久才发现是 sizeof 的问题,从此以后所有涉及数组的函数都养成传长度的习惯。

3.3 交换与赋值顺序:插入排序的三行代码

写排序代码时,交换两个元素的“三步走”是基本功,但出错率依然很高。很多人会把临时变量写在中间,比如先执行arr[j] = arr[j+1]再把原来的arr[j+1]赋回去,结果发现原值早就被覆盖了。正确写法必须是先存临时变量,再依次赋值。

插入排序里没有显式的两两交换,而是“平移元素”。它的关键步骤是:先保存key = arr[i],然后通过 while 循环把比 key 大的元素向右移动一个位置,最后在空出来的位置放入 key。如果漏掉提前保存 key 那一步,一旦arr[j] > key触发赋值,key原本所在位置就被覆盖了,后面再想放回去就缺少原始数据。

我建议学的时候强制自己在每段代码里说清楚:哪些变量在什么时候会被改变,哪些需要提前保存。这个习惯对之后学链表操作、指针操作都特别有用。

3.4 递归深度与稳定性:看得见的取舍

归并排序和快速排序都是递归写法,递归深度直接影响程序是否栈溢出。归并排序每次都严格对半切分,递归深度稳定在 O(log n) 级别,大约几十层,很安全。但快速排序在极端情况下递归深度是 O(n),比如 n=100000 的有序数组如果 pivot 总取最小或最大值,递归会一直嵌套一万层甚至更多,很容易爆栈。

所以后来我看到很多工程实现会把快排的递归改成“尾递归优化”或“只对短的一边递归”,就是为了控制栈深。这是第一个由复杂度分析引出的工程细节。

稳定性这个问题的关键点在于“相等元素的相对顺序”。比如归并排序里合并两个有序区间时,如果写if (arr[i] <= arr[j]) temp[k++] = arr[i++];,遇到相等元素时优先取左边,所以稳定;如果写成<,相等的元素会从右边先取,相对顺序就反了,排序就变得不稳定。这告诉我们,稳定性不是一个抽象概念,它藏在比较符号的微末细节里。

4. 复杂度的感性理解与实际选型

4.1 别被大O符号吓到:先算一笔实际账

学习排序时最容易被忽视却又最该重视的,是复杂度背后的“真实速度感受”。O(n²) 和 O(n log n) 的差异在数据量小的时候不明显,一旦数据量上来,差别是数量级级别的。

用 n=100000 来算一笔账:O(n²) 意味着大约 10^10 次基本操作,假设一台普通电脑每秒能执行 10^8 次简单运算,那就是一百秒左右;而 O(n log n) 大约只需要 1.7×10^6 次操作,连 0.1 秒都不到。这就是为什么没有人拿冒泡排序去处理十万级以上数据的原因。

我做过一个直观的小实验:生成 50000 个随机整数,冒泡排序跑了差不多 2 秒多,快速排序几乎瞬间完成,肉眼都看不到等待时间。这个体验比任何教科书上的复杂度公式都更能让人记住。从这以后,我写任何代码前都会先估计一下数据规模和复杂度,这个习惯很大程度上是从学习排序的时候建立起来的。

4.2 数据特征比复杂度更重要

复杂度的最好、平均、最坏情况不是平行的,它们由输入数据的特征决定。比如插入排序在几乎有序的数组上能跑出接近O(n)的耗时,因为内层循环往往一次比较就结束;而快速排序在同样“有序”的数组上如果选到最差的 pivot,反而会退化到O(n²)。这个反差让我意识到:不能只看平均复杂度,还要关注最坏情况和适用场景。

再比如数组中存在大量重复元素时,普通的快排会退化得很严重。因为 partition 会把等于 pivot 的元素随机分到两边,导致左右区间极不平衡。遇到这种情况,三路快排(把数组分成小于、等于、大于三部分)才是更合理的方案。类似的考量还有很多,数据规模小的时候适合用插入排序,数据规模大且有内存限制时可能要考虑基于堆的原地排序。

4.3 一张选型表把结论整理清楚

我把五个经典排序的核心结论整理成一张表,方便对照选择:

排序算法最好时间平均时间最坏时间空间稳定性适用场景
冒泡排序O(n)O(n²)O(n²)O(1)稳定教学入门、极小规模数据
插入排序O(n)O(n²)O(n²)O(1)稳定几乎有序的数据、小规模混合排序收尾
选择排序O(n²)O(n²)O(n²)O(1)不稳定交换代价高、比较代价低的场景
归并排序O(n log n)O(n log n)O(n log n)O(n)稳定外部排序、链表排序、需要稳定排序
快速排序O(n log n)O(n log n)O(n²)O(log n)不稳定通用大规模内存数组排序

表格背下来容易,真到用的时候要结合数据特征和稳定性需求来判断。比如后端任务里需要稳定排序且内存也够,优先归并;如果只需要把内存数组排快一点,快排加内省优化通常是最好的选择。

5. 从排序到工程思维:库函数、内省优化与后续铺垫

5.1 qsort回调函数:排序的通用化写法

学了自定义排序之后,我开始用 C 标准库自带的 qsort 函数,才发现排序还可以写得很“通用”。qsort 通过函数指针接收一个比较器,允许我们对任意类型的数组排序,核心函数原型是这样的:

int cmp_int(const void *a, const void *b) { int x = *(const int *)a; int y = *(const int *)b; return (x > y) - (x < y); } // 调用方式 qsort(arr, n, sizeof(int), cmp_int);

这里要注意比较器返回值的写法。初学者容易直接写return *a - *b,当作 int 排序没问题,但换成其他地方就有溢出的风险。判断两个数大小应该这样写:相等返回0,小于返回负数,大于返回正数。我一开始也不理解为什么要这么绕,后来写多了才明白,这种写法在极端取值下才是安全的。

qsort 的价值还在于:它让我第一次接触到了回调函数和 void* 指针,这是C语言从“面向过程语法”转向“抽象设计”的一个重要入口。理解 qsort 之后,再看其他需要传入函数指针的接口就不会觉得陌生。

5.2 快排为什么会退化,内省排序怎么救场

学习快排之后我一直在想:既然快排平均性能那么好,为什么工程实现不直接用课本里的 quick_sort?后来我发现,真正在库里面被广泛使用的排序,往往不是单靠一个算法,而是多种算法的组合,比如内省排序。

内省排序的思路很简单:开始用快速排序,但如果递归深度超过某个阈值,就改用堆排序,避免最坏情况退化到O(n²);当待排序区间缩小到一定规模比如16个元素以内时,就改用插入排序。因为小规模数据上递归和分区开销反而大于插入排序的直接比较。这种“大问题用聪明办法,小问题用笨办法”的混合策略,比单一算法要稳健得多。

我学到这里最大的触动是:工程实现不是课本算法的直接搬运,而是在正确性、性能、最坏情况之间做权衡。排序这个入门主题,其实是理解工程优化思路的极好样本。

5.3 排序对后续算法和数据结构学习的铺垫

排序算法学到深一点,会发现它和很多后续知识是互相呼应的。比如堆排序让我真正理解了完全二叉树和数组下标的对应关系,之后学优先队列、Top-K 问题的时候轻松很多;归并排序的“合并两个有序区间”思路,是链表排序、逆序对统计的基础;快排的 partition 思想在寻找第 K 大元素、荷兰国旗问题时几乎可以直接平移。

排序还是联系多种 C 语言基本功的绝佳练习。写一个完整的排序代码,你需要懂数组和指针、递归和栈、时间复杂度和内存管理,出错时还得会用断点或打印去观察每一步的状态。这些能力都是靠反复练习堆出来的,不是看书能看会的。

回头看整个学习过程,我最后悔的是没有更早开始“改条件重跑测试”。同一个排序,换成逆序数组、重复数组、几乎有序数组,跑出来的性能和现象完全不同,这些差异比任何理论分析都让人印象深刻。如果你也在学 C 语言排序,我的建议是:每个算法都自己默写一遍,再准备三组特殊数据分别测试,很多疑惑会自己冒出来,也会在动手调试中自己找到答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询