如果你也学过C语言,八成绕不开排序算法这四个字。不管是课程设计、笔试面试,还是自己写着练手,冒泡、选择、插入、快速这四兄弟几乎是必然碰面的。我用C语言把这四种排序完整实现了一遍,过程中踩了不少坑,也把一些容易忽视的细节捋清楚了。这篇东西就是我的实操记录,既有完整可运行的代码,也有逐步拆解的原理说明,还有调试验证时的真实经历。适合正在学排序算法的人看,也适合准备面试前想快速把基础捡起来的人参考——我尽量把每种算法的脾气、适用范围和容易翻车的地方都讲明白。
1. 项目脉络:这四个排序算法到底在解决什么问题
排一排数字,听起来是件特别基础的事,但它背后藏的全是算法设计的核心思想。先想清楚这个问题,后面写代码才有方向,否则你只是背了四个模板而已。
1.1 排序是算法思维的起点
我第一次系统学排序算法时也没想太多,以为就是“把无序数组变成有序数组”这么简单。真正动手之后才发现,排序是一种天然的算法训练场:你需要考虑怎么比较、怎么交换、怎么减少不必要的操作、怎么处理边界条件,还要分析它在不同数据下的表现。
这四个算法恰好代表了三种完全不同的解决思路。冒泡和选择是一类——它们都基于“反复扫描”的朴素想法,区别只在扫描之后做什么;插入排序是一类——它模拟的是日常整理扑克牌的直觉动作;快速排序又是一类——它直接把问题切小,用分治的思路让工作量指数级下降。
把四种写法放在一起对比,能很直观地感受到:同样是解决同一个问题,思考方式不同,代码结构不同,运行效率可以差出好几个数量级。这种对比带来的冲击,比单纯背一段代码要大得多。
1.2 四个算法三种复杂度套路
从复杂度这张“成绩单”来看,四个算法的命运从一开始就不一样。
| 算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 冒泡排序 | 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²) | O(log n)(递归栈) |
冒泡和选择是典型的“老老实实”算法,两层循环嵌套,怎么优化都跳不出平方级别;插入排序虽然在平均情况下也是平方级别,但它有一个别家羡慕的优点——数据越接近有序,它跑得越快,最好情况直接线性。快速排序则是靠分治把平均复杂度拉到 n log n 级别,这也是它在工程上被广泛使用的最核心原因。
空间复杂度这块也值得注意:前三个都是“原地排序”,只需要常数级额外空间;快速排序虽然也是原地交换元素,但递归调用会占用系统栈,最坏情况下递归深度可能达到 n,不过平均情况下是 log n 级别。这个细节在面试里经常被追问,写代码时却很容易忽略。
1.3 为什么用C语言来写排序
很多人一开始学排序用的是伪代码,或者直接用现成库函数调一下完事。但用C语言重写一遍,体验完全不同。C语言没有任何“帮你干活的魔法”——数组就是连续内存,交换就得手动用临时变量,循环边界稍微错一点,程序直接越界崩溃。这种“亲手搬砖”的挫败感恰恰是最好的老师。
C语言的指针也让你更清楚地看到数据是怎么被搬来搬去的。我写的交换函数用到了指针传参,如果换成值传递,函数内部换得再开心,外面数组一动不动——这种经典的C语言坑,在写排序算法时几乎是必然踩一次,踩完就再也不会忘了。
还有一点,C语言生成的程序离机器很近,性能表现也足够真实。同一个排序算法,你用C写和用脚本语言写,跑出来的差距能让你直观理解“常数因子”是什么意思。后面我会放一组实测数据,就是在我的开发环境里直接编译运行的结果。
2. 核心实现:四套C语言代码与逐行拆解
说再多理论不如直接上代码。这一部分我会按顺序给出四种排序的完整C语言实现,并不只是贴代码,还会把每段代码里的关键步骤、边界处理逻辑和可以优化的细节讲清楚。这些代码我都编译运行过,可以直接抄走在自己机器上验证。
2.1 开工前的准备工作:swap与打印调试函数
写排序之前,我先把两个辅助函数准备好。第一个是交换函数,第二个是打印数组函数。别小看这两个小函数,它们决定了后面调试的效率。
#include <stdio.h> #include <stdlib.h> #include <time.h> void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; } void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); }swap函数必须用指针传参,这是C语言里最经典的一个知识点。如果你写成 void swap(int a, int b),函数内部确实交换了a和b的值,但这对函数外的原数组毫无影响。我见过很多新手在这里卡壳,其实就是没理解“值传递”和“地址传递”的区别——C语言函数参数默认是值拷贝,你拷贝了一份数字去交换,原件当然不会变。
print_array函数则是调试神器。排序算法最怕“看起来对了,其实错了一位”的情况,每次排序完打印一遍数组,真假立现。实测下来,这个小函数在排查边界问题时比任何调试器都直观。
2.2 冒泡排序的优化写法
冒泡排序是很多人接触的第一个排序算法,基本思想很简单:每一轮从头往后扫,比较相邻元素,如果顺序不对就交换,这样每一轮结束,最大的数就像气泡一样“浮”到最后面。重复 n-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]) { swap(&arr[j], &arr[j + 1]); swapped = 1; } } if (!swapped) { break; } } }我写的这个版本加了一个 swapped 标志位,这算是对经典冒泡的优化。如果没有发生任何交换,说明数组已经有序,直接跳出循环。这个优化让冒泡排序在最好情况下(原本就有序)变成 O(n),虽然日常没什么人用冒泡排数据,但理解这个优化对写作很有启发。
内层循环的边界 n - 1 - i 也值得琢磨。每一轮结束,数组末尾已经有 i 个元素排到最终位置,下一轮就没有必要再去碰它们了。如果你写成了 n - 1,大概率也能跑,但做了大量无用功,而且有过一次越界风险——当 j = n - 2 时访问 arr[n - 1] 没问题,但 j 最多到 n - 2 才对。
还有一个经典易错点:比较时写 arr[j] > arr[j+1] 是升序,如果改成 < 就是降序。这个没啥难度,但就是因为太简单,反而经常在改需求时不小心改错。
2.3 选择排序:最直观的“打擂台”
选择排序的思路可以用“打擂台”来形容:每一轮都在剩余元素中找最小的那个,然后把它放到当前轮次的起始位置。它的特点是“交换次数少”——n 个元素最多交换 n-1 次,因为每一轮最多做一次交换。
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) { swap(&arr[i], &arr[min_idx]); } } }核心点在于 min_idx 的更新。内层循环从头到尾扫描,把当前最小元素的索引记下来。这里我特意用索引而不是直接保存最小值,是因为交换时你需要知道“最小值在哪个位置”,只存值的话还得再遍历一次才能找到位置,那又白费功夫。
很多人写选择排序时会发现它和冒泡看起来很像,都是两层循环,但内层循环的起点不同。冒泡的内层循环是从头扫到 n-1-i,选择排序是从 i+1 扫到 n-1。这个区别决定了它们的交换方式完全不同:冒泡是“边走边换”,选择是“先找再换”。
选择排序还有一个特点容易被人忽略——它的比较次数是固定的,无论数据原本是什么样子,比较次数都是 n(n-1)/2 次。所以它没有任何“最好情况”的优势,哪怕数组已经有序,它还是会老老实实扫描完所有元素。
2.4 插入排序:处理近乎有序数据的神器
插入排序的思路和整理扑克牌一模一样:从第二个元素开始,把当前元素当成一张新牌,往前面已经有序的牌堆里找到合适的位置插进去。前面元素不断后移,给新牌腾出位置。
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; } }这段代码最重要的一个设计是先把 arr[i] 保存到 key 变量里。为什么?因为在后面的 while 循环中,arr[j+1] = arr[j] 会把 arr[i] 原本的值覆盖掉。如果不提前保存,后面的元素一移动,原始值就丢了,那你还插什么?这是新手最容易翻车的地方,我一开始也吃过这个亏。
边界条件 j >= 0 一定要先判断。如果 j 已经变成 -1,说明 key 比所有已排序元素都小,应该插到数组最前面。此时如果先访问 arr[j] 再做判断,就是数组越界。C语言里数组越界不一定立刻崩溃,但结果是未定义的,可能在某种数据组合下才暴露,很难排查。
插入排序真正的价值在于它的“适应性”——数组越接近有序,移动次数越少。最极端情况下,如果数组已经有序,while 循环一次都不会执行,整个算法跑下来就是一次线性扫描。这个特性让它成为处理“近乎有序”数据的首选,也是后面聊到快速排序优化时的重要搭配。
2.5 快速排序:挖坑法实现与递归边界
快速排序是整个四兄弟里的重头戏。它的核心思想是分治:从数组里挑一个基准值,把比它小的放到左边,比它大的放到右边,然后对左右两个子区间递归地做同样的事情。
我采用的是经典的挖坑法实现。所谓“挖坑”,就是先把基准值存起来,此时基准值的位置成了一个“坑”,然后用两个指针从两端交替扫描,把满足条件的元素填到坑里,自己再变成新的坑。整个过程像是倒腾位置,最后把基准值放进最终的位置。
void quick_sort(int arr[], int low, int high) { if (low >= high) { return; } int pivot = arr[low]; int i = low; int j = high; while (i < j) { while (i < j && arr[j] >= pivot) { j--; } if (i < j) { arr[i++] = arr[j]; } while (i < j && arr[i] <= pivot) { i++; } if (i < j) { arr[j--] = arr[i]; } } arr[i] = pivot; quick_sort(arr, low, i - 1); quick_sort(arr, i + 1, high); }这个实现的细节非常密,每一个 while 循环都不能写错。先从右往左扫,找到一个比 pivot 小的元素,把它填到左边坑里;然后从左往右扫,找到一个比 pivot 大的元素,把它填到右边坑里。夹逼过程中,i 和 j 逐渐靠近,最后相遇的位置就是基准值的最终位置。
特别注意条件里写的是 arr[j] >= pivot,不是 arr[j] > pivot。如果你写成大于,遇到和基准值相等的元素时,右指针不会跳过它,两个指针的移动逻辑会变得很别扭,甚至导致死循环。用 >= 和 <= 可以跳过相等元素,让指针稳定地向中间靠拢。这个细节看起来无关紧要,实际效果差很多。
递归跳出的条件是 low >= high,也就是子区间为空或只有一个元素时,无须再排序。这里的 >= 不能简化成 ==,因为当 low 和 high 相差为1时,中间没有元素,也需要退出。我一开始只判断了低等于高的情况,结果在处理一些特定数组时栈直接爆了。
快速排序的平均时间复杂度是 O(n log n),但最坏情况会退化到 O(n²)。什么情况下最坏?当每次选择的基准值都是当前区间的最小值或最大值时,分区极不平衡,递归深度直接拉满。这就是为什么后面要专门讲三数取中或者随机选基准的优化手段。
3. 复杂度评估与实测对比:快不一定只是快
理论分析归理论分析,代码到底行不行,还是要跑起来看。我专门准备了一组随机数据用来测试这四个算法的实际表现,同时也验证了一下理论上的预期。结果既在预料之中,又有些意外。
3.1 三个维度的复杂度对照
在真正上机测试前,先要理清一个概念:时间复杂度并不是一个精确的秒表读数,而是描述“数据量变大时,运行时间增长的趋势”。O(n²) 的意思是,数据量翻倍,运行时间大约变成4倍;O(n log n) 的意思是,数据量翻倍,运行时间大约只变成略多于2倍。
这四个算法里,冒泡、选择、插入的平均复杂度都是 O(n²),它们的运行时间曲线是一条抛物线——数据规模小的时候看不出差距,一旦数据量上来,增长速度会非常吓人。快速排序则是 n log n 级别的曲线,增长明显缓慢得多。
空间复杂度也是一样需要关注。快速排序虽然不额外占用大块内存,但递归本身要消耗调用栈空间。如果数据已经有序,快速排序每次都选到最差基准值,递归深度会达到 n,这对嵌入式等栈空间受限的场景是个潜在风险。其他三个算法都用迭代实现,空间复杂度恒定是 O(1)。
3.2 稳定性为什么重要
稳定性是一个排序算法的重要属性,但很多初学的人不理解为啥要管这个。稳定性的含义是:如果两个元素值相等,排序后它们的相对顺序能保持原样,那这个排序就是稳定的。
我列个表把这四个算法的稳定性标一下:
| 算法 | 是否稳定 | 原因 |
|---|---|---|
| 冒泡排序 | 稳定 | 只有 arr[j] > arr[j+1] 时才交换,等于时不交换 |
| 选择排序 | 不稳定 | 交换可能把相同元素的相对顺序打乱 |
| 插入排序 | 稳定 | 从后往前移动,遇到等于 key 的元素就停下来插到它后面 |
| 快速排序 | 不稳定 | 分区交换过程会改变相同值元素的相对顺序 |
为什么稳定性重要?举个常见的例子:学生成绩表先按总分排了一遍,现在需要按班级排,并且希望同班同学内部仍然保持总分的降序。如果第二次排序用的是不稳定排序,第一次排序的成果就可能被破坏。工程上排序经常是多重排序的叠加,稳定性这时候就是硬要求。
C语言标准库里的 qsort 是不稳定排序,而很多稳定排序的需求要靠归并排序来解决。这也是为什么排序算法不能只知道一个快速排序——不同场景需要不同属性的算法。
3.3 用随机数据测一把,让结论落地
我自己写了个小测试程序:随机生成不同规模的数组,分别用四个排序算法排序,记录运行时间。为了公平,我让四个算法都处理完全相同的输入数据——先创建一份随机数组,然后用不同函数处理不同的副本,避免某个算法恰好碰到对它不利的数据分布。
测试规模我选了三个档位:1千、1万、10万个整数。数据规模小时,冒泡排序和快速排序的差距并不明显,都是瞬间完成;到1万这个规模,冒泡和选择开始变得肉眼可见地慢,插入排序稍好一点,但也在同一水平线上;到10万时差距就拉开得非常明显了——冒泡排序在我的一台普通测试机器上要跑好几秒,而快速排序只需要几十毫秒。
这个结果完全印证了理论预期。中间还出现了一个有意思的现象:插入排序在“随机数据”下的表现其实和选择排序差不多,甚至略差,因为它需要经常移动元素。但如果我把测试数据改成“基本有序的数组”,插入排序立刻快得惊人,几乎秒杀其他三个。这提醒了我一件事:脱离数据分布谈算法优劣都是耍流氓。
快速排序在这个测试里表现最好,但我也发现它吃到了一个隐藏红利——测试数据是随机生成的,而随机分布恰好让每次选择的基准值都接近中位数,避开了它的最坏情况。如果改成有序数组再测一次,结果会完全不同,这也是下一章要详谈的坑。
4. 避坑实录:我在写排序时踩过的典型问题
代码能跑通和代码写得对之间隔着一堆隐蔽的问题。我把自己在调试这四种排序时遇到的最典型的问题和排查方法整理一遍,逐个记录下来,很多坑都是一开始完全没意识到的。
4.1 错误样例:数组越界的隐蔽触发方式
我在写快速排序时栽过一个跟头,就是内层两个 while 循环里的条件。第一次我写的是:
while (i < j && arr[j] > pivot) { j--; }当时我的想法是:找到比基准值小的才停下来吗,那用大于号不是正好?结果在特殊数据下,程序要么死循环,要么直接段错误。
原因是这样的:如果 arr[j] 等于 pivot,用严格大于号判断时,右指针不会跳过等于基准值的元素,它会一直卡在某个等于 pivot 的位置。然后左指针向右靠过来,两个指针交错或死循环。更严重的情况是 j 一直不减小,最后跑出数组边界,访问到非法内存。
正确的做法是用 >= 和 <= 跳过相等元素。这个例子特别典型地说明了一个问题:在算法里,比较运算符的细微差别可能导致完全不同的行为。写排序这类算法时,你得把“相等怎么办”想清楚,不能只考虑“大于”和“小于”两种情况。
4.2 哨兵与边界条件的统一处理
数组边界问题贯穿所有排序算法的始终。插入排序里的 while (j >= 0 && arr[j] > key),快速排序里的 if (low >= high),还有冒泡排序里的 j < n-1-i,每一个边界条件都踩过一次。
我总结出一个心得:写循环边界时,先想清楚“循环变量能取的最小值、最大值分别是什么”,然后在脑子里模拟一轮最极端的走位。比如插入排序,当 i = 1、j = 0 时,arr[j] 是合法访问,但如果 j 自减到 -1 就非法了。所以判断顺序必须是“先判断 j >= 0,再访问 arr[j]”,顺序反过来就是未定义行为。
C语言里数组越界不会像一些高级语言那样抛出异常,它很可能只是“悄悄”动了相邻内存的数据,然后在某个遥远的函数里突然崩溃。这种 bug 最难排查,因为错误现象和错误根源之间往往隔了好几步调用链。我的做法是每次写完排序算法后,专门用一个“边界值测试集”来测——包含空数组、单元素数组、两个元素的数组、全部相同元素的数组、已经有序的数组、完全逆序的数组。这些极端输入能把90%的边界问题暴露出来。
4.3 快排在特定输入下的退化问题
快速排序有个著名的“阿喀琉斯之踵”——当输入数据已经有序或者接近有序时,如果每次都选第一个元素作为基准值,分区结果会非常不平衡。左边可能全是小于基准值的元素,右边是空区间,递归深度瞬间拉满,时间复杂度退化到 O(n²)。
我有一次做性能对比测试,用了一个已经排好序的数组去测快速排序,结果它的耗时甚至超过了优化过的冒泡排序。当时我一度怀疑是自己的实现有问题,后来查资料才明白,这是快速排序“基准值选择策略”的问题,不是代码本身的逻辑错误。
解决这个问题有几种常见方案:一是三数取中法——从区间的左、中、右三个位置取三个值,选中间大小的那个作为基准值,这样能够有效避免最坏情况;二是随机选基准——从当前区间随机挑一个位置作为分区点,让最坏情况变成一个概率事件而非必然事件;三是小区间插入排序——当递归区间缩小到一定规模(比如小于16个元素)时,改用插入排序来完成剩余工作,避免过深的递归开销。
我后来把三数取中加入了快速排序,再拿有序数据去测,性能退化问题立刻改善。而且这个改动只额外增加了常数级别的比较次数,成本可以忽略不计。
4.4 检测工具与调试手法小结
写排序算法的调试手法,我实测下来最有效的方法居然是“打印大法”——在每个关键步骤后打印数组内容。排序算法不像业务逻辑那样有复杂的条件分支,它的问题往往就出在某一步交换错位了。打印出每一步中间状态,问题一眼就能定位。
举个例子,当时快速排序输出结果总是首位不对,我就把每次分区后的数组状态打印出来,很清楚地看到基准值被放到了正确位置,但有一侧的子区间里混进了不该出现的元素。仔细看代码后才发现,原来是挖坑法里 arr[i++] = arr[j] 后,i 自增了,但后续循环的条件没有同步调整,导致跳过了某些元素。
除了打印,我还用一个简单的“正确性校验函数”来验证结果:排序结束后遍历一遍数组,如果发现某处 arr[t] > arr[t+1],就输出错误信息。这个自动化校验比人眼盯着打印结果靠谱得多,尤其是处理1万个随机整数时,就算问题出现在数组中间位置,也逃不过这双“电子眼”。
调试工具也不是不能用,C语言的调试器可以断点观察变量,但对排序这种数据快速流动的场景,断点反而拖慢思路。发布代码前的最终验证,我建议用随机多组测试数据的自动化方式,而不是靠肉眼。
5. 工程视角:从手写排序到正确选型
学完这四种排序算法,如果直接跑到工程里用,第一个困惑肯定是:我自己写的快速排序,和系统库提供的排序函数,到底该用哪个?这个问题值得好好聊聊,因为答案没那么简单。
5.1 现实项目里排序通常不用自己写
在大多数应用开发场景中,你并不会真的去手写快速排序。C标准库提供了 qsort 函数,其他语言的标准库也各有各的排序实现。这些库函数的排序实现往往是高度优化的:它们会根据数据规模、数据分布自动切换策略,小数据用插入排序,大数据用快速排序或归并排序,甚至混合多种算法。你手动实现的那些版本,在大多数场景下是跑不过它们的。
qsort 的用方法很简单,它的核心是一个比较函数指针。你需要自己实现“元素之间如何比较”这个规则,然后交给库函数去执行排序。比如对整型数组排序,比较函数可以这么写:
int compare_int(const void *a, const void *b) { int ia = *(const int *)a; int ib = *(const int *)b; return (ia > ib) - (ia < ib); }这里值得注意的一点:返回值不是简单地做减法!如果两个 int 差值的绝对值超过 INT_MAX,直接用 ia - ib 可能溢出,得到错误的比较结果。这是 C 语言 qsort 使用中的经典高频坑,我见过不止一个项目在这里栽过。用 (ia > ib) - (ia < ib) 这种写法就永远不会溢出。
5.2 手写排序的不可替代场景
那既然标准库都有了,还有必要手写排序吗?有,而且不只是学习阶段的必要性。我把常见的“必须自己实现排序”的场景列出来:
- 嵌入式开发:资源极其受限,不能依赖完整的标准库,排序逻辑需要自己裁剪。
- 学习性质的项目:课程设计、算法实验,要求理解算法原理并自行实现。
- 特殊数据结构:对链表排序、对超大文件外部排序、对自定义复杂对象排序,标准排序函数不一定适配。
- 对性能有极致要求的场景:你清楚数据的分布规律,可以写出针对性的优化排序,让库函数汗颜。
我接触过一个模拟项目,内部维护一个实时更新的有序链表,每次插入新节点时直接在链表中定位插入位置。这种情况下用标准库 qsort 反而别扭,自写一个插入排序的链表版本才是合理方案,时间复杂度从重新排序的 O(n log n) 降到插入定位的 O(n)。
所以手写排序算法不是无用的重复造轮子,而是理解排序本质后,在特定场景下做的灵活变通。关键是你要知道哪种算法在什么条件下靠谱,而不是死记硬背模板。
5.3 知识延伸:从四件套走向更广阔的排序森林
学完这四种排序,下一步该干嘛?我建议往两个方向拓展。第一个方向是认识更多的高级排序算法:归并排序是稳定的 n log n 排序,同时也是外部排序的基石;堆排序通过二叉堆实现选择排序的改进,最坏情况也稳定在 O(n log n),而且不需要额外空间;计数排序、基数排序这些非比较排序,在特定数据范围内能达到 O(n) 的恐怖效率。
第二个方向是深入理解“排序算法混合”的思想。现代高性能排序实现很少只用单一算法,它们通常用快速排序做全局分区,当分区变小后切成插入排序;或者用归并排序保证稳定性,但内部对小数据块使用插入排序优化。你学的不是四个孤立的算法,而是四个可以自由组合的积木。
还有一处值得琢磨的细节:内层循环的热路径优化。冒泡排序慢,不只是因为它比较次数多,还因为它每次交换都触发读写;插入排序之所以在某些场景下比选择排序快,是因为它的元素移动是连续内存操作,缓存友好。理解了这些微观层面的原因,才算把排序算法学透了。
最后聊几句实用心得
代码写完、测试跑完,最大的收获反而不是“我会四种排序了”,而是把算法分析如何落地这件事想通了。我以前总觉得复杂度分析只是考试纸面上的东西,直到看到10万数据下冒泡和快速排序那如同天壤之别的耗时差距,才真正理解“增长趋势”四个字意味着什么。如果你也想自己动手练一遍这四个算法,我提两个小建议:第一,一定自己敲代码,不要复制粘贴,边界条件只有自己写错过一次才能记得牢;第二,写完之后用多种数据形态去测,不要只用随机数据,多试试有序、逆序、重复元素这些特殊场景。先把这四种跑熟了,再去碰归并和堆排序,你会觉得整个排序体系清晰多了。