递 归 递 归 冯文科 一、递归的基本概念。 一个函数、概念或数学结构,如果在其定义或说明内部直接或间接地出现对其本身的引用,或者是为了描述问题的某一状态,必须要用至它的上一状态,而描述上一状态,又必须用到它的上一状态……这种用自己来定义自己的方法,称之为递归或递归定义。在程序设计中,函数直接或间接调用自己,就被称为递归调用。 二、递归的最简单应用:通过各项关系及初值求数列的某一项。 在数学中,有这样一种数列,很难求出它的通项公式,但数列中各项间关系却很简单,于是人们想出另一种办法来描述这种数列:通过初值及 与前面临近几项之间的关系。 要使用这样的描述方式,至少要提供两个信息:一是最前面几项的数值,一是数列间各项的关系。 比如阶乘数列 1、2、6、24、120、720…… 如果用上面的方式来描述它,应该是: 如果需要写一个函数来求 的值,那么可以很容易地写成这样: int f(int n) { if(n==1) return 1; return n*f(n-1); } 这就是递归函数的最简单形式,从中可以明显看出递归函数都有的一个特点:先处理一些特殊情况——这也是递归函数的第一个出口,再处理递归关系——这形成递归函数的第二个出口。 递归函数的执行过程总是先通过递归关系不断地缩小问题的规模,直到简单到可以作为特殊情况处理而得出直接的结果,再通过递归关系逐层返回到原来的数据规模,最终得出问题的解。 以上面求阶乘数列的函数 为例。如在求 时,由于3不是特殊值,因此需要计算 ,但 是对它自己的调用,于是再计算 ,2也不是特殊值,需要计算 ,需要知道 的值,再计算 ,1是特殊值,于是直接得出 ,返回上一步,得 ,再返回上一步,得 ,从而得最终解。 用图解来说明,就是 下面再看一个稍复杂点的例子。 【例1】数列 的前几项为 1、 、 、 、…… 输入 ,编程求 的精确分数解。
: 这个题目较易,发现 ,其它情况下有 。如要求实数解的话,这基本已经可以写出递归函数了。但由于题目要求精确的分数解,还需做一些调整。设 ,则由递归关系,有 ,再约分化简,即得 。但发现一个问题:求出 时,需要返回两个整数:分子 与分母 ,而通常的函数只能返回一个整数。 这个问题一般有两类解决办法,一种是让求值函数返回一个结构体变量,这样就可以返回两个变量了(其实还可以不只两个呢);另一种是在求值函数的参数表中加入两个指针变量或引用变量,通过参数给带回数值。但由于后一种做法会使程序结构不清晰——返回值是由参数表得到的,因此我们使用前一种方法。 另外,在通过 得出 后, 就已经是最简分数了,无须化简。证明如下: 若 是最简分数,即说明 的最大公约数为1,即对任何 ,都有 与 不全为0,不防记 、 ,则有 只要 与 不全为0,且 ,就有 与 不全为0。因此对任何的 ,有 与 不全为0。 而对于 的情况而言,记 ,则有 由于 ,因此同样有 与 不全为0。 所以对任意 ,都有 与 不全为0,因此它们的最大公约数为1,即 是最简分数。虽然这是个要求 (即 )是最简分数的结论,但由于数列第二项为 ,是最简分数,因此可以证明第三项也是最简分数,同时也证明对所有的 ,求出的 就是最简分数,无须化简。 具体代码如下: // Exam1.cpp #include
using namespace std; struct FS { unsigned long long q, p; }; FS f(int n) { FS r; if(n==1) { r.q=1; r.p=1; return r; } r=f(n-1); r.p=r.p+r.q; r.q=r.p-r.q; return r; } int main() { FS r; int n; cin>>n; r=f(n); cout<标准是它能使问题的规模变小,且变小后的问题与原问题在本质上是一样的。当找到递归关系后,我们的递归函数只须处理特殊情况与递归关系,不需要处理其它的信息——至于下一步的事情,就让下一步去做吧。 另一个需要考虑的事情就是递归函数的参数表,首先,在通常情况下,参数表都要使用变量参数,且递归函数中尽量使用局部变量——这会减少很多不必要的麻烦;其次,参数表中,大多都有一个表示当前在执行第几步的参数。 【例2】下图是一个有向图,输入 ,打印 的所有路径。 分析: 仔细研究这个图的特点,发现以下规律:对任何结点 ,都可以走到 和 ,当然如果它们不超过9的话。由于要打印路径,因此需要保存查找过程中的部分路径信息。 可以做一个全局数组path[]来存储这个信息,由于结点0没有来路,且是所有路径的起点,因此记path[0]=0,递归函数负责填写之后的路径结点。 我们这样设计递归函数: 首先,这个递归函数的参数表中至少需要一个参数i,它的意义是表示现在在填路径中的第几个结点,而path[i]可以填的数,要么是上一个结点加1,要么是上一个结点加2,即path[i]=path[i-1]+1或path[i]=path[i-1]+2; 其次,特殊情况的讨论:我们要找的是终止到 的路径,因此若出现path[i-1]= 的情况,就说明已经找到路径,无须将当前层再填入结点,可以将path中的信息输出并结束函数了——这是递归函数的特殊情况出口; 第三、递归关系的处理:若还没到达结点 ,则填写本结点path[i],上文已说明, path[i]=path[i-1]+1或path[i]=path[i-1]+2,当然如果它们都不过9的话。将结点 填好后,说明路径向下走了一步,距离结点 更近了一步,问题规模已经变小。不要处理其它东西,直接递归,通过递归调用去填写i+1结点就可以了。 这里有处和【例1】不相同的地方,即path[i]是有两种可能可选的,我们的处理这这样的,先令path[i]=path[i-1]+1,然后递归调用,填写i+1结点,当这个调用返回时,说明所有path[i]为path[i-1]+1的路径都已经讨论完成了,再令path[i]=path[i-1]+2,再递归,当它返回时,整个函数执行完毕,形成正常的执行完成时的出口。 具体代码如下: // Exam2.cpp #include
using namespace std; #define MAX 9 int path[MAX], N; int write(int i) { int j; for(j=0;j"; cout<>N; path[0]=0; f(1); system("pause"); return 0; } 【例3】我的画笔。 Windows中的画笔从Windows3.x时代开始就已经有了,虽然功能与Photoshop不能相比,但它小巧而迅速,一般的简单功能还是很方便的。 画笔中的填色工具是油漆桶,选定它,再指定一个颜色,在图片中一点,所有与这个点颜色相同且相连的象素就都被填充了。如下图示: 画笔的油漆桶工具填充的是一个叫“四连通”的区域,即它只从上、下、左、右四个方向向外扩展。 请编写程序,模拟油漆通工具。 输入文件:Exam3In.txt中有10行,每行是一个10字符的字符串,表示一个10*10的图象,不同的字符表示不同的颜色;之后的一行有两个用空格分开的整数,表示油漆桶点中的位置,再后面一行是一个字符,表示油漆桶的填充颜色。 输出文件:Exam3Out.txt,输出10行,每行10个字符的字符串,表示填充后的图象。 分析: 本题给了一个点的位置,查找所有与它“四连通”的点就成为本题的核心问题。我们的算法是这样的:先从这个起点出发,沿四个方向展开,看这“直接相连”的四个点是否可以填充,若有可以的,则再以这个点为中心,再向四个方向展开……直到所有可能展开的点都不能再展开了为止。 递归函数这样设计:首先它的参数表需要两个参数,表示本次从哪个位置点展开;其次,依次讨论它四个方向上相邻的点是否可填充——与起点颜色相同,如果可以,则填充,并以此点为中心,递归调用。 代码如下: // Exam3.cpp #include using namespace std; #define N 10 ifstream fin("Exam3In.txt"); ofstream fout("Exam3Out.txt"); char pic[N][N+1], c, p; int col, row; void fill(int i, int j) { if(i-1>=0 && pic[i-1][j]==p) { pic[i-1][j]=c; fill(i-1, j); } if(i+1=0 && pic[i][j-1]==p) { pic[i][j-1]=c; fill(i, j-1); } if(j+1>pic[i]; fin>>row>>col; fin>>c; p=pic[row][col]; pic[row][col]=c; fill(row, col); for(i=0;i using namespace std; #define M 100 ifstream fin("Exam4In.txt"); ofstream fout("Exam4Out.txt"); char tree[M]; int L; void run(int i) { if(2*i+1>tree; L=strlen(tree); run(0); return 0; } 【例5】以字符串形式给出一棵完全二叉树的先序遍历与中序遍历序列,编程输出用字符串形式表示的完全二叉树的结构。 分析: 先序遍历的首结点一定是整棵树的根,因此可以直接标在0处。在中序遍历序列中,它左边的结点构成左子树,右边的结点构成右子树,从而可以知道,左子树与右子树的结点个数,进而得到及左子树与右子树的先序遍历与中序遍历序列,递归查找所有子树的根即可。 为了得到整棵树的结构,设置全局数组tree[]来存储,全局数组s[]的意义是:s[i]存储整数表示第i层结点的起始下标。 代码如下: // Exam5.cpp #include using namespace std; #define M 100 ifstream fin("Exam5In.txt"); ofstream fout("Exam5Out.txt"); char pre[M], mid[M], tree[M]; int L, s[M]; void build(int layer, int ps, int pe, int ms, int me) { tree[s[layer]]=pre[ps]; s[layer]++; if(ps==pe) return; int i; i=ms; while(mid[i]!=pre[ps]) i++; build(layer+1, ps+1, ps+i-ms, ms, i-1); build(layer+1, ps+i-ms+1, pe, i+1, me); } int main() { fin>>pre>>mid; L=strlen(pre); s[0]=0; int i, j; i=1; j=2; while(s[i] using namespace std; #define MAX 100 int n, q[MAX], c; bool mark[MAX]; void write() { int i; char t[MAX]; memset(t, '.', MAX*sizeof(char)); t[n]='\0'; for(i=0;i>n; memset(mark, 0, MAX*sizeof(bool)); c=0; search(0); cout<书 上讲的用栈计算的方法本质不同的方法。 在详细说明这个算法之前,需要首先明确这个算法用到的概念 1、单元:一个单元可能是用括号括起来的一个表达式,或是一个整数; 2、项:一个项是指由*与/连接起来的若干单元; 3、表达式:一个表达式是指由+或-连接起来的若干项。 要建立表达式树,需要三个函数互相调用的函数:一个是getunit,用于建立一个单元;一个是getexpr,用于建立一个项,另一个就是build,用于建立一个表达式。 getunit函数较易,如果字符串首字母是(的话,那么从它后面的字符开始用build建立一个表达式,这个表达式就是一个单元;否则,就处理一个整数; getexpr函数是建立在getunit之上的,它先用getunit建立一个单元,然后不停地考察之后地连接符号是不是*或/,若是,则不停地重复读连接符、建立另一个单元、建立连接的操作,直到连接符号不是*或/为止。 build函数是用于最终建立表达式的,它先用getexpr建立一个项,再用符号将剩余的各项连接成二叉树。 代码如下: // Exer.cpp #include