学习笔记——数据结构与算法.递归

递归是一种非常广泛使用的编程技巧,递归非常的重要,很多的复杂算法的编码实现都通过递归来实现。搞懂递归非常的重要。

示例

假如你想知道到你这代是家族中第几辈了,这个时候你会去找你爸爸,你爸爸也不知道,就会找你爷爷。假如你爷爷知道,告诉了你爸爸,到你爷爷那代是第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) {
 
if (n == 1) {
   
return 8;
 
}

 
return countGeneration(n - 1) + 1;
}

        

递归问题的求解(重点)

         使用递归来求解问题,需要满足3个条件:

  1. 一个问题,可以分解为几个子问题的解。

子问题,就是数据规模更小的问题的解,在求解辈数的问题中,你要知道“自己在第几辈”,可以分解为“上一辈的人在哪一辈”的问题。

 

  1. 这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样。

还是求解辈数的问题。你求解“自己在哪一辈” 的问题与上一辈求解“自己在哪一辈”的思路,完全一样。

  1. 存在递归终止条件。

把问题分解为子问题,再把子问题分解为子子问题,一层一层往下分解。不能存在无限循环。必须要有终止条件。

在求解辈数的问题中,爷爷那辈知道自己在第几辈了,不需要再向上询问自己是第几辈了,知道自己在哪一辈了,即为f(1)=8,这就是递归终止条件。

 

 

实战递归

 

编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式, 找到终止条件,然后翻译代码。不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。不要试图用人脑理解整个递和归,不然就会把自己给绕进去。

 

斐波那契数列

斐波那契数列指的是这样一个数列 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233377610987159725844181676510946177112865746368........

从第三项开始,每项都是前两项的和

 

我们要求的斐波那契数为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) {
 
if (n == 1) {
   
return 1;
 
}
 
if (n == 2) {
   
return 1;
 
}
 
return fibonacci(n - 1) + fibonacci(n - 2);
}

        

统计目录下所有文件大小

  这可是我们经常编程会遇到的一个问题,给定一个目录,统计下此目录下所有文件的大小。

我们如何来求解此问题呢。

目录由目录和文件组成,当我们遇到文件时,进行统计,遇到目录,则继续递归,当我们所有的目录遍历完成后,就统计得到了所有文件的大小。

用公式表示下

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) {

 
long sum = 0;

  if
(file.listFiles().length > 0) {
   
for (File item : file.listFiles()) {
     
if (item.isFile()) {
        sum += item.length()
;
     
} else if (item.isDirectory()) {
        sum += directoryCount(item)
;
     
}
    }
  }

 
return sum;
}

 

 

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) {
 
if (n == 1) {
   
return 1;
 
}
 
return factorial(n - 1) * n;
}

 

 

 

递归存在的问题

递归代码需要警惕堆栈溢出

在实际的软件开发中,编写递归代码会遇到很多的问题,比如堆栈溢出,而堆栈溢出会造成系统性的崩溃,后果会非常严重,来一起看几个会引起堆栈溢出的示例。

 

按默认的栈大小设置,在求解100000个数的斐波那契数列时,就发会生堆栈溢出。

 

为什么会发生栈溢出呢?

函数调用会使用栈来保存临时变量。每调用一次函数,都会将临时封装为栈帧压入内存栈,等函数调用返回才出栈,系统栈或者虚拟机栈的空间一般都不大,如果递归求解的规模很大,调用层次很深,一直压入内存栈,就会有堆栈溢出的风险。

 

如何避免发生栈溢出?

可以通过限制最大递归的深度来解决。如:递归调用深度超过100就不在递归了,直接返回报错。现在来改造下递归代码:


public int fibonacciSetDept(int n) {
 
return fibonacciSetMax(n, 0);
}

