很多人第一次接触排序算法,不是从课本上的伪代码开始的,而是在牌桌上。摸一张牌,从右往左比过去,找到合适的位置插进去,顺手把后面的大牌往后挪一格——这个动作重复二十次,手里的牌就整整齐齐。后来学数据结构我才知道,这个动作有个正式名字,叫插入排序。
插入排序是排序算法里最贴合人类直觉的一个,也是我在实际项目中用得最频繁的排序之一。它思想简单,代码也短,但真要把它讲透,能挖出不少东西:为什么稳定、为什么对近似有序的数组特别快、折半插入排序到底优化了什么、以及它为什么至今还活在 TimSort、JDK 排序这类高级算法内部。这篇文章就从这几个角度,把插入排序从头到尾拆一遍。
文章适合这几类读者:刚学算法想弄懂基础排序的初学者,准备面试想答好"插入排序"相关追问的人,以及在嵌入式或性能敏感场景里纠结"到底用哪个排序"的工程师。我会结合代码、手算过程和工程实践来讲,保证每个结论都有依据,不搞玄学。
1. 核心思想:把人玩牌的动作翻译成代码
1.1 一张牌一张牌地理牌
想象你在斗地主,左手已经握着一把按从小到大排好的牌,右手从桌上拿起一张新牌。接下来你干什么?从右往左看手里的牌,遇到比新牌大的就往右挪一个位置,直到遇到一张比新牌小的,或者已经看到最左边,然后把新牌放到那个空出来的位置。
整个过程里,左手始终是一把有序的牌,右手每次只处理一张新的。这种"一边维护有序序列,一边把新元素插进去"的思路,就是插入排序的全部核心。翻译成数组语言:从左到右扫描数组,假设当前位置左边的子数组已经有序,把当前元素插入到左边子数组的正确位置,使左边的子数组仍然有序。
这个思路决定了插入排序的两个重要特性:它是在线的——你可以在数据源源不断到来时逐个插入并保持整体有序;它也是稳定的——两个相等的元素不会因为排序而交换相对位置,原因后面细说。
1.2 数组视角下的三个动作
把上面的过程拆成数组操作,其实只有三个动作:
- 取出当前待处理的元素,暂存到一个变量里,比如叫
key。 - 比较并右移:从当前元素的前一个位置开始,依次往左比较;凡是比
key大的元素,统一往右挪一位。 - 插入:当遇到第一个不比
key大的元素,或者已经扫到数组起点,就把key放到当前空出的位置。
用生活类比来说,这就像在一条已经排好队的人群里插入一个新成员。你从队尾往队头走,每经过一个比你高的人,就请他往后退一步,直到前面是个比你矮的人,你站到他身后。队伍始终有序,新成员也找到了正确位置。
1.3 手算一轮完整的插入过程
光说概念不过瘾,我们用一个具体的例子走一遍。假设数组是[5, 2, 4, 6, 1, 3]:
- 初始状态:第一个元素
5视作已经有序的左子数组。 - 处理
2:2比5小,5右移一位,2放到开头。数组变为[2, 5, 4, 6, 1, 3]。 - 处理
4:和5比,5右移;和2比,2不比4大,停下。4放到原来5的位置。数组变为[2, 4, 5, 6, 1, 3]。 - 处理
6:和5比,5不比6大,直接停在原地。数组不变。 - 处理
1:一路比过去,6、5、4、2全部右移一位,1放到开头。数组变为[1, 2, 4, 5, 6, 3]。 - 处理
3:6、5、4右移,3放到2后面。数组变为[1, 2, 3, 4, 5, 6]。
特别注意处理4那一步:当key = 4和左边的2比较时,因为2 < 4,所以停止移动。这个"遇到等于或者小于就停"的细节,正是插入排序稳定性的来源。如果用>=作为移动条件,相等元素的相对位置就会被破坏,稳定性就丢了。这一点后面单独开一节讲。
1.4 为什么说它是"在线"的
"在线"这个词听起来玄乎,其实就是说:你不需要提前拿到全部数据。数据来一个,插一个,整个过程结束后所有数据有序。
这个特性在几个真实场景里特别值钱。比如你在服务器上维护一个排行榜,用户积分不断变化,每次只需要把新分数插入到一个有序数组里;又比如你在处理实时日志流,想随时输出当前已经收到的日志中耗时最长的前若干条。这些场景天然适合插入排序,因为它不需要"预知未来"。
相比之下,快速排序和归并排序通常需要先拿到完整数据才能开始工作。这也是为什么插入排序虽然平均时间复杂度是 O(n²),但在很多在线场景里依然无法被替代。
2. 手写实现:移位和交换只差一个赋值
2.1 最常见的两版代码
直接给出一份最标准的 Python 实现:
def insertion_sort(arr): for i in range(1, len(arr)): key = arr[i] j = i - 1 while j >= 0 and arr[j] > key: arr[j + 1] = arr[j] j -= 1 arr[j + 1] = key return arrC 语言版本几乎一模一样:
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; } }两版代码的逻辑完全一致:外层循环控制"当前插入哪个元素",内层循环负责"把比它大的元素往右挪"。
2.2 几个容易被问倒的细节
第一,为什么不写成交换?
很多新手会写成这样:内层循环里判断arr[j] > arr[j+1]就交换两个元素。这样写也能排序,但没有必要。交换一次需要三次赋值,而移位只需要一次赋值。插入排序的内层循环本质是"把一系列元素都右移一格,最后只写一次key",所以用移位而不是交换,常数更小。遇到数组元素是结构体或者对象时,这个差异会被放大。
第二,为什么先从i-1开始向左扫描?
因为我们要找的是"第一个不大于key的位置",而i-1是当前元素的前一个位置,从它开始向左比较,天然是从有序子数组的末尾往前扫。这利用了有序子数组的局部性:越靠右的元素越接近key真正的位置,多数情况下不需要扫完整个左子数组。
第三,arr[j] > key和arr[j] >= key有什么区别?
区别就在稳定性上。用>时,遇到相等的元素就停下,把key放到相等元素后面,所以相等元素的相对顺序不变;用>=时,相等的元素会被移动到key的右边,顺序就反了。绝大多数排序需求要求稳定排序,标准写法都用>。
2.3 哨兵优化真的有用吗
老教材里经常出现一种"哨兵"优化:在数组最前面留一个空位,每次插入前先把这个空位填上key,然后内层循环就不需要判断j >= 0了。
// a[0] 做哨兵,实际数据从 a[1] 开始,n 为数据个数 void insertion_sort_with_sentinel(int a[], int n) { for (int i = 2; i <= n; i++) { int key = a[i]; int j = i - 1; a[0] = key; while (a[j] > key) { a[j + 1] = a[j]; j--; } a[j + 1] = key; } }原理是:当j一直减到0时,a[0]已经被设成了key,此时a[0] > key为假,循环自然退出,不需要再单独判断j是否为负。内层循环的判断条件从两个(j >= 0 && a[j] > key)减到一个(a[j] > key),每条指令都能省。
但我的实际体会是:这个优化在现代 CPU 上收益极其有限,甚至可以说几乎感觉不到。原因有二。第一,现代 CPU 的分支预测器对j >= 0这种高度规律的模式预测准确率非常高,这个判断基本不消耗额外时间;第二,哨兵版本牺牲了数组的a[0]位置,调用方必须把数据从下标 1 开始存,对工程代码来说很不友好,容易引入"差一错误"。
所以我对哨兵优化的评价是:理解它的思路有价值——它在教你"减少每次循环里的判断次数、用空间换时间"这种底层优化思维;但实际项目里我不会为了省一个判断而让数组下标从 1 开始。真正值得用哨兵的地方是超大规模数据配合汇编级优化,普通业务代码里收益不成正比。
2.4 复杂度到底怎么算
插入排序的时间复杂度取决于数据的初始有序程度,这是它和其他 O(n²) 排序最不一样的地方。
- 最好情况:数组已经完全有序。每个元素只需要和它前一个元素比较一次,发现不需要移动,直接进入下一轮。比较次数是 n-1,移动次数是 0,时间复杂度是O(n)。
- 最坏情况:数组完全逆序,比如
[n, n-1, ..., 1]。第 i 个元素插入时需要和前面所有 i 个元素比较并移动,总比较次数和移动次数都是 1+2+...+(n-1) = n(n-1)/2,约等于n²/2,时间复杂度O(n²)。 - 平均情况:每个元素大约需要移动一半的位置,总移动次数约为n²/4,时间复杂度仍然O(n²)。
- 空间复杂度:只用了一个临时变量
key,所以是O(1),原地排序。
这个"最好情况 O(n)"的特性,在排序算法里有个专门名词叫适应性。一个算法越能利用数据已有的有序性,适应性越强。插入排序的适应性是所有基础排序里最强的,这也是它在工程中依然有一席之地的根本原因。
3. 折半插入排序:省了比较,没省移动
3.1 它优化了什么
很多人学完插入排序后会想:内层循环每轮都在做两件事——比较和移动。如果我能更快地找到插入位置,不就能让整个排序变快了吗?
这个思路就是折半插入排序的出发点。既然左子数组已经有序,那为什么还要从左往右一个一个比?直接在上面做二分查找定位插入位置,不是更高效吗?
理论上确实如此。直接插入排序在插入第 i 个元素时,平均要比较 i/2 次;折半插入排序通过二分查找把比较次数降到 log₂(i) 次。整个排序下来,比较次数从 O(n²) 降到了O(n log n)。
def binary_insertion_sort(arr): n = len(arr) for i in range(1, n): key = arr[i] left, right = 0, i while left < right: mid = (left + right) // 2 if arr[mid] <= key: left = mid + 1 else: right = mid # left 即为 key 应该插入的位置 for j in range(i, left, -1): arr[j] = arr[j - 1] arr[left] = key return arr注意这里二分查找的边界条件:right一开始是i而不是i-1。这能让查找区间覆盖到"插入到最右边"的情况——也就是key比左子数组所有元素都大的时候,left最终会是i,位置合法。如果你把right设成i-1,边界情况就会出错。
3.2 二分定位要小心边界
折半插入排序最容易写错的地方,不是二分查找本身,而是查找相等元素时该往哪边收缩。
我的代码里用的是if arr[mid] <= key: left = mid + 1这个分支。当arr[mid] == key时,继续往右半部分找,这样最终得到的left指向的是第一个大于key的位置,也就是相等元素区间的右边。把key插到这个位置,所有和key相等的元素都会留在它前面,相对顺序不变,稳定性得以保留。
如果你把条件写成if arr[mid] < key,那么left会收敛到第一个大于等于key的位置,key会插到相等元素的最前面,相等元素的相对顺序就倒过来了。数组元素是整数时无所谓,但数组元素是对象、需要按某个字段排序同时又要求其他字段保持原顺序时,这个差别会直接导致结果不符合预期。
3.3 实测中的收益和大数组陷阱
那折半插入排序是不是全面优于直接插入排序?不是,这里有个非常大的误区。
折半插入排序只是把比较次数从 O(n²) 降到了 O(n log n),但移动次数仍然是 O(n²)。因为不管你怎么找到位置,插入一个元素时,它后面的所有元素照样得往右挪一格。换句话说,算法的时间复杂度依然是O(n²),只是常数变小了。
举个例子:对一个长度为 10000 的逆序数组,直接插入排序大约需要比较和移动各 5000 万次;折半插入排序比较次数降到约 13 万次,但移动次数还是约 5000 万次。总耗时确实少了,但少的是"比较"这部分,移动的大头一点没动。
所以折半插入排序的真正适用场景是:元素比较的开销远大于移动的开销。比如数组里存的是很长的字符串、复杂的结构体,一次比较可能要遍历几百上千字节;而移动只是一个指针赋值,代价极小。这时候把比较次数从 O(n²) 降到 O(n log n) 是实实在在的收益。反过来,如果数组元素是整数、浮点数这种比较开销极小的类型,折半带来的优化几乎感知不到,代码还更复杂,我通常就直接用普通插入排序。
还有一个容易忽略的实际问题:折半插入排序失去了插入排序的"强适应性"。直接插入排序在数据近乎有序时,内层循环往往比较一两次就退出,几乎不移动;但折半插入排序无论数据是否有序,每个元素都要完整走一遍二分查找,固定消耗 O(i log i) 的比较次数。有序数组用直接插入是 O(n),用折半插入反而是 O(n log n),完全倒挂了。所以如果你的数据大概率是"几乎有序"的,老老实实用直接插入排序,别用折半版本。
4. 稳定性与适应性:面试官最爱追问的隐藏属性
4.1 稳定性的来源是那个">"号
什么叫稳定排序?简单说就是:如果数组里有两个值相等的元素,排序后它们的相对前后顺序不能变。
插入排序正是稳定的,关键就在代码里的那个>号。当key遇到一个等于它的元素时,移动循环立刻停止,key就被插到那个相等元素的后面。从头到尾,两个相等元素的相对顺序没有被破坏。
这个特性在很多真实场景里很重要。比如你有一批订单,先按下单时间排好序,再按金额排序。如果用的是稳定排序,相同金额的订单仍然按下单时间顺序排列;如果用不稳定排序,相同金额的订单顺序就乱了。
对比一下选择排序:它每次选出最小值放到前面,如果某个位置之前正好有一个与之相等的元素,交换时就会把相等的元素交换到后面去,破坏稳定性。这也是为什么我常说,在 O(n²) 级别的基础排序里,插入排序的稳定性是白送的,不需要额外付出任何代价。
4.2 适应性:近乎有序数据的杀手锏
插入排序对"几乎有序"的数据处理得极快,这是它最容易被低估的优点。
想象一个数组,只有最后几个元素是乱的,前面几万个元素已经有序。快速排序大概会递归若干层,每次划分都要做一轮遍历;归并排序需要额外的 O(n) 空间;但插入排序只需要处理那几个乱序的元素,每个乱序元素往左移动一小段距离就结束,整体复杂度接近 O(n)。
我自己在线上环境处理过类似问题:一个配置表每天只有少量条目变化,其余几千条保持原顺序。直接用插入排序对全量数据排序,每次耗时在毫秒级;换成 O(n log n) 的排序算法反而因为常数大、递归栈深,耗时更高。这个场景下,插入排序的"适应性"比理论上的时间复杂度重要得多。
4.3 和其他 O(n²) 排序的对比
把插入排序、选择排序、冒泡排序放在一起看,很多特性一目了然:
| 算法 | 平均时间 | 最好时间 | 最坏时间 | 稳定性 | 适应性 | 额外空间 |
|---|---|---|---|---|---|---|
| 插入排序 | O(n²) | O(n) | O(n²) | 稳定 | 强 | O(1) |
| 选择排序 | O(n²) | O(n²) | O(n²) | 不稳定 | 无 | O(1) |
| 冒泡排序 | O(n²) | O(n) | O(n²) | 稳定 | 弱 | O(1) |
选择排序唯一的优势是交换次数固定为 n-1,如果写操作的代价远高于读操作(比如 Flash 存储),它有存在价值;但排序速度上它没有任何优势,因为它无论数据是否有序,都要完整扫描完整个数组。
冒泡排序也有适应性和稳定性,但它的实现天生要频繁交换相邻元素,每次交换需要三次赋值,而插入排序的移位只需要一次赋值。同样处理近似有序的数组,插入排序的常数比冒泡小很多。所以我常说:这三个 O(n²) 排序里,插入排序是综合最优的那个,工程上几乎总是优先选它。
5. 大规模工程里的插入排序:它从未离开
5.1 快排的阈值和 TimSort
有个反直觉的事实:快速排序在数据量很小的时候,并不比插入排序快,甚至更慢。原因在于快速排序有递归调用、有函数栈、有更复杂的划分逻辑,这些开销对几十个元素来说完全是浪费。
所以几乎所有工业级的快速排序实现,都会在递归到小数组时切换到插入排序。这个切换阈值通常在 8 到 32 之间。比如经典的快速排序优化策略就是:当子数组长度小于等于INSERTION_SORT_THRESHOLD时,调用插入排序而不是继续递归。
另一个更典型的例子是TimSort。Python 的sorted()和 Java 对对象数组的排序,用的都是 TimSort。它的核心思路是:把数组切成若干个已经有顺序的"run",对太短的 run 用二分插入排序扩展到最小长度,然后再用归并把这些 run 合并起来。换句话说,插入排序在这个高级算法里担任了"基石"的角色。
5.2 JDK 里的成对插入排序
我印象最深的一个优化来自 JDK 的DualPivotQuicksort。它对小于 47 个元素的数组,用的是成对插入排序,英文叫 pair insertion sort。
这个名字听着高级,思路其实很朴素:普通插入排序每轮处理一个元素,每轮都要维护一次外层循环的计数器 i 和比较逻辑;成对插入排序每轮固定处理两个相邻元素,先把较大的那个插入到前面有序区,再把较小的那个插入到前面。这样做最大的好处是外层循环的次数减半,循环控制本身的分支判断也少了一半。
示意思路如下:
// 成对插入排序的核心思想:每次处理两个元素 for (int i = 1; i < n; i += 2) { // 取 a[i] 和 a[i+1] 两个元素,较大的先插入,较小的再插入 // 外层循环次数比普通插入排序少一半 }这个优化在数据量小的时候效果显著,因为小数组排序的瓶颈往往不在比较次数,而在循环控制和分支预测。我第一次看到 JDK 这段源码时挺震撼的,原来"算法已经最优"之后,工程上还能从指令级别再扣出性能来。
5.3 你天天在用的增量排序
除了作为大算法内部的组件,插入排序本身在业务代码里也经常直接上场。
最典型的是增量维护有序序列。比如游戏服务器里有一个玩家排行榜,玩家数量不多,几百人。新玩家注册,或者老玩家分数更新,你需要把新的分数插入到有序列表里。这时候用插入排序的思维,就是最自然的解法:从列表尾部往前扫描,把比新分数低的玩家往后挪一位,找到位置插进去。整个过程 O(n),不需要对全表重新排序。
再比如实时监控系统里,你需要维护当前延迟最高的前 100 个请求。每来一条新请求,如果它的延迟能进前 100,就把它插入到有序数组的合适位置,把第 101 个挤出去。这种"数据流式到达,随时需要有序结果"的场景,插入排序几乎是量身定做的。
5.4 我的实践建议
做了这么多年开发,我在不同场景下对排序的选型经验可以总结成几条:
- 数组长度小于 16,无脑用插入排序,别纠结。哪怕数据完全乱序,它也是最稳的选择,代码量小,没有递归栈,不容易出错。
- 数据量中等但大概率近乎有序,用插入排序。利用它的适应性,往往比上来就快排快得多。
- 比较开销远大于移动开销,用折半插入排序。典型的例子是字符串数组或者含大结构体的数组。
- 需要稳定排序且不想引入额外内存,插入排序是最容易写对的稳定 O(n²) 排序。
有一次我在嵌入式设备上处理传感器数据排序,芯片主频低,内存只有几十 KB,不能用递归算法,也不能申请额外数组。最后就是直接用插入排序,代码不到二十行,稳定、原地、常数小,跑得稳稳当当。这种场景下,"高级"算法反而帮不上忙,反而是最朴素的插入排序最可靠。
关于插入排序,想说的就这么多。它看似简单,但往深了挖,有稳定性、适应性、二分优化、哨兵技巧,甚至还有像成对插入排序这样的工业级微优化。理解它最好的方式,还是自己多写几遍,然后故意把数据改成完全逆序看一眼耗时,再改成完全有序看一眼耗时,那种差距会让你印象非常深刻。下次再有人问"插入排序不就是个 O(n²) 的简单算法吗",你就可以告诉他:简单归简单,它每天在 TimSort 里、在 JDK 里、在你眼前的数据库排序里,替你干了不知道多少活。