数据结构与算法5: 递归(Recursion)

来源:互联网 发布:出国英文翻译软件 编辑:程序博客网 时间:2024/06/08 00:52

数据结构与算法5: 递归(Recursion)

写在前面

               《软件随想录:程序员部落酋长Joel谈软件》一书中《学校只教java的危险性》一章提到,大学计算机系专业课有两个传统的知识点,但许多人从来都没搞懂过,那就是指针和递归。我也很遗憾没能早点熟练掌握这两个知识点。本节一些关键知识点和部分例子,都整理自教材或者网络,参考资料列在末尾。如果错误请纠正我。


思考列表:

1)什么程序具有递归解决的潜质?

2)递归还是非递归算法,怎么选择?

3)递归程序构造的一般模式



1.递归定义

   首要引入递归定义这一概念。  

   通常我们定义一个新概念,总是使用已经定义过的或者意义显然的术语,而递归定义则是一种根据自身概念定义自身的定义。

   例如阶乘函数的定义:

      

     例如斐波那契数列定义如下:

     

      递归定义,主要有两个作用,一是产生新的元素,另一个是判断一个元素是否属于某个集合.

     具有这种递归定义的函数,是很容易编程实现的。 

2. 递归是怎么实现计算过程的?

    假设我们讲上述阶乘函数转换为程序代码如下:  

//using recursionint factr(int n){  if ( n ==0) return 1;  return n*factr(n-1);}

   我们调用fact(3)则返回6,这里有一个疑问,我好像什么都没做啊?

  如果我们选用他的迭代实现:

   

//using iterationint facti(int n){   int result = 1;   for(int i = 1 ; i <= n;i++)       result *= i;   return result;}

  则感觉是在利用累乘计算我们的阶乘,一下子就比较清楚了。我们的疑问在于: 递归实现中,是怎么执行计算过程的?

  首先要了解系统中函数调用时大致情况(此处不详细学习,要想更深入和全面,请参考:Wiki Call stack)。在高级语言编写的程序中,调用函数和被调用函数之间的链接及信息交换通过栈来进行。(该段参考自【1】)

  通常,在运行被调用函数之前,系统需要做3件事,包括:

  •   将所有的实在参数、返回地址等信息传递给被调用函数保存
  •  为被调用函数的局部变量分配存储区
  •  将控制转移到被调用函数的入口

  从被调用函数返回调用函数之前,系统也要完成3件事:

  •   保存被调用函数的计算结果
  •   释放被调用函数的数据区
  •  依照被调函数保存的返回地址将控制转移到调用函数

归纳起来,就是函数调用的过程中处理要素包括:函数控制权的转接工作(利用函数入口地址和返回地址),局部变量的空间分配工作,实参和形参的传递,函数的返回结果传递一个函数的状态由一个5元组决定,即function(入口地址,返回地址,形参值,局部变量值,返回值)保存所有这些信息的数据区称为活动记录(activation record)或者叫做栈结构(stack frame),它是在运行时栈上分配空间的。活动记录,在函数开始执行的时候得到动态分配的空间,在函数退出的时候就释放其空间。main函数的活动记录比其他活动记录生命周期长。

    这里注意的是,活动记录保存函数参数的时候,既可以值传递也可以传递地址,如果是值传递则保存数据元素的副本,如果是传递数组或者按引用则活动记录包含数组第一个元素的地址(数组首地址)或者该变量的地址。同时对于局部变量,活动记录只包含他们的描述符和指向存储它们的位置的指针。

简单的函数调用过程,例如如下代码:

int main(){      int m,n;      ... /*110*/ first(m,n); /*111*/ ...      }int first(int s,int t){     int i;    ...    /*210*/ second(i);    /*211*/ ...}int second(int d){  int x,y  ...}

那么函数调用的过程中形成的活动记录栈的内容如下:

对于递归函数调用,表面上看,好像我们什么都没做,就完成了功能,实际上在函数递归调用的过程中,函数的活动记录不停的分配和回收,计算过程在进行着。

递归调用不是表面上的函数自身调用,而是一个函数的实例调用同一个函数的另一个实例。(参考自【2】)

