递归是一种非常广泛使用的编程技巧,递归非常的重要,很多的复杂算法的编码实现都通过递归来实现。搞懂递归非常的重要。
示例
假如你想知道到你这代是家族中第几辈了,这个时候你会去找你爸爸,你爸爸也不知道,就会找你爷爷。假如你爷爷知道,告诉了你爸爸,到你爷爷那代是第8辈了,然后你爷爷就会告诉你爸爸,到你爸爸那代是第9辈了,然后你的爸爸告诉你,到你这里已经是第10辈了。
这就是一个递归的问题,去的过程叫“递”,回来的过程叫“归”。
基本上所有递归问题都可以用递推推公式来表示:
f(n)=f(n-1)+1,f(1)=8
f(n)表示你自己想知道自己在第几辈了,f(n-1)表示你上一辈所在的辈数,f(1)表示你爷爷知道自己是第8辈了。
那将递推公式翻译成代码就是:
public int countGeneration(int n) { |
递归问题的求解(重点)
使用递归来求解问题,需要满足3个条件:
- 一个问题,可以分解为几个子问题的解。
子问题,就是数据规模更小的问题的解,在求解辈数的问题中,你要知道“自己在第几辈”,可以分解为“上一辈的人在哪一辈”的问题。
- 这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样。
还是求解辈数的问题。你求解“自己在哪一辈” 的问题与上一辈求解“自己在哪一辈”的思路,完全一样。
- 存在递归终止条件。
把问题分解为子问题,再把子问题分解为子子问题,一层一层往下分解。不能存在无限循环。必须要有终止条件。
在求解辈数的问题中,爷爷那辈知道自己在第几辈了,不需要再向上询问自己是第几辈了,知道自己在哪一辈了,即为f(1)=8,这就是递归终止条件。
实战递归
编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式, 找到终止条件,然后翻译代码。不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。不要试图用人脑理解整个递和归,不然就会把自己给绕进去。
斐波那契数列
斐波那契数列指的是这样一个数列 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,377,610,987,1597,2584,4181,6765,10946,17711,28657,46368........
从第三项开始,每项都是前两项的和
我们要求的斐波那契数为n,每一个数都是由前两个之和组成。那如何表示前一个数,当然n-1, 那前两个数之和表示就是(n-1)+(n-2)。
是否满足一个问题可以分解为子问题的解呢?
我们需要求解当前第n个数,我们将这个问题分解下,它是由(n-1)加上(n-2)组成的值,n(n-1)这个数列也同样满足由前两项之和,这也同时满足:除数据规模不一样,求解思路一致的要求。
用公式表示出来就是: f(n) = f(n-1)+f(n-2)
那递归终止的条件又是什么呢?
从第三项开始,第项都是前两个数之和,那也就是说f(1)=1,f(2)=1
递推公式完整的表示就是:
f(n) = f(n-1)+f(n-2) 终止条件: f(1)=1,f(2)=1
将递推公式翻译成代码:
public int fibonacci(int n) { |
统计目录下所有文件大小
这可是我们经常编程会遇到的一个问题,给定一个目录,统计下此目录下所有文件的大小。
我们如何来求解此问题呢。
目录由目录和文件组成,当我们遇到文件时,进行统计,遇到目录,则继续递归,当我们所有的目录遍历完成后,就统计得到了所有文件的大小。
用公式表示下
f(n)=f(n-1)+count(d) 其中f(n)表示需要求解的目录文件大小,f(n-1)表示子目录的求解,count(d)表示当前目录下所有文件大小。
那递归的终止条件是什么呢?
一个目录下只有文件,没有目录,那这个子目录的统计就结束了。
完整的表示就是:
f(n)=f(n-1)+count(d) 终止条件:f(1)=0,即不存在子目录或者子目录为0
将递推公式翻译成代码:
public long directoryCount(File file) { |
N的阶乘
解释:
一个正整数的阶乘(factorial)是所有小于及等于该数的正整数的积,并且0的阶乘为1。自然数n的阶乘写作n!
阶乘函数(符号:!)的意思是把逐一减小的自然数序列相乘。例如:
4! = 4 × 3 × 2 × 1 = 24
7! = 7 × 6 × 5 × 4 × 3 × 2 × 1 = 5040
1! = 1
n | n! |
| 结果 |
1 | 1 | 1 | 1 |
2 | 2*1 | 2*1! | 2 |
3 | 3*2*1 | 3*2! | 6 |
4 | 4*3*2*1 | 4*3! | 24 |
5 | 5*4*3*2*1 | 5*4! | 120 |
…… | …… | …… | …… |
通过此得到一个阶乘的规律:
n!=n*(n-1)!
n > 0
将递推公式转化为代码:
public int factorial(int n) { |
递归存在的问题
递归代码需要警惕堆栈溢出
在实际的软件开发中,编写递归代码会遇到很多的问题,比如堆栈溢出,而堆栈溢出会造成系统性的崩溃,后果会非常严重,来一起看几个会引起堆栈溢出的示例。
按默认的栈大小设置,在求解100000个数的斐波那契数列时,就发会生堆栈溢出。
为什么会发生栈溢出呢?
函数调用会使用栈来保存临时变量。每调用一次函数,都会将临时封装为栈帧压入内存栈,等函数调用返回才出栈,系统栈或者虚拟机栈的空间一般都不大,如果递归求解的规模很大,调用层次很深,一直压入内存栈,就会有堆栈溢出的风险。
如何避免发生栈溢出?
可以通过限制最大递归的深度来解决。如:递归调用深度超过100就不在递归了,直接返回报错。现在来改造下递归代码:
|
这种做法虽然解决了堆栈溢出的问题,但却并没有将问题解决,因为最大允许的递归深度与当前线程栈剩余的空间大小有关,事先无法计算。如果要求解的问题,本身递归深度较大,如10,50,100,这类,这种方法比较合适,否则此方法并不适用于此场景。
递归的重复计算的问题
还是以斐波那契数列为例吧:
递推公式:
f(n) = f(n-1)+f(n-2) 终止条件: f(1)=1,f(2)=1
从图中可以看出,在求解f(6)时,会计算f(5)+f(4),而f(5)需要计算f(4)+f(3)就这样f(4)被计算了多次,而f(3)也被计算了很多次。这就是数据重复计算的问题。
如何解决重复计算的问题?
可以使用一个散列表保存下已经求解过的数据。当有数据需要计算时,优先从散列表中去检查是否已经被计算过,如果已经计算过,则直接获取结果。没有,则需要进行一次计算,然后将结果放入到散列表中。
针对斐波那契数据的求解,来进行一下改造:
Map<Integer, Integer> cacheValue = new HashMap<>(); |
时间空间的复杂度
递归的过程中,会有很多的函数的调用,当这些函数调用数量较大时,会积累成一个可观的时间成本。
在空间的复杂度中,每次函数的调用都会在内存栈中保存一次现场数据,在空间的复杂度分析中,需要考虑此部分额外的开销,就像斐波那契数列的代码,空间复杂度不是O(1),而是O(n)
递归的利弊
利:递归的表达能力很强。
弊:空间复杂度高、堆栈溢出的风险、重复计算、过多的函数调用耗时较多等问题。
递归代码改写为非递归的代码
求第几辈的问题
递推公式是: f(n)=f(n-1)+1,f(1)=8
改写为非递归的代码。
public int countGenerationLoop(int n) { |
斐波那契数列
递推公式完整的表示就是:
f(n) = f(n-1)+f(n-2) 终止条件: f(1)=1,f(2)=1
改写为非递归的代码
public int fibonacciLoop(int n) { |
N的阶乘
首先得到递推公式:
n!=n*(n-1)!
n > 0
改写为非递归的代码。
public int factorialLoop(int n) { |
是不是所有递归代码都能改写成非递归?
笼统来讲,是可以的,递归本身是借助栈来实现的,我们一般使用的系统的栈或者虚拟机的栈,我们没有感知,如果我们在内存上实现栈,手动模拟入栈与出栈,这样所有的代码都可以使用实现看上去非递归代码的样子。
这种思路实际上是将递归改为了手动递归,本质没有变。而且也没有解决递归的一些问题,反而增加了复杂度。
总结
递归是一种高效、简洁的编码技巧。只要满足3个条件就可以使用递归来解决。
递归三条件:
1. 一个问题,可以分解为几个子问题的解。
2. 这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样。
3. 存在递归终止条件。
不过递归代码也比较难写、难理解。编写递归代码的关键是不要把自己绕进去,正确的姿势是写出递推公式、找到终止条件,再翻译成递归代码。
递归代码虽然简洁高效,但是也有很多的弊端,如:重复计算、函数调用过多、空间复杂度高等,在编写代码时需要控制好这些副作用。