目标
1) 判定所给出的递归方法是否能在有限时间内顺利结束
2) 写一个递归方法
3) 评估递归方法的时间复杂度
4) 识别尾递归并能用迭代来替代它
迭代:for、while等循环。包含想要重复执行的语句及控制重复次数的机制。
缺点:有时会非常复杂,找到或验证这样的方案很困难。
↓
递归:替代复杂的迭代(有时也不能用,因为效率极低)
目录
7.1 什么是递归
7.2 跟踪递归方法
7.3 返回一个值的递归方法
7.4 递归处理数组
7.5 递归处理链
7.6 递归方法的时间效率
7.6.1 countDown的时间效率
7.6.2 计算Xn的时间效率
7.7 困难为题的简单求解方案
7.8 简单问题的低劣求解方案
7.9 尾递归
7.10 间接递归
7.11 使用栈来替代递归
小结
7.1 什么是递归
当你解决一个问题时,将它划分为更小的问题且用相同的方法来解决。问题求解过程中每次具体的变形,除了其大小外,较小的问题与原来的问题是一样的。这种特殊的处理称为递归(recursion)。递归的一个关键是,最终你能到达一个较小的问题,而这个较小问题的解决方案你是知道的,或者因为答案很明显,或者因为已经给出了答案。这个最小问题的求解或许不是原始问题的求解方案,但它能帮助你达成目标。无论在求解更小问题之前或之后,通常你都解决了问题的一部分。这个不烦与其他更小的部分的解决方案一起,得到更大问题的求解方案。
注:调用自己的方法称为递归方法(recursive method)。调用是递归调用(recursive call或recursive invocation)。
当设计递归方案时要回答的问题
1) 方案的哪个部分的工作能让你直接完成? 2) 哪些较小且相同的问题已有了求解方案,当加上你的贡献时,能提供对原始问题的求解? 3) 过程何时结束?即,哪个更小但相同的问题已有能让你达成目标或基础情形(base case)的已知的解决方案? |
public static void countDown(int integer){ if(integer > 1){ //询问是否到达了基础情形 } } |
注:成功递归的设计原则
1) 必须给方法一个输入值,通常作为参数给出; 2) 方法定义必须含有这个输入值并能导致不同情形的逻辑。一般地,这样的逻辑包含一个if语句或一个switch语句; 3) 这些情形中的一个或多个,应该不再需要递归。这些是基础情形,或终止情形(stopping case) 4) 一个或多个情形必须包含对方法的递归调用。这些递归调用应该含有一些步骤,这些步骤通过使用“更小的”参数,或者由方法完成的“更小”版本的任务的求解,在某种意义上逐步导向基础情形。 |
程序设计技巧:无穷递归
1)不检查基础情形或者缺少基础情形的递归方法,将“永远”执行。这种情形称为无穷递归。 2)迭代方法含有一个循环。递归方法调用自己。虽然有些递归方法也含有一个循环且调用自身,但如果在递归方法中写while语句,一定要确信不是想写if语句。 |
7.2 跟踪递归方法
图7-3表示方法countDown的多个副本。实际上,并不存在多个副本。对方法的每次调用(递归或非递归)Java都记录方法执行的当前状态,包括它的参数和局部变量的值,以及当前指令的位置。每个记录称为一个活动记录,它提供运行期间方法状态的快照。记录放入程序栈中。栈按时间先后组织这些记录,所以当前正执行的方法的记录位于栈顶。
注:一般地,递归方法比迭代方法使用更多的内存,因为每次递归调用都要产生一个活动记录。
程序设计技巧:栈溢出
进行许多次递归调用的递归方法,将在程序栈中放很多个活动记录。递归调用太多可能用掉程序栈中可用的所有内存,使栈满。结果,出现出错信息“栈溢出”。无穷递归或较大规模问题都可能导致这个错误。(对于大n很可能导致栈溢出,迭代不会有这样的困难)
7.3 返回一个值的递归方法
程序设计技巧:调试递归方法
1) 方法至少有一个输入值吗? 2) 方法含有测试输入值的语句,且能导向不同情形吗? 3) 考虑了所有可能情形吗? 4) 至少有一种情形导致至少一次的递归调用吗? 5) 这些递归调用调用更小的实参、更小的任务或接近于解决方案的任务吗? 6) 如果这些递归调用产生或返回了正确的结果,则方法产生或返回正确结果了吗? 7) 是不是至少有一种情形(即基础情形)没有递归调用? 8) 基础情形足够吗? 9) 每个基础情形都不能得到对应于这种情形的结果吗? 10) 如果方法返回一个值,每种情形都返回一个值吗? |
7.4 递归处理数组
1)从array[first]开始,displayArray(array, first+ 1, last)
2)从array[last]开始,displayArray(array, first, last-1)
3)将数组分半,mid=(first+last)/2,基础情形是含有一个元素的数组。
注:寻找数组的中点
计算数组中间元素的下标,可以使用语句 int mid = first + (last - first) / 2; 来代替 int mid = (first + last) / 2; 防止last+first发生溢出为负数,从而导致ArrayIndexOutOfBoundsException异常。 |
7.5 递归处理链
反向显示链:反向遍历链的节点,用迭代是困难的,递归如下。
private void displayChainBackward(Node nodeOne){ if (nodeOne != null){ displayChainBackward(nodeOnde.getNextNode()); System.out.println(nodeOne.getData()); } } |
7.6 递归方法的时间效率
7.6.1 countDown的时间效率
public static void countDown(int integer){ if(integer > 1){ } } |
当n为1时,countDown显示1。这是基础情形,需要常数级的时间。令t(n)表示countDown(n)的时间需求,则可以写为:
t(1) = 1
t(n) = 1 + t(n - 1) n > 1
表示t(n)的方法称为递推关系(recurrence relation),因为函数t的定义中又含有自身——递推。
求解递推关系。设一个n=4——t(4) = 4,设t(n) = n n >= 1
证明t(n) = n。因为t(n) = 1 + t(n - 1) n > 1成立
需要替换方程右侧的t(n - 1),如果当n > 1时,有t(n - 1) = n – 1,则当n > 1时,t(n) = 1 + n – 1 = n是正确的。所以,如果能找到整数k,满足t(k) = k,则下一个整数也将满足它。归纳法证明。所以方法是O(n)的。
7.6.2 计算Xn的时间效率
(1)Xn = XXn-1 X0 = 1 (2)Xn = (Xn/2)2 当n是正偶数时 Xn = X(X(n -1)/2)2 当n是正奇数时 X0 = 1 |
计算可由方法power(x, n)实现,它含有递归调用power(x, n/2)。因为Java中整除是截断结果,所以不管n是偶数还是奇数,这个调用都是合适的。所以power(x, n)将调用power(x, n/2)一次,然后对结果求平方,如果n是奇数再将平方乘以x,乘积都是O(1)操作。所以power(x, n)的运行时间与递归调用的次数成正比。
时间
t(n) = 1 + t(n/2) 当n >= 2时 t(1) = 1 t(0) = 1 |
猜想:t(n) = 1 + log2n
证明:对于n ≥ 1这个猜想确实是对的。对于n = 1, t(1) = 1 + log21 = 1 成立。对于n > 1,t(n) = 1 + t(n/2)是正确的。替换t(n/2),对于所有的n < k,设都有t(n) = 1 + log2n,有t(k/2) = 1 + log2(k/2),因为k/2 < k,所以t(k) = 1 + t(k/2) = 1 + (1 + log2(k/2)) = 1 + log2k。
假定对于所有的n<k,有t(n) = 1 + log2n,表明t(k) = 1 + log2k。所以对所有的n ≥ 1, 有t(n) = 1 + log2n。因为power的时间需求由t(n)表示,所以方法是O(log n)的。
7.7 困难为题的简单求解方案
汉诺塔问题
Algorithm solveTowers(numberOfDisks, starPole, tempPole, endPole) if (numberOfDisks == 1) 将盘子从startPole移到endPole else{ 将盘子从startPole移到endPole solveTowers(numberOfDisks – 1, tempPole, startPole, endPole) } |
若选择0个盘子代替1个盘子作为基础情形,则可以稍稍简化算法,但是会执行更多次的递归调用。
Algorithm solveTowers(numberOfDisks, starPole, tempPole, endPole) // 版本2 if (numberOfDisks > 0) { 将盘子从startPole移到endPole solveTowers(numberOfDisks – 1, tempPole, startPole, endPole) } |
效率
m(n)表示solveTowers求解n个盘子时必须的移动步数。显然m(1)=1,对于n>1,算法使用两次递归调用,每次调用解决n-1个盘子的问题。每种情形中,所需的移动步数是m(n-1)。所以根据算法有m(n)=m(n-1)+1+m(n-1)=2 x m(n-1)+1,推算m(n)猜测m(n)=2n-1,使用数学归纳法证明为正确。移动步数乘指数增长m(n) = O(2n)。
证明汉诺塔问题的求解不能少于2n-1步
7.8 简单问题的低劣求解方案
斐波那契数列
Algorithm Fibonacci(n) if (n <= 1) return 1 else return Fibonacci(n - 1) + Fibonacci(n - 2) |
递归调用会反复的计算值,Fibonacci(n-1)会再次计算Fibonacci(n-2),又都会计算Fibonacci(n-3),会形成下面的树形结构
算法Fibonacci的时间效率
t(n) 表示计算Fn的算法的时间需求,则有t(n)=1+t(n-1)+t(n-2) n≥2, t(1) = 1, t(0) = 1,这个递推关系很像是斐波那契数自己。对于n>=2,有t(n)>Fn.
程序设计技巧:不要使用在递归调用中重复解决同一问题的递归方案。
7.9 尾递归
当递归方法执行的最后一个动作是递归调用时发生尾递归。countDown就是尾递归
public static void countdown(int integer){ countdown(integer - 1); } // end if } // end countDown |
从尾递归转为迭代比较容易,这些开销主要是涉及内存,而不是时间,如果必须节省空间,就应该考虑用迭代来替代递归。
修改汉诺塔
Algorithm solveTowers(numberOfDisks, startPole, tempPole, endPole) while (numberOfDisks > 0){ 将盘子从startPole移到endPole numberOfDisks— 交换tempPole和startPole的内容 } |
7.10 间接递归
方法A调用方法B,方法B调用方法C,而方法C用调用方法A。这个的递归(称为间接递归)更难理解并跟踪,但在某些应用中自然存在。
例如,下列规则描述了是合法代数表达式的字符串:
1) 一个代数表达式或者是一项,或者是由+或-运算符分开的两项
2) 一项或者是一个因子,或者是由*或/运算符分开的两个因子
3) 一个因子或者是一个变量,或者是一个包含在圆括号内的代数表达式
4) 一个变量是一个单字符
假定方法isExpression、isTerm、isFactor和isVariable分别检测一个字符串是否是表达式、项、因子及变量。
间接递归的一个特殊情况,即方法A调用方法B,而方法B调用方法A,称为相互递归(mutual recursion)。
7.11 使用栈来替代递归
使用迭代替代递归的一个方法是模拟程序栈。事实上,可以使用一个栈替代递归,从而实现递归算法。
将displayArray修改为类内的一个非静态方法,带有一个数组作为数据域:
public void displayArray(int first, int last){ System.out.println(array[first] + “ ”); else{ int mid = first + (last - first) / 2; displayArray(first, mid); displayArray(mid+1, last); } // end if } // end displayArray |
通过使用模拟程序栈的一个栈,将前一段给出的递归方法displayArray替换为迭代版本。创建局部于方法的一个栈。Java程序栈中的活动记录含有方法的实参、它的局部变量和指向当前指令的引用。
为表示一个记录,我们需要定义一个类
private class Record{ private Record(int firstIndex, int lastIndex){ last = lastIndex; } // end constructor } // end Record |
一般地,当该方法开始运行时,它将一个活动记录压入程序栈中。当它返回时,从这个栈中弹出一个记录。让迭代的displayArray来维护自己的栈。当方法开始运行时,它应该将一个记录压入这个栈中。每次递归调用都应该这样做。当栈不空时,该方法应该从栈中删除一个记录,并根据记录的内容来执行。当栈空时该方法结束运行。
private void displayArray(int first, int last){ StackInterface<Record> programStack = new LinkedStack<>(); programStack.push(new Record(first, last)); while(!done && !programStack.isEmpty()){ Record topRecord = programStack.pop(); first = topRecord.first; last = topRecord.last; if(first == last) System.out.println(array[first] + “ ”); else{ int mid = first + (last - first) / 2; // Note the order of the records pushed onto the stack programStack.push(new Record(mid+1, last)); programStack.push(new Record(first, mid)); } // end if } // end while } // end displayArray |
小结
1) 递归是将问题划分为更小的同样问题的求解问题 2) 递归方法的定义必须含有能处理方法的输入(常常是一个参数)的逻辑,并导向不同的情形。其中的一个或多个情形包括方法的递归调用,通过求解“更小”版本的任务,而向基础情形迈进 3) 对方法的每次调用,Java将方法参数和局部变量的值记录在活动记录中。将记录放入栈中,并按时间顺序组织。最近入栈的记录是当前正在运行的方法。这样,Java可以暂停递归方法的执行,并用新的参数值重新执行它 4) 当递归方法处理一个数组时,常常将数组分成几部分。对方法的递归调用将处理数组的每个部分 5) 处理链式节点链的递归方法,需要一个指向链的第一个节点的引用作为参数 6) 用来实现ADT的递归方法常常是私有的,因为它的使用需要对底层数据结构的了解。虽然这样的方法不适合作为ADT的操作,但它能被实现操作的公有方法调用 7) 递推关系用函数自己来表示函数。可以使用递推关系来描述递归方法所做的事情 8) 有n个盘子的汉诺塔问题的求解,至少需要2n-1次移动。这个问题的递归方案清晰,且尽可能地高效。但对于一个O(2n)的算法,只对很小的n值是可用的 9) 斐波那契数列中的每个数(头两个之后)都是前两个数的和。递归地计算斐波那契数是不高效的,因为所需的前面的每个数都被计算了多次 10) 当递归方法的最后一个动作是递归调用时发生尾递归。这个递归调用执行了可用迭代完成的重复部分。将尾递归方法转换为迭代方法,通常是一个简单的过程 11) 当一个方法调用一个方法,后者又调用一个方法,等等,直到又调用第一个方法时,导致间接递归 12) 可以使用栈替代递归来实现递归算法。这个栈模拟了程序栈的行为。 |
程序设计技巧
1) 迭代方法包含一个循环、递归方法调用自己。虽然有些递归方法内含有循环且调用自身,但如果你在递归方法内写一个while语句,确定你不是要写一个if语句 2) 不检查基础情形或者丢掉基础情形的递归方法,不会正常终止。这种情况称为无穷递归 3) 递归调用太多会导致错误信息“stack ovweflow”(栈溢出)。这意味着活动记录的栈已经满了。本质上,方法使用了太多的内存。无穷递归或大规模的问题容易引起这个错误 4) 不要使用在递归调用中重复求解同一问题的递归方案 5) 如果递归方法没有得到想要的结果,则回答下列问题。任何否定的答案都可能帮助你找到错误
|