递归
什么是递归?首先递归是一种广泛的算法或者编程技巧,很多数据结构和算法的实现都依赖递归,比如DFS(深度优先遍历),二叉树的前中后序遍历等等。
示例:
举个简单的例子,比如排队,你排在第n个位置,具体n是多少,你不知道,所以你就问你前面一个人:“你是第几个人?”然而你前面的人也不知道他是第几个人,所以他就问他前面的人是第几个人,这样一直向前问,知道问到第一个人,然后第二个人知道自己是第二然后告诉第三个人,然后第三个人知道自己是第三,然后一直向后面这样反馈,到你这里,你也就知道自己是第几个人了,把这个过程抽象成一个算法就是递归!去的过程叫做“递”,回来的过程叫做“归”,而这个递归函数就可以抽象成f(n)=f(n-1)+1,其中,f(1)=1。
递归满足条件:
通过上面那个例子,我们可以理解递归到底是什么,那么怎么分析一个问题能否用递归来解决呢?
1.一个问题可以分解成几个子问题。
就比如上面的例子中,想知道第n个位置是多少,可以分解为n的前面一个(n-1)是多少这样一个子问题。
2.这个问题与分解后的子问题,除了数据规模不同,求解思路一样
就是说我知道了(n-1)位置是多少,我就知道了n的位置是多少。
3.存在递归终止条件
上面的问题中,我们不可能一直问前面一个人,总会到底一个人的时候,那么当到了第一个人的时候,即f(1)=1的时候,就是递归的终止条件。
练习:
我们在通过一个简单的例子来理解递归,比如n个台阶,一次只能上1步或者2步,问:有多少种走法?这是一道经典的递归算法题。
思路解析:首先我们第一步就有两种解法,一个是1步1个台阶,第二个是1步2个台阶,当我们走完第一步的时候,那么我们第二步有几种走法呢?还是两种,一步一个台阶和一步两个台阶,所以我们能推导出f(n)=f(n-1)+f(n-2);
为什么是f(n-1)和f(n-2)?你一步走了1个台阶还剩下(n-1)个,同理(n-2)!公式我们推导出来了,子问题也分析完了,那么终止条件呢?如果最后只剩下一个台阶我们可以一步走完,如果剩下两个台阶也可以一步走完,也可以分两步走完,如果剩下三个台阶就是f(3)= f(2)+f(1),所以我们定义终止条件是f(2)=2,f(1)=1。所以我们把公式和终止条件放到一起就是:
f(2)=2;
f(1)=1;
f(n)=f(n-1)+f(n-2);
通过这个公式,我们的最终代码就是:
int f(n){
if(n == 1) return 1;
if(n == 2) return 2;
return f(n-1) + f(n-2);
}
注意:
我们平时思考问题的时候总是喜欢,按照线性思考,就是一条流水线似的,思考问题,但是面对递归问题,如果也这样思考,想把递归问题平铺展开,脑子就会一层一层循环的思考下一层怎么调用,这样的思维方式是错误的,很容易被绕进去,所以在思考递归问题的时候,我们只需要把递归抽象成一个递归公式,不用想一层层的调用关系,不用去分解递归的每个步骤。
递归产生问题:
递归代码可能导致堆栈溢出,因为函数调用会使用栈来保存临时变量,每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般内存都不大。如果递归函数的求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。
递归代码要注意重复计算,这个问题我们用刚才那个上台阶的例子解释,如图:
从图中我们可以看到计算f(5)需要计算f(4)和f(3),但是f(4)和f(3)已经被计算过了,所以会存在一个函数会存在多次重复计算,为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经进求过解的值,比如f(k)计算过后,存到散列表中,下次在遇到f(k)直接从散列表中取值就行,不需要再计算了,所以上面的代码可以优化为:
int f(n){
if(n == 1) return 1;
if(n == 2) return 2;
//利用一个map数组存储key和value,key是n,value是f(n)的结果
if( map.containsKey(n)){
return map.get(n);
}
int res= f(n-1) + f(n-2);
map.put(n,res);
return res;
}
时间复杂度O(n),空间复杂度O(n);
递归代码改为非递归代码实现
递归代码好处:表达能力强,写起来简洁
递归代码弊端:空间、时间复杂度高,有堆栈溢出的风险,存在重复计算,过多的函数调用比较耗时。
我们先把前面示例中的f(n)=f(n-1) +1代码改写:
//递归写法
public int f1(int n){
if(n == 1){
return 1;
}
return f1(n-1)+1;
}
//非递归写法
public int f1(int n){
int ret = 1;
for(int i=2;i<=n;i++){
ret= ret+1;
}
return ret;
}
同样第二个练习题同样改写为:
public int f3(int n){
if (n == 1) return 1;
if (n == 2) return 2;
int ret = 0;
int pre = 1;
int next = 2;
for(int i=3;i<=n;i++){
ret = pre + next;
pre=next;
next=ret;
}
return ret;
}
这里读者直接看代码,可能会一脸懵逼,所以我给解释一下:
ret用来保存最终的返回值。
pre用来定义每次计算的前一个值。
next表示用来计算的后一个值。
我们来用上台阶的那张图充分解释一下这几个值的含义:比如当我们要计算f(3)的结果,那么从图中可以看,我们需要知道f(1)和f(2)的值,f(1)和f(2)的值我们都已经知道了,是1和2,那么f(3)就是3,好了我们现在知道了f(3)的值了,那么我们现在计算f(4)的值怎么算呢?从图中我们可以看出就需要知道f(2)和f(3)的值,f(3)我们已经知道了,那么是不是f(4)也就知道了,然后我们在计算f(5)的值,是不是需要知道f(3)和f(4)的值,f(3)和f(4)我们也都知道了,那么f(5)的值我们也知道了,同理f(6)、f(7)....f(n)都知道了,那么映射到代码怎么实现呢?
代码实现:通过定义终态值pre=1和next=2代表f(1)和f(2)的值,然后我们从i遍历到n,第一次遍历我们能得到f(3)的值,就是ret=pre+next,此时的ret就是f(3),那么接下来我们要求解f(4),需要f(2)就是next和f(3)就是ret,所以我们按照上台阶的图中的顺序,将pre = next,将next= ret,然后开始求解i=4,即f(4)的值,f(4)需要f(2)和f(3),那么此时pre就代表f(2),next代表f(3),刚才的(将pre = next,将next= ret)也就是实现这个语句的意思。然后重复pre = next,将next= ret,继续i=5,直到i=n,就可以求解了。