11. 实现递归
警告!除非你已经能够熟练地实现 recursive 函数,否则不应阅读本节。
学生学习递归时最大的障碍之一,是过分关注递归的"过程"。
思考递归的正确方式是只思考递归调用返回的值。
去思考那个答案 如何 计算出来,只会妨碍理解。
理解递归是如何实现的确实有充分的理由,但帮助你编写递归函数并不在其中。
也许最常见的、使用 stacks 的计算机应用甚至对用户不可见。
这就是大多数编程语言 运行时环境 中子例程调用的实现。
子例程调用通常是通过把子例程的必要信息(包括返回地址、参数和局部变量):term:入栈 <push> 来实现的。
这些信息称为 活动记录 。
进一步的子例程调用会往栈上添加内容。
每次从子例程返回时,都会从栈顶 出栈 一个活动记录。
作为一个例子,下面是阶乘函数的一个递归实现。
// Recursively compute and return n!
static long rfact ( int n ) {
// fact(20) is the largest value that fits in a long
if (( n < 0 ) || ( n > 20 )) { return - 1 ; }
if ( n <= 1 ) { return 1 ; } // Base case: return base solution
return n * rfact ( n - 1 ); // Recursive call for n > 1
}
下面是内部处理过程如何工作的图示。
\(\beta\) 值表示当前函数调用完成后要返回的程序指令地址。
每次对 fact 进行递归函数调用时,返回地址和 n 的当前值都必须保存。
每次从 fact 返回时,都会从栈顶弹出一个活动记录。
考虑用值 4 调用 fact 时会发生什么。
我们用 \(\beta\) 表示发出 fact 调用的程序指令地址。
因此,栈必须首先存储地址 \(\beta\) ,值 4 被传递给 fact 。
接下来,对 fact 进行一次递归调用,这次传入的值为 3。
我们把发出该调用的程序地址命名为 \(\beta_1\) 。
地址 \(\beta_1\) 连同 \(n\) 的当前值(即 4)一起保存在栈上。
函数 fact 以输入参数 3 被调用。
以类似的方式,又用输入参数 2 进行了一次递归调用,这要求把发出调用的地址(记为 \(\beta_2\) )和 \(n\) 的当前值(即 3)存储在栈上。
最后用输入参数 1 进行一次递归调用,这要求栈存储调用地址(记为 \(\beta_3\) )和当前值(即 2)。
此时,我们已经到达 fact 的基本情况,于是递归开始展开。
每次从 fact 返回都涉及从栈中弹出所存储的 \(n\) 值以及函数调用的返回地址。
fact 的返回值与恢复的 \(n\) 值相乘,然后返回结果。
因为每次子例程调用都必须创建一个活动记录并将其放入栈中,所以进行子例程调用是相对昂贵的操作。
虽然递归常常被用来让实现变得简单清晰,但有时你可能想消除递归函数调用带来的开销。
在某些情况下,例如上面的阶乘函数,递归可以很容易地用迭代来替代。
Example 7.11.1
作为用栈替代递归的一个简单例子,考虑下面这个非递归版本的阶乘函数。
// Return n!
static long sfact ( int n ) {
// fact(20) is the largest value that fits in a long
if (( n < 0 ) || ( n > 20 )) { return - 1 ; }
// Make a stack just big enough
Stack S = new AStack ( n );
while ( n > 1 ) { S . push ( n -- ); }
long result = 1 ;
while ( S . length () > 0 ) {
result = result * ( Integer ) S . pop ();
}
return result ;
}
这里,我们只是把越来越小的 \(n\) 值依次入栈,直到到达基本情况,然后反复弹出所存储的值并把它们乘入结果中。
阶乘函数的迭代形式比示例中所示的版本既更简单也更快。
但并非总能以迭代替代递归。
在实现需要多次分支的算法(例如汉诺塔算法)时,或者在 traversing a binary tree 时,递归或对它的某种模仿是必要的。
Mergesort 和
Quicksort 排序算法
也需要递归。
幸运的是,总是可以用栈来模仿递归。
现在我们来看汉诺塔函数的一个非递归版本,它无法用迭代方式完成。
Example 7.11.2
下面是汉诺塔的一个递归实现。
// Compute the moves to solve a Tower of Hanoi puzzle.
// Function move does (or prints) the actual move of a disk
// from one pole to another.
// n: The number of disks
// start: The start pole
// goal: The goal pole
// temp: The other pole
static void TOHr ( int n , Pole start , Pole goal , Pole temp ) {
if ( n == 0 ) { return ; } // Base case
TOHr ( n - 1 , start , temp , goal ); // Recursive call: n-1 rings
move ( start , goal ); // Move bottom disk to goal
TOHr ( n - 1 , temp , goal , start ); // Recursive call: n-1 rings
}
// Compute the moves to solve a Tower of Hanoi puzzle.
// Function move does (or prints) the actual move of a disk
// from one pole to another.
// n: The number of disks
// start: The start pole
// goal: The goal pole
// temp: The other pole
static void TOH ( int n , Pole start , Pole goal , Pole temp ) {
if ( n == 0 ) return ; // Base case
TOH ( n -1 , start , temp , goal ); // Recursive call: n-1 rings
move ( start , goal ); // Move bottom disk to goal
TOH ( n -1 , temp , goal , start ); // Recursive call: n-1 rings
}
TOH 进行两次递归调用:
一次把 \(n-1\) 个圆环从最底下的圆环上移开,另一次把这 \(n-1\) 个圆环移回目标柱子。
我们可以用一个栈来存储 TOH 必须执行的三种操作的表示,从而消除递归:
两次递归调用和一次移动操作。
为此,我们必须先给出各种操作的一种表示,将其实现为一个类,该类的对象将被存储在栈上。
static class TOHobj {
int op ;
int num ;
Pole start , goal , temp ;
// Recursive call operation
TOHobj ( int o , int n , Pole s , Pole g , Pole t )
{ op = o ; num = n ; start = s ; goal = g ; temp = t ; }
// MOVE operation
TOHobj ( int o , Pole s , Pole g )
{ op = o ; start = s ; goal = g ; }
}
static void TOHs ( int n , Pole start , Pole goal , Pole temp ) {
// Make a stack just big enough
Stack S = new AStack ( 2 * n + 1 );
S . push ( new TOHobj ( TOH , n , start , goal , temp ));
while ( S . length () > 0 ) {
TOHobj it = ( TOHobj ) S . pop (); // Get next task
if ( it . op == MOVE ) { // Do a move
move ( it . start , it . goal );
}
else if ( it . num > 0 ) { // Imitate TOH recursive solution (in reverse)
S . push ( new TOHobj ( TOH , it . num - 1 , it . temp , it . goal , it . start ));
S . push ( new TOHobj ( MOVE , it . start , it . goal )); // A move to do
S . push ( new TOHobj ( TOH , it . num - 1 , it . start , it . temp , it . goal ));
}
}
}
class TOHobj {
int op ;
int num ;
Pole start , goal , temp ;
// Recursive call operation
TOHobj ( int o , int n , Pole s , Pole g , Pole t )
{ op = o ; num = n ; start = s ; goal = g ; temp = t ; }
// MOVE operation
TOHobj ( int o , Pole s , Pole g )
{ op = o ; start = s ; goal = g ; }
}
void TOHs ( int n , Pole start , Pole goal , Pole temp ) {
// Make a stack just big enough
Stack S = new AStack ( 2 * n + 1 );
S . push ( new TOHobj ( TOH , n , start , goal , temp ));
while ( S . length () > 0 ) {
TOHobj it = ( TOHobj ) S . pop (); // Get next task
if ( it . op == MOVE ) // Do a move
move ( it . start , it . goal );
else if ( it . num > 0 ) { // Imitate TOH recursive solution (in reverse)
S . push ( new TOHobj ( TOH , it . num -1 , it . temp , it . goal , it . start ));
S . push ( new TOHobj ( MOVE , it . start , it . goal )); // A move to do
S . push ( new TOHobj ( TOH , it . num -1 , it . start , it . temp , it . goal ));
}
}
}
我们首先枚举可能的操作 MOVE 和 TOH,分别表示对 move 函数的调用和对 TOH 的递归调用。
类 TOHobj 存储五个值:一个操作值(表示是 MOVE 还是新的 TOH 操作)、圆环数目以及三根柱子。
注意,移动操作实际上只需要存储关于两根柱子的信息。
因此有两个构造函数:一个用于在模仿递归调用时存储状态,另一个用于存储移动操作的状态。
这里使用基于数组的栈,因为我们知道栈恰好需要存储 \(2n+1\) 个元素。
新版本的 TOH 首先把一个 \(n\) 个圆环的初始问题的描述放入栈中。
函数的其余部分只是一个 while 循环,它弹出栈并执行相应的操作。
对于 TOH 操作(当 \(n>0\) 时),我们把递归版本所执行的三种操作的表示存储在栈上。
然而,这些操作必须以相反的顺序放入栈中,这样才能以正确的顺序弹出。
当描述一个子问题所需的信息量很小时,递归算法很适合用栈来高效实现。
例如,Quicksort 可以有效地用栈来替代其递归,因为只需要保存待处理子数组的边界信息。