Skip to content

3.2 插入排序与循环不变式

这一节,你能解决什么问题

学完这一节,你能够:

  1. 判断一个排序算法是否正确,不是靠"跑一遍看看",而是用循环不变式证明
  2. 估算排序算法在不同情况下的代价,知道什么时候 Θ(n²) 是可接受的
  3. 审查 agent 给出的排序代码,发现边界条件错误和隐藏性能问题

问题情境

你正在整理一叠扑克牌。

牌已经拿在手里了,你需要把它们按从小到大排序。你会怎么做?

大多数人会这样:

拿起一张牌,插入到已整理好的牌堆中的正确位置。

这很自然。但问题是:为什么这个方法是对的?能不能更快?

让我们先把直觉变成算法,然后回答这两个问题。


直观思路

整理扑克牌的过程可以分解成几个步骤:

  1. 第一张牌:只有一个元素,天然有序
  2. 第二张牌:和第一张比,该在前就在前,该在后就在后
  3. 第三张牌:和前面两张比,找到正确位置插入
  4. ...以此类推

关键思想:每次只处理一张新牌,把它插入到已整理部分

这样的好处是什么?

  • 不需要知道后面还有多少牌
  • 可以随时停下来,前面部分始终有序
  • 如果牌几乎已经有序,只需很少的比较

问题:这个思路怎么变成代码?


规范定义

先定义问题:

输入:数组 A[0..n-1],包含 n 个元素 输出:数组 A[0..n-1],元素从小到大排列 约束:原地排序,只使用 O(1) 额外空间

插入排序的定义

对于每个位置 i(从 1 到 n-1):

  • 把 A[i] 插入到 A[0..i-1] 的正确位置
  • 插入后,A[0..i] 有序

为什么这样定义?

因为我们要保证一个不变量:在任何时刻,已处理部分始终有序。

这个不变量有三个好处:

  1. 每次只需考虑一个新元素
  2. 任何时候停下来,前面部分都可以使用
  3. 可以用于"增量排序"——数据逐步到达时

算法实现

python
def insertion_sort(A):
    for i in range(1, len(A)):
        key = A[i]          # 拿起这张牌
        j = i - 1           # 从已整理部分的最后往前找
        
        while j >= 0 and A[j] > key:
            A[j + 1] = A[j] # 大的牌往后移,腾出位置
            j -= 1
        
        A[j + 1] = key      # 插入到正确位置

一步步执行

初始:[5, 2, 4, 6, 1, 3]

i=1: key=2, 找位置
     [5, 2] → 5 > 2, 后移 → [|2, 5] → 2 插入位置0
     结果:[2, 5, 4, 6, 1, 3]

i=2: key=4, 找位置
     [2, 5] 中,5 > 4, 后移 → [2, |4, 5]
     结果:[2, 4, 5, 6, 1, 3]

i=3: key=6, 找位置
     [2, 4, 5] 中,都 < 6, 不移动
     结果:[2, 4, 5, 6, 1, 3]

i=4: key=1, 找位置
     [2, 4, 5, 6] 都 > 1, 全部后移
     结果:[1, 2, 4, 5, 6, 3]

i=5: key=3, 扻位置
     [1, 2, 4, 5, 6] 中,4,5,6 > 3, 后移
     结果:[1, 2, 3, 4, 5, 6]

这个方法是怎么想到的

你可能会问:为什么用"插入"而不是"交换"?

让我们看看另一种思路:

思路A(选择排序):每次找最小的,放到前面

  • 找最小值需要扫描整个数组
  • 每次只能确定一个位置
  • 即使数据已经有序,还是要全部扫描

思路B(插入排序):每次把新元素插入正确位置

  • 只需要和前面已有序部分比较
  • 如果数据已经有序,每个元素只需一次比较

对比

场景选择排序插入排序
已有序数据Θ(n²)(还是要全部扫描)Θ(n)(每次只比一次)
逆序数据Θ(n²)Θ(n²)
随机数据Θ(n²)Θ(n²)

洞察:插入排序的优势在于"增量维护有序"——如果数据已经接近有序,插入排序只需要很少的工作。


正确性分析

现在回答第一个问题:为什么这个方法是对的?

循环不变式证明。

什么是循环不变式?

循环不变式是数学归纳法在程序上的应用:

  1. 初始化:循环开始前,不变式成立
  2. 保持:每次迭代后,不变式仍然成立
  3. 终止:循环结束时,不变式给出我们想要的结论

插入排序的循环不变式

不变式:在 for 循环的每次迭代开始时,子数组 A[0..i-1] 包含原来在 A[0..i-1] 的元素,且已排序。

证明

1. 初始化

i = 1 时,A[0..0] 只有一个元素。单个元素天然有序。不变式成立。

2. 保持

假设迭代开始时 A[0..i-1] 已排序。

算法把 A[i] 插入到 A[0..i-1] 中:

  • while 循环把所有 > key 的元素后移
  • 最后把 key 放到空出的位置

插入后,A[0..i] 有序,且包含原来 A[0..i] 的所有元素。

迭代结束后,i 增加到 i+1。新的迭代开始时,A[0..(i+1)-1] = A[0..i] 已排序。不变式保持。

3. 终止

循环结束条件:i = n。

此时 A[0..n-1] 已排序。这就是整个数组。

