使用递归需要满足的条件
1、大问题可以分解成多个规模较小的子问题。
2、这些子问题的求解思路与原问题一致。
3、子问题的分解不能无限循环下去,存在终止条件。
为此写递归代码的思路为:根据问题的分解过程,找到问题的递归公式与终止条件,将公式与终止条件翻译成代码。
比如有n个台阶,每次下台阶有2种走法:一次一个台阶或者一次两个台阶,那么总共有多少种走法。
第一步的走法,可以先走一个台阶或者先走两个台阶。n个台阶的走法(记为f(n))其中为:第一次走一个台阶后剩余n-1个台阶的走法(记为f(n-1))+第一次走两个台阶后剩余n-2个台阶的走法(记为f(n-2))+第n个台阶的的2个走法。其递归公式为:
f(n) = f(n-1)+f(n-2)+2
那么终止条件是多少呢?由于台阶的个数一定是大于0的,为此终止条件为
f(1) = 1
f(2) = 2
将它们翻译成代码就是
int f(n)
{
if(n==1)
{
return 1;
}
if(n==2)
{
return 2;
}
return f(n-1)+f(n-2)+2;
}
代码是不是非常简洁。
递归代码思维误区
很多人写递归代码的时候,会想方设法的理解一层一层的调用与返回过程,这样一层层的分解下去,当问题的规模较大时,往往很容易把自己绕进去。
假如一个问题A,可以分解成B、C、D三个子问题,那么正确的做法应该是:假设问题B、C与D都已经解决,然后在B、C与D的解决结果里面推导出A的问题的解(即递归公式),忽略掉计算机执行递归的过程。
递归导致的问题
1、堆栈溢出,我们知道函数调用过程中使用到的临时变量,直到退出其作用域了才会释放。那么如果递归函数的调用很深,那么栈空间将很快耗尽,从而导致堆栈溢出。
我们一般可以通过限制递归的调用深度,来解决。但是递归代码开始运行时,我们往往不清楚剩余的堆栈大小,为此限制调用的深度比较小的时候才有用。
2、重复计算问题,重复计算我们一般通过保存结果值来解决,递归调用时先判断一下是否有求解过,如果已经求解过,则返回保存的值,否则继续递归。对于上面的例子,假如n为5,那么第一次递归后问题分解为f(4)、f(3);第二次分解为f(3)、f(2)、f(2)、f(1)。存在多个f(2)与f(3),由于f(2)是递归的终止条件,不需要保存结果,但是可以把第一次调用f(3)的结果保存到一个数组中,下次调用的时候直接返回保存的结果。新的代码如下
int result[n];
int f(n)
{
if(n==1)
{
return 1;
}
if(n==2)
{
return 2;
}
if(result[n] != 0)
{
return result[n];
}
return f(n-1)+f(n-2)+2;
}
3、递归代码执行效率往往比非递归的低,特别是如果递归深度比较高的话,因为函数调用涉及到函数调用栈等的调整,还涉及到临时变量的入栈出栈。
时间复杂度分析
我们知道递归其实是通过将大问题一层一层的分解为规模更小的子问题,这样一层层的分解下去,直到问题的规模足够小到可以直接求解(这个过程称为递),然后将小问题的求解一步步合并,最终得到整个问题的解(这个过程称为归)。如果我们把这个递的过程画成一个图,那么就是一颗树,我们称之为递归树。
对于如上的走楼梯的例子,其递归树如下:
从递归树可以看出,树的每一层的节点个数都是上一个节点的2倍。如果每次分解操作都减1,那么就是沿着树的最右边进行分解,树的高度就是n。如果每次分解操作都减2,那么就是沿着树的最左边进行分解,树的高度就是n/2。为此根节点到叶子节点的高度就是介于[n/2,n]之间。而每次分解与合并操作只是固定几个加与减法操作,时间复杂度为O(1)。为此总的时间复杂度就是节点的个数,而节点的总个数是1+2+4+8+…+2的n/2次方+…>2的n/2次方。因此总的时间复杂度就是一个指数级的时间复杂度。