我们将上面的阶乘函数重写,标上一个地址标号(这是一个粗略的标号,实际上底层的机器地址不是这样的):

int main(){  /*102*/ int n = factr(3);  /*103*/ std::cout << "Factorial of "<<n<<" : "<<factr(n)<<std::endl;}//using recursion/*201*/ int factr(int n){/*202*/  if ( n == 0) /*203*/      return 1;/*204*/  return n*factr(n-1);}

则我们在main函数中调用fact(3)是执行的活动记录过程如下:


可以看出实际上递归调用时,系统不停的分配活动记录,调用从main()--->fact(3)--->fact(2)-->fact(1)--->fact(0) 一层层深入,然后再一层层回退,直到main函数中。由于活动记录中保存了函数局部变量,因此每次调用之间互补干扰,从一个被调用函数返回调用函数时能够保证计算出准确的结果,并返回上一层的函数,依此进行。活动记录栈是分析递归程序的一种好的方法。

3.递归类别

   递归有许多级别和许多不同量级的复杂度。

  我们从尾部递归与非尾部递归,直接递归和间接递归来分类了解。

  简单的如,尾部递归。尾部递归,即那种在函数实现的末尾只使用一个递归调用。尾部递归的特点是,当进行调用时,函数中没有其他剩余的语句要执行,并且在这之前也没有其他直接或者间接的递归调用。

 尾部递归示例程序:

 

#include <iostream>#include <list>#include <string>using namespace std;void printListi(list<int>::iterator itCur,list<int>::iterator end);void printListr(list<int>::iterator itCur,list<int>::iterator end);int main(int argc,char** argv){   list<int> iList;   for(int i = 0;i < 10 ;i++)       iList.push_back(i);   if ( argc == 2 && string(argv[1]) == "-r")      printListr(iList.begin(),iList.end());   else      printListi(iList.begin(),iList.end());}//using tail recursionvoid printListr(list<int>::iterator itCur,list<int>::iterator end){    if( itCur == end)    {      std::cout<<std::endl;      return;    }    std::cout<<*itCur++<<" ";    printListr(itCur,end);//at tail ,call itself}//using iterationvoid printListi(list<int>::iterator itCur,list<int>::iterator end){    while(itCur != end)        std::cout<<*itCur++<<" ";    std::cout<<std::endl;}
这里使用尾递归或者迭代方式输出链表内容,可以看出尾递归只是一个变形的循环,可以很容易用循环来代替。在含有循环结构的语言中,不推荐使用尾部递归。

除了尾部递归,当然存在非尾部递归,例如:

#include <iostream>#include <string>void reverse1();void reverse2();void reverse3();int main(int argc,char** argv){     std::cout<< "input somethind:"<<std::endl;   reverse2();   std::cout<<std::endl; }//all chars reverse,no newlinevoid reverse1(){   char ch;   std::cin.get(ch);   if( ch != '\n')   {      reverse1();      std::cout.put(ch);   }}//with newline at head line,other chars reversevoid reverse2(){   char ch;   std::cin.get(ch);   if( ch != '\n')      reverse2();   std::cout.put(ch);}//output only newlinesvoid reverse3(){   static char ch;//note the static   std::cin.get(ch);   if( ch != '\n')      reverse3();   std::cout.put(ch);}

这里提供了三个版本的函数,旨在帮助理解递归调用特性。版本1,会对输入进行逆向输出,另外两个版本你可以分析其输出。当然,可以利用栈或者缓存实现逆向输出的迭代版本。

上面的尾部和非尾部递归,都是直接递归,也就是函数自身调用自己。另外一种情形是,一个函数通过其他函数间接调用自身,例如f()-->g()-->f()这种间接形式。

还有所谓的嵌套递归,即函数不仅根据自身定义,而且还作为该函数的一个参数进行传递。例如Ackermann函数:

这种情形是很复杂的。


4 递归经典问题

4.1 Tower of Hanoi

汉诺塔问题已经将的很多了,这里给出其递归和迭代实现,然后讨论一些注意点。

