有一次我在给一位刚学 C 语言的朋友讲排序。他皱着眉头问我:冒泡排序我勉强能看懂,插入排序到底在干嘛?为什么要把元素一个一个往前挪?我当时没有急着翻课本,而是从桌上拿了一副扑克牌,抽了六张,像发牌那样摆成一排,反问他:你平时打牌理牌,是怎么理的?他愣了一下说:起了牌之后,顺手插到该放的位置啊。我说:对,这就是插入排序。
插入排序是所有排序算法里最接近人类直觉的一个。它不搞什么花哨策略,就是重复一个动作:拿起一个元素,插进前面已经排好序的序列里,让它待在正确的位置。这个动作重复 n-1 次,整组数据就排好了。如果你准备零基础学排序,我真正建议你先把插入排序彻底搞懂,而不是急着去啃快速排序或者归并排序。原因不复杂:插入排序的价值不在“快”,而在把“排序”这个抽象问题,还原成了“整理”这个具体动作。它是最好的第一块跳板。
1. 为什么说插入排序是最接近人类直觉的排序算法
1.1 打牌理牌:你其实一直在手动执行插入排序
想象一下你正在玩扑克。摸完一轮牌,你手里已经有几张牌,比如是 5、3、7。这时候你又摸到一张 4。正常人不会把整手牌重新排一遍,而是很自然地看一圈,发现 4 应该放在 3 和 5 之间,于是把 5 和 7 往旁边挪一挪,腾出位置,把 4 放进去。
这个动作分解到计算机里,恰好就是插入排序的三个子步骤:
- 取牌:把当前要整理的元素拿出来。
- 腾位:从右往左依次比较,把比它大的牌往后挪。
- 落位:把牌放进腾出来的空位。
同样的动作重复若干次,整手牌就整整齐齐了。很多人觉得算法是课本里才有的东西,但插入排序恰恰是少数几种“你早就用过,只是不知道它叫这个名字”的算法。
我经常用这个类比去说服初学者:如果你的程序需要维护一个“始终保持有序”的数组,比如排行榜、成绩表、库存列表,每次新来一个数据都要插到合适位置,那么你其实是在重复使用插入排序的思想。
1.2 先建立一个正确的心理模型:它不是在“整体重排”
初学者最容易搞混的一点,是觉得插入排序像冒泡排序那样,从第一轮开始就在全局反复交换。不是的。插入排序每一轮只做一件事:把当前元素插到它前面那个“已经有序的序列”里。
所以在任意一轮进行中,数组都分成两段:
- 左边一段:已经排好序。
- 右边一段:还没处理,顺序保持原样。
随着轮次推进,左边这段不断变长,右边这段不断变短。最终右边消失,整个数组有序。
理解这个“半边有序、半边待处理”的心理模型,比记住代码本身更重要。因为后面你学二分插入排序、希尔排序,甚至归并排序,都离不开“局部有序”的概念。插入排序把这个概念展示得最直白。
2. 动画背后:一次完整的插入排序过程拆解
2.1 拿六个数字把整个过程走一遍
动画看的时候总是很快,容易一晃而过。我建议你拿笔在纸上,跟着下面这个例子手动推演一遍。用数组{5, 2, 4, 6, 1, 3}来演示。
先约定:我们把第一个元素 5 看作“已经排好序的部分”。从第二个元素开始,每一轮取出一个元素往前插。
| 轮次 | 取出的 key | 插入前已排序部分 | 操作摘要 | 插入后的数组 |
|---|---|---|---|---|
| 初始 | - | [5] | 把第一个元素视为已排序 | 5, 2, 4, 6, 1, 3 |
| 1 | 2 | [5] | 5 后移,2 放到开头 | 2, 5, 4, 6, 1, 3 |
| 2 | 4 | [2, 5] | 5 后移,2 前停止,4 插入中间 | 2, 4, 5, 6, 1, 3 |
| 3 | 6 | [2, 4, 5] | 5 < 6,不需要移动 | 2, 4, 5, 6, 1, 3 |
| 4 | 1 | [2, 4, 5, 6] | 6、5、4、2 依次后移,1 放到开头 | 1, 2, 4, 5, 6, 3 |
| 5 | 3 | [1, 2, 4, 5, 6] | 6、5、4 后移,遇到 2 停止,3 插入 | 1, 2, 3, 4, 5, 6 |
这六轮结束,数组变成有序。注意第 3 轮,key 是 6,它比前面已排序部分的最后一个元素 5 还大,所以一个都不用挪,直接原地不动。这是插入排序在“数据已经比较有序”时效率高的原因之一。
2.2 每一轮内部其实只有三步
很多人看动画被带偏,以为插入排序是在“交换元素”。实际上它更准确地说是在“移动元素”,并且只在最后做一次真正的插入。
每一轮循环内发生的事情是:
- 把
arr[i]的值存到变量key里,此时原位置相当于被“掏空”了。 - 用一个下标
j从i-1开始向左移动,凡是比key大的元素,都往右复制一位。 - 直到遇到一个不大于
key的元素,或者已经遍历到数组最左边,循环停止。此时把key放进arr[j+1]。
注意第二步里“复制”这个词。插入排序内部大量操作是把arr[j]赋值给arr[j+1],这本质上是元素的后移,而不是交换。这一点在阅读代码时非常关键,很多初学者会疑惑:为什么我没写swap,数组却在变化?因为后移本身就是一种移动。
2.3 动画里真正值得盯住的三个细节
看插入排序动画的时候,我建议你刻意去盯三件事:
- key 那个被抽出来的元素,它在每一轮开始时是“悬空”的,动画里通常会高亮。
- 比较方向永远是“从右往左”,也就是从已排序部分的末尾往开头走。这个方向决定了你写 while 循环时
j--的原因。 - 元素不是交换过去的,而是一个一个“挤”过去的,视觉上像多米诺骨牌往右倒。
把这三个细节在脑子里和后面的代码对应起来,你就能做到“看动画能想到代码,看代码能想到动画”。
注意:插入排序每一轮只处理一个元素,千万不要一次性想把整个数组都排好。算法最忌讳“贪多”,一轮只解决一个元素的归属,是插入排序最朴素的智慧。
3. C 语言代码实现:先跑通,再讲优化
3.1 一个能直接运行的最小版本
先不用考虑各种花哨写法,下面这段是插入排序最经典、最容易理解的 C 语言实现:
#include <stdio.h> void insertion_sort(int arr[], int n) { int i, j, key; for (i = 1; i < n; i++) { key = arr[i]; // 取出当前要插入的元素 j = i - 1; // 从它前面一个位置开始向前找 // 只要前一个元素比 key 大,就把它往后挪 while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; // 空出来的位置放入 key } } int main() { int arr[] = {5, 2, 4, 6, 1, 3}; int n = sizeof(arr) / sizeof(arr[0]); insertion_sort(arr, n); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); return 0; }这段代码在常见编译器环境下可以直接编译运行,输出结果是1 2 3 4 5 6。
3.2 逐行拆解核心循环
很多初学者拿到代码就背,背完就忘。我建议你换一种方式:一行一行地问自己“这一行在干什么,为什么在这里”。
第一行核心代码是:
for (i = 1; i < n; i++)为什么不从 0 开始?因为第 0 个元素单独看就已经是“长度为 1 的有序序列”了,不需要插入。我们从第 1 个元素开始,把它插到前面长度为 1 的序列里;下一轮处理第 2 个元素,把它插到前面长度为 2 的序列里。i 的含义是“当前要处理的下标”。
第二句:
key = arr[i];这句的意义是把当前元素备份出来。为什么要备份?因为后面的 while 循环会把前面的元素往右复制,有可能覆盖掉arr[i]的位置。如果不提前存到 key 里,等你想放回去的时候,原值已经丢了。
第三句:
j = i - 1;j 代表“当前正在和 key 比较的那个位置”,从已排序部分的最后一个位置开始。
第四句是整个算法的灵魂:
while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; }这里有两个条件,缺一不可:
j >= 0:防止访问数组下标变成负数。arr[j] > key:只要前面的元素比 key 大,就需要往后挪。
循环内部做的事情,就是把arr[j]复制到它右边一位。注意,这里每一轮都会覆盖掉arr[j+1]原来的值,但那个值要么已经在上一轮被备份走了,要么就是被掏空的位置,所以不会丢失数据。
循环结束后,j 指向的是“最后一个不大于 key 的元素”的位置。因为循环退出前 j 又执行了一次j--,所以 key 的正确落点是j + 1。最后一句:
arr[j + 1] = key;就是完成插入。
3.3 给排序加上过程输出,自己验证一遍
只看代码还是不够直观。我强烈建议你在排序函数里加几行打印,亲眼看看每一轮数组怎么变化:
#include <stdio.h> void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); } void insertion_sort(int arr[], int n) { int i, j, key; for (i = 1; i < n; i++) { key = arr[i]; j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; printf("第 %d 轮插入后: ", i); print_array(arr, n); } } int main() { int arr[] = {5, 2, 4, 6, 1, 3}; int n = sizeof(arr) / sizeof(arr[0]); printf("初始数组: "); print_array(arr, n); insertion_sort(arr, n); return 0; }运行后你会看到类似这样的输出:
初始数组: 5 2 4 6 1 3 第 1 轮插入后: 2 5 4 6 1 3 第 2 轮插入后: 2 4 5 6 1 3 第 3 轮插入后: 2 4 5 6 1 3 第 4 轮插入后: 1 2 4 5 6 3 第 5 轮插入后: 1 2 3 4 5 6这个输出和第 2 节的手工推演完全一致。能对上,说明你对算法的理解没有偏差。
3.4 教科书版本:用 for 循环压缩写法
很多教材和源码会把 while 改写成 for 循环,看起来更紧凑:
void insertion_sort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; int j; for (j = i - 1; j >= 0 && arr[j] > key; j--) { arr[j + 1] = arr[j]; } arr[j + 1] = key; } }这个版本和 while 版本逻辑完全一样,只是把“初始化 j、判断条件、j--”合并到了 for 语句里。我建议你两个版本都写一遍,理解它们是等价的。考试和面试时,遇到哪种写法都能马上反应过来。
4. 复杂度、稳定性与适用边界
4.1 时间复杂度要看三种情况
初学者容易把插入排序直接定性成“O(n²) 的慢排序”,这个说法太粗糙了。它的时间复杂度应该分三种情况看:
| 情况 | 条件 | 比较/移动次数 | 时间复杂度 |
|---|---|---|---|
| 最好 | 数据已经从小到大有序 | 每轮只比较 1 次,不移动 | O(n) |
| 平均 | 数据随机排列 | 每轮大约移动一半已排序元素 | O(n²) |
| 最坏 | 数据完全逆序 | 每轮都要把前面所有元素往后挪 | O(n²) |
最好情况很好理解:如果数组本来就是有序的,arr[j] > key第一次比较就失败,while 循环直接不进去,每一轮只做一次比较和一次赋值。所以插入排序在“处理基本有序的数据”时,效率其实很高,能达到线性级别。
最坏情况是逆序数组{6,5,4,3,2,1},每一轮的 key 都小于前面所有元素,所有已排序元素都要后移。比较次数和移动次数加起来大约是n(n-1)/2,也就是 O(n²)。
空间复杂度则是 O(1)。它只用了 i、j、key 这几个额外变量,在原地完成排序,不申请额外的数组。这一点在嵌入式环境或者内存受限的场景里是很大的优势。
4.2 稳定性:一个细节决定排序的“品格”
稳定性的定义是:如果数组里有两个相等的元素,排序之后它们的相对顺序不能变。
插入排序的稳定性,取决于 while 循环里比较条件用的是>还是>=。
- 写成
arr[j] > key:遇到相等的元素时,while 循环停止,key 被插到相等元素后面。相等元素的原始顺序不变,算法是稳定的。 - 写成
arr[j] >= key:遇到相等的元素时,仍然会把它往后挪,key 被插到相等元素前面。相等元素的顺序被反转,算法变得不稳定。
在只做单字段排序时,这个差别看不出来,结果都是有序的。但真实业务里经常有多字段排序,比如学生成绩表先按总分排,总分相同再按学号排。如果你在第二次排序时用了不稳定的算法,前一次按学号排好的顺序可能被破坏。所以稳定性不是理论洁癖,而是真实工程需求。
4.3 什么时候该用它,什么时候别用它
适合插入排序的场景,我总结为四个:
- 数据量小,比如几十个到几百个元素。
- 数据已经基本有序,只有少数元素位置不对。
- 数据是动态到来的,比如实时流式数据,每来一条就插入到已排序列表里。
- 内存紧张,不能申请额外的大数组。
不适合的场景也很明确:
- 数据量大且无序,比如几万、几十万个随机数,这时候快速排序、归并排序明显更合适。
- 对排序耗时极敏感的服务端场景,插入排序的 O(n²) 会成为瓶颈。
- 数据本身已经是稳定有序的结构,但你需要频繁大量调整顺序时,应该考虑更高效的数据结构,比如平衡树。
注意:插入排序是“小数据友好型”算法。把它用在大规模随机数据上,等于拿着一把水果刀去砍树,不是刀不行,是场景选错了。
5. 新手最容易栽的坑,以及一套排查顺序
5.1 三个高频 bug
我自己见过初学者写插入排序,最容易出现三个问题。
第一个:while 条件漏写j >= 0。直接写成while (arr[j] > key),当 j 减到 -1 时,会去访问arr[-1]。这在 C 语言里是未定义行为,运气好读到垃圾值,运气不好直接段错误崩溃。
第二个:把arr[j] > key写成arr[j] >= key。排序结果依然有序,所以很难发现,但算法从稳定变成了不稳定。如果后面你拿它做多字段排序,就会埋下隐患。
第三个:循环结束后插入位置写错。很多人想当然写成arr[j] = key,却忘了 while 循环退出前 j 已经多减了一次。应该是arr[j + 1] = key。这个 bug 的典型症状是:数组里某个元素丢失,或者某两个位置出现重复值。
还有一个不是循环本身的坑,而是数组大小的坑。在main里用sizeof(arr) / sizeof(arr[0])计算数组长度是对的,但一旦数组作为参数传进函数,它就退化成指针,sizeof(arr)不再是整个数组的大小,而是指针的大小。所以不要在函数内部重新用 sizeof 算长度,应该在调用前算好传进去。
5.2 一套从现象到根因的排查顺序
如果你写完代码发现结果不对,不要慌,按下面这个顺序排查:
- 先看现象是什么:是完全没排序?还是只有局部有序?还是最后一位不合法?还是直接崩溃?
- 把数组缩小到 3 到 5 个元素,用手推一遍中间结果,确定算法逻辑本身对不对。
- 在 while 循环里加打印,输出每一轮的 i、key、j,以及当前数组状态,看看卡在哪一步。
- 检查边界输入:空数组、单元素、重复元素、已经有序、完全逆序、含负数。
- 检查 n 的传递:是不是在函数里误用了
sizeof。 - 检查比较方向:
>还是>=,升序还是降序,j--还是j++。
这个排查顺序的核心是:先确认“算法逻辑”对不对,再确认“代码实现”对不对,最后才考虑“边界情况”对不对。很多初学者一上来就在网上问为什么崩溃,其实只要加两行打印,自己就能发现是j >= 0漏了。
5.3 正确性验证:不要只看一次输出
只跑一个样例得到正确结果,不代表代码没问题。我建议你准备一组测试数据,至少覆盖这些情况:
// 逆序数组,最坏情况 int arr1[] = {6, 5, 4, 3, 2, 1}; // 正序数组,最好情况 int arr2[] = {1, 2, 3, 4, 5, 6}; // 重复元素,验证稳定性隐患 int arr3[] = {3, 1, 3, 2, 3}; // 含负数 int arr4[] = {0, -2, 7, -1, 5}; // 单元素 int arr5[] = {1};每次跑完,都写一个小的检查函数,确认数组确实是从小到大排列,而不是肉眼看一眼就完事。养成这个习惯之后,你以后学任何排序算法都会更快。
6. 从插入排序出发,建立算法学习的可复用框架
6.1 学任何一个排序算法的四个步骤
插入排序不只是教你一个算法,它还能帮你建立一套学习排序算法的方法论。以后你学冒泡排序、选择排序、快速排序、归并排序,都可以走同一个流程:
第一步,找一个生活场景。插入排序对应打牌理牌,冒泡排序对应气泡上浮,选择排序对应每轮挑最小的放最前面。没有生活场景,你对算法的记忆就是死记硬背。
第二步,手工推演。拿一个 6 个元素左右的小数组,把每一轮的中间状态写出来,至少走 3 个例子。
第三步,写最小可运行代码,加调试输出。先保证正确,再考虑优化或压缩写法。过程性打印是你最好的老师。
第四步,分析三个维度:时间复杂度的三种情况、空间复杂度、稳定性。最后明确它适合什么场景、不适合什么场景。
这套流程走完,你对一个算法的理解就不是“会用”,而是“懂它”。
6.2 两个顺势就能理解的进阶方向
理解了插入排序之后,有两个方向是你立刻就能往前走的。
第一个是二分插入排序。因为插入排序每一轮要插入的前半段已经有序,所以完全可以用二分查找快速定位插入位置。这样比较次数可以从 O(n²) 降到 O(n log n),但元素移动的次数仍然是 O(n²)。它的意义在于,让你看到“比较”和“移动”是两个可以分别优化的环节。
第二个是希尔排序。希尔排序的本质就是“多次插入排序”:先把数组按一定间隔分成若干组,对每组做插入排序,然后缩小间隔,直到间隔为 1。它的核心思想是让元素先进行大步移动,减少小步移动的总次数。如果你插入了插入排序的原理,希尔排序的代码你基本能看懂一半。
另外还有一个不那么直观但很重要的事情:很多现代混合排序算法,在处理小规模数组片段时,会退化到插入排序。比如 TimSort 在处理长度小于某个阈值的子数组时,就会调用插入排序,因为在小规模数据上,插入排序的常数开销相对更低。所以插入排序不是“被淘汰的算法”,它仍然藏在很多高性能排序的实现细节里。
6.3 回到一个更底层的经验
折腾完这一整条链路,我最想留给你的一句话是:学算法,先别急着追求最短的代码、最快的性能,先把“这个算法到底在重复做什么动作”想清楚。
插入排序重复的动作是“拿起一张牌,插进正确的位置”。你理解了这一点,代码怎么写都只是表达方式的问题。而当你理解了“为什么每轮要找右往左找、为什么挪完要插回 j+1、为什么相等元素不要越过”,你其实已经在用工程师的思维看算法了。
下一步,你可以试着把这份代码改成降序排序,或者改成二分插入排序,或者加上一个“如果本轮没有移动元素就直接结束”的提前退出。改着改着,你会发现自己不知不觉已经能独立折腾算法了。
先从这一份代码开始,把它跑通,把它打印出来,把它丢掉再重新默写一遍。搞定插入排序,30 分钟够用了。