第 2 章 迭代、归纳和递归
计算机的威力源自其反复执行同一任务或同一任务不同版本的能力。在计算领域,迭代这一主题会以多种形式出现。数据模型中的很多概念(比如表)都是某种形式的重复,比如“表要么为空,要么由一个元素接一个元素,再接一个元素,如此往复而成”。使用迭代,程序和算法可以在不需要单独指定大量相似步骤的情况下,执行重复性的任务,如“执行下一步骤1000次”。编程语言使用像C语言中的while语句和for语句那样的循环结构,来实现迭代算法。
与重复密切相关的是递归,在递归技术中,概念是直接或间接由其自身定义的。例如,我们可以通过“表要么为空,要么是一个元素后面再跟上一个表”这样的描述来定义表。很多编程语言都支持递归。在C语言中,函数F是可以调用自身的,既可以从F的函数体中直接调用自己,也可以通过一连串的函数调用,最终间接调用F。另一个重要思想——归纳,是与“递归”密切相关的,而且常用于数学证明中。
迭代、归纳和递归都是基本概念,会以多种形式出现在数据模型、数据结构和算法中。下面介绍了一些使用这些概念的例子,每项内容都会在本书中详细介绍。
1. 迭代技术。反复执行一系列操作的最简单方法就是使用迭代结构,比如C语言中的for语句。
2. 递归程序设计。C语言及其他众多语言都允许函数递归,即函数可以直接或间接地调用自己。对新手程序员来说,编写迭代程序通常比写递归程序更安全,不过本书的一个重要目标就是让读者习惯在适当的时候用递归的方式来思考和编程。递归程序更易于编写、分析和理解。
符号:求和符号和求积符号
加大字号的大写希腊字母∑通常用来表示求和,如
。这个特殊的表达式表示从1到n 这n个整数的和,也就是1+2+3+ … +n。更加一般化的情况是,我们可以对任何具有求和指标(summation index)i 的函数f (i )求和。(当然,这个指标也可能是i 以外的一些符号。)表达式
就表示
f (a) + f (a + 1) + f (a + 2) + … + f (b)
例如,
就表示4 + 9 + 16 + … + m2 的和,这里的函数f 就是“求平方”,而我们用了指标j 来代替i。
作为特例,如果b< a,那么表达式
不含任何项,当然,其值也就是0了。如果b=a,那么表达式只有i=a 时的那一项。因此,
的值就是f (a)。
用于求积的类似符号是个大号的大写希腊字母 Π。表达式
就表示
f (a) × f (a + 1) × f (a + 2) × … × f (b)
的积,如果b
3. 归纳证明。“归纳证明”是用来表明命题为真的一项重要技术。从2.3节开始,我们将广泛介绍归纳证明。下面是归纳证明最简单的一种形式。我们有与变量n相关的命题S(n),希望证明S(n)为真。要证明S(n),首先要提供依据,也就是n为某个值时的命题S(n)。例如,我们可以令n=0,并证明命题S(0)。接着,我们必须对归纳步骤加以证明,我们要证明,对应参数某个值的命题S,是由对应参数前一个值的相同命题S得出的,也就是说,对所有的n≥0,可从S(n)得到S(n+1)。例如,S(n)可能是常见的求和公式
(2.1)
这是说1到n这n个整数的和等于n(n+1)/2。特例可以是S(1),即等式(2.1)在n为1时的情况,也就是1=1×2/2。归纳步骤就是要表明,由
可以得出
,前者就是S(n),是等式(2.1)本身,而后者则是S(n+1),就是用n+1替换了等式(2.1)中的n。2.3节将会为大家展示如何进行这样的证明。
4. 程序正确性证明。在计算机科学中,我们常希望能够证明与程序有关的命题S(n)为真,不管是采用正式的还是非正式的方式。例如,命题S(n)可能描述了某个循环的第n次迭代中什么为真,或是对某个函数的第n次递归调用来说什么为真。对这类命题的证明一般都使用归纳证明。
5. 归纳定义。计算机科学的很多重要概念,特别是那些涉及数据模型的,最好用归纳的形式来定义,也就是我们给出定义该概念最简单形式的基本规则,以及可用来从该概念较小实例构建更大实例的归纳规则。举例来说,我们提到过的表就可由基本规则(空表是表)加上归纳规则(一个元素后面跟上一个表也是表)来定义。
6. 运行时间的分析。算法处理不同大小的输入所花的时长(算法的“运行时间”)是衡量其“优良性”的一项重要指标。当算法涉及递归时,我们会使用名为递推方程的公式,它是种归纳定义,可以预测算法处理不同大小的输入所花的时间。
本章会介绍前5项主题,程序的运行时间将在第3章中介绍。
2.1 本章主要内容
在本章中,我们将介绍以下主要概念。
迭代程序设计(2.2节)。
归纳证明(2.3节和2.4节)。
归纳定义(2.6节)。
递归程序设计(2.7节和2.8节)。
证明程序的正确性(2.5节和2.9节)。
除此之外,通过这些概念的例子,我们还会着重介绍计算机科学中一些有趣的重要思想。其中包括:
排序算法,包括选择排序(2.2节)和归并排序(2.8节)。
奇偶校验及数据错误的检测(2.3节)。
算术表达式及其代数变形(2.4节和2.6节)。
平衡圆括号(2.6节)。
2.2 迭代
新手程序员都会学习使用迭代,采用某种循环结构(如C语言中的for语句和while语句)。在本节中,我们将展示一个迭代算法的例子——“选择排序”。在2.5节中,我们还将通过归纳法证明这种算法确实能排序,并会在3.6节中分析它的运行时间。在2.8节中,我们要展示如何利用递归设计一种更加高效的排序算法,这种算法使用了一种称作“分而治之”的技巧。
常见主题:自定义和依据-归纳
在学习本章时,大家应该注意到有两个主题贯穿多个概念。第一个是自定义(self-definition),就是指概念是依据其自身定义或构建的。例如,我们提到过,表可以定义为空,或一个元素后跟一个表。
第二个主题是依据-归纳(basis-induction)。递归函数通常都含有某种针对不需要递归调用的“依据”实例,以及需要一次或多次递归调用的“归纳”实例进行测试。众所周知,归纳证明包括依据和归纳步骤,归纳定义也一样。依据-归纳这一对非常重要,在后文中每次出现依据情况或归纳步骤时,都会突出标记这些词语。
运用恰当的自定义不会出现悖论或循环性,因为自定义的子部分总是比被定义的对象“更小”。此外,在经过有限个通向更小部分的步骤后,就能到达依据情况,也就是自定义终止的地方。例如,表L 是由一个元素和比L 少一个元素的表构成的。当我们遇到没有元素的表,就有了表定义的依据情况:“空表是表”。
再举个例子,如果某递归函数是有效的,那么从某种意义上讲,某一函数调用的参数必须要比调用该函数的函数副本的参数“更小”。还有,在经过若干次递归调用后,我们必须要让参数“小到”函数不再进行递归调用为止。
2.2.1 排序
要排序具有n个元素的表,我们需要重新排表中的元素,使它们按照非递减顺序排列。
示例 2.1
假设有整数表{3,1,4,1,5,9,2,6,5}。我们要将其重新排列成序列{1,1,2,3,4,5,5,6,9},实现对该表的排序。请注意,排序不仅会整理好各值的顺序,使每个元素的值小于等于接下来那个元素的值,而且不会改变每个值出现的次数。因此,排序好的表中有两个1和两个5,而原表中只出现一次的数字都只有一个。
只要表的元素有“小于”的顺序可言,也就是具备我们通常用符号
词典顺序
要比较两个字符串,通常是依据它们的词典顺序进行比较的。假设c1c2…ck和d1d2…dm是两个字符串,其中每个c 和每个d 都只代表一个字符。字符串的长度k 和m 不一定是相同的。我们假设对字符而言也有
这样我们可以将字符串的这种顺序称为字典、词典或字母表顺序,如下所示。如果以下任意一条成立的话,我们就说c1c2…ck< d1d2…dm 。
1. 第一个字符串是第二个字符串的真前缀(proper prefix),这表示k< m,而且对i =1,2,…,k而言,都有ci =di。根据这条规则,就有bat
2. 对某个i>0的值,两个字符串的前i-1个字符都相同,但第一个字符串的第i 个字符要小于第二个字符串的第i 个字符。也就是说,对j=1, 2, …, i-1,都有cj =dj,而且cj < dj。根据这条规则,ball
a≤b这一比较关系总是表示,要么a< b,要么a 和b 具有相同的值。如果a1≤a2≤…≤an,也就是说,如果这些值有着非递减顺序,那么我们就说表(a1, a2, …, an)是已排序的。排序是这样一种操作,它接受任意表(a1, a2, …, an),并生成满足如下条件的表(b1, b2, …, bn)。
1. 表(b1, b2, …, bn)是已排序的;
2. 表(b1, b2, …, bn)是原表的排列。也就是说,表(a1, a2, …, an)中的每个值出现的次数,和那些值出现在(b1, b2, …, bn)中的次数是一模一样的。
排序算法接受任意的表作为输入,并生成对输入进行过排列的已排序表作为输出。
示例 2.2
考虑base,ball,mound,bat,glove,batter这列单词。有了该输入,排序算法会按照词典顺序生成输出:ball,base,bat,batter,glove,mound。
2.2.2 选择排序:一种迭代排序算法
假设要对一个具有n 个整数的数组A按照非递减顺序排序。我们可以通过对这个步骤的迭代来完成该工作:找出尚不在数组已排序部分的一个最小元素1,将其交换到数组未排序部分的第一个位置。在第一次迭代中,我们在整个数组A[0..n-1]中找出(“选取”)一个最小元素,并将其与A[0]互换位置。2在第二次迭代中,我们从A[1..n-1]中找出一个最小元素,并将其与A[1]互换位置。继续进行这种迭代。在开始第i+1次迭代时,A[0..i-1]已经是将A中较小的i 个元素按照非递减顺序排序了,而数组中余下的元素则没有特定的顺序。在第i+1次迭代开始前数组A的状态如图2-1所示。
1这里说“一个”最小元素是因为最小值可能出现多次。如果是这样,找到任何一个最小值就行了。
2为了描述数组中某个范围内的元素,我们采用了Pascal语言中的约定。如果A是数组,那么A[i..j]就表示数组A中下标从i 到j 这个范围内的那些元素。
图 2-1 在进行选择排序的第i+1次迭代前数组的示意图
对名字与值的约定
我们可以将变量视为具有名字和值的框。在提到变量时,比如abc,我们会使用等宽字体来表示其名字;在提到变量abc的值时,我们会使用斜体字,如abc。总之,abc表示框的名字,而abc则表示它的内容。
在第i+1次迭代中,要找出A[i..n-1]中的一个最小元素,并将其与A[i]互换位置。因此,在经过第i+1次迭代之后,A[0..i]已经是将A中较小的i+1个元素按照非递减顺序排序了。在经过第n+1次迭代之后,就完成了对整个数组的排序。
图2-2展示了用C语言编写的选择排序函数。这个名为SelectionSort的函数接受数组A作为其第一个参数。第二个参数n表示的是数组A的长度。
void SelectionSort(int A[], int n)
{
int i, j, small, temp;
(1) for (i = 0; i < n-1; i++) {
/* 将small 置为剩余最小元素第一次出现时
的下标*/
(2) small = i;
(3) for (j = i+1; j < n; j++)
(4) if (A[j] < A[small])
(5) small = j;
/* 到达这里时,small 是A[i..n-1]
/* 中第一个最小元素的下标, */
/* 现在交换A[small]与A[i]。 */
(6) temp = A[small];
(7) A[small] = A[i];
(8) A[i] = temp;
}
}
图 2-2 迭代的选择排序
第(2)到(5)这几行程序从数组未排序的部分A[i..n-1]中选取一个最小元素。我们首先在第(2)行中将下标small的值设为i。第(3)到(5)这几行的for循环会依次考量所有更高的下标j,如果A[j]的值小于A[i..j-1]这个范围内的任何数组元素的值,那么就将small 置为j。这样一来,我们就将变量small的值置为A[i..n-1]中最先出现的那个最小元素的下标了。
在为下标small选好值后,在第(6)到(8)行中,我们要将处于该位置的元素与A[i]处的元素互换位置。如果small=i,交换还是会进行,只是对数组没有任何影响。请注意,要交换两个元素的位置,还需要一个临时的位置来存储二者之一。因此,我们在第(6)行将A[small]里的值移到temp中,并在第(7)行将A[i]里的值移到A[small]中,最终在第(8)行将原来A[small]里的值从temp移到A[i]中。
示例 2.3
我们来研究一下SelectionSort针对各种输入的行为。首先看看运行SelectionSort处理没有元素的数组时会发生什么。当n=0时,第(1)行中的for循环的主体不会执行,所以SelectionSort很从容地“什么事都没做”。
现在考虑一下数组只有一个元素的情况。这次第(1)行中的for循环的主体还是不会执行,这种反应是令人满意的,因为由一个元素组成的数组始终是已排序的。当n为0或1时的情况是重要的边界条件,检测这些条件下算法或程序的性能是很重要的。
最后,我们要运行SelectionSort,处理一个具有4个元素的较小数组,其中A[0]到A[3]分别是
我们从i=0起开始外层的循环,并在第(2)行将small置为0。第(3)到(5)行构成了内层的循环,在该循环中,j 依次被置为1、2和3。对于j=1,第(4)行的条件是成立的,因为A[1],也就是30,要小于A[small ],即A[0],或者说是40。因此,在第(5)行我们会将small置为1。在(3)至(5)行第二次迭代时,有j=2,第(4)行的条件还是成立,因为A[2]