数据结构与算法-递归和回溯
第二章递归和回溯
基础引言
- 什么是递归:任何调用自身的函数称为递归。
- 为什么要用递归:一般来说,在编译或解释时,循环会转化为递归函数。当任务能够被相似的子任务定义时,采用递归处理十分有效。例如,排序、搜索和遍历等问题往往有简洁的递归解决方案。
递归函数的格式
理论
递归函数在执行一个任务时,需要调用函数自身来完成一些子任务。在某些时候,函数不需要继续调用函数自身就可以完成当前子任务。函数不再递归的情况称作基本情形(base case,也称为基本情况)。而函数调用自身来执行子任务的情况就称作递归情形(recursive case)
形式描述
举例
递归和内存(可视化)
理论
每次递归调用都在内存中生成一个新的函数副本(实际上仅仅是一些相关的变量)。
一旦函数结束(即返回某些数据),该返回函数的副本就从内存中删除。递归方案看起来简单,但是可视化和跟踪递归过程需要花费时间。为了更好地理解归递过程,考虑下面的例子。
在这个例子中,假设当参数n-4时调用print函数,内存分配的可视化过程如下图所示。
递归与迭代
-
递归
- 当到达基本情形时,递归终止。
- 每次递归调用都需要额外的空间用于栈帧(内存)开销。
- 如果出现无穷递归,程序可能会耗尽内存,并出现栈溢出。
- 某些问题采用递归方法更容易解决。
-
迭代
- 当循环条件为假时,迭代终止。
- 每次迭代不需要任何额外的空间开销。
- 由于没有额外的空间开销,所以若出现死循环,则程序会一直循环执行。
- 采用迭代求解问题可能没有递归解决方案那样显而易见。
递归说明
- 递归算法有两类情形:递归情形和基本情形。
- 每个递归函数必须终止于基本情形。
通常,迭代解决方案比递归解决方案更加有效(因为后者有函数调用的开销)。 - 一个递归算法可以通过使用栈代替递归函数的方式来实现,但通常是得不偿失的。
这意味着任何能用递归求解的问题也能用迭代来求解。 - 对于某些问题,没有明显的迭代求解算法。
- 有些问题非常适合用递归来求解,而有些则不适合。
递归算法的经典用例
- 斐波那契数列、阶乘。归并排序、快速排序。
- 二分查找。
- 树的遍历和许多树问题:中序遍历、前序遍历、后序遍历。
- 图的遍历:深度优先搜索、广度优先搜索。
- 动态规划例子。
- 分治算法。汉诺塔。
- 回溯算法(将在下一节讨论)。
递归相关的问题
问题一:汉诺塔迷题
解析:
汉诺塔是一个数学谜题。
有3根柱子(或木桩、塔)和一些可以在柱子之间来回移动的不同大小的圆盘。
开始时,所有的圆盘按照从小到大的次序自上而下叠放在一根柱子上,形成一个圆锥结构。
现在要求把整叠圆盘移动到另一根柱子上,移动时要遵守下面的规则:
- 每次只能移动一个圆盘。
- 每次移动,只能移动柱子最上面的一个圆盘到另一根柱子(这根柱子上有可能已有圆盘)。
- 任何时候不能出现大圆盘在小圆盘上方的情况。
算法思路:
- 将源柱最上面的n-1个圆盘移到辅助柱。
- 将第n个圆盘从源柱移到目的柱。
- 将辅助柱的n-1个圆盘移到目的柱。
- 源柱最上面的n-1个圆盘移到辅助柱又可以认为是一个新问题,并且可以用同样的方式解决。
- 一旦能解决了只有3个圆盘的汉诺塔问题,那么这个算法可以求解任意数量圆盘的汉诺塔问题。
public static void HRT(int n, char frompeg, char topeg, char auxpeg) {
if (n == 1) {
System.out.println("移动:从" + frompeg + "到" + topeg);
return;
}
//利用C做辅助柱将A上n-1个移动到B柱
HRT(n - 1, frompeg, auxpeg, topeg);
System.out.println("移动:从" + frompeg + "到" + topeg);
//利用A做辅助柱将B上n-1个移动到C柱
HRT(n - 1, auxpeg, topeg, frompeg);
}
问题二:给定一个数组,请用递归方法判定数组中的元素是否是有序的。
public static int isArrayInSortedOrder(int[] a, int index) {
if (index == 1) {
return 1;
}
return a[index - 1] <= a[index - 2] ? 0 : isArrayInSortedOrder(a,index-1);
}
回溯
什么是回溯
- 回溯是一种采用分治策略进行穷举搜索的方法。
- 有时求解一个问题的最好算法是尝试所有的可能性。这种方法通常很慢,但有标准工具能够辅助该过程。
- ·工具:生成基本对象的算法,例如二进制串(n位二进制串有2"种可能性)、排列
(n!)、组合(n!/r!(n-r)!),一般字符串(长度为n的k进制串有k"种可能性),等等。 - 通过剪枝回溯可以加速的穷举搜索。
回溯的经典用例
- 二进制串:产生所有的二进制串。
- 生成k进制串。
- 背包问题。广义字符串。
- 哈密顿回路(参见第9章)。
- 图着色问题。
回溯相关问题
- 问题3:生成所有n位长的字符串。假设A[0..n-1]是一个大小为n的数组。
public static void Binary(int n,int[] a) {
if (n < 1){
for (int aa:a) {
System.out.printf(aa+"");
}
System.out.println();
}else {
a[n-1]=0;
Binary(n-1,a);
a[n-1]=1;
Binary(n-1,a);
}
}
- 问题4 生成长度为n的所有k进制串,串中每位的取值为0..k-1.
public static void kstring(int n,int k,int[] a){
if (n < 1) {
for (int aa : a) {
System.out.printf(aa+"");
}
System.out.println();
}else{
for (int i = 0; i < k; i++) {
a[n-1] = i;
kstring(n-1,k,a);
}
}
}
浙公网安备 33010602011771号