迭代版本的实现,参考了http://www.ecse.rpi.edu的资料,然后自行整理的。迭代版本的实现方式如下:

将盘子从大到小编号1-n,将柱子编号0,1,2。规定:

1)每次只能移动一个盘,且只能将小盘放在大盘上面。

2)盘子偶数号码时,沿着逆时针方向移动即0-2-1-0;盘子奇数号码时,沿着顺时针移动,即0-1-2-0;

3)每次移动的盘子不能是上次移动过的盘子

注意,每次移动的过程中,要选择一个上次没有移动过的盘,那么剩下的两个盘子中,肯定有一个大和一个小些的,一般总是选择最小的一个,如果没有最小的一个则移动那个大的盘(例如另外一个没有移动的盘已经压在了刚刚移动过的盘子下面)。同时如果初始时移动盘子数目为奇数,则最终盘子在1号柱子,否则在2号柱子。


// using recursionvoid hanoiRecursion(int n,char src,char mid,char target){       if(n == 0)    {      return; // do nothing    }    hanoiRecursion(n-1,src,target,mid); // move the up n-1 stack to mid        ++g_stepCnt;    cout<<"("<<n<<","<<src<<"-->"<<target<<")"<<endl;// move the n-th stack to target    hanoiRecursion(n-1,mid,src,target);// move the up n-1 stack from mid to target}// using iteration// if n odd then final post is 1,else is 2void hanoiIteration(int n){   stack<int> dStack[3];      if (n <= 0) return;      // put n disk at stack 0   for(int i = n;i > 0;i--)         dStack[0].push(i);   int lastItem = -1; //record last moved disk   while(!dStack[0].empty() || !dStack[n%2+1].empty())    {      //pick the smallest and not the last moved disk to move      int stackNum = 0,moveItem = n+1;      for(int i = 0;i < 3;i++)      {         if(dStack[i].empty() || dStack[i].top() == lastItem)             continue;         if(dStack[i].top() < moveItem)          {             stackNum = i;             moveItem = dStack[i].top();         }      }      lastItem = moveItem;      ++g_stepCnt;      //move odd-numbered disk clockwise ,move even-numbered disk counter-clockwise      int target = (moveItem % 2 == 0 )?(stackNum+2)%3:(stackNum+1)%3;      cout<<"("<<moveItem<<","<<stackNum<<"-->"<<target<<")"<<endl;      dStack[target].push(moveItem);      dStack[stackNum].pop();   }}

两种实现方法移动的都是最少次数。

如何计算盘子移动次数?

一个性质是,只要盘子总数确定,不过从哪儿移动到哪个目的柱子,总共要移动的次数是一样的。

假设n个盘子移动次数为h(n),则我们可以计算如下:

可以看出当n=64时,数目极其巨大,无法在有效时间内解决。

Hanoi塔的迭代版本可以使用位操作实现,不过好像技巧性比较强,有兴趣可以参考:How does this work? Weird Towers of Hanoi Solution.

4.2 Koch Snow

   Koch雪花问题,设计到递归实现问题。关于其一般介绍可参考Wiki koch snow,这里主要讲述与递归实现相关的部分。

   首先给出一个OpenGL绘制的效果图如下:

  

  关于这个雪花的实现有3个重要的数学问题。

   1)绘制的规律--利用转动角度和圆的坐标计算

      首先发现一下规律。

                

      上面分别为进行了0次分形,1,2,3次分形的图形,不管怎么分形有一个重要的性质(一开始我是没想到的,后来观察教材的实现代码是明白了)。

      

    第一次从O1点计算出O2点,O2在其圆周上,利用圆心坐标公式:

   

prevX = prevX + (side/3)*cos(angle*PI / 180.0);prevY = prevY + (side/3)*sin(angle*PI / 180.0);

即可计算,然后从O2点计算出O3点,这次在O2圆周上且圆周夹角为+60,后面angle依次加上-120度,+60,这样第一条便就绘制结束。第二条边与第一条边之间angle加上-120度,第三条和第二条也是angle加上-120度。这个angle是全局的,这一点性质很重要。

