前言
经常在一些算法大牛们的算法题解中看到「循环不变量」这个概念,初次看到时不明就里,然而这似乎又是一个很重要的概念,不能够蒙混过去,于是在查阅了部分资料后得作此文,权当抛砖引玉。
概述
循环不变量是在循环的每次迭代之前和迭代之后都必然为「真」的条件(迭代过程中的「真」或「假」此处没有提及)。
举例,在 Python 中,一个 While 循环满足如下形式,其中 B 是一个布尔表达式(我们称其为循环的守护条件)并且 S 是一系列命令/指令(我们称其为循环的主体)。
1 | while B: |
此类循环执行的流程图如下:

假设
B不会更改程序中变量的值。
介绍这个流程图中循环执行的逻辑结构,具体步骤:
- 开始节点:流程从
start节点开始。 - **前置条件
Q**:在开始时,假设前置条件Q成立,允许进入后续步骤。 - 循环判断:从
Q节点进入判断,如果不变式P成立且布尔条件B成立,执行步骤S。这个过程意味着在每次循环开始时,不变式P保持不变。 - 布尔条件判断:如果
B为真,流程继续执行步骤S;如果B为假,流程将跳出循环,进入停止节点。 - **结束条件
R**:在停止节点之前,确保后置条件R成立,表示循环已经正确执行并达到预期结果。
假设每次执行到流程图中的「关键位置」(即表示条件 B 的菱形框上方的位置)时 P 总是成立。选择一个作为循环不变式的 P 很容易,即在关键位置总是成立的 P。(例如,选择 P 为 true 就可以。)然而,选择一个在证明 R 成立时有用的 P 并不总是容易。为了有用,所选择的不变式 P 应满足以下条件:
1 | P & !B ==> R |
即 P 和循环守护条件 B 的否定同时为真时,R 也为真。为什么?因为这样我们可以确保,当循环结束时(此时 P & !B 必然成立),R 也会像我们期望的那样成立。
循环不变量如何保证「不变」?
你可能会好奇:如何保证你选择的 P 每次执行到关键位置时确实为真?
我们通过如下步骤进行分析:
首先,需要证明 Q ==> P,即前置条件 Q 的真实性能够保证不变式 P 为真。由此可以得出,执行第一次到达关键位置时,P 是成立的。
其次,需要证明每次后续执行到关键位置时,P 依然成立。为此,需要证明:如果在一个 P & B 成立的状态下开始执行 S(循环体),那么在 S 执行完毕时,P 必然仍然为真。(当然,这相当于证明 {P & B} S {P} 是一个有效的 Hoare triple。)
实际上,这相当于对循环迭代次数进行数学归纳法证明。第一步是基准情形,证明在执行第一次到达关键位置时(即在零次迭代之后),P 是成立的。第二步是归纳步骤,证明:如果对于任意正整数 n,P 在第 n 次执行到关键位置时成立(即在第 n-1 次迭代后),那么它在第 n+1 次(即第 n 次迭代后)也必须成立。
循环不变量如何保证循环「终止」?
要证明循环在有限次迭代后终止,我们需要表明每次迭代都在某种程度上向终止推进。技术上,这意味着要证明存在一个程序变量的整数函数 *g*,该函数:
- 在每次迭代中减少;
- 并且该函数的值必须大于零,才能进行下一次迭代(换句话说,
P&B的真实性保证该函数的值大于零)。
请注意,在每次循环迭代的开始,该函数的值是剩余要执行的迭代次数的上限。
例如,考虑以下循环:
1 | while i < n: |
在这里,我们可以将 g 定义为 n-i,可以看到每次迭代时 g 的值都会减少(因为 i 每次增加而 n 保持不变)。同时,循环守护条件 i < n 的真实性保证了 g 的值大于零,这是我们所需要的。
为了说明循环不变式概念的实用性,我们可以讨论一些有趣的小问题。
例子
红蓝混合的弹珠罐
假设存在一个装有一个或多个弹珠的罐子,每个弹珠的颜色是红色或者蓝色。你有不限量的红色弹珠可以使用,执行以下过程:
1 | while 罐子里的弹珠数 > 1: |
通过模拟这个过程,你可以很容易地验证,每次循环迭代后,罐子里的弹珠数都会减少正好一个。因此,如果罐子最初包含 N 个弹珠,那么经过 N-1 次循环迭代后,罐子里恰好会剩下一个弹珠。我们刚刚通过一个有说服力但非正式的论证表明,这个循环在有限次迭代后会终止。为了更加精确,我们可以显式定义 g 为一个函数,该函数应用于罐子中的弹珠时,返回罐子里弹珠的数量。然后,我们可以论证,每次循环迭代时,*g* 的值都会减少,此外,如果循环条件成立(即罐子里至少有两个弹珠),*g* 的值一定大于零。
假设我们知道罐子里最初有多少红色和蓝色弹珠。拥有这些信息后,我们是否能够确定地预测罐子里最后剩下的弹珠是什么颜色?(请注意,由于我们假设罐子最初不为空且每次迭代弹珠的数量都会减少一个,因此不可能最终得到一个空罐子。)
更正式地说:
是否存在一个函数f: N × N --> {BLUE, RED}(即 f 的定义域是自然数对的集合,值域是集合 {BLUE, RED}),它满足以下条件:
对于所有 K 和 M(其中至少有一个非零),如果我们从罐子里开始有 K 个红色弹珠和 M个蓝色弹珠,那么罐子里最后剩下的弹珠的颜色一定是 f(K,M)?
事实证明,这样的函数存在!关键在于首先识别与罐子里蓝色弹珠数量有关的循环不变量。
考虑一次循环迭代对罐子里蓝色弹珠数量的影响。如果选中的两个弹珠颜色相同,那么蓝色弹珠的数量要么减少两个(如果两个都是蓝色弹珠),要么保持不变(如果两个都是红色弹珠)。如果选中的两个弹珠颜色不同,蓝色弹珠的数量保持不变。
由此可以得出,单次循环迭代不会影响罐子里蓝色弹珠数量的奇偶性(即,罐子里蓝色弹珠的数量要么保持奇数,要么保持偶数)。同样地,这一特性适用于循环的任意次数的迭代。设 M 和 m 分别表示罐子里蓝色弹珠最初和当前的数量,则有:
当且仅当 M 是奇数时,m 也是奇数
这是循环的一个不变量。如果你觉得更容易理解,这一循环不变量也可以表达为:
(M 和 m 都是奇数)或(M 和 m 都是偶数)
假设最初罐子里的蓝色弹珠数量 M 是奇数,并且循环刚刚终止。(请记住,由于循环不变量在每次迭代结束时都成立,因此它特别在最后一次迭代结束时成立。)那么,罐子里剩下的唯一弹珠必须是蓝色的,因为否则我们会有 m = 0,这是偶数。同理,我们可以得出,如果 M 是偶数,那么剩下的弹珠一定是红色的,因为否则我们会有 m = 1,这是奇数。
因此,上述函数 f 如下:
1 | f(K,M) = { RED 当M为偶数时 |
有趣的是,最后剩下的弹珠的颜色完全不依赖于罐子里最初的红色弹珠数量。