数据结构基础之递归算法
说到递归,相信大家都用过递归,我们今天就通过一个经典的斐波那契数列问题,展开聊下递归算法。
斐波那契数列,1 1 2 3 5 8 13 … , 满足规律是,从第3个数开始,后一个数是前两个数相加,用数学公式来表示第n个数的值的话,那就是 f(n) = f(n-1) + f(n-2) ,条件是 n > 2; 同时 f(1) = 1, f(2) = 1。
如果用代码来实现:
/**
* 斐波拉契数列,求第n个数的值
* 1,1,2,3,5,8,13,21,。。。
*
* 3要素
* 1. 问题拆解公式 f(n) = f(n-1) + f(n-2)
* 2. 子问题计算逻辑 无
* 3. 终止条件 f(2) = f(1) = 1
*
* 这个递归算法的时间复杂度是O(2^n), 这是因为在一个问题在拆解成子问题时,拆解成了2个子问题,
* 那么拆解n次之后,会有2^n个子问题。
*
*/
public static int fabic(int n){
/**
* 普通递归的写法
* 1. 先写终止条件
* 2. 再写计算逻辑
* 3. 调用函数,求解子问题(拆解公式)
*/
if(n <= 2){
return 1;
}
return fabic(n-1) + fabic(n-2 );
}
画个图来分析一下递归的执行流程:
从上图可以看出,递归递归,先递后归,递就是问题拆解成子问题过程,归就是子问题把结果计算出来之后,往回进行回溯。比如你在某窗口排队人太多了,你不知道我排在第几个,那么你就问你前面的人排第几个,但前面的人也不知道排第几,他在往前问,这样一直问道第一个人(或者第一个知道他排第几),第一个人知道他排第一后,就往后回复,后面的人加上1就是自己排第几了,依次往后回复,最后你就会知道排第几了。这种往前问就是递的过程(拆解子问题),完后回复就是归的过程(子问题求解后回溯)。
通过以上的例子,我们可以发现要用递归来解决问题的话,需要满足一定的条件:
- 问题可以拆分为子问题,并且子问题的求解逻辑一样。
- 一定会有递归终止条件,也就是最小的子问题有解。
因此,要写递归的话,最重要的是两个,一个是问题拆解公式 另外一个就是递归终止条件。
现在,请你求一下n!的值, 你应该知道套路了吧。
接下类,我们来分析上面斐波拉契数列的时间复杂度吧,从前面的图我们可以发现,问题往下拆一级,子问题就会拆分为2个,那么f(n) = f(n-1) + f(n-2) = 2*f(n-1) = 4*f(n-2) … 时间复杂度为2的N次方,这是一个极高的时间复杂度了,怎么优化呢?
方案1, 通常来说,通过递归解决的问题,都能改写为通过循环来解决,针对斐波拉契问题,直接使用循环的代码
/**
* 上面的递归算法,对于分解子问题产生分叉(分解成多个子问题)时,会导致时间复杂度成指数级,空间复杂度也是指数级增加。
*
* 那么如何来解决这个问题?
*
* 方式之一, 就是使用循环来代替递归,时间复杂度会降为O(N)
*
*/
public static int fabic2(int n){
if (n<=2) {
return 1;
}
int res = 0; //计算结果
int p_pre = 1; //上一个位置的结果
int p_pre_pre = 1; //上上一个位置的结果
for (int i = 3; i < n; i++) {
res = p_pre + p_pre_pre;
p_pre_pre = p_pre; //上上一个位置的结果 往前移,即上一个位置
p_pre = res; //上一个位置的结果 往前移,即当前结果
}
return res;
//上面这种写法可以将p_pre,p_pre_pre理解为指向前两个结果的指针,随着循环往前滚动。可以画个图理解一下
//另外,可以使用一个数组,来将所有中间结果保存下来,这样循环计算中,取数组前两个的结果来计算也可以。
// int[] resArray = new int[n];
// resArray[0] = 1;
// resArray[1] = 1;
// for (int i = 3; i < n; i++) {
// resArray[i] = resArray[i-1] + resArray[i-2];
// }
// return resArray[n-1];
}
通过循环的话,时间复杂度为O(n)。 你可以两段代码运行1亿次,对比下运行时间。
既然循环都可以解决递归,并且通常还要高效,那么为什么还要写递归代码呢?
通过对比递归和循环的代码,你会发现递归代码更简洁,可读性也更高,这也是我们写递归代码的主要原因。
方案2,通过尾递归来优化。什么叫尾递归?尾递归就是在递归调用的那行代码,只有递归函数的调用,不会包含其他的计算表达式,这样运行时在空间上就可以复用一个方法栈,同时也省去了“归”的过程。 比如下面的尾递归代码:
/**
* 优化2,
* 尾递归,最后一行就是函数调用,不是一个计算表达式,这样就可以直接往下调用,而不用暂存当前方法临时变量。
* 思想就是将当前轮次的计算结果,一并传入下一次计算,不断的递下去,到最后一次计算完成了后,结果也就出来了,不用再有一个回"归"的过程。
* 这样的时间复杂度,通常就是 计算逻辑的复杂度 * 递的次数,通常也是O(N)
*/
public static int fabic3(int n, int res, int pre){
if(n <= 2){
return res;
}
/**
* res,为当前轮次的计算结果
* pre,为上一轮次的计算结果
* 当往前走一次时
* 新的res = 原res + pre
* pre = 原res
*
* res,pre 初始为1, 实际上对应的就是f(2)=f(1)=1
*
* 递归的过程,n没有参与计算,因此实际上n就是控制调用的轮次而已。
*/
int temp = res;
res = res + pre;
pre = temp;
return fabic3(n-1, res, pre);
}
最后,对于斐波拉契问题这个特定的问题,还有一种优化方式,从最前的分析图中,可以发现,我们进行了大量的重复计算,比如在计算f(6)的时候f(4)重复了2次,越往子问题走,重复的次数就越高,如果我们将计算结果缓存起来,不重复计算,就会极大的提高执行效率(其实如果将递归的图看着一棵树,缓存的方法就是在剪枝)。
/**
* 解决方式3
*
* 对于这个问题来说,虽然会分解成2^n个子问题,但是很多的都是重复的子问题,不重复的子问题个数其实是n。
* 那么我们可以使用缓存的方式,来避免重复问题的求解,从而将复杂度降低到O(n)的水平。
*/
static int[] cache;
public static int fabic3(int n){
if(n <= 2){
return 1;
}
//缓存取,取到返回,取不到才计算,结果放入缓存
if(cache[n] > 0){
return cache[n];
}
int res = fabic(n - 1) + fabic(n - 2);
cache[n] = res;
return res;
}
最后,总结一下, 递归确实是一个写代码的神器,可以写出整洁、可读性高的代码,但是使用起来一定要注意,栈溢出和时间复杂度问题。