递归最佳解析

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 递归最佳解析


摘要:递归是一种应用非常广泛的算法(或者编程技巧)。之后我们要讲的很多数据结构和算法的编码实现都要用到递归,比如 DFS 深度优先搜索、前中后序二叉树遍历等等。所以,搞懂递归非常重要,否则,后面复杂一些的数据结构和算法学起来就会比较吃力

推荐用户注册领取佣金很多人都遇到过,很多 App 在推广的时候都是这个套路。「萧何」引荐「韩信」加入刘邦阵营,「韩信」又引荐了那些年上铺的兄弟「韩大胆」加入。我们就可以认为「韩大胆」的最终推荐人是「萧何」,「韩信」的最终推荐人是「萧何」,而「萧何」没有最终推荐人。

用数据库记录他们之间的关系,soldier_id 表示士兵 id,referrer_id 表示推荐人 id。

soldier_id reference_id
韩信 萧何
韩大胆 韩信

那么问题来了,给定一个士兵 id,如何查找这个用户的「最终推荐人」,带着这个问题,我们正式进入递归。

递归三要素

有两个最难理解的知识点,一个是 动态规划一个是递归

大学军训,都会经历过排队报数,报数过程中自己开小差看见了一个漂亮小学姐,不知道旁边的哥们刚说的数字,所以再问一下左边哥们刚报了多少,只要在他说的数字 + 1 就知道自己树第几个了,关键是现在你旁边的哥们 看见漂亮小学姐竟然忘记刚刚自己说的数字了,也要继续问他左边的老铁,就这样一直往前问,直到第一个报数的孩子,然后一层层把数字传递到自己。

这就是一个非常标准的递归求解过程,问的过程叫「递」,回来的过程叫「归」。转换成递推公式:

f(n)=f(n-1) + 1, 存在 f(1) = 1

f(n) 表示自己的数字,f(n - 1) 表示前面一个人的报数,f(1) 表示第一个人知道自己是第一个报的数字

根据递推公式,很容易的转换成递归代码:

public int f(int n) {
  if (n == 1) return 1;
  return f(n-1) + 1;
}

到底什么问题可以用递归解决呢?总结了三个必要元素,只要满足以下下三个条件,就可以使用递归解决。

1.一个问题可以分解多个子问题

就是可以分解为数字规模更小的问题,比如要知道自己的报数,可以分解『前一个人的报数』这样的子问题。

2.问题本身与分解后的子问题,除了数据规模不同,求解算法相同

『求解自己的报数』和前面一个人『求解自己的报数』思路是一模一样。

3.存在递归终止条件

问题分解成子问题的过程中,不能出现无限循环,所以需要一个终止条件,就像第一排或者其中任何一个知道自己报数的孩子不需要再询问上一个人的数字,f(1) = 1 就是递归终止条件。

如何编写递归代码

其实最关键的就是 写出递推公式,找到终止条件,然后把递推公式转成 代码就容易多了。

再举一个「青蛙跳台阶」的算法问题,假设有 n 个台阶,每次可以跳 1 个或者 2 个台阶,走这 n 个台阶有多少种走法?

再仔细想想,实际上,根据第一步的走法可以把所有的走法分两类,第一类是第一步走了 1 个台阶,另一种是第一步走了 2 个台阶。所以 n 个台阶的走法就等于先走 1 阶后, n-1 个台阶的走法 + 先走 2 阶后, n-2 个台阶的走法。

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

继续分析终止条件,当只有一个台阶的时候不需要再继续递归,f (1) = 1。似乎还不够,假如有两个台阶呢?分别用 n = 2、n=3 验证下。f(2) = 2 也是终止条件之一。

所以该递归的终止条件就是 f(1) = 1,f(2) = 2。

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

根据公式转成代码则是

public int f(n) {
  if(n == 1) return 1;
  if(n ==2) return 2;
  return f(n-1) + f(n-2);
}

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

对于递归代码,我们不要试图去弄清楚整个递和归的问题,这个不适合我们的正常思维,我们大脑更适合平铺直叙的思维,当看到递归切勿妄想把递归过程平铺展开,否则会陷入一层一层往下调用的循环。

当遇到一个问题 1 可以分解若干个 2,3,4 问题,我们只要假设 2,3,4 已经解决,在此基础上思考如何解决 A。这样就容易多了。

所以当遇到递归,编写 代码的关键就是 把问题抽象成一个递推公式,不要想一层层的调用关系,找到终止条件。

防止栈溢出

递归最大的问题就是要防止栈溢出以及死循环。为何递归容易造成栈溢出呢?我们回想下之前说过的栈数据结构,不清楚的朋友可以翻阅历史文章。函数调用会使用栈来保存临时变量,每次调用一个函数都会把临时变量封装成栈帧压入线程对应的栈中,等方法结束返回时,才出栈。如果递归的数据规模比较大,调用层次很深就会导致一直压入栈,而栈的大小通常不会很大就会导致堆栈溢出的情况。

