1. 递归
**递归(recursion)**是指把一个很大的问题转化成同样形式但小一些的问题加以解决。
1.1 递归的简单说明
举一个例子,工作是筹集1000000美元的资金。
完成这项任务的办法就是把部分工作交给其他人做。如果能找到10个位于全国各地的志愿者,可以委任他们为各地的区域协调人,这样他们每个人只要筹集100000美元就够了。
这种代理的过程可以层层深入下去直到筹款人可以一次募集到所有他们需要的捐款。
可以将上述筹资策略用伪代码来表示,它的结构如下所示:
以上伪代码中,最重要的是
这一行。
这个问题就是原问题的再现,只是规模较原问题小一些。
这两个任务的基本特征都是一样的(募捐n美元),只是n值的大小不同。
再者,由于要解决的问题实质上是一样的,可以通过调用原函数来解决它。
因此,前述的伪代码可以被下列代码取代:
CollectContributions(n/10);
需要着重指出的是,如果捐款数额大于100美元,函数collectContributions最后会调用自己。
1.2 Factorial函数
用简单的数学函数来描述递归是最容易的。
一个整数n的阶乘(在数学里表示为n!) 是整数1到n相乘的积。
阶乘的一个重要性质。每个数的阶乘和比它小1的那个数的阶乘之间有如下关系:
n
!
=
n
∗
(
n
−
1
)
!
n! = n * (n-1)!
n!=n∗(n−1)!
所以,阶乘函数的一般数学定义为:
这个定义是递归的,因为它用n一1的阶乘定义n的阶乘。
由此,Factorial函数的代码实现如下:
int Factorial(n) {
if (n==0) {
return 1
} else {
return Factorial(n - 1) * n;
}
}
当main函数调用Factorial时,计算机创建了一个新的帧,并把实际参数值赋给形式参数n。
Factorial的帧暂时取代了main的帧, 如下图所示:
现在计算机继续执行函数体,该函数从if语句开始。因为n不等于0,所以控制转而执行else子句,这时程序必须计算并返回表达式
n * Factorial(n-1)
的值。
这需要进行递归调用,当这个调用返回时,程序所需要做的只是将返回值乘以n。
因此计算的当前状态如下图所示:
下一步计算是计算函数Factorial(n-1)的调用,这一步又是从计算实际参数表达式开始的。
由于当前n值为4,所以实际参数表达式n-1的值为3。
然后计算机再为Factorial创建一个新的帧,并把n-1的值赋给Factorial的形式参数。
因此,下一个帧如下图所示:
以此类推,最后形成的栈结构可用下图表示:
这时,因为这次n值为0,函数可以通过执行语句直接返回,1被返回到其主调函数所在的帧。
现在这个帧位于栈顶的位置,如下图所示:
从这一点开始,计算就由一次次返回的递归调用所组成,用每一层Factorial的返回值计算下一层的值。
例如,在现在这个帧中,函数Factorial(n-1)调用可以用1代替。
因此这一层的结果可以表示为:
该函数可以用下图中最上层的帧表示:
因为n现在为2,计算return语句会把2传递给前一层,如下图所示:
现在,程序把3×2返回到前一层,结果第一个被调用的Factorial函数的帧看上去为:
计算过程的最后一步包括计算4×6并把24返回给主程序。
1.3 递归范例
典型的递归函数的函数体都符合如下范例:
递归函数Factorial就符合这个范例,下面这个计算整数n的k次幂的函数也一样:
static int RaiseIntToPower(n, k){
if (k==0):
return 1
else {
return n * RaiseIntToPower(n, k - 1)
}
}
函数RaiseIntToPower的实现是建立在幂运算的数学特性的基础上的,幂函数的数学特性如下所示:
计算一个数的阶乘或幂这样的问题通常可以用递归方法来解决,因为这些问题都满足以下条件;
(1) 可以找出简单情况,对这些简单情况,答案很容易确定。
(2) 可以用递归分解把复杂问题分解成相同类型的简单问题;然后应用同一种方案解决这些相对简单的问题。
1.4 排列的生成
许多文字游戏中的大部分工作是重新排列一组字母形成一个单词。
在文字游戏中,这样的排列通常称为回文构词(anagram);在数学里,称之为排列(permutation)。
假设要写一个函数ListPermutations(s),显示字符串s的所有排列。
例如,调用
ListPermutations("ABC")
程序应该在屏幕上显示“ABC”的六种排列,如下所示:
六个排列的显示顺序并不重要,重要的是每种排列只能出现一次。
更一般化地说,要显示长度为n的字符串的所有排列,可以依次把n个字母中的每个字母置于列首,其后跟着剩下n-1个字母的所有排列。
这种解决方法的唯一问题在于递归子问题和原问题的形式并不完全相同,而这正违反了递归解决方案的一个必要条件。
在这种情况下,最好的办法就是定义一个新的过程PermuteWithFixedPrefix, 让它产生一个字符串的所有排列,并保证前k个字母保持不变。当k=0时,所有字母都可以变动,这也就是原问题。
伪代码形式如下:
C语言代码如下:
static void PermuteWithFixedPrefix(string str, int k) {
int i;
if (k == StringLength(str)) {
print("%s\n", str);
} else {
for (i=k; i < StringLength(str); i++) {
ExchangeCharacters(str, k, i);
PermuteWithFixedPrefix(str, k+1);
ExchangeCharacters(str, k, i);
}
}
}
static void ExchangeCharacters(string str, int p1, int p2) {
char tmp;
tmp = str[p1];
str[p1] = str[p2];
str[p2] = tmp;
}
static void ListPermutations(string str) {
PermuteWithFixedPrefix(str, 0);
}
客户所用的函数调用了内部函数,给这些额外参数传递初值,就像在ListPermutations中那样。
像List Permutations那样的函数, 它的目的是为一个更通用的函数提供额外参数,那么这样的函数叫做包装(wrapper)。
参考
《C语言的科学和艺术》 —— 17 深入学习