算法基础(九)——循环不变式如何证明一个算法是正确的

1. 定位导航

前面已经学习了算法的基本概念、运行时间分析,以及插入排序的执行过程。现在要补上另一块非常重要的内容:正确性证明

运行时间回答的是:

text 复制代码
算法快不快?

循环不变式回答的是:

text 复制代码
算法为什么对?

2. 概念术语

术语 定义 举例
循环不变式 循环过程中始终保持成立的性质 插入排序中左侧子数组始终有序
初始化 循环第一次执行前,不变式成立 左侧只有一个元素,天然有序
保持 每轮循环后,不变式仍然成立 插入 key 后,左侧仍有序
终止 循环结束时,不变式推出正确结果 整个数组已经有序
正确性证明 说明算法对所有合法输入都能输出正确结果 不是只靠测试样例

关键澄清:

  1. 循环不变式不是循环条件。
  2. 循环不变式不是最终结果本身。
  3. 循环不变式要在每轮循环前后都能成立。
  4. 它的作用是把"每一步没错"连接到"最终结果正确"。

3. 什么是循环不变式

循环不变式可以理解成:

text 复制代码
在循环执行过程中,一直保持为真的关键性质。

它像一条安全绳:

  • 循环开始前,它成立;
  • 每执行一轮,它仍然成立;
  • 循环结束时,它帮助我们推出最终结论。

4. 三步证明法:初始化、保持、终止

4.1 初始化

证明循环第一次执行之前,不变式已经成立。

4.2 保持

假设某一轮循环开始前不变式成立,证明这一轮结束后,不变式仍然成立。

4.3 终止

证明循环结束时,不变式能够推出算法的正确输出。

5. 插入排序中的循环不变式

插入排序可以维护这样一个不变式:

在每轮外层循环开始前,当前下标左边的子数组已经有序,并且包含的是原数组对应前缀元素的一个重排。

这句话包含两个关键点:

第一,左边部分是有序的。

第二,左边部分没有丢元素,也没有多元素,只是顺序发生了变化。

6. 动态推演:不变式如何贯穿排序过程

下面用一个动态过程观察循环不变式如何保持。

以数组:

text 复制代码
[5, 2, 4, 6, 1, 3]

为例,插入排序每一轮都把当前 key 插入到左侧有序部分的正确位置。每轮结束后,左侧有序部分会扩大一格。直到最后,整个数组都成为有序部分。

7. 用三步法证明插入排序正确

7.1 初始化

第一次外层循环开始前,左侧只有第一个元素。

一个只包含一个元素的数组,天然是有序的,所以循环不变式成立。

7.2 保持

假设某一轮开始前,左侧子数组已经有序。

这一轮会取出当前元素 key,然后从右向左扫描左侧有序部分,把所有比 key 大的元素向右移动一格。

当找到合适位置后,把 key 放进去。因为左侧原本有序,比 key 大的元素整体右移,key 被插入到正确位置,所以插入后,左侧扩展后的子数组仍然有序。

7.3 终止

当外层循环结束时,已经处理完所有元素。根据循环不变式,当前元素左边的部分已经有序。此时这个部分就是整个数组,因此整个数组有序。

所以插入排序正确。

8. 如何写出一个好的循环不变式

一个好的循环不变式,通常要满足三点:

  • 足够强:循环结束后能推出最终结论;
  • 能被保持:每轮执行后不会被破坏;
  • 容易验证初始化:循环开始前可以成立。

9. 代码实践:用断言检查不变式

python 复制代码
def is_sorted_prefix(arr, end):
    for i in range(1, end):
        if arr[i - 1] > arr[i]:
            return False
    return True


def insertion_sort_with_invariant_check(nums):
    arr = nums[:]

    assert is_sorted_prefix(arr, 1)

    for j in range(1, len(arr)):
        assert is_sorted_prefix(arr, j)

        key = arr[j]
        i = j - 1

        while i >= 0 and arr[i] > key:
            arr[i + 1] = arr[i]
            i -= 1

        arr[i + 1] = key
        assert is_sorted_prefix(arr, j + 1)

    assert is_sorted_prefix(arr, len(arr))
    return arr


nums = [5, 2, 4, 6, 1, 3]
print(insertion_sort_with_invariant_check(nums))

输出:

text 复制代码
[1, 2, 3, 4, 5, 6]

这些 assert 不是排序必须的一部分,而是帮助我们把循环不变式显式检查出来。

10. 常见误区

误区一:把循环条件当成循环不变式

例如 i < n 是循环条件,不是证明正确性的关键性质。

误区二:不变式太弱

比如只说"循环一直在处理数组元素",无法推出数组最终有序。

误区三:不变式太强

如果一开始就说"整个数组已经有序",初始化通常不成立。

误区四:只描述过程,不证明保持

很多人会说"每次插入到正确位置",但没有说明为什么插入后左侧仍然有序。

11. 现代延伸

循环不变式不只用于排序算法,它在很多工程场景中都有影子。

场景 类似的不变式思想
数据库事务 事务执行前后保持数据一致性
分布式系统 状态机复制要求节点状态一致
缓存淘汰 缓存结构始终满足淘汰策略
堆结构 每次插入删除后仍保持堆性质
红黑树 插入删除后仍保持颜色和平衡性质
图算法 每轮维护已确定节点集合的性质

很多复杂系统的正确性,本质上就是维护某种"不变性质"。

12. 思考题

  1. 循环不变式和循环条件有什么区别?
  2. 为什么插入排序中"左侧前缀有序"是一个合适的不变式?
  3. 初始化、保持、终止三步分别解决什么问题?
  4. 如果一个不变式无法推出最终结果,它有什么问题?
  5. 尝试给"寻找最大值"算法写一个循环不变式。

13. 本篇小结

本篇重点讲了循环不变式。

核心结论是:

  • 循环不变式是循环过程中始终保持成立的关键性质;
  • 正确性证明通常分为初始化、保持、终止三步;
  • 插入排序中的不变式是:当前元素左侧的子数组已经有序;
  • 测试只能发现部分错误,循环不变式可以帮助证明整体正确性。

以后看到循环算法时,可以多问一句:

text 复制代码
这个循环到底一直维护着什么性质?

这句话往往就是理解算法正确性的关键。

相关推荐
ps酷教程1 小时前
Jackson 解决没有无参构造函数的反序列化问题
java
NiceCloud喜云1 小时前
Opus 4.8 的 Effort Control 怎么选:Low 到 Max 五档策略
android·java·大数据·前端·c++·python·spring
小羊在睡觉1 小时前
力扣84. 柱状图中最大的矩形
后端·算法·leetcode·golang·go
3DVisionary2 小时前
蓝光三维扫描:医疗制造的精度焦虑怎么解
人工智能·算法·制造·蓝光三维扫描·医疗制造·三维检测·义齿检测
AI玫瑰助手2 小时前
Python函数:默认参数的定义与注意事项
开发语言·python·信息可视化
好评笔记2 小时前
机器学习面试八股——常用损失函数
人工智能·深度学习·算法·机器学习·校招
weixin_468466852 小时前
全局与局部注意力机制新手实战指南
人工智能·python·深度学习·算法·自然语言处理·transformer·注意力机制
油炸自行车2 小时前
Claude Code 错误:API Error: 400 Failed to deserialize the JSON body into the
开发语言·javascript·json·trae·claude code·api error 400
肩上风骋2 小时前
C++14特性
开发语言·c++·c++14特性
_日拱一卒2 小时前
LeetCode:994腐烂的橘子
java·数据结构·算法·leetcode·深度优先