数据结构与算法分析:(八)如何写好递归代码?

n = 2 时,f(2) = f(1) + f(0)。如果递归终止条件只有一个 f(1) = 1,那 f(2) 就无法求解了。所以除了 f(1) = 1 这一个递归终止条件外,还要有 f(0) = 1,表示走 0 个台阶有一种走法,不过这样子看起来就不符合正常的逻辑思维了。所以,我们可以把 f(2) = 2 作为一种终止条件,表示走 2 个台阶,有两种走法,一步走完或者分两步来走。

所以,递归终止条件就是 f(1) = 1,f(2) = 2。这个时候,你可以再拿 n = 3,n = 4 来验证一下,这个终止条件是否足够并且正确。

我们把递归终止条件和刚刚得到的递推公式放到一起就是这样的:

f(1) = 1

f(2) = 2

f(n) = f(n-1) + f(n-2)

有了这个公式,我们转化成递归代码就简单多了。最终的递归代码是这样的:

int f(int n) {

if (n == 1) return 1;

if (n == 2) return 2;

return f(n-1) + f(n-2);

}

编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。

总结一下:写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。

四、警惕堆栈溢出


在实际的软件开发中,编写递归代码时,我们会遇到很多问题,比如堆栈溢出。而堆栈溢出会造成系统性崩溃,后果会非常严重。为什么递归代码容易造成堆栈溢出呢?我们又该如何预防堆栈溢出呢?

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

那么,如何避免出现堆栈溢出呢?

我们可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如 1000)之后,我们就不继续往下再递归了,直接返回报错。还是以前面那个跨台阶为例:

// 全局变量,表示递归的深度。

int depth = 0;

int f(int n) {

++depth;

if (depth > 1000) throw Exception;

if (n == 1) return 1;

if (n == 2) return 2;

return f(n-1) + f(n-2);

}

但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较小,比如 10、50,就可以用这种方法,否则这种方法并不是很实用。

五、警惕重复计算


除此之外,使用递归时还会出现重复计算的问题。还是看上面那个跨台阶的例子,如果我们把整个递归过程分解一下的话,那就是这样的:

在这里插入图片描述

从图中,我们可以直观地看到,想要计算 f(5),需要先计算 f(4) 和 f(3),而计算 f(4) 还需要计算 f(3),因此,f(3) 就被计算了很多次,这就是重复计算问题。

为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。

public int f(int n) {

if (n == 1) return 1;

if (n == 2) return 2;

HashMap<Integer, Integer> map = new HashMap<>();

// 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;

}

除了堆栈溢出、重复计算这两个常见的问题。递归代码还有很多别的问题。

六、怎么将递归代码改写为非递归代码?


递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,我们要根据实际情况来选择是否需要用递归的方式来实现。

那我们是否可以把递归代码改写为非递归代码呢?还是那个跨台阶的例子:

pre 表示 f(n-1) prepre 表示 f(n-2)

int f(int n) {

if (n == 1) return 1;

if (n == 2) return 2;

int res = 0;

int pre = 2;

int prepre = 1;

for (int i = 3; i <= n; ++i) {

res = pre + prepre;

prepre = pre;

pre = res;

}

return res;

}

笼统地讲,所有的递归代码都可以改为这种迭代循环的非递归写法。因为递归本身就是借助栈来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。数据量规模比较大的话,可以采用这种循环迭代,这样就不会因为深度太深而导致堆栈溢出。

七、典型例子


1、斐波那契数列

public class FibonacciSequence {

/**

  • 用递归实现斐波那契数列,适用于求解比较小的位置数值

  • 0 1 1 2 3 5 8 13 21 …

  • @param n

  • @return

*/

public int getFibonacciSequence(int n) {

if (n < 2) return 1;

return getFibonacciSequence(n - 1) + getFibonacciSequence(n - 2);

}

}

2、求阶乘

public class Factorial {

/**

  • 求阶乘

  • n!=n*(n-1)(n-2)…*1

  • @param n

  • @return

*/

public static int getFactorial(int n) {

if (n == 1) {

System.out.print(1 + “=”);

return 1;

} else {

System.out.print(n + “*”);

return getFactorial(n - 1) * n;

}

}

}

3、列出某个目录下所有子目录和文件

public class DirectoryFile {

/**

  • 列出某个目录下所有子目录和文件

  • @param path

  • @throws Exception

*/

public static void getDir(String path) throws Exception {

File file = new File(path);

if (file.isDirectory()) {

System.out.println(“Dir” + file.getPath());

File[] fileArr = file.listFiles();

for (File f : fileArr) {

getDir(f.getPath());

}

} else if (file.isFile()) {

System.out.println(“File” + file.getPath());

} else {

throw new Exception(file.getPath() + “非Dir非File!”);

}

}

}

4、汉诺塔问题

public class HanoiTower {

private final static String from = “柱子A”;

private final static String mid = “柱子B”;

private final static String to = “柱子C”;

/**
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后

小编利用空余时间整理了一份《MySQL性能调优手册》,初衷也很简单,就是希望能够帮助到大家,减轻大家的负担和节省时间。

关于这个,给大家看一份学习大纲(PDF)文件,每一个分支里面会有详细的介绍。

image

这里都是以图片形式展示介绍,如要下载原文件以及更多的性能调优笔记(MySQL+Tomcat+JVM)!
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
s/e5c14a7895254671a72faed303032d36.jpg" alt=“img” style=“zoom: 33%;” />

最后

小编利用空余时间整理了一份《MySQL性能调优手册》,初衷也很简单,就是希望能够帮助到大家,减轻大家的负担和节省时间。

关于这个,给大家看一份学习大纲(PDF)文件,每一个分支里面会有详细的介绍。

[外链图片转存中…(img-kSMNHdSJ-1713713551353)]

这里都是以图片形式展示介绍,如要下载原文件以及更多的性能调优笔记(MySQL+Tomcat+JVM)!
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

  • 17
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值