使用循环不变式证明算法的正确性,常用于分析带有循环的算法。这种方法的核心是通过构造一个在循环执行过程中始终成立的性质(即循环不变式),来证明算法在结束时能够正确地解决问题。

使用循环不变式方法。我们需要定义一个不变式,并通过三个步骤(初始化、保持性、终止性)证明算法在任何输入下都能正确地运行。

定义循环不变式

找出一个性质,它在每次进入循环迭代时都应为真。这一性质通常与算法的部分正确性(partial correctness)相关。

验证循环不变式

  • 初始化:证明循环的第一次循环迭代之前,循环不变式成立
  • 保持:假设循环不变式在循环的某次迭代之前成立,证明在这一迭代结束后(下次迭代之前)它仍然成立
  • 终止:在循环终止时,结合循环不变式和终止条件,证明算法达到了预期的正确结果。

例子

数组求和算法的正确性 CLRS 2.1-2

题目

Consider the procedure SUM-ARRAY on the facing page. It computes the sum of the n numbers in array A[1 : n]. State a loop invariant for this procedure, and use its initialization, maintenance, and termination properties to show that the SUM-ARRAY procedure returns the sum of the numbers in A[1 : n].

考虑对面页的过程 SUM-ARRAY。它用于计算数组 A[1 : n] 中 n 个数的总和。为该过程陈述一个循环不变式,并利用循环不变式的初始化、保持和终止性质,证明 SUM-ARRAY 过程返回的是 A[1 : n] 中数的总和。

伪代码

SUM-ARRAY(A, n)

sum = 0
for i = 1 to n
	sum = sum + A[i]
return sum

输入:数组 A 和 正整数 n(表示数组的元素个数)
输出:数组前 n 个元素的总和,即 $$ \sum_{i=1}^{n} A[i] $$

证明

循环不变式:在循环的每次迭代开始时,变量 sum 的值等于数组前 i - 1 个元素的总和,即:

验证循环不变式:

初始化:首先证明在第一次循环迭代之前(当 i = 1 时),循环不变式成立。

在循环的第一次迭代之前,i = 1,此时 sum 被初始化为 0,而数组前 i - 1 = 0 个元素的总和也是 0。因此,循环不变式成立。

保持性:证明每次迭代保持循环不变式。

假设在某次迭代开始时,循环不变式成立,即

在这一迭代中,代码执行 sum = sum + A[j]。

更新后,sum 的值变为:

此时,变量 sum 等于数组前 i 个元素的总和。

下一次迭代开始时,i 增加1,循环不变式依然成立。

终止性:结合循环不变式和终止条件,证明算法达到了预期的正确结果。

当循环终止时,i = n + 1,此时变量 sum 包含了数组前 n 个元素的总和:

算法返回 sum,其值等于 ,是问题的正确结果。

总结:

  1. 部分正确性:循环不变式确保在每次迭代中,sum始终等于数组已处理部分的元素和。
  2. 终止性:当循环终止时,sum 包含数组前 n 个元素的总和,算法返回正确值。

因此,伪代码是正确的。

线性查找算法的正确性 CLRS 2.1-4

题目

Consider the searching problem:

Input: A sequence of n numbers <a1, a2, … , an> stored in array A[1 : n] and a value x.

Output: An index i such that x equals A[i] or the special value NIL if x does not appear in A.

Write pseudocode for linear search, which scans through the array from beginning to end, looking for x. Using a loop invariant, prove that your algorithm is correct. Make sure that your loop invariant fulfills the three necessary properties.

考虑以下查找问题:

输入:n 个数的一个序列 A = <a_1, a_2, ..., a_n> 和一个值 x

输出:下标 i 使得 x = A[i] 或者当 v 不在 A 中出现时,x为特殊值 NIL。

写出线性查找的伪代码,它扫描整个序列来查找 x。使用一个循环不变式来证明你的算法是正确的。确保你的循环不变式满足三条必要的性质。

伪代码

// 线性查找的伪代码

