排序算法是C语言算法学习的“第一块敲门砖”。我对这个说法的理解是:排序算法能同时锻炼数组、循环、函数、递归、甚至内存操作等一整套底层基本功。很多人觉得C语言排序很枯燥,无非就是背代码,实际上如果真正用心手写一遍,你会发现它在算法学习中的地位几乎无可替代。这篇文章是我从“只会调qsort”到“能独立实现快排、归并、堆排序”的记录,也是我踩了很多坑之后整理出的一套学习心得。适合刚开始学C语言、准备计算机二级、或者正在刷算法题的朋友们参考。
这里先说明一下:排序算法属于C语言算法基础的核心内容,看似简单,但越挖越深。今天围绕冒泡、选择、插入、快排、归并、堆排序这六种最常见的排序,把实现思路、C语言代码、边界条件、调试技巧都拆开讲清楚,希望能帮你少走一些弯路。
1. 先理清楚排序算法的学习地图
1.1 为什么排序算法是C语言算法入门的第一课
C语言最接近底层,数组和指针本质上就是内存操作。而排序算法几乎都建立在“数组 + 循环 + 比较 + 交换”这套组合上。写一次冒泡排序,你就能体会到数组下标的边界有多容易踩穿;写一次插入排序,你能感受数据搬移和交换的区别。排序算法的复杂度也清晰可见,非常适合用来验证自己是否真正掌握了时间复杂度的分析。
很多学弟学妹问我:“C语言语法学完了,下一步练什么?”我通常建议先手写排序算法,而不是急着上链表和二叉树。因为排序算法能把循环嵌套、函数封装、递归、内存分配、调试工具这些最核心的东西串起来。更重要的是,排序结果可以立刻验证:随机生成一组数据,排完序检查是否升序,马上就能判断代码有没有问题。这种即时反馈对学习信心很有帮助。
1.2 从简单到复杂:我建议的路线
我的学习路线分三步走:先啃三类O(n^2)的基础排序——冒泡、选择、插入;再学两个分治排序——快排和归并;最后挑战堆排序。这个顺序有讲究:基础排序帮你理解“比较、交换、移动”的基本动作;快排和归并让你掌握递归分治;堆排序逼你跳出线性思维,用数组模拟完全二叉树。等到这六种写完,再去看桶排序、基数排序,会觉得轻松很多。
有些同学一上来就练快排,代码背得滚瓜烂熟,但一问“快排什么时候会变成O(n^2)?”就答不上来。原因就是跳过了基础排序练习,对“有序数据”这个坑没有感觉。所以我的建议是不要嫌冒泡排序简单,把它当成手感和调试工具来练,后面所有高级排序的边界判断都会受益。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种基础排序的C语言实现与踩坑记录
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;
}
}
}
}
这段代码最容易出错的就是内层循环的终止条件。我第一次写的是 j < n - 1,结果每一轮都把已经排好的最后几个元素又比较了一遍,虽然不是致命的逻辑错误,但白白增加了不少比较次数。为什么写成 n - 1 - i?因为每完成一轮,数组末尾就会有 i+1 个元素已经处于最终位置,不需要再参与之后的比较了。这个细节很多人背代码时没注意,但在笔试和面试里经常被拿出来问。
冒泡排序有一个经典优化:如果某一轮里没有任何交换发生,说明数组已经整体有序,可以直接结束。我建议你亲手加一遍这个优化,哪怕最后用不上,也能加深对“交换”这个动作的敏感度。实现也不难:定义一个 int swapped = 0;,在交换时置为1;每一轮结束时如果 swapped 仍然是0,就 break。这种“用状态变量判断提前退出”的写法,在很多算法场景里都用得上。
2.2 选择排序:先把“找最小值”和“交换”分开
选择排序的核心是“每轮找到未排序区间的最小值,然后把它放到当前区间的起始位置”。和冒泡排序不同,它不追求相邻元素实时交换,而是先扫描一遍,找到最小值的下标后再统一交换。
c复制void selection_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 temp = arr[i];
arr[i] = arr[min_idx];
arr[min_idx] = temp;
}
}
}
很多人会疑惑:选择排序每一轮同样要遍历那么多元素,为什么说它交换次数少?因为无论找最小值的过程中发生多少次比较,最终只执行一次交换。比较次数仍然是 n-1 + n-2 + ... + 1,但交换次数最多是 n-1 次。所以当“交换两个元素”的代价很高(比如结构体变量赋值)时,选择排序会比冒泡排序更合适。
写选择排序最容易犯的错是在找到新的最小值时就立刻交换,而不是等整个区间扫描完。我有一回就犯了这个错误,结果数组被来回倒腾得乱七八糟,因为后面可能还有更小的元素需要继续参与扫描。记住:先记住下标,扫描结束后再交换,这是选择排序的正确姿势。另外,因为选择排序是“越过相同值直接交换”,它是不稳定排序,这在后面的进阶部分会再展开。
2.3 插入排序:一张新扑克牌插进手里的牌堆
插入排序特别像打扑克牌时整理手牌:手里的牌是有序的,抓到一张新牌,就从右往左找到合适位置插进去。在数组里实现这个操作,需要把比新牌大的元素逐个右移一位,最后腾出的位置放入 key。
c复制void insertion_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] 一定要在移动元素之前保存下来,否则后面的 arr[j + 1] = arr[j] 会把 arr[i] 的值覆盖掉,新来的牌就“丢”了。第二,这里的 arr[j + 1] = arr[j] 是搬移,不是交换。搬移只做一次赋值,而交换要做三次赋值,这是插入排序在数据量小且基本有序时效率很高的原理之一。
最好情况下,数组本身已经接近有序,插入排序的while循环几乎不执行,时间复杂度近似O(n)。这种特性被很多工程排序算法利用:比如数据量小于某个阈值时,转而使用插入排序,因为常数小、局部性好。我之前用C语言处理一个字符串排序题时,字符串数量只有几十个,直接用插入排序配合 strcmp 就通过了,说明简单排序不是没用,而是要看场景。
3. 进阶排序:从O(n^2)到O(n log n)
3.1 快速排序:递归、分治、边界条件三座大山
快速排序是面试和笔试出现频率最高的排序算法之一。核心是“选一个基准值,把数组分成左边比基准小、右边比基准大的两段,然后递归排序两段”。C语言实现通常有两种分区方案:Lomuto分区和Hoare分区。Lomuto更直白,适合教学:
c复制void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
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++;
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[high]);
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);
}
}
这个版本很容易写出bug的地方有三个。第一个是 pivot = arr[high],它被保存在 partition 函数的局部变量中,后面在循环里无论数组怎么交换,都不会影响 pivot 的值,这点是安全的;但如果你在循环中直接拿 arr[high] 当作基准,一旦交换发生后 arr[high] 被改动,整个分区逻辑就会错乱。第二个是递归终止条件,必须写成 low < high,不能写成 low <= high,否则分区后会无限递归直到栈溢出。第三,当数组中重复元素很多时,Lomuto分区可能两边极不平衡,复杂度退化到 O(n^2)。
所以很多工程实现会随机选pivot或三数取中。随机选pivot只需要在函数开头交换一下 arr[high] 和一个随机下标即可;三数取中则比较 low、mid、high 三个下标对应的值,取中间值作为基准。我建议你两个都练一练,因为面试官喜欢追问这些优化细节。快速排序的平均复杂度O(n log n),但由于它使用递归栈,空间复杂度O(log n)到O(n)。它本身不稳定,但在很多应用里,只要不要求稳定,快排的性能通常是最好的。
我自己的经验是:写快排之前最好先在纸上画一个只有5个元素的数组,手动走一遍 partition,体会 i 和 j 的变化。画过一遍之后,代码里的那些边界条件就不再是死记硬背了。
3.2 归并排序:递归分治加一个临时数组
归并排序是另一种完全不同的分治思路:先把数组对半拆成两半,直到每个子数组长度为1,再把两个有序子数组合并成一个有序数组。这个过程天然是递归的,而且合并部分需要额外的临时数组来保存中间结果。
c复制void merge(int arr[], int left, int mid, int right, int tmp[]) {
int i = left, j = mid + 1, k = left;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
tmp[k++] = arr[i++];
} else {
tmp[k++] = arr[j++];
}
}
while (i <= mid) tmp[k++] = arr[i++];
while (j <= right) tmp[k++] = arr[j++];
for (int t = left; t <= right; t++) {
arr[t] = tmp[t];
}
}
void merge_sort(int arr[], int left, int right, int tmp[]) {
if (left >= right) return;
int mid = left + (right - left) / 2;
merge_sort(arr, left, mid, tmp);
merge_sort(arr, mid + 1, right, tmp);
merge(arr, left, mid, right, tmp);
}
这里有一个非常重要的细节:mid 的计算不要写成 (left + right) / 2,因为当 left 和 right 都接近 INT_MAX 时,相加会溢出。写成 left + (right - left) / 2 可以避免这个问题,这是一个在算法题里很常见的小优化。合并时为什么条件是 arr[i] <= arr[j] 而不只是 <?因为 <= 能让两个有序子数组中相同值的元素先取左边子数组的,从而保证归并排序是稳定的。如果这里写成 <,在结构体排序或者需要按多个关键字排序时,结果就会不符合预期。
归并排序的缺点是需要O(n)额外空间,但它的时间复杂度稳定在O(n log n),不管输入是否有序都一样。所以它对“几乎有序的数据”或“严重逆序的数据”表现都很稳定。学习中值得注意的地方是临时数组 tmp[] 的作用域:不要在 merge 函数里面重复创建局部数组,否则每次递归都会申请内存,不仅是性能问题,还可能在数据量大时直接栈溢出。正确做法是在外层分配好 tmp,然后在递归过程中一直复用。
我后来在刷“求逆序对数量”的题目时,就是直接把归并排序的合并逻辑拿来改:合并时如果左边元素大于右边元素,说明左边剩余的所有元素都比右边的当前元素大,逆序对数量就增加“左边剩余元素个数”。这让你发现问题之间是可以触类旁通的。
3.3 堆排序:用数组模拟完全二叉树的细节
堆排序是六种排序里最有“数据结构感”的一个,因为它需要把数组看成一棵完全二叉树。下标 i 的父节点是 (i - 1) / 2,左孩子是 2 * i + 1,右孩子是 2 * i + 2。堆排序分两步:先建大顶堆,再把堆顶元素换到数组末尾,缩小堆范围继续调整。
c复制void sift_down(int arr[], int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest]) largest = left;
if (right < n && arr[right] > arr[largest]) largest = right;
if (largest != i) {
swap(&arr[i], &arr[largest]);
sift_down(arr, n, largest);
}
}
void heap_sort(int arr[], int n) {
for (int i = n / 2 - 1; i >= 0; i--) {
sift_down(arr, n, i);
}
for (int i = n - 1; i > 0; i--) {
swap(&arr[0], &arr[i]);
sift_down(arr, i, 0);
}
}
第一个容易踩的坑是建堆的起点。最后一个非叶子节点的下标是 n / 2 - 1,它之前的所有节点都需要自底向上做一次“下沉”调整。有些同学从 n - 1 开始向下调整,结果没有任何错误提示,但堆的形态不对,排序结果就错了。第二个是 sift_down 里递归调用时的参数,必须传 largest,而不是原来的 i。因为交换之后,原来在 i 位置的值跑到了 largest 位置,这个新位置可能继续破坏堆性质,需要继续往下调整。
第三个是排序阶段循环里,交换堆顶到末尾后,参与堆调整的数组长度变成了 i,所以调用 sift_down(arr, i, 0) 要把长度设成 i。如果仍然传 n,已经排好的末尾最大值又会被当成堆的一部分,前功尽弃。堆排序的时间复杂度严格O(n log n),空间复杂度O(1),但它通常在实际运行时不如快排快,因为常数大、缓存命中率不高。它的优势在于:没有最坏情况,也不需要额外空间,适合嵌入式和内存受限环境。
我实际练了几遍后发现,堆排序掌握的是“堆”这个概念,它不只能排序,还能解决TopK问题:比如维护一个大小为K的小顶堆,扫描数组时如果元素比堆顶大,就替换堆顶并下沉调整。所以学堆排序别只盯着“把数组排好”这个结果,更要理解“下滤调整”这个动作本身。
4. 排序算法的对比与取舍
4.1 一张表看懂六种排序的复杂度与稳定性
学完几种排序后,一定要能背出下面这张表。面试手撕算法时,面试官几乎必问时间复杂度和稳定性。
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n^2) | O(n^2) | O(1) | 稳定 |
| 选择排序 | O(n^2) | O(n^2) | O(1) | 不稳定 |
| 插入排序 | O(n^2) | O(n^2) | O(1) | 稳定 |
| 快速排序 | O(n log n) | O(n^2) | O(log n) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
为什么选择排序不稳定?举个例子,数组 [5a, 5b, 2],第一轮找到最小值2,和第一个元素5a交换,结果变成 [2, 5b, 5a]。原来5a在5b前面,现在反而在5b后面,相对顺序被破坏。快速排序不稳定是因为最终需要把pivot交换到某个中间位置,这个交换可能跨过若干相同值的元素。归并排序只要合并条件写成 <= 就稳定,这也是它在实际应用中常被选择的原因之一。
4.2 稳定性到底有什么用?什么时候必须关注
稳定性这个词听起来抽象,但在真实业务场景里非常关键。最常见的就是“多关键字排序”。比如有一个学生结构体数组,先按姓名升序排好,再按考试成绩降序排;如果第二次排序是稳定排序,那么分数相同的学生仍然保持第一次排序后的姓名顺序;如果第二次排序不稳定,姓名顺序就会被打乱,结果就是“分数相同的学生,姓名乱序”,不符合展示逻辑。
所以在封装一个排序接口时,如果你没有明确说“不要求稳定”,默认都应该优先考虑稳定排序。这也是为什么归并排序常常用在需要稳定性的地方,而快排虽然快,却因为不稳定在部分场景不能直接用。稳定性还涉及一个问题:当你自己实现比较函数时,C标准库的 qsort 是不保证稳定的,而C++的 std::stable_sort 则保证稳定。如果笔试题目要求保持原有顺序,就别依赖 qsort,可以自己写归并排序或插入排序。
4.3 有了qsort,为什么还要手写排序
C语言标准库提供了 qsort,可以排序任意类型的数组,只要提供一个比较函数。比如:
c复制int cmp_int(const void *a, const void *b) {
return *(int *)a - *(int *)b;
}
qsort(arr, n, sizeof(int), cmp_int);
很多初学者觉得“既然有现成的,为什么还要手写?”我的看法是:排序算法是“算法思维训练”,不是“写一个排序的功能”。你不手写快排,就永远不知道分区函数和边界条件有多容易出错;你不写归并排序,就不知道临时数组怎么管理最合理;你不写堆排序,就没法理解“堆”到底是怎么通过数组模拟出来的。
这些思维是后续很多算法题的地基。比如利用快排分区思想找第K大元素,这不是调用 qsort 后再取第K个,那样复杂度是O(n log n),而快排思想的 quick_select 平均复杂度是O(n)。又比如求逆序对数量,直接排序得不到答案,必须利用归并合并过程去统计。所以“手写排序”不是落后,而是为了训练你理解数据结构和算法之间更深层的联系。
5. 学习过程中常见问题与排查实录
5.1 数组越界:排序代码最常见的崩溃点
排序代码越界,最典型的是冒泡排序把内层循环写成 j < n,然后访问 arr[j + 1] 时越界。程序在本地可能没报错,但一旦数组边界后是其他变量,就会出现莫名其妙的魔改。排查时不要靠肉眼干瞪眼,用编译器自带工具最快。GCC编译时加上 -g -fsanitize=address,undefined,运行出错后会精准定位到越界的代码行和具体访问的内存。在Linux环境下学习C语言,这套组合强烈推荐。如果是Windows环境,可以用Visual Studio的调试模式和“分析->编译器的安全检查”来做类似检查。
写循环条件时,我习惯先在注释里写出下标的合法范围。例如冒泡排序内层循环的合法下标是 [0, n-2],因为要访问 arr[j + 1]。写完代码再回来看一眼这个范围,能避免大多数越界问题。
5.2 递归深度:快排和归并的栈溢出风险
递归实现的排序算法都可能栈溢出。快排最坏情况下递归深度接近n,如果数据已经有序列,而pivot恰好选到最大或最小值,那递归深度就是n,100万条数据可能直接爆栈。所以在测试时,先用小规模数据验证正确性,比如10万个随机数,等调试通过再逐步增大数据量。
想直观感受递归深度,可以在函数入口加一个静态计数变量或全局变量,记录最大深度。我在学习时用这个办法,看到快排在逆序数据上的递归深度比随机数据大了几万倍,才真正理解“为什么快排怕有序数据”。归并排序的递归深度是 log2(n),相对安全,但也要注意不要因为数组太大导致栈帧过多。
5.3 交换的三种写法:临时变量才是最优解
交换两个变量有很多写法,常见的是临时变量、加减法、异或。我见过不少同学为了展示“技巧”在排序里用异或交换,但这是有风险的:如果传入的两个参数是同一个地址,比如 swap(&arr[i], &arr[i]),异或写法会把值清零。虽然排序里不会主动传同一个地址,但某些递归调用可能偶然传入相同下标,一旦发生就是很难查的bug。加减法写法也类似,中间结果可能溢出,虽然最终值在数学上是对的,但可读性不足。最稳妥的还是临时变量:
c复制int temp = *a;
*a = *b;
*b = temp;
现代编译器会优化得很好,你不用为了“省变量”做无谓的创意。
5.4 用GDB快速定位排序逻辑错误
排序结果不对时,我会用GDB看中间状态。常用技巧包括:
- 在关键交换语句处打断点,例如
if (arr[j] > arr[j + 1])那一行,然后反复next,观察arr[j]和arr[j + 1]的值。 - 用
p arr[0]@n一次性查看数组前n个元素,能快速发现某轮排序后数组变形的位置。 - 递归排序可以设置条件断点,比如
if (right - left > 3),避免一进入递归就停住。 - 配合
display arr[j]让GDB在每次next时自动显示表达式值,比手动print快很多。
调试里最有用的辅助是我自己写的 is_sorted 函数,每次排序后调用一次,返回0就说明排序失败:
c复制int is_sorted(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
if (arr[i] > arr[i + 1]) return 0;
}
return 1;
}
调用它之后,如果返回0,立刻打印排序前后的数组,可以大幅缩小排查范围。
6. 一些自己踩过的坑和给新手的小建议
6.1 先关掉书本,再手写代码
看排序算法的书,全程都有“啊,我懂了”的感觉,但合上书自己写就会发现到处是模糊的。我的方法很简单:学完一个排序算法,放下资料,从零开始写C语言实现。写完之后再对照参考代码,你会发现自己漏掉的边界条件。这个过程比看十遍书有用。手写三四个算法之后,你对循环边界、递归终止、数组下标的敏感度会明显提升。
6.2 测试数据要够“脏”:随机、重复、负数、边界值
只用升序数组测试排序,测一百遍都是“正确”,一点抗干扰能力都没体现出来。我建议自己写一个测试函数,生成随机数据、重复数据、负数数据、以及包含INT_MAX和INT_MIN的数据。还要分别测试已经升序和已经逆序的输入,因为不同排序算法在这两类输入上的性能差异巨大。比如快排在逆序数据上会慢成O(n^2),插入排序在升序数据上会跑得飞快。准备好这些测试,才能帮助自己判断到底哪些边界没有处理好。
6.3 画图比背代码更能建立直觉
排序算法不是靠背,而是靠“脑内模拟”。每学一个算法,我会在草稿纸上画一条数组,把每个下标、每次交换都写下来。比如插入排序,画一张扑克牌排序示意图,整个过程一目了然。快排的 partition 更要多画几次,把 i 和 j 的移动轨迹画出来,才能理解为什么最后要把 pivot 换到 i + 1 的位置。这些图虽然不产出一行代码,但能帮助你在真正写代码时少很多犹豫。
6.4 面试中排序算法的高频追问
在面试场景里,排序算法的追问通常围绕几个点:手写快排并解释复杂度,问最坏情况和优化方式;如何用归并排序思想求逆序对数量;如何用堆排序解决TopK问题,时间复杂度是多少;排序算法的稳定性有什么用,举例说明。如果自己动手实现过,这些问题都好答。但是要注意,不要为了背答案而背,要能现场推演。在面试时我还会准备一个“叙事线”:从冒泡引入,说明为什么要优化,引出插入排序、快排、归并、堆排序。这种线会让面试官觉得你是有理解而不是背代码。
说实话,能把这些排序算法全部用C语言写明白的人,C语言基础一般不会差。我后来面试时,很多算法题看似和排序无关,但总能从排序思想里找到灵感。比如找数组第K大元素,其实就是快排分区的变形;求逆序对数量,就是归并排序的附加操作。如果你现在还在为排序算法头疼,别急,先把最简单的冒泡排序写出自己的风格,再加一个选择排序、插入排序,你会发现自己的代码能力和调试能力都会往上走一个台阶。最后一个小建议:写完每个排序后,把代码里数组下标的变化画成一张图,用笔在纸上走几趟,这比任何视频教程都更容易刻进脑子里。
