这里讲的消除递归是用栈来模拟系统的函数调用从而消除递归。
要说明一下的是,我说的栈就是Stack,后进先出的一种数据结构;而有的书翻译成堆栈。堆(heap)有两种意义。第一种是一种线性数据结构,满足node[i]>=node[2i+1],node[i]>=node[2i+2]。第二种一般是在程序设计语言动态申请内存的时候说的,代表一个系统内存区。内存的申请一般有两种。比如在一个函数中申请一个变量 int a,就是在栈中定义了一份空间。如果用Integer i=new Integer(0);,就是在堆中申请了一份内存空间。当函数结束,栈中的空间会自动释放(变量的作用域结束);而堆中的空间不会,C++的话要自己delete,否则就会造成内存泄露,而java会定时用垃圾回收器自动回收这些空间。这样看来堆(heap)和栈(stack)不但代表两种数据结构,而且代表两种内存使用方法。就栈来说,两者似乎比较统一——栈内存使用了栈这种数据结构;但堆内存和堆数据结构有什么关系呢?是不是系统堆使用了堆这种数据结构呢?我不知道。似乎管理内存用不了堆这种数据结构,堆数据结构主要用于实现优先队列。不知道内存管理是不是和优先队列有关?
由于消除递归是要使用goto语句,所以这部分的代码都是用c++实现的。
首先我们来看一个简单的函数调用。
int sum(int a,int b){
return a+b;
}
void main(){
…..
int c=sum(3,4);
…….
我们在编写程序的时候会觉得函数调用太简单了。但如果是在汇编语言中,这并不是件容易的事,如果汇编语言中不让你使用CALL和RET指令怎么实现函数调用?
我们看看简单的一条int c=sum(3,4);语句,系统都为我们做了什么?
函数的调用分三个步骤:传递参数;调用函数;返回。
我们编写函数sum时,拿着a和b就使用。如果让我们自己传递参数,会怎么样呢?回忆一下在汇编语言中传递参数的方法。就是函数调用者和函数“约定”一个内存空间(或者寄存器)来存放参数。调用者先把参数放到约定好的地方,然后就准备调用函数,调用其实就是把函数的地址放到IP寄存器,当然为了能够调用后返回,还得把调用者调用完函数后的下一条指令的地址放到一个“约定”的位置,如果使用CALL和RET指令,这些工作它就帮你做了。还有一个要说明的是,在汇编语言中是没有返回值这个概念的。所谓的返回值其实就是对一个变量的修改。比如c=int(2,3);就是要根据输入参数2,3,修改c的值,在汇编语言中实现的方法是把c的地址作为一个参数放到一个约定的地点,sum函数把2+3的结果放到c的地址里。
下面看一个例子。这个函数模拟sum函数。stack用来传递参数,我们假设调用者和函数都能访问算stack。
首先,调用者把参数都压入stack。然后goto L_Sum;调用函数。
函数首先在预定的地点(stack中)取出参数,计算后修改了c。然后goto L0;返回。
void SimulatingCalling()
{
int sum;
int a=3;
int b=4;
Stack stack;
stack.Push(a);
stack.Push(b);
stack.Push((int)&sum);
cout<<"Calling Sum function"<<endl;
goto L_Sum;
L0:
cout<<"After Calling"<<endl;
cout<<"Sum of "<<a<<" and "<<b<<" is "<<sum<<endl;
goto L1;
L_Sum:
cout<<"Entering Sum Function"<<endl;
int *local_sum,addrofsum;
stack.Pop(addrofsum);
local_sum=(int*)addrofsum;
int local_a,local_b;
stack.Pop(local_b);
stack.Pop(local_a);
*local_sum=local_a+local_b;
cout<<"Calling End"<<endl;
goto L0;
//end of Sum function
L1:
cout<<"end of SimulatingCalling()"<<endl;
}
当然,这里调用是在SimulatingCalling中的,而SimulatingCalling其实是一个函数管理者。它知道sum函数的地址在哪里,然后goto那里。如果我们不知道sum函数的地址呢?我们只想告诉它“我要调用sum函数,参数是3和4,&c,调用后转到某个地方”。它该怎么办呢?INVOKE指令就是用来干这个的。CALL是没有参数的,你不能 CALL (3,4,&c),传递3,4,&c的任务是由调用者完成的,就想代码中的调用前得自己把他们压入stack。而INVOKE就把这些工作做好了,你如果使用INVOKE 3,4,&c,它就会把3,4,&c压入。
3,4,&c的压入方式可以是先3,后4,再&c,也可以是先&c,后4,再3。这就有调用方式的区别。从右到左的是STDCALL和C调用习惯;而从左到右是PASCAL调用习惯。
STDCALL和C的区别是清除堆栈的任务不同。前者是把这个任务交给了函数,后者则叫给了调用者。什么是清除堆栈?比如我们把3,4,&c压入了堆栈,这时ESP会指向了不同的位置,调用结束后应该恢复。如果是STDCALL,那么函数一个承担这个责任,结束前RET 12 ;C的话就要调用者在调用完后修改ESP 比如 ADD ESP 12。PASCAL习惯的清楚堆栈和STDCALL一样。
在使用不同的语言设计的程序交流时可能会碰到这些问题。比如用VC++写的dll却不能在C++Builder中使用,很可能就是调用习惯的问题。
现在来用栈模拟函数调用消除递归。我们可能觉得递归函数和别的函数不同,但从CPU的角度来看它们没有什么区别。
我们来看一个简单的例子。
f(n)=n+1 当n<=1
f(n)=f([n/2]) * f([n/4]) 当n>=2
先写递归算法:
void fun(int n,int *result)
{
if(n<=1)
{
*result=n+1;
}
else{
int u1,u2;
fun(n/2,&u1); //第一个调用自己的地方,它返回后应该执行它下面的的语句。
fun(n/4,&u2); //第二个调用自己的地方,它返回后应该执行它下面的的语句。
*result=u1*u2;
}
}
非递归的算法怎么写呢?
再回忆一下前面的东西。函数调用做了三件事:传递参数(包括返回地址)并转到函数入口;获得参数并处理参数;根据传入的返回地址返回(返回前要清除堆栈)。现在这些工作不能指望编译器帮我们完成,自己动手才能丰衣足食了。先把代码贴到下面再解释。
void nonrec(int n,int*f){
ELEM x,tmp;
Stack stack;
x.rd=3;x.pn=n;=f;
stack.Push(x);
L0:
stack.GetTop(x);
if(x.pn<=1){
*()=x.pn+1;
}
else{
x.rd=1;x.pn=x.pn/2;
=stack.GetTopQ1Addr();
stack.Push(x);
goto L0;
L1:
stack.GetTop(x);
x.pn=x.pn/4;
x.rd=2;
=stack.GetTopQ2Addr();
stack.Push(x);
goto L0;
L2:
stack.GetTop(x);
*()=x.q1*x.q2;
}
L3:
stack.Pop(x);
switch(x.rd){
case 1:
goto L1;
break;
case 2:
goto L2;
break;
case 3:
break;
default:
cout<<"error"<<endl;
break;
}
}
由于运行时需要5个参数,n,*f,u1,u2,返回地址(rd)。每次传递时都push与pop5次也很麻烦,而且由于这些参数可能类型不同,所以把他们放到一个结构体中比较方便。
typedef struct {
int rd,pn,*pf,q1,q2;
}ELEM;
最前面的一段:
ELEM x,tmp;
Stack stack;
x.rd=3;x.pn=n;=f;
stack.Push(x);
这就是调用递归函数者首先要把参数放到“约定”的地点stack中。它的返回地址是3,我们并不关心返回地址的觉得内存地址,因为我们并不是真的像汇编语言一样JMP到那里去,我们只是要知道调用这个递归函数后应该执行下面的那条语句,所以只要用int类型区别一下就可以了。在fun函数中一共有两个地方调用自己用1,2表示。而最外面调用递归函数的用3来表示。
L0:
stack.GetTop(x);
这段代码是说:L0就是函数的入口。进来的第一件事就是获得参数到x中。
在没有递归调用的地方,和递归函数的写法完全相同,只不过参数是用x中的变量而已。
if(x.pn<=1){
*()=x.pn+1;
}
else{
这都和递归的写法一样。接着又要调用函数(自己)了。怎么调用函数呢?还是传递参数然后转到函数入口。
x.rd=1;x.pn=x.pn/2;
=stack.GetTopQ1Addr();
stack.Push(x);
goto L0;
需要说明的是=stack.GetTopQ1Addr();的意思。我这里Stack的实现是用链表来实现的。struct ListNode{
ELEM data;
ListNode *next;
};
class Stack{
private:
ListNode *top;
……
void GetTop(ELEM & elem){
if(top==NULL) return ;
elem=top->data;
}
……..
把得到栈顶元素(不弹出)的方法是GetTop,它只是把栈顶元素复制一份到elem之中,而不是把栈顶元素的指针传出来。因为如果传指针出来的话似乎不太好,拿着指针的人修改了栈,栈自己却浑然不觉。因为是复制一份,所以我们给赋值时会遇到一个问题。本来应该指向x.u1的地址,也就是=&x.q1;但用于x是栈顶的拷贝,而我们想修改的是栈顶那个元素的u1。所以我打了个补丁,给栈多了两个方法GetTopQ1Addr()和GetTopQ2Addr()。代码写得实在很糟糕,主要是c++很久不用,写代码的时候没有考虑到这里还调试了老半天才发现这个bug,好在只有这部分是用c++实现的,所以大家凑合看看就得了。
然后下面应该第二次调用自己了:
L1:
x.pn=x.pn/4;
x.rd=2;
=stack.GetTopQ2Addr();
stack.Push(x);
goto L0;
这和前面的代码基本类似。
第二次递归返回后的代码:
L2:
*()=x.q1*x.q2;
}
然后是函数结束,该返回了:
L3:
stack.Pop(x);
switch(x.rd){
case 1:
goto L1;
break;
case 2:
goto L2;
break;
case 3:
break;
}
返回时应该根据x.rd判断应该goto到哪里。因为函数结束了,所以应该清除堆栈中的变量(它们的生命周期完了):stack.Pop(x);
注意每次要使用变量时都应该从stack中用stack.GetTop()获得。因为goto来goto去x可能已经不是当前的栈顶的元素了。
说明:事实上获取参数只要一次stack.GetTop()就可以了,例子中不行的原因是调用时把x改变了。比如:
L1:
x.pn=x.pn/4;
x.rd=2;
=stack.GetTopQ2Addr();
如果用另一个变量来完成这些工作就不会用任何问题。
昨天没搞清楚就把“罪名”加到goto身上了。
此外我把Ackmann函数也用goto实现了非递归,有兴趣的话可以看看源代码。