2)周长是无限的

   每进行一次细分,则边的数目是原来的4倍,同时边的长度是上次的1/3,因此有:

  

  当n趋于无穷大时,长度不收敛,为无穷大。

3)面积是有限的

   面积推导也比较简单,每次分形后,面积都在前面的基础上增加,而增加的部分就是向外扩展的小三角形的面积。设原始边长为a,则前几次分形的面积计算如下:

  

  通过发现规律,可以计算出n趋于无穷时的面积为:

 

由此可以看出,Koch雪花在有限的面积内,周长却无限大。

实际在利用OpenGL绘制Koch雪花时,只需要保存所有的点即可,然后一次渲染即可。

关键部分实现如下:

//predefined variablesstd::vector< glm::vec4 > vertexVec;//hold pointsfloat prevX = 0.0f,prevY =0.0f;//the previous pointint angle = 0;float side = 3.0f;int level = 6;//prepare snow datavoid prepareData(){    float originX = 0,originY =0;    vertexVec.push_back(glm::vec4(originX,originY,0,1));    for(int i=0;i< 3;i++)    {    drawFourLine(side,level);         angle += -120;    }}//draw four linesvoid drawFourLine(float side,int level){if (level == 0){  prevX = prevX + (side/3)*cos(angle*PI / 180.0);  prevY = prevY + (side/3)*sin(angle*PI / 180.0);  vertexVec.push_back(glm::vec4(prevX,prevY,0,1));}else{drawFourLine(side/3,level-1);angle += 60;drawFourLine(side/3,level-1); angle += -120; drawFourLine(side/3,level-1); angle += 60; drawFourLine(side/3,level-1);}}
    代码表明,我们实际上把一个雪花看做3个4段组成的,而一个4段的每一段又可以继续分为4段,特殊情况例如没有分形时只有一条边,而这条边可以看做一个4段的特殊情况。


4.2 全排列问题

  曾经遇到过一个全排列的问题,即给定无重复值的字符串,给出其全排列,例如ab,全排列即为ab,ba.

   实际上在用递归实现时,基本算法描述为:

permutaion(input,output)    如果只有一个字符,则将字符添加到output即可;    否则:        每次从input中取出一个不同的字符作为头部字符head                  然后拿出剩下的部分leftPart进行排列(返回的是一个子串的排列的集合)                  对剩余部分排列的每个结果的首部加上头部head,得到的结果添加到output
简单来讲,就是固定一个头部,然后让剩下的子串全排列,将头部和子串全排列的每个结果串链接起来,从而得到完整的全排列。

对于子串重复执行这个过程,直到遇到只有一个字符时,它不用排列了,直接返回即可。


算法实现为:

// permutation the input string and save it to resultvoid permutation(string input,vector<string> &result){   if(input.length() == 1)   {      result.push_back(input);      return;   }   for(string::size_type i= 0;i < input.length();++i)   {        string leftPart = input;        leftPart.erase(i,1);//get left part        vector<string> strVec;        permutation(leftPart,strVec);// use  left part to permutate        // add this char with left part result        for(vector<string>::iterator it = strVec.begin();it != strVec.end();++it)           result.push_back(input[i] + *it);   }}
例如输入"abc",则输出结果为:


Permutation of: abc ,kind: 6abcacbbacbcacabcba

用递归实现的还有很多程序,例如迷宫问题,8皇后问题等等,不再列举。

5. 递归与非递归选择

    刚刚学习python的时候写过一个Fibonacci数列的程序,如下:

  

def fibr(n):    """get the n-th Fibonacci series number,using recursion"""    global callCnt    callCnt += 1 # count how many time function called    if n < 2:       return n    return fibr(n-1)+fibr(n-2)

当然这个callCnt是之后加上去的。程序运行正常,可是我输入n=40,n=100的时候,程序好像死机了,然后我就开始责怪python效率低(刚开始我没有分析复杂度,确实错怪了Python:)。

我们看下实际情形(粗略的时间估计):