Exception in thread "main" java.lang.StackOverflowError

如何防止呢?

我们只能在代码里面限制最大深度,直接返回错误,使用一个全局变量表示递归的深度,每次执行都 + 1,当超过指定阈值还没有结束的时候直接返回错误。

警惕重复计算

青蛙跳台阶的问题就有重复计算的问题,我们试着把递归过程分解下,想要计算 f(5),需要先计算 f(4) 和 f(3),而计算 f(4) 还需要计算 f(3),因此,f(3) 就被计算了很多次,这就是重复计算问题。为了避免重复计算,我们可以通过一个数据结构(比如 HashMap)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。

public int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  // hasSolvedList 可以理解成一个 Map,key 是 n,value 是 f(n)
  if (hasSolvedMap.containsKey(n)) {
    return hasSovledMap.get(n);
  }
  int ret = f(n-1) + f(n-2);
  hasSovledMap.put(n, ret);
  return ret;
}

递归的空间复杂度因为每次调用都会在栈上保存一次临时变量,所以它的空间复杂度就是 O(N),而不是 O(1)。

如何将递归转换成非递归代码

递归有利有弊,递归写起来很简洁,而不好的地方就是空间复杂度是 O(n),有堆栈溢出风险,存在重复计算。要根具体情况来选择是否需要递归。

还是军训排队报数的例子,如何变成非递归。

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

public int f(n) {
  int r = 1;
  for(int i = 2; i <= n; i++) {
    r += 1;
  }
  return r;
}

对于台阶问题也是可以改成循环实现。

public int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  int ret = 0;
  int pre = 2;
  int prepre = 1;
  for (int i = 3; i <= n; ++i) {
    ret = pre + prepre;
    prepre = pre;
    pre = ret;
  }
  return ret;
}

寻找最佳推荐人

现在递归说完了,我们如何解答开篇的问题:根据士兵 id 找到最佳推荐人?

public int findRootReferId(int soldierId) {
  Integer referId = "select reference_id from [table] where soldier_id = soldierId";
  if (referId == null) return soldierId;
  return findRootReferId(referId);
}

递归是一种非常高效、简洁的编码技巧。只要是满足“三个条件”的问题就可以通过递归代码来解决。

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

递归代码虽然简洁高效,但是,递归代码也有很多弊端。比如,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,所以,在编写递归代码的时候,一定要控制好这些副作用。


相关文章
|
19天前
|
前端开发 JavaScript 开发者
揭秘前端高手的秘密武器:深度解析递归组件与动态组件的奥妙,让你代码效率翻倍!
【10月更文挑战第23天】在Web开发中,组件化已成为主流。本文深入探讨了递归组件与动态组件的概念、应用及实现方式。递归组件通过在组件内部调用自身,适用于处理层级结构数据,如菜单和树形控件。动态组件则根据数据变化动态切换组件显示,适用于不同业务逻辑下的组件展示。通过示例,展示了这两种组件的实现方法及其在实际开发中的应用价值。
27 1
|
3月前
|
缓存 JavaScript 前端开发
|
6月前
|
域名解析 网络协议 安全
【域名解析DNS专栏】DNS递归查询与迭代查询的区别及影响
【5月更文挑战第24天】DNS的递归查询与迭代查询是域名解析的两种方式。递归查询由客户端发起,DNS服务器负责全程解析,速度快但可能增加服务器负载和安全风险。迭代查询则需客户端参与多次查询,虽慢但分散负载,提高安全性。理解两者差异有助于优化网站访问体验和安全性。
908 0
【域名解析DNS专栏】DNS递归查询与迭代查询的区别及影响
|
5月前
|
SQL 算法 数据可视化
LeetCode题目92:反转链表ll 【python 递归与迭代方法全解析】
LeetCode题目92:反转链表ll 【python 递归与迭代方法全解析】
|
6月前
|
机器学习/深度学习 C语言
函数递归深入解析(C语言)
函数递归深入解析(C语言)
|
6月前
|
算法 Java
Java必刷入门递归题×5(内附详细递归解析图)
Java必刷入门递归题×5(内附详细递归解析图)
92 1
|
6月前
迭代归并:归并排序非递归实现解析
迭代归并:归并排序非递归实现解析
42 0
|
6月前
|
算法
递归算法:二叉树前序、中序、后序遍历解析与递归思想深度剖析
递归算法:二叉树前序、中序、后序遍历解析与递归思想深度剖析
94 0
|
6月前
|
网络协议 网络安全 数据安全/隐私保护
【应用层】P2P、DNS、递归查询、迭代查询、FTP、电子邮件系统、万维网和HTTP
【应用层】P2P、DNS、递归查询、迭代查询、FTP、电子邮件系统、万维网和HTTP
113 0
|
3天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
14 2

推荐镜像

更多