public int fibonacciSetMax(int n, int dept) {
  dept++
;
  if
(n == 1) {
   
return 1;
 
}
 
if (n == 2) {
   
return 1;
 
}

 
if (dept > MAX_DEPT_NUM) {
   
throw new IllegalArgumentException(" dept more " + MAX_DEPT_NUM);
 
}

 
return fibonacciSetMax(n - 1, dept) + fibonacciSetMax(n - 2, dept);
}

 

 

 

这种做法虽然解决了堆栈溢出的问题,但却并没有将问题解决,因为最大允许的递归深度与当前线程栈剩余的空间大小有关,事先无法计算。如果要求解的问题,本身递归深度较大,如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<>();

public int
fibonacciAddCache(int n) {

 
if (n == 1) {
   
return 1;
 
}
 
if (n == 2) {
   
return 1;
 
}

 
// 当数据已经存在,则直接返回缓存中的数据
 
if (cacheValue.containsKey(n)) {
   
return cacheValue.get(n);
 
}

 
// 进行计算然后将数据放入表到缓存的散列表中
 
int countValue = fibonacciAddCache(n - 1) + fibonacciAddCache(n - 2);
 
cacheValue.put(n, countValue);

  return
countValue;
}

 

时间空间的复杂度

递归的过程中,会有很多的函数的调用,当这些函数调用数量较大时,会积累成一个可观的时间成本。

在空间的复杂度中,每次函数的调用都会在内存栈中保存一次现场数据,在空间的复杂度分析中,需要考虑此部分额外的开销,就像斐波那契数列的代码,空间复杂度不是O(1),而是O(n)

 

 

递归的利弊

利:递归的表达能力很强。

弊:空间复杂度高、堆栈溢出的风险、重复计算、过多的函数调用耗时较多等问题。

 

 

递归代码改写为非递归的代码

求第几辈的问题

递推公式是: f(n)=f(n-1)+1,f(1)=8

 

改写为非递归的代码。

 

public int countGenerationLoop(int n) {
 
int start = 8;

  if
(n == 1) {
   
return start;
 
}

 
int countGeneration = start;

  for
(int i = 1; i < n; i++) {
    countGeneration = countGeneration +
1;
 
}

 
return countGeneration;
}

 

 

斐波那契数列

递推公式完整的表示就是:

f(n) = f(n-1)+f(n-2) 终止条件: f(1)=1,f(2)=1

 

改写为非递归的代码

public int fibonacciLoop(int n) {
 
if (n == 1) {
   
return 1;
 
}
 
if (n == 2) {
   
return 1;
 
}

 
int s = 1;
  int
t = 1;

  int
v = 0;

  for
(int i = 3; i <= n; i++) {
    v = s + t
;
   
s = t;
   
t = v;
 
}

 
return v;
}

 

 

N的阶乘

首先得到递推公式:

n!=n*(n-1)!

n > 0

改写为非递归的代码。

public int factorialLoop(int n) {
 
if (n == 1) {
    
return 1;
 
}

 
int sum = 1;

  for
(int i = 2; i <= n; i++) {
    sum = sum * i
;
 
}

 
return sum;
}

 

 

是不是所有递归代码都能改写成非递归?

笼统来讲,是可以的,递归本身是借助栈来实现的,我们一般使用的系统的栈或者虚拟机的栈,我们没有感知,如果我们在内存上实现栈,手动模拟入栈与出栈,这样所有的代码都可以使用实现看上去非递归代码的样子。

    这种思路实际上是将递归改为了手动递归,本质没有变。而且也没有解决递归的一些问题,反而增加了复杂度。

 

总结

递归是一种高效、简洁的编码技巧。只要满足3个条件就可以使用递归来解决。

递归三条件:

1.      一个问题,可以分解为几个子问题的解。

2.      这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样。

3.      存在递归终止条件。

 

不过递归代码也比较难写、难理解。编写递归代码的关键是不要把自己绕进去,正确的姿势是写出递推公式、找到终止条件,再翻译成递归代码。

 

递归代码虽然简洁高效,但是也有很多的弊端,如:重复计算、函数调用过多、空间复杂度高等,在编写代码时需要控制好这些副作用。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值