~ python3 fibr.py 30fib(30)=832040 called 'recursive function fibr' 2692537 times consumed 1029.3409824371338 ms**************************************************~ python3 fibr.py 40fib(40)=102334155 called 'recursive function fibr' 331160281 times consumed 125293.18809509277 ms
现在知道实际上在递归调用斐波那契数列时例如n=40时函数fibr调用了3亿多次,花了2分多钟才计算完毕!

同样书写了一份迭代版本,去除程序中多余的时间统计和函数调用统计语句后,粗略地比较了c++/Python计算的时间:



通过分析上述数据,可以得出:迭代算法的效率要比递归效率高,C++编译型语言执行计算时比解释型语言Python要快10倍左右(这个比较不代表python在其他方面没有优势)。

下面要对Fabonacci数列的递归实现和迭代实现做简单分析。

对于递归实现,归纳总结得出:

也就是说,对于Fib(n),计算时执行函数调用2Fib(n+1)-1次,执行加法Fib(n+1)-1次。

而对于迭代实现:

//using iterationlong long fibi(int n){   if(n < 2)       return n;    else    {       long long last =0;       long long cur = 1;       for(;n > 1;n--)       {           long long tmp = cur;           cur += last;           last = tmp;       }       return cur;    }}
对于n>1,进入for循环,一共执行(n-1)次。每次循环中执行3次复制操作,隐含一个加法操作,那么一共需要3(n-1)次赋值和(n-1)次加法运算。
Fibonacci数列增长很快,迭代算法不需要递归调用的函数开销,同时加法和赋值操作也比递归版本少,因此,对于Fibonacci数列使用递归算法是不恰当的,应该采取其迭代版本。

这个例子告诉我们,虽然递归算法很容易书写,但是具体应该用递归还是迭代实现,应该视情况而定;最好对迭代和递归版本实现的复杂度和开销进行分析,或者在实际机器上比较算法执行效率。

6.总结

对于递归算法,总结如下:

1)一般对如要解决的问题,如果能进行分解,且分解为一个和原问题具有相同特征,则可以利用递归实现。

例如移动Hanoi塔分解后就是将上面的(n-1)个塔从一个塔移动到另外一个塔;例如全排列问题,取出一个作为头部后,对于剩下的元素,同样要求出其全排列;对于迷宫问题,总是从当前位置开始,如果是结束位置则停止,否则尝试4个方向走出迷宫,每走到一个新位置,又作出同样的抉择。

2)递归程序的编写,有一个普遍的模式,即程序有一个基底或者叫做出口,另外的部分就是调用自身即可。请注意递归程序一定要选择好出口,否则就成了盗梦空间里回不来了。

出口部分,例如只有一个盘子的Hanoi塔,只需移动他即可;只有一个字符的全排列问题,只需要返回它即可;这些都是程序的出口。递归程序的一个模式就是:

function(param)   if 出口:      处理并返回;  否则:        ...        function(param)      .....


3)是谁在背后支持我们的递归调用? 一个是语言本身的支持,另一个是操作系统的运行时栈的支持以及可能的硬件支持。

递归函数避免不了递归调用时的栈开销,但这也并不意味着它的效率一定比迭代方法低。

对如一个问题,迭代实现和递归实现需要作出比较和分析,然后确定到底使用哪种算法实现。

如果想进一步了解,可以参考[4]上面的讨论。


最后,贴上StackOverflow上面的关于递归的一个挺有趣的解释:

A child couldn't sleep, so her mother told her a story about a little frog,    who couldn't sleep, so the frog's mother told her a story about a little bear,         who couldn't sleep, so the bear's mother told her a story about a little weasel...             who fell asleep.        ...and the little bear fell asleep;    ...and the little frog fell asleep;...and the child fell asleep.

参考资料:

[1]  《数据结构》  严蔚敏 吴伟明  清华大学出版社

[2] 《数据结构与算法 c++版 第三版》   Adam Drozdek编著  清华大学出版社

[3]   Wiki Tower of Hanoi

[4]  What is recursion and when should I use it?

[5]   Wiki  Koch snowflake

[6] a simpler iterative solution to the towers of hanoi problem
0 0
原创粉丝点击