结论:插入排序正确。


复杂度分析

现在回答第二个问题:代价是什么?

时间复杂度

关键量:比较次数移动次数

让我们分析三种情况:

情况一:已有序

[1, 2, 3, 4, 5]

每个元素只需和前一个比一次,发现已经有序。

  • 比较次数:n-1
  • 移动次数:0
  • 复杂度:Θ(n)

情况二:逆序

[5, 4, 3, 2, 1]

每个元素都要和前面所有元素比,并且全部移动。

i=1: key=4, 比较1次, 移动1次
i=2: key=3, 比较2次, 移动2次
i=3: key=2, 比较3次, 移动3次
i=4: key=1, 比较4次, 移动4次

总比较:1+2+3+4 = (n-1)n/2
总移动:1+2+3+4 = (n-1)n/2

复杂度:Θ(n²)

情况三:随机

一个关键概念:倒置数

倒置是数组中逆序的一对元素:i < j 但 A[i] > A[j]。

  • 已有序:0 个倒置
  • 逆序:n(n-1)/2 个倒置
  • 随机排列:期望倒置数 = n(n-1)/4

为什么是 n(n-1)/4?

n(n-1)/2 对元素,每对有 1/2 概率是倒置。期望 = 总数 × 概率。

插入排序每次比较和移动消除一个倒置。

期望比较次数 ≈ 期望倒置数 = Θ(n²/4)

复杂度:Θ(n²)

空间复杂度

原地排序,只用了几个变量(key, i, j)。

空间复杂度:Θ(1)


什么时候不该用这个方法

插入排序不是万能的。什么时候该用,什么时候不该用?

该用的情况

  1. 小规模数据(n < 50)

    • Θ(n²) 和 Θ(n log n) 差别很小
    • 插入排序实现简单,常数小
  2. 几乎有序的数据

    • 倒置数很少时,接近 Θ(n)
    • 比如:已排序数组中插入少量新元素
  3. 数据逐步到达

    • 可以增量维护有序
    • 不需要等所有数据到达再排序
  4. 作为其他排序的子过程

    • 快排、归并在小数组时切换到插入排序
    • 实际库函数中的常见优化

不该用的情况

  1. 大规模随机数据

    • Θ(n²) 太慢
    • 应该用 Θ(n log n) 的算法
  2. 逆序或接近逆序的数据

    • Θ(n²) 最坏情况
    • 如果数据分布未知,不应该依赖插入排序
  3. 需要稳定性的场景,但数据量大

    • 插入排序是稳定的,但太慢
    • 应该用归并排序(稳定且 Θ(n log n))

本节小结

解决了什么问题?

  • 原地排序数组,使用简单的"插入"思想
  • 用循环不变式证明算法正确,不靠"跑一遍看看"
  • 分析了不同情况下的代价

核心方法是什么?

  • 增量有序:每次只处理一个新元素,维护已有序部分
  • 循环不变式:数学归纳法用于程序证明

为什么正确?

循环不变式保证:

  • 开始时:单元素有序
  • 每次迭代:保持有序
  • 结束时:整个数组有序

代价是什么?

  • 最好:Θ(n)(已有序)
  • 最坏:Θ(n²)(逆序)
  • 平均:Θ(n²)
  • 空间:Θ(1)

适用场景?

小规模、几乎有序、增量到达。不适合大规模随机数据。

能否自己使用?

能。关键是记住:

  • 不要盲目用于大数据
  • 用循环不变式验证代码(包括 agent 写的)
  • 倒置数决定代价

练习

基本理解

练习 1:对数组 [3, 1, 4, 1, 5, 9, 2, 6],画出插入排序每一步的状态。

练习 2:计算数组 [3, 1, 4, 1, 5, 9, 2, 6] 的倒置数。

练习 3:为什么插入排序是稳定的?稳定性在什么场景重要?

方法应用

练习 4:写出插入排序内循环的循环不变式,并证明:

while 循环开始时,A[j+1..i] 的元素都大于 key,且保持原来顺序。

练习 5:设计一个"在线排序"场景:数据逐步到达,每收到一个新数据就维护有序。写出伪代码。

错误诊断

练习 6:以下代码有什么问题?

python
def insertion_sort_bug(arr):
    for i in range(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

提示:用循环不变式验证 i=0 时的情况。

练习 7:为什么 A[j + 1] = key 而不是 A[j] = key?写出边界条件分析。

方案比较

练习 8:比较插入排序和选择排序:

维度插入排序选择排序
最好复杂度
最坏复杂度
空间复杂度
是否稳定
适用场景

填完表格后,给出一个选择建议:什么时候用插入,什么时候用选择?

开放设计

练习 9:二分插入排序。

用二分查找找插入位置,减少比较次数。写出算法并分析:

  • 比较次数减少了吗?
  • 移动次数减少了吗?
  • 时间复杂度改变了吗?

练习 10:审查 agent 输出。

让 agent 写一个插入排序实现,然后用循环不变式审查:

  1. 循环范围是否正确?
  2. 内循环条件是否正确?
  3. 插入位置是否正确?
  4. 边界情况(空数组、单元素、全相等)是否处理?

写一份审查报告。


上一节:3.1 理论极限

下一节:3.3 分治排序

新时代的算法课程