这次我们直接从一张乱序的扑克牌说起。你在打牌的时候,摸到一张新牌,会把它插到手里已经排好序的牌堆里合适的位置——这个过程,就是插入排序最朴素的原型。插入排序是最容易理解、也最容易手写出来的排序算法之一,它的代码量极小,核心逻辑只有两个循环,但是要真正讲清楚它的每一步“为什么这么做”,尤其是用动画把每一轮插入过程放大来看,很多初学者还是会卡在“边界条件”和“元素搬移”这两个点上。
这篇文章会用动画拆解的方式,把插入排序从“第一轮比较”到“最后一次插入”完整过一遍,同时给出可以直接运行的 Python 动画演示代码和排序实现代码。看完你不仅能理解插入排序的原理,还能自己动手画出一张排序过程动画图,用来加深记忆或者做教学演示。
先给一个结论:插入排序的代码复杂度排在整个排序算法家族里最低的一档,学习成本几乎为零;它的平均时间复杂度是 O(n²),最好情况(基本有序)下是 O(n);相比冒泡排序和选择排序,插入排序在“接近有序”的数据上表现最好,也是很多高级排序算法(比如希尔排序、Timsort)的底层基础构件。所以把它拆明白,后面学快速排序、归并排序时能省掉很多理解成本。
1. 插入排序核心能力速览
| 能力项 | 说明 |
|---|---|
| 算法类型 | 比较类排序,基于元素插入构建有序序列 |
| 思维模型 | 打扑克牌整理手牌 |
| 时间复杂度 | 最坏与平均 O(n²),最好 O(n) |
| 空间复杂度 | O(1),原地排序,不需要额外数组 |
| 稳定性 | 稳定排序,相同元素的相对顺序不会改变 |
| 代码难度 | 极低,几行核心循环即可实现 |
| 支持可视化 | 适合用动画逐轮拆解,教学效果好 |
| 常见优化方向 | 折半插入排序、希尔排序(分组插入) |
| 典型应用场景 | 数据量小、基本有序、链表排序、高级排序的底层优化 |
从核心能力看,插入排序不是大数据量场景下的主力排序器,而是“打地基”级别的算法。它的价值在于:逻辑链完整、代码可以背、过程可以用动画展示得一清二楚。初学者把插入排序彻底吃透,之后再理解折半插入排序、希尔排序,就是水到渠成的事。
2. 插入排序的完整执行流程拆解
插入排序的思想并不复杂:维护一个“已排序区域”和一个“待处理区域”,每一轮从待处理区域取出第一个元素,把它插入到已排序区域的正确位置。因为插入过程会把后面的元素往后搬移,所以实现时的核心动作是“比较 + 搬移”,而不是“交换”。
下面用一个长度为 7 的数组来完整拆解每一轮动作:
初始数组:
[5, 2, 4, 6, 1, 3]在第一轮开始前,我们认定数组的第一个元素5自己就是“已排序区域”,因为单个元素天然有序。待处理区域是[2, 4, 6, 1, 3]。
2.1 第一轮:插入 2
把2和已排序区域的5比较,2 < 5,所以把5向后搬移一位,空出位置,然后把2放到数组开头。
第 1 轮结束:[2, 5, 4, 6, 1, 3]注意,这里不是交换2和5,而是先把5的后半段整体搬移,再把2放到空出来的位置。动画演示时最容易看清的就是这一步:被插入元素先被“暂存”,然后已排序区域内的较大元素依次后移,最后“落位”。
2.2 第二轮:插入 4
当前已排序区域是[2, 5],待插入元素是4。
比较动作:
4和5比较,4 < 5,5后移一位。4和2比较,4 > 2,停止比较。
把4放入5移动后空出的位置。
第 2 轮结束:[2, 4, 5, 6, 1, 3]这一轮里非常关键的一个细节是:停止比较的时机不是“找到了比它小的元素”,而是“没有比它大的元素”或“到达数组开头”。对应到代码里,就是while j >= 0 and arr[j] > key这行循环条件。
2.3 第三轮:插入 6
已排序区域是[2, 4, 5],待插入元素是6。
6 与 5 比较,6 > 5,一次比较就结束了。元素不需要搬移,6 直接留在原位。
第 3 轮结束:[2, 4, 5, 6, 1, 3]每一轮插入不一定都要搬移元素。当一个元素恰好大于已排序区域最后一个元素时,它直接待在原地,这一轮的比较次数也是 1 次。这也是为什么在“基本有序”的数据集上,插入排序能跑到接近 O(n) 的原因。
2.4 第四轮:插入 1
已排序区域是[2, 4, 5, 6],待插入元素是1。
比较动作:
1与6比较,1 < 6,6 后移。1与5比较,1 < 5,5 后移。1与4比较,1 < 4,4 后移。1与2比较,1 < 2,2 后移。- 此时已到数组开头,循环结束。
把1放入数组第一个位置。
第 4 轮结束:[1, 2, 4, 5, 6, 3]这是最典型的一轮“全量搬移”,动画看起来非常直观:待插入元素一路向左“挤过去”,所有大于它的元素像多米诺骨牌一样依次向右挪。初学者最容易在这里犯的错,是忘记保存arr[j]被覆盖前的值,从而在比较时把数组里原来的值丢掉。
2.5 第五轮:插入 3
已排序区域是[1, 2, 4, 5, 6],待插入元素是3。
比较动作:
3与6比较,3 < 6,6 后移。3与5比较,3 < 5,5 后移。3与4比较,3 < 4,4 后移。3与2比较,3 > 2,停止比较。
把3放入4移动后空出的位置。
第 5 轮结束:[1, 2, 3, 4, 5, 6]此时所有元素有序。注意插入排序每一轮过后,前i+1个元素一定是局部有序的,但它们的最终位置可能还没有确定。这一点和选择排序不同——选择排序每一轮把最小值放到最终位置,而插入排序每一轮只是把新元素放入当前有序序列的正确位置。
3. 用 Python 实现插入排序
理解过程之后,代码只需要对照刚才的搬移逻辑写。插入排序的标准实现如下:
def insertion_sort(arr): # 从第 2 个元素开始向前插入 for i in range(1, len(arr)): key = arr[i] j = i - 1 # 将比 key 大的元素依次后移 while j >= 0 and arr[j] > key: arr[j + 1] = arr[j] j -= 1 # 把 key 放到正确位置 arr[j + 1] = key return arr如果你的代码环境支持类型标注,也可以写得更严谨一点:
from typing import List def insertion_sort(arr: List[int]) -> List[int]: 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 arr这两个版本的逻辑完全一致,核心就三个动作:
- 用
key暂存当前待插入元素。 - 把已排序区域里所有大于
key的元素向右搬移一位。 - 把
key写入空出来的位置。
跑一个简单测试:
arr = [5, 2, 4, 6, 1, 3] sorted_arr = insertion_sort(arr) print(sorted_arr) # 输出:[1, 2, 3, 4, 5, 6]这段代码里最关键也最容易出错的地方在于:
while条件中的j >= 0不能省略。当待插入元素比已排序区域的所有元素都小时,j会递减到 -1,此时如果还访问arr[j]就会越界报错。key必须提前保存。因为在搬移过程中,arr[i]所在位置会被前一个元素覆盖,如果不先备份,原值就丢了。- 比较条件是
arr[j] > key,而不是arr[j] >= key。如果写成>=,排序虽然也能完成,但会破坏稳定性;写成严格大于,相同元素的相对顺序就能保持原样。
为了验证边界条件是否处理正确,可以加一组测试用例:
test_cases = [ [], [1], [2, 1], [1, 2, 3, 4, 5], [5, 4, 3, 2, 1], [3, 3, 3, 3], [5, 2, 4, 6, 1, 3], ] for case in test_cases: result = insertion_sort(case.copy()) expected = sorted(case) print(f"{case} -> {result}, 正确: {result == expected}")这组用例覆盖了空数组、单元素、倒序、正序、重复元素、乱序数组,跑通基本可以确认代码实现没有低级问题。
4. 用动画拆解插入排序的每一轮插入
只看文字描述,还是不够直观。更推荐的方式是让数组里的每个元素变成一根柱子,然后用 Python + Matplotlib 画出每一轮的搬移动画。这样对初学者来说,排序过程是“看得见”的,理解起来快得多。
下面给出一份可直接运行的动画演示代码。它会把每一轮比较、后移、插入动作逐帧渲染出来,按一次运行就能看到完整过程:
import matplotlib.pyplot as plt import matplotlib.animation as animation import numpy as np def insertion_sort_visual(arr): frames = [] colors = [] # 记录初始状态 frames.append(arr.copy()) colors.append(['#1f77b4'] * len(arr)) for i in range(1, len(arr)): key = arr[i] j = i - 1 # 动画:标记当前待插入元素 arr_copy = arr.copy() color_copy = ['#1f77b4'] * len(arr) color_copy[i] = '#ff7f0e' # 橙色标记待插入元素 frames.append(arr_copy.copy()) colors.append(color_copy.copy()) while j >= 0 and arr[j] > key: # 后移前记录状态,标记正在比较的元素 arr_copy = arr.copy() color_copy = ['#1f77b4'] * len(arr) color_copy[j] = '#d62728' # 红色标记正在比较的元素 color_copy[j + 1] = '#ff7f0e' frames.append(arr_copy.copy()) colors.append(color_copy.copy()) # 执行后移 arr[j + 1] = arr[j] j -= 1 # 插入 key arr[j + 1] = key # 记录插入完成状态,已排序区域用绿色标记 arr_copy = arr.copy() color_copy = ['#2ca02c'] * (i + 1) + ['#1f77b4'] * (len(arr) - i - 1) frames.append(arr_copy.copy()) colors.append(color_copy.copy()) # 最终整体绿色 frames.append(arr.copy()) colors.append(['#2ca02c'] * len(arr)) return frames, colors def animate_insertion_sort(data): frames, colors = insertion_sort_visual(data.copy()) fig, ax = plt.subplots(figsize=(10, 5)) def update(frame_idx): ax.clear() arr = frames[frame_idx] color = colors[frame_idx] x = np.arange(len(arr)) bars = ax.bar(x, arr, color=color, width=0.6) ax.set_xticks(x) ax.set_xticklabels(arr) ax.set_ylim(0, max(data) + 2) ax.set_title(f"插入排序动画 - 第 {frame_idx} 步") return bars anim = animation.FuncAnimation( fig, update, frames=len(frames), interval=800, repeat=False ) return anim, fig if __name__ == "__main__": test_data = [5, 2, 4, 6, 1, 3] anim, fig = animate_insertion_sort(test_data) plt.show()运行这段代码,你会看到以下阶段:
- 初始状态:所有柱子都是蓝色。
- 每一轮开始前,待插入元素被标记为橙色。
- 红色柱子表示当前正在和待插入元素比较的元素,它会向右挪动一位。
- 一轮插入完成后,已排序区域变为绿色。
- 最后所有柱子变绿,排序结束。
如果想将动画保存为 gif 文件,可以在plt.show()前加一行:
anim.save("insertion_sort.gif", writer="pillow", fps=2)在 Jupyter Notebook 里,也可以用HTML(anim.to_html5_video())直接嵌在单元格中播放。动画代码本身也很适合作为课程演示,或者自己复习时快速回顾排序流程。
5. 插入排序的复杂度与稳定性分析
分析复杂度时,重点看两个动作:比较的次数和搬移的次数。
5.1 时间复杂度
最好情况:数据已经完全有序。每一轮只需要比较一次,发现待插入元素已经大于已排序区域最后一个元素,直接进入下一轮。此时总比较次数大约是 N-1 次,时间复杂度为 O(n)。
最坏情况:数据是逆序的。每一轮待插入元素都要和已排序区域的所有元素比较,并且每个元素都要后移。总比较次数是:
1 + 2 + 3 + ... + (n-1) = n(n-1)/2所以最坏时间复杂度为 O(n²)。
平均情况:数据完全随机,每一轮大约比较一半的元素,总比较次数仍然和 n² 同阶,所以平均时间复杂度也是 O(n²)。
5.2 空间复杂度
插入排序是原地排序,只使用了一个key变量临时保存元素值。不管数据规模多大,额外空间始终是常数级别,空间复杂度 O(1)。
5.3 稳定性分析
稳定性关注的是:数组中有相同元素时,排序后它们的相对顺序有没有改变。
插入排序的搬移条件是arr[j] > key,也就是只有在前面的元素严格大于待插入元素时才会后移。如果前面的元素等于待插入元素,循环停止,待插入元素被放到相等元素后面的位置。因此相同元素的原始相对顺序不会改变,插入排序是稳定排序。
稳定性在实际工程中重要吗?重要。例如先按姓名排序,再按年龄排序,稳定排序能保证后一次排序不会打乱前一次排序的顺序。很多高级排序算法要求底层排序是稳定的,正是因为这种可叠加性。
6. 折半插入排序:用二分查找减少比较次数
插入排序每一轮在找插入位置时,是从后往前逐个比较的。但已排序区域本身是有序的,这意味着完全可以使用折半查找(二分查找)来定位插入点,从而把每轮比较次数从 O(n) 降到 O(log n)。
这种优化后的版本叫折半插入排序。注意它只是减少了比较次数,并没有减少元素搬移次数。搬移操作仍然是 O(n),所以总时间复杂度依然是 O(n²)。但它的常数更小,实际运行会略快。
折半插入排序的 Python 实现:
def binary_insertion_sort(arr): for i in range(1, len(arr)): key = arr[i] # 在 [0, i-1] 区间内二分查找 key 的插入点 low, high = 0, i - 1 while low <= high: mid = (low + high) // 2 if arr[mid] > key: high = mid - 1 else: low = mid + 1 # low 就是 key 应该插入的位置 # 将 [low, i-1] 的元素整体后移一位 for j in range(i, low, -1): arr[j] = arr[j - 1] arr[low] = key return arr这段代码里,二分查找结束后low指向第一个大于key的位置,也就是插入点。然后把[low, i-1]区间内的元素依次后移,最后把key放到low位置。
测试对比一下普通插入排序和折半插入排序:
import random import time data = list(range(10000)) random.shuffle(data) # 普通插入排序 arr1 = data.copy() start = time.time() insertion_sort(arr1) print("普通插入排序耗时:", time.time() - start) # 折半插入排序 arr2 = data.copy() start = time.time() binary_insertion_sort(arr2) print("折半插入排序耗时:", time.time() - start)从实际运行效果看,折半插入排序因为减少了比较次数,在随机数据下通常会更快一些。但对于数据量大到十万以上的场景,O(n²) 的搬移成本仍然占主导,插入排序的适用场景依然是小规模数据和基本有序数据。
这里有一个值得注意的细节:二分查找的比较不等号选取会影响稳定性。上面的代码里二分查找条件是arr[mid] > key时收缩右边界,这样遇到相等元素时会继续向右查找,最终把新元素放到相等元素之后,保持了稳定性。如果把条件改成>=,排序就变得不稳定了。
7. 插入排序 vs 冒泡排序 vs 选择排序
初学者往往会在三种 O(n²) 排序算法之间纠结。直接上对比表:
| 排序算法 | 最好时间 | 最坏时间 | 空间 | 稳定性 | 主要操作 |
|---|---|---|---|---|---|
| 插入排序 | O(n) | O(n²) | O(1) | 稳定 | 比较 + 搬移 |
| 冒泡排序 | O(n) | O(n²) | O(1) | 稳定 | 比较 + 交换 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 比较 + 交换 |
三个算法里,插入排序有两大优势:
第一,最好情况下复杂度是 O(n)。当数据接近有序时,插入排序几乎只需要线性扫描一遍,而选择排序无法提前终止,始终是 O(n²) 的比较次数。
第二,插入排序的搬移操作比交换更高效。冒泡排序每次交换要执行三次赋值,而插入排序的后移是一次赋值。虽然两者复杂度同阶,但实际常数不同。
选择排序唯一明显优于插入排序的地方是交换次数少,理论上每轮最多一次交换。但它的比较次数始终是固定的 n(n-1)/2 次,而且不稳定。
所以在实际工程里,如果数据量很小或者数据基本有序,插入排序往往是首选。Python 内置的sorted函数底层使用 Timsort,而 Timsort 的核心思路之一就是利用插入排序处理小规模子序列。
8. 插入排序的工程应用与批量测试
插入排序虽然简单,但它不是“玩具算法”。它在真实工程中有三个不可替代的位置:
8.1 小规模数组排序
当数据量在几十个以内时,插入排序的常数极小,实际运行速度可能比快速排序、归并排序更快。很多标准库在递归排序到某个深度时会切换到插入排序。
比如在 Java 的Arrays.sort()底层,当划分后的数组长度小于 47 时,会直接使用插入排序。CPython 的 Timsort 中,小于 64 的子数组也会用插入排序完成。
8.2 链表排序
插入排序天然适合链表结构,因为链表节点的插入不需要搬移元素,只需要修改指针。代码实现也和数组版本略有差异:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def insertion_sort_list(head): dummy = ListNode(0) cur = head while cur: # 记住下一个节点 next_node = cur.next # 在已排序链表中找到插入位置 prev = dummy while prev.next and prev.next.val < cur.val: prev = prev.next # 插入节点 cur.next = prev.next prev.next = cur # 处理下一个节点 cur = next_node return dummy.next链表版本的插入排序,比较逻辑和数组版本一致,但没有元素搬移这一说,插入操作变成了 O(1) 的指针调整。这也是链表中插入排序优于大多数数组排序算法的地方。
8.3 批量测试多个数组
如果你想验证插入排序在多种数据分布下的性能,可以写一个批量测试:
def test_multiple_datasets(): datasets = { "random": [random.randint(0, 1000) for _ in range(1000)], "sorted": list(range(1000)), "reverse": list(range(1000, 0, -1)), "duplicate": [random.choice([1, 2, 3, 4, 5]) for _ in range(1000)], } results = {} for name, data in datasets.items(): arr = data.copy() start = time.time() insertion_sort(arr) elapsed = time.time() - start is_sorted = arr == sorted(data) results[name] = {"耗时": f"{elapsed:.5f}s", "排序正确": is_sorted} return results for name, result in test_multiple_datasets().items(): print(f"{name}: {result}")从这个测试结果可以看到:sorted数据集耗时几乎是线性的,reverse数据集耗时最大,random居中,duplicate数据集因为有大量相同元素、搬移不会触发,耗时也不会太大。
9. 性能观测与调优思路
想把插入排序的性能摸清楚,最直接的办法是量级对比。分别在 100、1000、5000、10000 个随机数上跑一遍,记录耗时:
import random import time for n in [100, 500, 1000, 2000, 5000, 10000]: data = list(range(n)) random.shuffle(data) arr = data.copy() start = time.time() insertion_sort(arr) elapsed = time.time() - start print(f"n={n}: {elapsed:.5f}s")运行后你会看到:数据规模从 1000 增加到 2000,耗时大约翻 4 倍;从 5000 增加到 10000,耗时同样大约翻 4 倍。这正是 O(n²) 复杂度的典型特征。
如果觉得插入排序太慢,有几个常见的调优思路:
- 用折半插入排序,减少比较次数。
- 把数组分块,先用插入排序处理小块,再用归并方式合并,这就是 Timsort 的核心思路。
- 对大规模乱序数据,先用希尔排序做粗略排序,再交给插入排序收尾。
但如果你只是想在 O(n²) 的算法里找到最优解法,插入排序已经是很接近“最优简单解”了。换一句话说,不要试图优化一个 O(n²) 算法去对抗快速排序,正确的用法是在小规模或近似有序的数据上发挥它的优势。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 数组越界报错 | 缺少j >= 0判断 | 检查 while 条件 | 在while j >= 0 and arr[j] > key中保留j >= 0 |
| 排序结果不正确 | key没有提前保存 | 检查是否在循环前执行key = arr[i] | 在 for 循环内第一行保存 key |
| 排序后相同元素顺序改变 | 使用了>=比较 | 检查搬移条件 | 改为arr[j] > key,严格大于 |
| 动画中待插入元素丢失 | 直接在原数组上修改未备份 | 检查动画帧记录方式 | 每帧生成arr.copy()保存 |
| 动画运行后不显示 | matplotlib 后端问题 | 终端运行python 文件名.py或更换 IDE 环境 | 在 Jupyter 中尝试内嵌显示,或使用 pyplot 默认后端 |
| 保存 gif 失败 | 缺少 pillow | 检查依赖 | pip install pillow |
| 递归或循环时间过长 | 数据规模过大 | 检查 n 的取值 | 插入排序演示数据控制在 1 万以内 |
| 折半插入排序不稳定 | 二分查找条件用错 | 检查arr[mid] > key是否为大前提 | 保持等于时向右搜索,确保后插入元素在相等元素之后 |
初学者最常见的错误集中在前三行。把这三个问题记牢,基本就能保证一次写对插入排序。
11. 最佳实践与学习建议
基于前面完整的拆解,给你一套插入排序的学习和实践建议。
第一,先看动画再写代码。如果对每一步搬移过程没有直观印象,直接写代码容易在边界条件上反复出错。我建议你先把第 3 节的动画代码跑起来,把数组换成[8, 3, 5, 1, 9, 2]再看一遍,心里有图,代码自然有底气。
第二,从数组版迁移到链表版。数组版的插入排序是靠“搬移”完成的,链表版是靠“指针调整”完成的。这两种实现看似不同,底层思想完全一样。把两种都写一遍,你对插入排序的理解会更深。
第三,用调试器或 print 加深理解。如果你想看每一轮循环后数组的变化,可以在代码里加一行输出:
def insertion_sort_with_log(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 print(f"第 {i} 轮: {arr}") return arr输出效果如下:
第 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]写日志是一个非常好的学习习惯,尤其是对算法类的代码,每一轮的状态变化就是最好的学习材料。实际工作中排查排序问题,也可以沿用这个方法。
第四,整理一个最小可运行示例,作为索引。比如把你的插入排序实现加上测试用例、动画演示放在同一个目录里。下次需要讲给别人听,或者自己复习,直接跑一份脚本就能回忆起整个逻辑。
第五,如果想把插入排序应用到真实项目中,优先考虑它适合的场景:数据量小于几十、数据近乎有序、链表结构、或者作为高级排序的底层优化。不要用它处理百万级数据。
第六,注意数据分布的影响。插入排序在逆序数据上的表现是所有 O(n²) 排序里较差的,因为搬移次数最多。如果业务数据经常是逆序状态,建议换用归并排序或快速排序。
第七,用插入排序的思维扩展学习路径。插入排序的基础上加上“分组”和“大步长”概念,就是希尔排序;加上“二分查找”,就是折半插入排序;再加上“分块归并”,就是 Timsort 的思想。把这条演进链理清楚,比单独背十个排序算法有用得多。
12. 总结与下一步
插入排序全流程走完,核心要点可以压缩成三句话:
第一,插入排序的关键动作是“把新元素插入到已经有序的子序列中”,实现时要靠“搬移”而不是“交换”。
第二,它的时间复杂度是 O(n²),但数据接近有序时能达到 O(n),空间复杂度 O(1),稳定。
第三,动画拆解是理解它的最佳方式。把数组可视化成柱子、把每一轮插入过程标记成不同颜色,整个算法思路可以一次看懂。
现在你可以立刻做两件事:先把第 3 节的 Python 动画代码跑起来,再照着第 5 节的折半插入排序实现写一遍。如果while j >= 0 and arr[j] > key这个条件你能一秒内解释清楚为什么不能去掉j >= 0,插入排序这一关就算真正过了。
接下来适合扩展的方向有两个:一个是把插入排序改成希尔排序,理解“大步长分组插入”如何把 O(n²) 拉到接近 O(n^1.3);另一个是去看 Python 内置sorted的 Timsort 实现,你会发现插入排序在真实工程里的身影无处不在。这两个方向任选一个,排序算法的地基就算彻底打牢了。