插入排序 (Insertion Sort):小规模数据的王者
摘要:堆排序和归并排序保证了最坏 O(n log n),但引入了缓存不友好或 O(n) 空间的代价。插入排序反其道而行------O(n²) 的平均复杂度看似落后,但在小规模数据(n < 20)和近乎有序数据上却是实际最快的排序算法。本文从扑克牌整理出发,图解插入排序的逐步插入过程,给出支持升序/降序的 Python 完整实现,对比其与冒泡排序的本质差异,并分析 TimSort、IntroSort 等工业级排序为何将其作为小区间回退方案。
本文属于专栏《算法》系列 1 第 6 篇 | 上一篇:快速排序 (Quick Sort)| 下一篇:(待更新)
文章目录
- [插入排序 (Insertion Sort):小规模数据的王者](#插入排序 (Insertion Sort):小规模数据的王者)
一、问题引入
前面几篇文章中,快速排序、归并排序、堆排序都已达到 O(n log n)。那为什么还要学一个 O(n²) 的排序?
考虑以下场景:
- TimSort(Python/Java 默认排序):当区间缩小到 64 个元素时,切换为插入排序
- IntroSort (C++
std::sort):当区间缩小到 16 个元素时,切换为插入排序 - 近乎有序数据:1000 个元素中只有 10 个错位,插入排序比快排还快
这些工业级排序在递归到小区间时都不约而同地选择了插入排序作为回退方案。为什么?
因为插入排序有两个其他 O(n²) 排序不具备的优势:
- 最好情况 O(n):已有序数据只需一轮扫描,无需任何移动
- 移动代替交换:用"元素后移腾位 + 一次插入"代替冒泡排序的"反复交换",写操作减少一半以上
生活中的直觉:整理扑克牌。左手持已排序的牌,右手从桌上逐张拿牌,从右向左在左手牌中找到合适位置插入------这正是插入排序。
问题定义:
- 输入 :含 n 个元素的可比较数组
arr - 输出:按升序(或降序)排列的数组
- 核心操作:逐个将元素插入已排序部分的正确位置
二、算法原理图解
核心思想
将数组分为已排序部分 和未排序部分,每次从未排序部分取第一个元素,在已排序部分中从右向左找到正确位置插入。
[已排序部分 | current | 未排序部分]
↑ 待插入
j 指向 ←
图解执行过程
以 [5, 3, 8, 1, 2] 升序排序为例,| 标记已排序部分与未排序部分的边界:
初始状态: [5 | 3, 8, 1, 2] 已排序: [5]
第 1 轮: current=3
3 < 5,5 后移 → [_, 5, 8, 1, 2]
j 越界,插入 3 → [3, 5 | 8, 1, 2]
已排序: [3, 5]
第 2 轮: current=8
8 > 5,不移动,直接插入 → [3, 5, 8 | 1, 2]
已排序: [3, 5, 8]
第 3 轮: current=1
1 < 8,8 后移 → [3, 5, _, 8, 2]
1 < 5,5 后移 → [3, _, 5, 8, 2]
1 < 3,3 后移 → [_, 3, 5, 8, 2]
j 越界,插入 1 → [1, 3, 5, 8 | 2]
已排序: [1, 3, 5, 8]
第 4 轮: current=2
2 < 8,8 后移 → [1, 3, 5, _, 8]
2 < 5,5 后移 → [1, 3, _, 5, 8]
2 < 3,3 后移 → [1, _, 3, 5, 8]
2 > 1,不移动,插入 2 → [1, 2, 3, 5, 8]
已排序: [1, 2, 3, 5, 8]
最终结果: [1, 2, 3, 5, 8]
关键观察
- 每轮只移动必要的元素:比 current 大的元素逐个后移,current 一次插入到位,而非像冒泡排序那样反复交换
- 已有序时一轮即完成:第 2 轮 current=8 时,8 > 5 无需移动,直接进入下一轮------n 个元素只需 n-1 次比较
- 逆序对决定复杂度:每个逆序对需要一次后移,总移动次数 = 数组的逆序对数量
插入排序 vs 冒泡排序:本质区别
| 维度 | 冒泡排序 | 插入排序 |
|---|---|---|
| 核心操作 | 相邻比较 + 交换 | 后移腾位 + 一次插入 |
| 每个逆序对 | 3 次赋值(交换) | 1 次赋值(后移) |
| 已有序数据 | O(n)(提前终止) | O(n)(无后移) |
| 近乎有序 | 较好 | 更好(后移次数少) |
| 写操作 | 多(每次交换 3 次写) | 少(后移 1 次写 + 插入 1 次写) |
核心差异 :冒泡排序每消除一个逆序对需要 3 次赋值(a↔b),插入排序只需 1 次赋值(后移)+ 最后 1 次插入。写操作减少约 2/3,这是插入排序实际更快的根本原因。
三、代码实现
完整代码
通过网盘分享的文件:算法
链接: https://pan.baidu.com/s/1DTJt1X2Is_IQeH5fAXvtZg?pwd=yyqf 提取码: yyqf
--来自百度网盘超级会员v4的分享
完整实现
python
def insertion_sort(arr, ascending=True):
"""
插入排序:将每个元素插入到已排序部分的正确位置。
类似整理扑克牌:左手持已排序的牌,右手从桌上逐张拿牌,
从右向左在左手牌中找到合适位置插入。
时间复杂度:O(n²) | 空间复杂度:O(1) | 稳定排序
参数:
arr: 待排序列表
ascending: 排序方向,True=升序(默认),False=降序
返回:
排序后的列表(原地排序)
"""
n = len(arr)
if n <= 1:
return arr
# 从第 2 个元素开始,逐个插入到前面已排序部分
for i in range(1, n):
current = arr[i] # 待插入元素(右手拿到的牌)
j = i - 1 # 已排序部分的右边界
# 升序:current 小于前驱则前驱后移;降序:current 大于前驱则前驱后移
if ascending:
while j >= 0 and arr[j] > current:
arr[j + 1] = arr[j] # 元素后移,腾出位置
j -= 1
else:
while j >= 0 and arr[j] < current:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = current # 插入到正确位置
return arr
三个关键设计:
current暂存待插入元素 :避免后移过程中覆盖arr[i],这是插入排序区别于冒泡排序的核心while从右向左扫描:比 current 大的元素逐个后移(而非交换),遇到不大于 current 的位置立即停止arr[j + 1] = current一次插入:后移完成后,current 一次写入正确位置,写操作仅 (后移次数 + 1) 次
运行验证
python
if __name__ == "__main__":
data = [64, 34, 25, 12, 22, 11, 90]
print(f"排序前: {data}")
print(f"升序: {insertion_sort(data[:])}")
print(f"降序: {insertion_sort(data[:], ascending=False)}")
# 边界测试
print(f"空列表: {insertion_sort([])}")
print(f"单元素: {insertion_sort([42])}")
print(f"已有序: {insertion_sort([1, 2, 3, 4, 5])}")
print(f"全相同: {insertion_sort([7, 7, 7, 7, 7])}")
print(f"逆序: {insertion_sort([5, 4, 3, 2, 1])}")
# 小规模数据性能对比:插入排序的优势场景
import time
import random
# 近乎有序数据(插入排序的王者场景)
nearly_sorted = list(range(1000))
for _ in range(10):
i, j = random.randint(0, 999), random.randint(0, 999)
nearly_sorted[i], nearly_sorted[j] = nearly_sorted[j], nearly_sorted[i]
start = time.time()
insertion_sort(nearly_sorted[:])
print(f"\n近乎有序(1000): 插入排序 {time.time() - start:.6f}s")
start = time.time()
insertion_sort(random.sample(range(1000), 1000))
print(f"随机数据(1000): 插入排序 {time.time() - start:.6f}s")
输出:
排序前: [64, 34, 25, 12, 22, 11, 90]
升序: [11, 12, 22, 25, 34, 64, 90]
降序: [90, 64, 34, 25, 22, 12, 11]
空列表: []
单元素: [42]
已有序: [1, 2, 3, 4, 5]
全相同: [7, 7, 7, 7, 7]
逆序: [1, 2, 3, 4, 5]
近乎有序(1000): 插入排序 0.000474s
随机数据(1000): 插入排序 0.020274s
近乎有序数据下比随机数据快 42 倍------这就是插入排序在小规模和近乎有序场景下的王者实力。
四、复杂度分析
时间复杂度
| 情况 | 复杂度 | 说明 |
|---|---|---|
| 最好 | O(n) | 数组已有序,每轮只比较 1 次不后移 |
| 平均 | O(n²) | 每个元素平均后移 i/2 次,总移动 n²/4 |
| 最坏 | O(n²) | 完全逆序,每个元素后移到底 |
推导过程:
第 i 轮:current = arr[i],最多与前面 i 个元素比较并后移
最好情况(已有序):
每轮比较 1 次,后移 0 次
总比较 = 1 + 1 + ... + 1 = n-1 = O(n)
最坏情况(完全逆序):
第 1 轮:比较 1 次,后移 1 次
第 2 轮:比较 2 次,后移 2 次
...
第 n-1 轮:比较 n-1 次,后移 n-1 次
总比较 = 1 + 2 + ... + (n-1) = n(n-1)/2 = O(n²)
平均情况:
第 i 轮平均后移 i/2 次
总移动 ≈ n²/4 = O(n²)
核心结论 :插入排序的复杂度与数组的逆序对数量成正比。逆序对越少,排序越快。这正是近乎有序数据下性能极佳的原因。
空间复杂度
O(1)------仅使用 current、i、j 等常数个辅助变量,原地排序。
稳定性
稳定排序 。while 条件为 arr[j] > current(严格大于),相等元素不会后移,相对顺序保持不变。如果改为 >=,相等元素也会后移,破坏稳定性。
五、横向对比
插入排序与同系列算法的对比:
| 算法 | 平均时间 | 最好时间 | 最坏时间 | 空间 | 稳定性 | 特点 |
|---|---|---|---|---|---|---|
| 冒泡排序 | 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) | 不稳定 | 通用场景最快 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 | 稳定 + 不退化 |
插入排序 vs 冒泡排序 vs 选择排序
三种 O(n²) 排序的细微差异:
| 维度 | 冒泡排序 | 插入排序 | 选择排序 |
|---|---|---|---|
| 每轮操作 | 相邻比较交换 | 后移 + 插入 | 找最小值 + 一次交换 |
| 写操作/逆序对 | 3 次(交换) | 1 次(后移) | 1 次(末尾交换) |
| 最好情况 | O(n) | O(n) | O(n²) |
| 稳定性 | 稳定 | 稳定 | 不稳定 |
| 近乎有序 | 较快 | 最快 | 慢(仍需全扫描) |
| 实际速度 | 最慢 | 中等 | 中等 |
选型建议:
- 小规模数据(n < 20):插入排序(写操作少,常数因子最小)
- 近乎有序数据:插入排序(逆序对少,接近 O(n))
- 交换代价极高的场景:选择排序(交换次数最少,每轮最多 1 次)
- 大规模数据:快速排序或 TimSort
六、工程实战
TimSort 中的插入排序
Python list.sort() 和 Java 对象排序使用 TimSort,其核心设计之一就是在小区间切换为插入排序:
TimSort 流程:
1. 扫描自然有序的 Run(连续升/降序片段)
2. 若 Run 长度 < 32(MIN_MERGE),用插入排序补充到 32
3. 多个 Run 在栈上归并
为什么选插入排序而非冒泡排序?
| 维度 | 插入排序 | 冒泡排序 |
|---|---|---|
| 写操作 | 后移 1 次 + 插入 1 次 | 交换 3 次 |
| 已有序片段 | 直接跳过 | 仍需逐对比较 |
| 局部性 | 扫描已排序部分 | 相邻比较 |
IntroSort 中的插入排序
C++ std::sort 使用 IntroSort(快排 + 堆排 + 插入排序),当递归到小区间(通常 < 16 元素)时切换为插入排序:
python
def intro_sort(arr, low, high, depth_limit):
if high - low < 16:
# 小区间:插入排序
insertion_sort_range(arr, low, high)
return
if depth_limit == 0:
# 递归过深:堆排序
heap_sort_range(arr, low, high)
return
# 默认:快速排序
pivot = partition(arr, low, high)
intro_sort(arr, low, pivot - 1, depth_limit - 1)
intro_sort(arr, pivot + 1, high, depth_limit - 1)
为什么阈值是 16~64?
当 n 很小时,O(n²) 的常数 n²/4 < O(n log n) 的常数 n log n。具体来说:
- n=16 时:插入排序约 64 次比较,快排约 64 次比较 + 递归开销
- n=64 时:插入排序约 1024 次比较,TimSort 阈值选 32 作为平衡点
二分插入排序
插入排序的变体------用二分查找确定插入位置,减少比较次数:
python
def binary_insertion_sort(arr):
for i in range(1, len(arr)):
current = arr[i]
# 二分查找插入位置
lo, hi = 0, i
while lo < hi:
mid = (lo + hi) // 2
if arr[mid] > current:
hi = mid
else:
lo = mid + 1
# 后移 + 插入
for j in range(i, lo, -1):
arr[j] = arr[j - 1]
arr[lo] = current
return arr
| 维度 | 直接插入 | 二分插入 |
|---|---|---|
| 比较次数 | O(n²) | O(n log n) |
| 移动次数 | O(n²) | O(n²)(不变) |
| 总复杂度 | O(n²) | O(n²)(移动仍为主) |
二分插入排序减少了比较次数,但移动次数不变,总复杂度仍为 O(n²)。在比较代价高的场景(如字符串排序)有优势。
性能实测
python
import random
import time
# 近乎有序数据
nearly_sorted = list(range(1000))
for _ in range(10):
i, j = random.randint(0, 999), random.randint(0, 999)
nearly_sorted[i], nearly_sorted[j] = nearly_sorted[j], nearly_sorted[i]
start = time.time()
insertion_sort(nearly_sorted[:])
print(f"插入排序(近乎有序): {time.time() - start:.6f}s")
start = time.time()
sorted(nearly_sorted[:])
print(f"TimSort(近乎有序): {time.time() - start:.6f}s")
# 随机数据
random_data = random.sample(range(1000), 1000)
start = time.time()
insertion_sort(random_data[:])
print(f"插入排序(随机): {time.time() - start:.6f}s")
start = time.time()
sorted(random_data[:])
print(f"TimSort(随机): {time.time() - start:.6f}s")
典型输出(n=1000):
插入排序(近乎有序): 0.000474s
TimSort(近乎有序): 0.000090s
插入排序(随机): 0.020274s
TimSort(随机): 0.000200s
近乎有序场景下插入排序已经非常接近 TimSort 的速度,且实现极简。随机数据下 TimSort 快 100 倍,但 TimSort 内部本身也在用插入排序处理小 Run。
七、常见误区与面试题
高频面试题
Q1:插入排序和冒泡排序都是 O(n²),为什么插入排序实际更快?
核心差异在写操作 。冒泡排序每次交换需要 3 次赋值(a↔b 需临时变量),插入排序用"后移"代替"交换",每个逆序对只需 1 次赋值(后移)+ 最后 1 次插入。总写操作约为冒泡排序的 1/3。
此外,插入排序在已排序部分的扫描有局部性,CPU 缓存利用率更高。
Q2:插入排序最好情况为什么是 O(n)?
当数组已有序时,每个 current 都比前一个元素大,while 条件 arr[j] > current 立即为 False,不执行任何后移。n-1 轮各比较 1 次,总比较次数 n-1,即 O(n)。
Q3:插入排序是稳定的吗?为什么?
是稳定的。while 条件为 arr[j] > current(严格大于),相等元素不触发后移,current 插入到相等元素之后,相对顺序不变。如果改为 >=,相等元素会被后移,current 插入到相等元素之前,破坏稳定性。
Q4:为什么 TimSort 和 IntroSort 都用插入排序作为小区间回退?
三个原因:
- 常数因子最小:n < 16~64 时,n²/4 < n log n + 递归开销
- 写操作少:后移代替交换,减少内存写
- 自适应有序:如果小区间恰好有序,插入排序直接 O(n) 完成
常见实现错误
| 错误 | 说明 | 修正 |
|---|---|---|
忘记暂存 current |
后移时覆盖了 arr[i] |
先 current = arr[i] 暂存 |
while 条件用 >= |
相等元素后移,破坏稳定性 | 用严格 > |
| 后移方向写反 | arr[j] = arr[j + 1] 把后面的值覆盖前面 |
应为 arr[j + 1] = arr[j] |
| 插入位置算错 | 写成 arr[j] = current |
后移后 j 已减 1,应为 arr[j + 1] |
| 外层从 0 开始 | 第一个元素无需排序 | range(1, n) 从第二个开始 |
八、总结
插入排序的核心要点:
- 扑克牌模型------左手已排序、右手逐张拿牌、从右向左插入
- 后移代替交换------每个逆序对仅 1 次赋值,写操作比冒泡少 2/3
- 最好 O(n)------已有序数据一轮扫描完成,近乎有序数据接近线性
- 稳定 + 原地------严格大于比较保证稳定性,O(1) 空间
- 工业级排序的回退方案------TimSort(< 32)和 IntroSort(< 16)小区间首选
插入排序证明了一个道理:算法的实际性能不仅取决于渐近复杂度,还取决于常数因子和数据分布。在合适场景下,O(n²) 的插入排序可以比 O(n log n) 的快速排序更快。这也是 TimSort、IntroSort 等工业级排序采用"混合策略"的根本原因------没有一种排序是万能的,关键在于根据数据特征选择最合适的工具。
📌 专栏导航 :算法
⬅️ 上一篇 :快速排序 (Quick Sort) ➡️ 下一篇:(待更新)
如果这篇文章对你有帮助,欢迎 点赞、收藏、关注,支持专栏持续更新!