什么是递归
递归是指在方法(函数)定义中调用方法(函数)自身。
下面的例子就是最简单的递归:
public class TestRecursion {
public static void main(String[] args) {
f();
}
static void f() {
f();
}
}
运行上面的代码会出现异常StackOverflowError(堆栈溢出)。这是因为例子中f()方法不断的调用自身,无法停止下来,所以设计递归一定要设计出口,让程序不至于陷入死循环。出口是设计递归的三个原则之一。
递归的设计原则
- 找重复——大问题拆解为小问题
- 找重复中的变化量——参数
- 找参数变化的趋势——出口
初级练习
下面的几道题使用循环就可以很容易就做出来,但是为了熟悉递归,我们刻意使用递归来解题。
1、求n!
要用递归来解这道题,首先要设计递归。按照设计原则,我们需要找到三个关键点:重复、变化和出口。
public class PrimaryRecursion1 {
public static void main(String[] args) {
System.out.println(f(10));
}
/**
* 求n!
* 找重复: 求n!可以分解为求n*(n-1)!,而求(n-1)!又可以分解为求(n-1)*(n-2)!,
* 大问题可以不断的被拆解为小问题
* 找变化: n在不断的减小
* 找出口: n = 1时,n! = 1
*
* @param n
*/
private static int f(int n) {
if (n == 1)
return 1;
return n * f(n - 1);
}
}
2、打印从i到j的数字
思路和上一题相同,也是三步走:
public class PrimaryRecursion2 {
public static void main(String[] args) {
f(0, 10);
}
/**
* 打印从i到j的数字
* 找重复: 该问题可以拆解为打印i并打印从(i+1)到j的数字,而打印从(i+1)到j的数字仍可以拆解为更小的问题
* 找变化: i在不断的增大
* 找出口: i > j
*
* @param i
* @param j
*/
static void f(int i, int j) {
if (i > j)
return;
System.out.println(i);
f(i + 1, j);
}
}
3、对数值数组中元素求和
public class PrimaryRecursion3 {
public static void main(String[] args) {
System.out.println(f(new int[] {1, 2, 3, 4, 5}, 0));
}
/**
* 对数值数组中数据求和
* 找重复: 此问题可以拆解为第一个元素加上后续元素之和,其余元素之和可以拆解为第二个元素加上后续元素之和
* 找变化: 指定元素坐标不断后移
* 找出口: 指定元素坐标不得超过数组长度
*
* @param arr
* @param begin
*/
static int f(int[] arr, int begin) {
if (begin == arr.length - 1)
return arr[begin];
return arr[begin] + f(arr, begin + 1);
}
}
4、翻转字符串
public class PrimaryRecursion4 {
public static void main(String[] args) {
String s = "abcdefg";
System.out.println(f(s, s.length() - 1));
}
/**
* 翻转字符串
* 找重复: 该问题可以拆解为拼接字符串最后一个字符与前面字符串反转结果,
* 同样前面字符串翻转的结果可接续拆解为拼接倒数第二个字符和前面字符串翻转的结果
* 找变化: 指定字符坐标不断前移
* 找出口: 指定字符坐标不得小于0
*
* @param src
* @param end
* @return
*/
static String f(String src, int end) {
if (end == 0)
return "" + src.charAt(end);
return src.charAt(end) + f(src, end - 1);
}
}
总结
这部分的题,我们将大问题拆解所使用的拆解模式,概括出来是这样的:直接量 + 子问题。这是一种单路径的拆解,即每次拆解之后只会出现一个规模更小的子问题。如下图:
进阶练习
这部分的题使用递归解答会比使用其他方法来的更简单。
1、求解斐波那契数列中指定项的值
斐波那契数列指的是这样一个数列1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,377.......这个数列从第3项开始,每一项都等于前两项之和。
public class MiddleRecursion1 {
public static void main(String[] args) {
System.out.println(f(10));
}
/**
* 斐波那契数列求和
* 找重复: f(n)=f(n-1)+f(n-2), 而f(n-1)=f(n-2)+f(n-3),f(n-2)=f(n-3)+f(n-4)
* 找变化: n在不断减小
* 找出口: n = 1或者n = 2
*
* @param n
* @return
*/
static int f(int n) {
if (n == 1 || n == 2)
return 1;
return f(n - 1) + f(n - 2);
}
}
2、求解m和n的最大公约数
这道题的求解使用了辗转相除法。
public class MiddleRecursion2 {
public static void main(String[] args) {
System.out.println(f(6, 5));
}
/**
* 求解m和n的最大公约数
* 找重复: f(m, n) = f(n, m % n)
* 找变化: m % n的值会不断减小,并最终趋向于0
* 找出口: m % n = 0
*
* @param m
* @param n
* @return
*/
static int f(int m, int n) {
if (n == 0)
return m;
return f(n, m % n);
}
}
总结
求解这部分的题我们不能再像初级练习那样中使用直接量 + 子问题来拆解问题,因为这部分的题无法使用这种拆解模式来拆解。所以针对这部分的题我们使用的拆解模式是等价替换,第二题最能体现这种拆解模式。
递归的时间复杂度
以第一题为例,第一题和第二题以及初级练习中所有题所不同的是,第一题使用的是多路径拆解,即每次拆解之后大问题会变成多个规模更小的子问题。多路径拆解所遵循的顺序是先纵后横,先在纵向上将问题全部拆解之后,再横向拆解问题。见下图:
在之前的练习中你可能没有体会出来,因为那些题都是单路径的拆解。但是在这道题中——随着n增大到一个定程度之后(比如45),n每增加1,使用递归求解所花费的时间几乎是倍数级的增加。下面分别使用递归和循坏来求解第一题,比较两种方法所花费的时间:
public class CompareRecursionAndCycle {
public static void main(String[] args) {
long s1 = System.currentTimeMillis();
int f1 = f1(45);
long e1 = System.currentTimeMillis();
long s2 = System.currentTimeMillis();
long f2 = f2(45);
long e2 = System.currentTimeMillis();
System.out.println("f1 result: " + f1 + ", used time: " + (e1 - s1));
System.out.println("f2 result: " + f2 + ", used time: " + (e2 - s2));
}
static int f1(int n) {
if (n == 1 || n == 2)
return 1;
return f1(n - 1) + f1(n - 2);
}
static long f2(int n) {
long first = 1;
long second = 1;
if (n == 1) {
return first;
} else if (n == 2) {
return second;
} else {
long temp;
for(int i = 3; i < n; i++) {
temp = first;
first = second;
second = temp + first;
}
return first + second;
}
}
}
控制台的打印结果如下(不同的电脑在时间的花费上可能存在略微差异):
f1 result: 1134903170, used time: 3505
f2 result: 1134903170, used time: 0
通过结果不难发现,递归的时间复杂度明显要高于循环。所以如果能不使用递归来求解问题的话,最好使用可以替代递归的方案。
高级练习
这部分的题不使用递归来解答会很困难。
汉诺塔
有三根杆子A,B,C。A杆上有N个 (N>1) 穿孔圆盘,盘的尺寸由下到上依次变小。要求按下列规则将所有圆盘移至C杆:
- 每次只能移动一个圆盘
- 大盘不能叠在小盘上面
提示:可将圆盘临时置于B杆,也可将从A杆移出的圆盘重新移回A杆,但都必须遵循上述两条规则。
public class HighRecursion {
public static void main(String[] args) {
f(3, "A", "B", "C");
}
/**
* 汉诺塔: 将盘子1~n(假设盘子大小从1至n依次递增)从A移动到B,C为辅助,保持大盘子始终在下
* 找重复: 1、 将盘子1~n-1从A移动到C,B为辅助
* 2、 将盘子n从A移动到B
* 3、 将盘子1~n-1从C移动到B,A为辅助
*
* @param n 盘子数量
* @param from 盘子所在位置
* @param to 盘子要移动到的位置
* @param help 辅助位置
*/
static void f(int n, String from, String to, String help) {
if (n == 1) {
System.out.println("move " + n + " from " + from + " to " + to );
} else {
f(n-1, from, help, to);
System.out.println("move " + n + " from " + from + " to " + to );
f(n-1, help, to, from);
}
}
}
总结
- 并不是在所有场景都适合使用递归(初级练习)。
- 如果同时存在递归和其他方式都可以解决问题,那么最好使用其他方式,因为递归的时间复杂度可能更高(进阶练习)。
- 在某些特定的时候,递归能发挥更好的效果(高级练习)。