三.三个例子 好了上面的理论知识已经足够了,下面让我们看看几个例子,结合例子加深我们对问题的认识 .即使上面的理论你没有完全明白,不要气馁,对事物的认识总是曲折的,多看多想你一定可以明 白(事实上我也是花了两个星期的时间才弄得比较明白得).
1)例子一:
f(n) = n + 1; (n <2) f[n/2] + f[n/4](n >;= 2); 这个例子相对简单一些,递归程序如下: int f_recursive(int n) { int u1, u2, f; if (n < 2) f = n + 1; else { u1 = f_recursive((int)(n/2)); u2 = f_recursive((int)(n/4)); f = u1 * u2; } return f; }
下面按照我们上面说的,确定好递归调用树的结构,这一步是最重要的.首先,什么是叶子结点 ,我们看到当n < 2时f = n + 1,这就是返回的语句,有人问为什么不是f = u1 * u2,这也是一个 返回的语句呀?答案是:这条语句是在u1 = exmp1((int)(n/2))和u2 = exmp1((int)(n/4))之后 执行的,是这两条语句的父结点. 其次,什么是当前结点,由上面的分析,f = u1 * u2即是父结点 .然后,顺理成章的u1 = exmp1((int)(n/2))和u2 = exmp1((int)(n/4))就分别是左子树和右子 树了.最后,我们可以看到,这个递归函数可以表示成后序遍历的二叉调用树.好了,树的情况分析 到这里,下面来分析一下栈的情况,看看我们要把什么数据保存在栈中,在上面给出的后序遍历的如果这个过程你没 非递归程序中我们已经看到了要加入一个标志域,因此在栈中要保存这个标志域;另外,u1,u2和 每次调用递归函数时的n/2和n/4参数都要保存,这样就要分别有三个栈分别保存:标志域,返回量 和参数,不过我们可以做一个优化,因为在向上一层返回的时候,参数已经没有用了,而返回量也 只有在向上返回时才用到,因此可以把这两个栈合为一个栈.如果对于上面的分析你没有明白,建 议你根据这个递归函数写出它的递归栈的变化情况以加深理解,再次重申一点:前期对树结构和 栈的分析是最重要的,如果你的程序出错,那么请返回到这一步来再次分析,最好把递归调用树和 栈的变化情况都画出来,并且结合一些简单的参数来人工分析你的算法到底出错在哪里. ok,下面给出我花了两天功夫想出来的非递归程序(再次提醒你不要气馁,大家都是这么过来 的).
int f_nonrecursive(int n) { int stack[20], flag[20], cp;
/* 初始化栈和栈顶指针 */ cp = 0; stack[0] = n; flag[0] = 0; while (cp >;= 0) { switch(flag[cp]) { case 0: /* 访问的是根结点 */ if (stack[cp] >;= 2) { /* 左子树入栈 */ flag[cp] = 1; /* 修改标志域 */ cp++; stack[cp] = (int)(stack[cp - 1] / 2); flag[cp] = 0; } else { /* 否则为叶子结点 */ stack[cp] += 1; flag[cp] = 2; } break; case 1: /* 访问的是左子树 */ if (stack[cp] >;= 2) { /* 右子树入栈 */ flag[cp] = 2; /* 修改标志域 */ cp += 2; stack[cp] = (int)(stack[cp - 2] / 4); flag[cp] = 1; } else { /* 否则为叶子结点 */ stack[cp] += 1; flag[cp] = 2; } break; case 2: /* */ if (flag[cp - 1] == 2) { /* 当前是右子树吗? */ /* * 如果是右子树, 那么对某一棵子树的后序遍历已经 * 结束,接下来就是对这棵子树的根结点的访问 */ stack[cp - 2] = stack[cp] * stack[cp - 1]; flag[cp - 2] = 2; cp = cp - 2; } else /* 否则退回到后序遍历的上一个结点 */ cp--; break; } } return stack[0]; }
算法分析:a)flag只有三个可能值:0表示第一次访问该结点,1表示访问的是左子树,2表示 已经结束了对某一棵子树的访问,可能当前结点是这棵子树的右子树,也可能是叶子结点.b)每 遍历到某个结点的时候,如果这个结点满足叶子结点的条件,那么把它的flag域设为2;否则根据 访问的是根结点,左子树或是右子树来设置flag域,以便决定下一次访问该节点时的程序转向.
2)例子二
快速排序算法 递归算法如下:
void swap(int array[], int low, int high) { int temp; temp = array[low]; array[low] = array[high]; array[high] = temp; } int partition(int array[], int low, int high) { int p; p = array[low]; while (low < high) { while (low < high && array[high] >;= p) high--; swap(array,low,high); while (low < high && array[low] <= p) low++; swap(array,low,high); } return low; }
void qsort_recursive(int array[], int low, int high) { int p; if(low < high) { p = partition(array, low, high); qsort_recursive(array, low, p - 1); qsort_recursive(array, p + 1, high); } }
需要说明一下快速排序的算法: partition函数根据数组中的某一个数把数组划分为两个部分, 左边的部分均不大于这个数,右边的数均不小于这个数,然后再对左右两边的数组再进行划分.这 里我们专注于递归与非递归的转换,partition函数在非递归函数中同样的可以调用(其实 partition函数就是对当前结点的访问). 再次进行递归调用树和栈的分析: 递归调用树:a)对当前结点的访问是调用partition函数;b)左子树: qsort_recursive(array, low, p - 1);c)右子树:qsort_recursive(array, p + 1, high); d)叶子结点:当low < high时;e)可以看出这是一个先序调用的二叉树 栈:要保存的数据是两个表示范围的坐标.
void qsort_nonrecursive(int array[], int low, int high) { int m[50], n[50], cp, p; /* 初始化栈和栈顶指针 */ cp = 0; m[0] = low; n[0] = high; while (m[cp] < n[cp]) { while (m[cp] < n[cp]) { /* 向左走到尽头 */ p = partition(array, m[cp], n[cp]); /* 对当前结点的访问 */ cp++; m[cp] = m[cp - 1]; n[cp] = p - 1; } /* 向右走一步 */ m[cp + 1] = n[cp] + 2; n[cp + 1] = n[cp - 1]; cp++; } }
3)例子三 阿克曼函数:
akm(m, n) = n + 1; (m = 0时) akm(m - 1, 1); (n = 0时) akm(m - 1, akm(m, n - 1)); (m != 0且n != 0时)
递归算法如下:
int akm_recursive(int m, int n) { int temp; if (m == 0) return (n + 1); else if (n == 0) return akm_recursive(m - 1, 1); else { temp = akm_recursive(m, n - 1); return akm_recursive(m - 1, temp); } }
这个例子相对难一些,不过只要正确的分析递归调用树和栈的变化情况就不难解决,先卖个关子,晚上再来公布答案,感兴趣的可以先想想.
好了,让我们回到递归与非递归的世界中,继续未完的旅途.
这道题的难点就是确定递归调用树的情况,因为从akm函数的公式可以看到,有三个递归调用,一般 而言,有几个递归调用就会有几棵递归调用的子树,不过这只是一般的情况,不一定准确,也不一定非要 机械化的这么作,因为通常情况下我们可以做一些优化,省去其中的一些部分,这道题就是一个例子. 递归调用树的分析:a)是当m=0时是叶子结点;b)左子树是akm(m - 1, akm(m, n - 1))调用中的 akm(m, n - 1)调用,当这个调用结束得出一个值temp时,再调用akm(m - 1, temp),这个调用是右子树 .c)从上面的分析可以看出,这个递归调用树是后序遍历的树. 栈的分析:要保存的数据是m, n,当n = 0 或 m = 0时开始退栈,当n = 0时把上一层栈的m值变为 m - 1,n变为1,当m = 0时把上一层栈的m值变为0,n变为n + 1.从这个分析过程可以看出,我们省略了 当n = 0时的akm(m - 1, 1)调用,原来在系统机械化的实现递归调用的过程中,这个调用也是一棵子树, 不过经过分析,我们用修改栈中数据的方式进行了改进.
int akm_nonrecursive(int m, int n) { int m1[50], n1[50], cp; cp = 0; m1[0] = m; n1[0] = n; do { while (m1[cp] >; 0) { /* 压栈, 直到m1[cp] = 0 */ while (n1[cp] >; 0) { /* 压栈, 直到n1[cp] = 0 */ cp++; m1[cp] = m1[cp - 1]; n1[cp] = n1[cp - 1] - 1; } /* 计算akm(m - 1, 1),当n = 0时 */ m1[cp] = m1[cp] - 1; n1[cp] = 1; } /* 改栈顶为akm(m - 1, n + 1),当m = 0时 */ cp--; m1[cp] = m1[cp] - 1; n1[cp] = n1[cp + 1] + 1; } while (cp >; 0 || m1[cp] >; 0); return n1[0] + 1; }三.递归程序的分类及用途
递归程序分为两类:尾部递归和非尾部递归.上面提到的几个例子都是非尾部递归,在一个选择分支中有至少
一个的递归调用.相对而言,尾部递归就容易很多了,因为与非尾部递归相比,每个选择分支只有一个递归调用,
我们在解决的时候就不需要使用到栈,只要循环和设置好循环体就可以了.下面再举几个尾部递归的例子吧,比较
简单我就不多说什么了.
1)例子一
g(m, n) = 0 (m = 0, n >;= 0) = g(m - 1, 2n) + n; (m >; 0, n >;= 0)
a)递归程序
int g_recursive(int m, int n) { if (m == 0 && n >;= 0) return 0; return (g_recurse(m - 1, 2*n) + n); }
b)非递归程序
int g_nonrecursive(int m, int n) { int p; for (p = 0; m >; 0 && n >;= 0; m--, n *= 2) p += n; return p; }
2)例子二
f(n) = n + 1 (n = 0) n * f(n/2) (n >; 0)
a)递归程序
int f_recursive(int n) { if (n == 0) return 1; return (n * f_recurse(n/2)); }
b)非递归程序
int f_nonrecursive(int n) { int m; for (m = 1; n >; 0; n /= 2) m *= n; return m++; }
分析完了递归程序的分类,让我们回头看看在向非递归转换的过程中用到了什么来实现转换:
1)循环,因为程序要在某个条件下一直执行下去,要代替递归程序,循环必不可少,对于尾部递归,循环结束的
条件十分容易确定,只要按照不同分支的条件写出来就可以了.而对于非尾部递归程序,循环结束的条件一
般是当栈为空时或者是结束了对递归调用树的遍历从树的根结点退出时,而且有的时候写成while()的形式
,有时写成do ...while的形式(如上面的akm函数),具体怎样,很难说清楚,取决于你对整个递归程序的分析
.
2)递归调用树,树的结构在转换的过程中是不可见的,你不必为转换专门写一个树结构,不过能不能把递归调用
中的树遍历方式以及叶子结点,左子树,右子树等元素确定好是你能否正确解决问题的关键(这一点已经在
上面的分析过程中展露无疑),确定好这些后,剩下的工作大部分就是按照给出的几种不同的遍历树的方式
把程序进行改写,这个过程就考验你对树结构还有遍历方式是否很好的掌握了(看出基础的重要了吗?如果
回答是,那么和我一样好好的打好基础吧,一切都还不晚!!).对于尾部递归而言,可以看作没有递归调用树,
所以尾部递归的难度大大降低了.
3)栈,非尾部调用中需要栈来保存数据,这一点已经很清楚了,需要注意几个问题:a)栈有时可能会出现不够的
情况,拿上面的akm函数来说,我用的50个元素的数组,你如果把m和n值设置得大一些,这个栈就不能用了,有
时你的算法正确了,不过没有注意到这个问题还是会出错的;反过来说,在递归调用中,系统或者编译器的优
化功能不够好的化,在这个栈上可能会消耗很多空间,这个时候如果你把程序改成非递归的形式,然后再用
动态分配技术分配栈可能就会把程序的性能提高一大块--这也是我们学习这门技术的意义之一,因为系统
是机械化的,你如果知道更好的优化办法,为什么不用呢?
什么时候可以用递归解决问题?到了这一步,如果你对于上面说的已经相当明白的话,这个问题不难回答,如果
我们要解决的问题要分成几个小的部分,而其中的一些与你要解决的问题是一样的,只不过是问题的规模(如
参数等)小了,那么这样的问题可以用递归来解决.根据问题设计好一个递归是所有这些的基础,转换也是在原
来的递归程序上进行的,所以这一步一定要做好.通常,设计一个递归程序要注意一下几个问题:a)可以递归解
决的问题是什么?b)入口和出口参数是什么--即要明确好出入的接口.
四.学习过程中参考到的一些资料
1)<<算法导论>;>; 国防科大出版社 张益新 沈雁编著
这本书比较老了,也许很难找了,不过参考价值不大,里面专门用了一章来讲这个问题,可惜用的是goto来实
现递归调用的选择分支(所以我说参考价值不大),如果没有也无所谓的.
2)http://db.pku.edu.cn/mzhang/ 北京大学张铭老师的主页.
上面可以找到一个张老师讲解递归和非递归转换的视频,有大概50多分钟吧,我没有听完,原先看第一本书
的对于如何不用goto实现选择分支十分不解,张老师讲到的递归调用树真正让我豁然开朗,从此以后虽然还
是有很多问题,不过明白了这个大体的解决思路就有了.
3)<<数据结构>;>; 清华大学出版社 严蔚敏著
这本书讲到栈的时候略微讲到了一些这方面的内容,网上可以找到一位叫一具的仁兄写的这本书的习题解答
,可以自己去找一找,我的中序和后序遍历的算法完全看的他写的. 4) http://www.tztvu.edu.cn/kfjy/bkjy/jsj/bxk/sjjg/kcdh/忘了是什么的网页了 里面的一个dn5.doc的文件中有讲解转换akm函数的方法,给我的启发很大,如果你对于我说的akm函数还有疑 问,那么可以看看人家写的,他把递归调用树和栈的变化情况都写出来了,非常直观.
|