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

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 复制代码
这个循环到底一直维护着什么性质?

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

相关推荐
Devin~Y1 小时前
大厂Java面试:Spring Boot + Redis/Kafka + Spring Cloud + JVM + RAG/向量检索(小Y翻车实录)
java·jvm·spring boot·redis·spring cloud·kafka·mybatis
寻道模式1 小时前
【开发心得】给私有部署OpenClaw添加PDF阅读技能
开发语言·python·pdf
逐梦苍穹1 小时前
Claude Code调用Codex失败复盘:从10个Agent、0次codex exec到Bash-only Worker + Hook强制委托
开发语言·chrome·bash
wuweijianlove1 小时前
算法稳定性分析中的输入扰动建模的技术7
算法
赏金术士1 小时前
Kotlin 从入门到进阶 之泛型 模块(七)
android·开发语言·kotlin
代码中介商1 小时前
C++ 异常处理完全指南
开发语言·c++
朝新_1 小时前
【LangChain】少样本提示(few-shorting) 大模型 Few-Shot 提示工程:四大 Example Selector应用
java·人工智能·自然语言处理·langchain
MATLAB代码顾问1 小时前
粒子群优化算法(PSO)原理与Python高级实现
开发语言·python·算法
Epiphany.5562 小时前
连通块的遍历
c++·算法·蓝桥杯