10.递归算法最佳解析

关注公众号 码哥字节,设置星标获取最新推送。后台回复 “加群” 进入技术交流群获更多技术成长。java

摘要:递归是一种应用很是普遍的算法(或者编程技巧)。以后咱们要讲的不少数据结构和算法的编码实现都要用到递归,好比 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 = 二、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);
}

递归是一种很是高效、简洁的编码技巧。只要是知足“三个条件”的问题就能够经过递归代码来解决。

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

递归代码虽然简洁高效,可是,递归代码也有不少弊端。好比,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,因此,在编写递归代码的时候,必定要控制好这些反作用。

码哥字节

推荐阅读

1.跨越数据结构与算法

2.时间复杂度与空间复杂度

3.最好、最坏、平均、均摊时间复杂度

4.线性表之数组

5.链表导论-心法篇

6.单向链表正确实现方式

7.双向链表正确实现

8.栈实现浏览器的前进后退

9.队列-生产消费模式

原创不易,以为有用但愿随手「在看」「收藏」「转发」三连。

相关文章
相关标签/搜索