循环不变式——从理论上证明算法的正确性

4,520 阅读4分钟

最近在看《算法导论》这本书,在它的第二章出现了一个非常有意思的概念——循环不变式(Loop Invariant)。一言以蔽之,提出循环不变式的目的是为了从理论上证明某个算法是正确的

那么,如何从理论上证明某个算法是正确的?

说实话我以前没有思考过这个问题。习惯养成的原因,我在调试代码时,总是试各种各样的输入和查看对应的输出以保证整个程序是正确的。并不是说这样是错误的,而是指这个习惯让我没有去思考从理论上证明算法正确性这条路。如果只是盲目地多试几个输入的话,就好像让电脑用算术去证明哥德巴赫猜想一样,是非常不优雅的。你可以证明非常大的数满足哥德巴赫猜想,但你永远也无法说哥德巴赫猜想是对的。

其实从理论上证明算法的正确性简单得令人惊讶,这个过程跟数学归纳法如出一辙。 我们先来看看数学归纳法是怎样的。

数学归纳法有这么两步

  1. 奠定基数:一般验证n取最小项时成立
  2. 推导:假设n=m时成立,通过推导可以证明n=m+1时也成立。

我们来举个例子

1+2+3+\cdots+n = \frac{n(n+1)}{2}

等差数列求和公式如何进行求证呢?

  1. 验证该公式在 n = 1 时成立。即有左边=1,右边= \frac{1(1+1)}{2}=1,所以这个公式在n = 1时成立。

  2. 需要证明假设n = m 时公式成立,那么可以推导出n = m+1 时公式也成立。步骤如下:

    • 假设n = m时公式成立,即1+2+\cdots+m=\frac{m(m+1)}{2}(等式1)
    • 然后在等式两边同时分别加上m + 1得到1+2+\cdots+m+(m+1)=\frac{m(m+1)}{2}+(m+1)(等式2) 这就是n = m+1时的等式。我们下一步需要根据 等式1证明 等式2 成立。通过因式分解合并,等式2的右边=\frac{m(m+1)}{2}+\frac{2(m+1)}{2}=\frac{(m+1)(m+2)}{2}=\frac{(m+1)[(m+1)+1]}{2}也就是1+2+3+\cdots+(m+1)=\frac{(m+1)[(m+1)+1]}{2}

这样我们就完成了由n=m成立推导出n=m+1成立的过程,证毕。

结论:对于任意自然数n,公式均成立。

那么接下来就让我们用类似于数学归纳法证明公式的方法来证明循环不变式以证明算法的正确性。

我们以插入排序为例子(升序,在这里用伪代码来表示,数组的下标是1..n):

INSERTION-SORT(A)
  for j = 2 to A.length
    key = A[j]
    i = j-1
    while i>0 and A[i] > key
      A[i+1] = A[i]
      i = i-1
    A[i+1] = key

就好像1+2+3+\cdots+n = \frac{n(n+1)}{2}这样的公式一样,在此我们可以为插入排序提出一个循环不变式:在每次for循环迭代开始之前,子数组A[1..j-1]还是原来位置在1..j-1的元素并且已经排好序了。

那么就跟数学归纳法证明公式一样,我们需要两步来证明它的正确性:

  1. 初始化(Initialization):循环的第一次迭代之前循环不变式为真。
  2. 保持(Maintenance):假设循环的某次迭代之前为真,那么可以推导出下次迭代之前它依然为真。

但因为我们需要循环不变式来证明算法的正确性,我们需要着重看循环结束时的情况,那么就引出了我们需要证明循环不变式的第三个性质:

  1. 终止(Termination):在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。

那么让我们来看看是怎样具体证明的:

  1. 初始化:循环的第一次迭代之前(j=2),A[1..j-1]由单个元素A[1]组成,所以循环不变式成立。
  2. 保持:假设j=m时,循环不变式成立,也就是,子数组A[1..m-1]是原来位置在1..m-1的元素并且已经排好序了。当前这个循环迭代会不断左移A[m]直到找到第一个比它小的元素。当这个循环迭代结束之后,A[1..m]还是原来位置在1..m的元素并且是排好序的。所以推导出j=m+1时,循环不变式也成立。
  3. 终止:导致 for 循环终止的条件是j>A.length=n, 所以循环结束时,j=n+1。在循环不变式中,我们将jn+1代替,也就是子数组A[1..n]还是原来位置在1..n的元素并且是排好序的。因此算法正确。

你会发现利用循环不变式来证明算法的正确性是如此得简单和优雅。