LINEAR_SEARCH(A, x):
for i = 1 to A.length
	if A[i] == x
		return i
	
return NIL

// 输入:一个数组 A 和一个值 x。
// 输出:如果 x 存在于 A 中,返回其第一个匹配元素的索引;否则,返回 NIL。

证明

循环不变式:在循环的每次迭代开始时,在数组 A 的前 i - 1 个元素中,若 x 存在,则其索引已经被找到并返回;否则,x 不在前 i - 1 个元素中。

初始化:首先证明在第一次循环迭代之前(当 i = 1 时),循环不变式成立。

在第一次迭代之前(即 i = 1),算法还未检查任何元素,前 i - 1 = 0 个元素为空集。显然,空集中不存在 x,循环不变式成立。

保持性:证明每次迭代保持循环不变式。

假设在某次迭代开始时,循环不变式成立,即前 i - 1 个元素已经被正确检查。在本次迭代结束后:

  • 如果 A[i] == x,算法立即返回 i,说明找到了 x 的索引,算法正确结束。
  • 如果 A[i] != x,则说明 x 不在第 i 个元素处,算法进入下一次迭代。在此时,前 i 个元素已经被正确检查,循环不变式仍然成立。

终止性:结合循环不变式和终止条件,证明算法达到了预期的正确结果。

循环终止有两种情况:

  1. 找到目标值 x:如果 x 存在于 A 中,算法会在某次迭代中返回其索引 i。根据“保持性”所述,此时 x 在 i 位置,且算法正确结束。
  2. 未找到目标值 x:如果 x 不存在于 A 中,算法会检查到 i = A.length + 1 时退出循环并返回 NIL。结合循环不变式,可以推断 x 不在数组 A 中,因此返回 NIL 是正确的。

总结:

通过循环不变式和终止性分析,线性查找算法的正确性得到证明。

  • 若 x 存在于数组 A 中,算法能返回 x 的第一个匹配位置。
  • 若 x 不存在于数组 A 中,算法正确返回 NIL。

因此,代码是正确的。

插入排序算法的正确性

伪代码

// 插入排序的伪代码

INSERTION-SORT(A):
for j = 2 to A.length
	key = A[j]
	// Insert A[j] into the sorted sequence A[1..j-1].
	i = j - 1
	while i > 0 and A[i] > key
		A[i + 1] = A[i]
		i = i - 1
	A[i + 1] = key

// 输入:一个数组 A。
// 输出:按升序排列的数组 A。

证明

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

初始化:首先证明在第一次循环迭代之前(当 j = 2 时),循环不变式成立。

当 j = 2 时,子数组 A[1.. j-1],也就是 A[1 .. 1] 只有一个元素,实际上就是 A[1] 原来的元素,而且该子数组可以认为是有序的,因此它是平凡(trivially)有序的。循环不变式成立。

保持性:证明每次迭代保持循环不变式。

假设在第 j 次迭代开始时,子数组 A[1..j-1]是有序的。

  • 目标:将元素 A[j] 插入到 A[1..j-1] 的正确位置,使得 A[1..j] 也有序。
  • 在内层循环中,i 从 j - 1 开始递减,将比 key 大的元素依次向右移动,腾出插入 key 的位置。
  • 当 i 0 或 A[i] key 时,算法退出循环并将 key 插入到正确位置 A[i + 1]。
  • 插入完成后,子数组 A[1..j] 成为有序的。

因此,循环不变式在每次迭代中被保持。

终止性:结合循环不变式和终止条件,证明算法达到了预期的正确结果。

当主循环结束时,j = A.length + 1,子数组 A[1 .. j-1] 指的是整个数组 A[1 .. A.length],整个数组经过完整的插入过程,根据循环不变式,此时它是有序的。

总结:

  1. 部分正确性:循环不变式保证了在每次迭代结束时,子数组 A[1 .. j-1] 是有序的。
  2. 终止性:当循环终止时,j = A.length + 1,整个数组 A 被完全排序。

因此,伪代码是正确的。

参考

  • 《算法导论 2.1》