前阵子帮团队做一次C语言面试,要求候选人手写快速排序,十个人里有六个把区间边界写错,还有两个写出了死循环。其实排序算法是C语言学习里最经典的一类练习,网上代码一搜一堆,但真正自己默写、调试、分析清楚的人并不多。这篇就围绕八种常见排序——冒泡、选择、插入、希尔、归并、快速、堆排序、基数排序,把我这些年写C排序的实战体会和踩过的坑一起聊一遍。我不会只贴代码,更重要的是讲清楚每个算法为什么这么设计、边界条件怎么定、稳定性到底怎么回事,以及工程上到底该怎么选。
1. 排序算法先别急着背:先搞清这几个评价维度
1.1 比较排序的非比较排序:两条完全不同的赛道
八种排序里,前七种都属于“比较排序”,核心操作是比较两个元素之间的大小关系,然后决定是否交换或移动。基数排序例外,它根本不比大小,而是按位分桶,走的完全是另一条路。
理解这一点很重要。计算机理论里有个结论:对于任意基于比较的排序算法,平均时间复杂度不可能低于 O(n log n)。原因很简单——比较排序本质上是在做一个决策过程,每次比较只能产生“大于、小于、等于”这么有限的几种结果,要把 n 个元素的排列顺序确定下来,至少需要 log2(n!) 次比较,根据斯特林公式这就接近 O(n log n)。所以快速排序、归并排序、堆排序这些算法,理论天花板就在这里。
基数排序为什么能突破这个天花板?因为它在特殊场景下利用了额外信息:它把整数看成多位数字的组合,每一位单独统计和分配,不靠元素间比较决定顺序。换句话说,空间换时间,前提是数据形态适合按“位”或“桶”处理。
1.2 稳定性、原地性与缓存局部性
很多初学者只看时间复杂度,但实际工程里稳定性往往才是关键。所谓稳定,就是两个相等的元素,排序后相对顺序不变。为什么在意这个?因为真实业务里经常要按多个关键字排序,比如先按部门排,再按工号排。如果第二次排序是稳定的,它会保留第一次排序的顺序,那么等于实现了“部门内按工号排序”的效果。C标准库的 qsort 并不保证稳定,所以需要稳定的时候得自己写归并排序。
原地性指算法是否只需要 O(1) 的额外空间。冒泡、选择、插入、希尔、快排、堆排都是原地排序,归并排序需要 O(n) 的临时数组,基数排序也需要额外数组存桶。别小看这个问题,嵌入式环境内存以 KB 计,一个归并排序直接可能把系统压垮。
还有一点容易被忽略:缓存局部性。快速排序在分区时访问数组是顺序往前扫的,归并排序虽然是跳跃的,但合并阶段也是顺序读写,堆排序则会频繁地跑向完全二叉树的不同分支,跳跃跨度大,缓存命中率偏低。这也解释了为什么实测中很多场景下快排比堆排快,尽管两者都是 O(n log n)。
1.3 C语言实现排序的通用边界习惯
写C排序最常踩的坑就是区间边界。我自己的习惯是:排序区间用左闭右闭 [left, right],这样 while (left < right) 的操作很清晰;也有人统一用左闭右开 [begin, end),递归时传 begin 和 end-1 即可。关键是全工程里只认一种,不要混着用。
另外,交换两个变量我会在学排序时直接定义成一个宏,或者用临时变量,避免每写一次都要手打三行。但要注意,如果数组元素是结构体,直接用等号赋值交换可能涉及浅拷贝问题,排序前先想清楚是排整数还是排指针。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. O(n^2)三兄弟:冒泡、选择、插入的取舍
2.1 冒泡排序:最适合入门,也最容易优化的反向案例
冒泡排序的思路是每次从头开始,相邻两个元素比较,如果顺序反了就交换。一轮走完,最大的元素就像气泡一样浮到数组末尾。重复 n-1 轮就全部有序。
基础版的冒泡:
c复制void bubble_sort(int *a, int n) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (a[j] > a[j + 1]) {
int t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
}
}
}
}
注意内层循环的 j 只需要到 n-1-i,因为每一轮结束,末尾 i 个元素已经排好,不需要再比较。如果忘了减 i,算法没错,但多做了无意义的比较。
很多人以为冒泡排序就是 O(n^2),其实加一个标志位之后,它最好的情况能到 O(n):当数组已经有序,第一轮扫描没有任何交换,就可以直接退出。
c复制void bubble_sort(int *a, int n) {
for (int i = 0; i < n - 1; i++) {
int swapped = 0;
for (int j = 0; j < n - 1 - i; j++) {
if (a[j] > a[j + 1]) {
int t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
swapped = 1;
}
}
if (!swapped) break;
}
}
冒泡排序是稳定排序,因为当 a[j] 和 a[j+1] 相等时,不执行交换,相同元素的相对顺序自然保留。它的短板也很明显:常规随机数据下需要大量交换,内存写操作频繁。有些人面试时用冒泡体现不出水平,但如果能讲出“什么时候冒泡反而好用”,比如数据基本有序且量级很小,反而是加分项。
2.2 选择排序:交换次数最少的 O(n^2) 算法
选择排序的思路特别直白:每一次从剩余元素里选出最小的,放到当前区间的开头。实现时通常会维护一个 min_idx 变量,内层循环只负责找下标,找到之后才交换一次。
c复制void selection_sort(int *a, int n) {
for (int i = 0; i < n - 1; i++) {
int min_idx = i;
for (int j = i + 1; j < n; j++) {
if (a[j] < a[min_idx]) {
min_idx = j;
}
}
if (min_idx != i) {
int t = a[i];
a[i] = a[min_idx];
a[min_idx] = t;
}
}
}
这里我犯过一个典型错误:一开始把交换写在 if (a[j] < a[min_idx]) 里,内层循环每次发现更小值就交换。虽然结果对,但交换次数从 O(n) 变成 O(n^2),性能大打折扣。所以选择排序的真正价值是“比较多次,交换少次”,适合那种元素交换成本极高的场景。
选择排序不稳定。举个例子:数组 [5a, 5b, 1],第一轮选到 1,把它和 5a 交换,数组变成 [1, 5b, 5a],两个 5 的相对顺序变了。很多人背结论“选择排序不稳定”,但说不清为什么,就是因为没意识到它是跨位置交换,而不是相邻交换。相邻交换一般不会破坏稳定性,跨位置交换则很容易破坏。
2.3 插入排序:打扑克时你一直在用的算法
插入排序的思路是,把数组从前往后看成两部分:前面已经有序,后面还未处理。每轮取当前元素 key,从后往前和前面已有序部分比较,找到位置后把后面的元素整体右移,再插入 key。
c复制void insertion_sort(int *a, int n) {
for (int i = 1; i < n; i++) {
int key = a[i];
int j = i - 1;
while (j >= 0 && a[j] > key) {
a[j + 1] = a[j];
j--;
}
a[j + 1] = key;
}
}
注意循环条件是 a[j] > key,不是 >=。如果用 >=,相等元素也会右移,导致相同 key 的位置跑到比原元素更靠前的位置,排序就不稳定了。保持 >,相等时停止移动,key 会插在相等元素后面,稳定性得以保留。
插入排序最好的情况是数组几乎有序,内层 while 基本不进去,时间复杂度逼近 O(n)。所以很多工程排序在数据规模很小或接近有序时,会直接用插入排序收尾。著名的 C++ std::sort 在递归到小区间时也会切到插入排序,就是因为它的常数极小,而且对缓存友好。
乱序数据下,插入排序仍然是 O(n^2),但它的实际表现通常优于冒泡和选择,因为移动操作比交换操作要轻量一些。我在开发中如果需要手写一个临时排序,数据量不超过一两千,我往往会直接写插入排序,省心且不容易错。
3. 希尔排序:插入排序的“跳棋”式改进
3.1 为什么直接插入排序不够好
插入排序有个很强的特征:它每次只把一个元素往前挪动一个位置。如果一个很小的元素在数组末尾,它需要一路比较、一路移动,才能回到头部附近,这非常消耗时间。希尔排序的想法,就是先让元素跨大步移动,再慢慢缩小步长,最终退化为普通插入排序完成精调。
用一个直观比喻:你在整理一摞散乱的扑克牌,如果要求你每一步只能把某张牌和紧挨着它的牌比较,速度会非常慢。但如果你先按“每隔 3 张为一组”整理一遍,再按“每隔 2 张为一组”整理一遍,最后按“每隔 1 张”整理,整体效率往往高得多。这就是希尔排序。
3.2 代码实现与增量序列的影响
最经典的增量序列是反复除以 2,也就是 gap 从 n/2、n/4、n/8 一直到 1。
c复制void shell_sort(int *a, int n) {
for (int gap = n / 2; gap > 0; gap /= 2) {
for (int i = gap; i < n; i++) {
int key = a[i];
int j = i;
while (j >= gap && a[j - gap] > key) {
a[j] = a[j - gap];
j -= gap;
}
a[j] = key;
}
}
}
这里的内部逻辑就是分组插排。i 从 gap 开始,每个元素和它前面隔 gap 的那些元素做插入;当 gap 逐渐减小时,分组已经大体有序,最后的 gap=1 只是做一次完整插排收尾。代码里最容易写错的是 while (j >= gap && a[j - gap] > key),很多人会写成 j > 0,那样会访问 a[-gap] 导致越界。
希尔排序的时间复杂度分析比较复杂,它不是简单 O(n^2)。常用的除以 2 序列最坏情况约 O(n^2),但实际在大数据下比 O(n^2) 快得多。Hibbard 序列、Sedgewick 序列可以进一步降低最坏情况,但工程上如果你不是研究排序理论,用 gap=n/2; gap/=2 已经足够应付大多数练习和考试。它最大的优点是:代码短、原地、对中等规模数据友好,在嵌入式场景里想要一个比插入排序强、又不想增加太多内存的排序,希尔排序很合适。
3.3 希尔排序为什么不稳定
因为希尔排序在 gap 大于 1 的时候,会把相距很远的元素直接交换位置。比如数组 [5a, 5b, 2],第一轮 gap=1?不对,要设计一个例子说明。其实更简单:假设 gap=2,数组下标 0 和 2 分组,5a 和 2 比较,2 被提到前面,原本的 5a、5b 这两个相等元素可能其中一个被跨组交换到另一个前面,稳定性就没了。
所以工程上一旦要求稳定排序,希尔排序要直接排除。我会把它定位成“插入排序的性能增强版”,适合解决“数据量大到插排吃力,又不想引入额外数组”的场景。
4. 归并与快排:分治法的两种套路
4.1 归并排序:稳定的代价是额外空间
归并排序的思路很清晰:把数组一分为二,分别排序,然后合并两个有序子数组。递归出口是区间只剩一个元素或空。实现上最关键的是合并函数。
c复制void merge(int *a, int left, int mid, int right, int *tmp) {
int i = left, j = mid + 1, k = left;
while (i <= mid && j <= right) {
if (a[i] <= a[j]) tmp[k++] = a[i++];
else tmp[k++] = a[j++];
}
while (i <= mid) tmp[k++] = a[i++];
while (j <= right) tmp[k++] = a[j++];
for (int t = left; t <= right; t++) a[t] = tmp[t];
}
void merge_sort(int *a, int left, int right, int *tmp) {
if (left >= right) return;
int mid = left + (right - left) / 2;
merge_sort(a, left, mid, tmp);
merge_sort(a, mid + 1, right, tmp);
merge(a, left, mid, right, tmp);
}
一个很常见的坑:在 merge_sort 内部临时 malloc 一个数组。每次递归都 malloc,不仅慢,而且如果递归深度大,内存碎片会非常严重。正确做法是在入口处一次性分配一个与 a 等长的临时数组,然后传给递归函数。
归并排序为什么稳定?因为合并时遇到相等元素,先把左半部分的元素放入临时数组,右半部分等值元素自然排在后面。代码里用的是 if (a[i] <= a[j]),就保证了这一点。如果把 <= 改成 <,稳定性就被破坏了。
归并排序的时间复杂度稳定在 O(n log n),不管数据是否有序都是如此。空间复杂度 O(n)。它适合链表排序,因为不需要随机访问,只需要修改指针。外部排序(内存装不下)也常用归并思想的变体,因为它天然适合分批处理。
4.2 快速排序:平均最快的通用排序,但要有防退化意识
快排的核心是选一个 pivot,把数组分成小于 pivot 和大于等于 pivot 的两部分,然后递归处理两部分。划分函数有很多写法,我常用的是 Lomuto 分区。
c复制int median_of_three(int *a, int left, int right) {
int mid = left + (right - left) / 2;
if (a[mid] < a[left]) { int t = a[left]; a[left] = a[mid]; a[mid] = t; }
if (a[right] < a[left]) { int t = a[left]; a[left] = a[right]; a[right] = t; }
if (a[right] < a[mid]) { int t = a[mid]; a[mid] = a[right]; a[right] = t; }
return mid;
}
void quick_sort(int *a, int left, int right) {
if (left >= right) return;
int pi = median_of_three(a, left, right);
int pivot = a[pi];
int t = a[pi];
a[pi] = a[right];
a[right] = t;
int store = left;
for (int j = left; j < right; j++) {
if (a[j] < pivot) {
t = a[store];
a[store] = a[j];
a[j] = t;
store++;
}
}
t = a[store];
a[store] = a[right];
a[right] = t;
quick_sort(a, left, store - 1);
quick_sort(a, store + 1, right);
}
这里我用了三数取中的优化:取 left、mid、right 三个位置的中间值作为 pivot,避免数组本身有序时,选择第一个或最后一个元素作为 pivot 导致 O(n^2)。网上很多简化版快排直接选数组第一个元素当 pivot,这在小数据无所谓,但在已经有序的大数组上会直接退化成冒泡一样慢,甚至因为递归过深导致栈溢出。
另一个常见问题是递归出口。我见过有人写 if (left == right) return,但分区之后可能出现 left > store-1 的情况,所以必须用 if (left >= right) return,否则某些边界会死循环。
快排是不稳定排序,因为分区时把小于 pivot 的元素前移,这种跨位置交换会打乱相等元素的顺序。如果想保持稳定,需要额外空间,那就失去快排原地快的特点了。
很多语言标准库里的 introsort 就是快排的进化版:先走快排,如果递归深度太深,改用堆排序;如果区间足够小,改用插入排序。它本质上是把三种排序组合起来,这也说明没有哪个排序算法是万能的。
4.3 递归栈溢出与迭代化改造
归并排序递归深度是 O(log n),非常安全。快排最坏递归深度是 O(n),如果数据设计得刚好让 pivot 选得不理想,可能栈溢出。实际编码中,有两个办法缓解:一是上面提到的三数取中/随机 pivot;二是限制递归深度,比如自己实现一个 introsort 的思路。
如果你确实要在无递归环境下用快排,可以用显式栈模拟递归。但说实话,99% 的 C 应用场景下面临的数组量级不会把函数栈压垮,真正危险的是“已排序数组 + 固定选第一个元素”这种组合。我调试线上问题时遇到过一次,不是因为 Stack Overflow,而是整个程序卡到像死循环一样,最后才发现是快排退化成了 O(n^2),数据量稍微一大,时间就很可怕。从那以后,我所有手写快排都默认加三数取中。
5. 堆排序:用完全二叉树给数组排队
5.1 数组下标与二叉树的映射
堆排序的思路是把数组看成一颗完全二叉树:下标 i 的节点,左孩子是 2i+1,右孩子是 2i+2,父节点是 (i-1)/2。堆排序需要先建一个大顶堆,也就是每一棵子树的根节点都大于等于它孩子。然后每次把堆顶(最大值)放到数组末尾,缩小堆的范围,再调整堆。
c复制void heapify(int *a, int n, int root) {
while (1) {
int largest = root;
int l = 2 * root + 1;
int r = 2 * root + 2;
if (l < n && a[l] > a[largest]) largest = l;
if (r < n && a[r] > a[largest]) largest = r;
if (largest == root) break;
int t = a[root];
a[root] = a[largest];
a[largest] = t;
root = largest;
}
}
void heap_sort(int *a, int n) {
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(a, n, i);
}
for (int i = n - 1; i > 0; i--) {
int t = a[0];
a[0] = a[i];
a[i] = t;
heapify(a, i, 0);
}
}
建堆的时候,从 n/2-1 开始,这是最后一个非叶子节点。很多初学者会从 n-1 开始,或者从 0 开始,都能跑,但前者多调用很多次无意义的 heapify,后者只从叶子节点开始也不对。回到定义理解:叶子节点本身是单节点堆,不需要调整;从最后一个非叶子节点往根节点调整,才能保证整棵树满足堆性质。
5.2 为什么堆排序不是稳定的
堆排序在“堆顶与末尾元素交换”这一步,会让一个可能位于数组最前面的最大值直接换到最后,跨过很多元素。如果数组里有多个相同最大值,这个跳跃式交换很容易改变它们的相对位置。所以堆排序和选择排序类似,都是“跨越式选择”类算法,稳定性天生不占优势。
堆排序的优点是不需要额外空间,最坏也是 O(n log n),没有快排那种退化风险。缺点是常数较大:建堆阶段 O(n),每次调整要沿着树向下走,比较次数大约是 2n log n 级别,而且访问内存是跳跃式的。实测中,堆排对随机数组往往明显慢于快排和归并,这一点在理解算法时要有数。
我一般在什么情况下用堆排序?系统里明确不能递归、不能用额外 O(n) 空间,但又要求 O(n log n) 保证的时候。比如某些嵌入式环境里,栈空间极小,调用归并或快排都有风险,堆排序是最稳的选择。另一个场景是“优先队列”,但那是数据结构问题,不是排序问题,不过它的核心就是堆调整。
6. 基数排序:不靠比较也能排序
6.1 LSD 基数排序的原理与实现
基数排序有两种方向:MSD(从高位到低位)和 LSD(从低位到高位)。最常见的是 LSD,配合稳定的计数排序,逐位处理整数。以十进制为例,先按个位排序,再按十位,再按百位,依次类推。因为每一轮都稳定,低位的顺序会在高位排序后被保留,最终整体有序。
实现上,每一轮统计每个桶里元素个数,再计算前缀和,从后往前回填,这样就保证了稳定。
c复制void counting_sort_for_radix(int *a, int n, int exp, int *output) {
int count[10] = {0};
for (int i = 0; i < n; i++) {
int d = (a[i] / exp) % 10;
count[d]++;
}
for (int i = 1; i < 10; i++) count[i] += count[i - 1];
for (int i = n - 1; i >= 0; i--) {
int d = (a[i] / exp) % 10;
output[count[d] - 1] = a[i];
count[d]--;
}
for (int i = 0; i < n; i++) a[i] = output[i];
}
void radix_sort(unsigned int *a, int n) {
unsigned int max_val = a[0];
for (int i = 1; i < n; i++) {
if (a[i] > max_val) max_val = a[i];
}
int *output = (int *)malloc(n * sizeof(int));
if (!output) return;
for (unsigned int exp = 1; max_val / exp > 0; exp *= 10) {
counting_sort_for_radix(a, n, exp, output);
}
free(output);
}
这里我把函数设计成 unsigned int 版本,因为负数处理比较复杂。实际项目里如果要对 signed int 用基数排序,一般会把负数单独处理,或者映射成无符号整数,这要格外小心,不能直接按补码位来排。
6.2 基数排序的应用边界
基数排序的时间复杂度是 O(d * (n + k)),d 是位数,k 是基数(十进制就是 10,二进制就是 2,字节维度就是 256)。当 d 很小、n 很大时,它可能比 O(n log n) 的比较排序更快。
但它的局限性非常明显:只适合整数、定长字符串这类可以按“位”拆分的对象。如果要对结构体按某个 float 字段排序,基数排序也不能直接用。并且它需要额外空间,n 特别大时内存开销可能很可观。如果数据范围很小,比如 0 到 10000,那么计数排序本身就可能比基数排序更直接,甚至比快排还快。我这里把基数排序放在八种排序里,主要希望读者理解“不比较也能排序”这条路线,而不是真的遇到任何整数都无脑用基数排序。
7. 八种排序实测复盘与日常选型
7.1 一张表看清复杂度、空间与稳定性
我把八种排序综合成一个表,方便对照:
| 排序算法 | 平均时间 | 最坏时间 | 最好时间 | 辅助空间 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 |
| 选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
| 插入排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 |
| 希尔排序 | 取决于增量序列 | O(n^2) | O(n log n) | O(1) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n^2) | O(n log n) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
| 基数排序 | O(d(n+k)) | O(d(n+k)) | O(d(n+k)) | O(n+k) | 稳定 |
快速排序的辅助空间这里写 O(log n),指的是递归栈,而不是数组空间。当你面试被问到“快排空间复杂度是多少”时,如果回答 O(1) 会被认为没考虑递归栈。标准答案一般是 O(log n) 到 O(n),取决于递归深度。
选择排序的“最好时间”理论上还是 O(n^2),因为它无论如何都要扫描剩余元素找最小,即使输入已经有序。冒泡和插入经过优化,才能在最好情况下达到 O(n),这是经常考的知识点。
7.2 工程选型:别为了“高级”而乱用
如果让我给出一个实用的决策顺序,我会这样选:
- 数据量很小(比如几百个):直接用插入排序,快且好写。
- 数据量大且不在乎稳定性:优先用快速排序,配合三数取中和小区间插入排序优化。
- 数据量大且要求稳定:用归并排序,但要接受额外 O(n) 空间。
- 系统栈很小、禁止递归、要求原地排序:用堆排序。
- 数据是整数且范围有限:考虑计数排序/基数排序,可能会带来惊喜。
- 数据基本有序:插入排序能直接秒杀很多 O(n log n) 算法。
不要每次排序都往快排上套。我遇到过有人拿着 50 个元素的结构体数组,非要写一个递归快排,结果代码比插入排序长好几倍,性能还差不多,这就是典型的“为高级而高级”。
在很多语言的标准库里,排序算法也不是单一选择。C 标准库的 qsort 虽然叫“快速排序”,但具体实现各平台不同,它不保证稳定。C++ 的 std::sort 通常是内省排序,std::stable_sort 是归并排序思路。这些库实现都经过深度优化,比我们随手写的版本可靠得多。日常业务代码里,能用库函数就用库函数,不要自己造轮子;笔试面试里,则要能徒手写出来,并且把边界条件、稳定性、复杂度讲清楚。
7.3 调试排序代码的几个实用技巧
最后分享几个我自己在调试 C 排序代码时觉得非常实用的方法。
第一,写一个随机数组生成器,专门用来压测。用 rand() 生成各种规模的数组,再写一个简单的 is_sorted 函数验证结果。这是最基础但最重要的测试手段。我见过太多同学写完排序后只在固定数组上跑一遍就认为没事,结果换一个随机用例就崩。
第二,边界条件测试一定包含:空数组、单元素、两个相等元素、全部相等、数组已经有序、数组逆序。这一套用例能测出绝大多数堆溢出和死循环问题。
第三,如果怀疑某个排序在某些输入下会退化,可以故意构造“已排序”和“反序”数据去对比耗时。快速排序如果退化成 O(n^2),在几十万数据量下会出现肉眼可见的卡顿,这时候基本就是 pivot 选法有问题。
第四,如果程序莫名崩溃,先怀疑数组越界,再怀疑递归没有出口。打印函数入口处的 left、right 和 pivot 值,通常一瞬间就能定位问题。别问我为什么知道,堆排序那段代码我曾因为 heapify 里少写了一个 while 的退出条件,在千万级数据上调试了整整一晚上。
我在实际项目中,很少真的需要徒手写这些排序,但每次对标准库排序行为产生疑问时,我都会回到这些基础算法里找答案。理解它们不是让你背代码,而是让你在遇到奇怪的数据特征时,知道性能瓶颈到底来自排序理论的下界,还是来自工程实现的细节。这八种排序就像八把不同的工具,真正用的时候选对工具,比工具本身更重要。
