热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

可能存在无限递归_递归算法看这一篇就够了|多图

前言递归是一种非常重要的算法思想,无论你是前端开发,还是后端开发,都需要掌握它。在日常工作中,统计文件夹大小,

前言

递归是一种非常重要的算法思想,无论你是前端开发,还是后端开发,都需要掌握它。在日常工作中,统计文件夹大小,解析xml文件等等,都需要用到递归算法。它太基础太重要了,这也是为什么面试的时候,面试官经常让我们手写递归算法。本文呢,将跟大家一起学习递归算法~

  • 什么是递归?
  • 递归的特点
  • 递归与栈的关系
  • 递归应用场景
  • 递归解题思路
  • leetcode案例分析
  • 递归可能存在的问题以及解决方案

什么是递归?

递归,在计算机科学中是指一种通过重复将问题分解为同类的子问题而解决问题的方法。简单来说,递归表现为函数调用函数本身。在知乎看到一个比喻递归的例子,个人觉得非常形象,大家看一下:

递归最恰当的比喻,就是查词典。我们使用的词典,本身就是递归,为了解释一个词,需要使用更多的词。当你查一个词,发现这个词的解释中某个词仍然不懂,于是你开始查这第二个词,可惜,第二个词里仍然有不懂的词,于是查第三个词,这样查下去,直到有一个词的解释是你完全能看懂的,那么递归走到了尽头,然后你开始后退,逐个明白之前查过的每一个词,最终,你明白了最开始那个词的意思。

来试试水,看一个递归的代码例子吧,如下:

public int sum(int n) {
    if (n <&#61; 1) {
        return 1;
    } 
    return sum(n - 1) &#43; n; 
}

递归的特点

实际上&#xff0c;递归有两个显著的特征,终止条件和自身调用:

  • 自身调用&#xff1a;原问题可以分解为子问题&#xff0c;子问题和原问题的求解方法是一致的&#xff0c;即都是调用自身的同一个函数。
  • 终止条件&#xff1a;递归必须有一个终止的条件&#xff0c;即不能无限循环地调用本身。

结合以上demo代码例子&#xff0c;看下递归的特点&#xff1a;

2ad3cd31b32e02e81f71af41f1892b1f.png

递归与栈的关系

其实&#xff0c;递归的过程&#xff0c;可以理解为出入栈的过程的&#xff0c;这个比喻呢&#xff0c;只是为了方便读者朋友更好理解递归哈。以上代码例子计算sum(n&#61;3)的出入栈图如下&#xff1a;6eb49a70986d71621bf75cae0f0cc47b.png

为了更容易理解一些&#xff0c;我们来看一下 函数sum(n&#61;5)的递归执行过程&#xff0c;如下&#xff1a;fd1b17edc74a138ed03436f89188a125.png

  • 计算sum(5)时&#xff0c;先sum(5)入栈&#xff0c;然后原问题sum(5)拆分为子问题sum(4)&#xff0c;再入栈&#xff0c;直到终止条件sum(n&#61;1)&#61;1&#xff0c;就开始出栈。
  • sum(1)出栈后&#xff0c;sum(2)开始出栈&#xff0c;接着sum(3)。
  • 最后呢,sum(1)就是后进先出&#xff0c;sum(5)是先进后出&#xff0c;因此递归过程可以理解为栈出入过程啦~

递归的经典应用场景

哪些问题我们可以考虑使用递归来解决呢&#xff1f;即递归的应用场景一般有哪些呢&#xff1f;

  • 阶乘问题
  • 二叉树深度
  • 汉诺塔问题
  • 斐波那契数列
  • 快速排序、归并排序(分治算法体现递归)
  • 遍历文件&#xff0c;解析xml文件

递归解题思路

解决递归问题一般就三步曲&#xff0c;分别是&#xff1a;

  • 第一步&#xff0c;定义函数功能
  • 第二步&#xff0c;寻找递归终止条件
  • 第二步&#xff0c;递推函数的等价关系式

这个递归解题三板斧理解起来有点抽象&#xff0c;我们拿阶乘递归例子来喵喵吧~

1.定义函数功能

定义函数功能&#xff0c;就是说&#xff0c;你这个函数是干嘛的&#xff0c;做什么事情&#xff0c;换句话说&#xff0c;你要知道递归原问题是什么呀&#xff1f;比如你需要解决阶乘问题&#xff0c;定义的函数功能就是n的阶乘&#xff0c;如下&#xff1a;

//n的阶乘(n为大于0的自然数)
int factorial (int n){

}

2.寻找递归终止条件

递归的一个典型特征就是必须有一个终止的条件&#xff0c;即不能无限循环地调用本身。所以&#xff0c;用递归思路去解决问题的时候&#xff0c;就需要寻找递归终止条件是什么。比如阶乘问题&#xff0c;当n&#61;1的时候&#xff0c;不用再往下递归了&#xff0c;可以跳出循环啦&#xff0c;n&#61;1就可以作为递归的终止条件&#xff0c;如下&#xff1a;

//n的阶乘(n为大于0的自然数)
int factorial (int n){
    if(n&#61;&#61;1){
      return 1;
    }
}

3.递推函数的等价关系式

递归的「本义」&#xff0c;就是原问题可以拆为同类且更容易解决的子问题&#xff0c;即「原问题和子问题都可以用同一个函数关系表示。递推函数的等价关系式&#xff0c;这个步骤就等价于寻找原问题与子问题的关系&#xff0c;如何用一个公式把这个函数表达清楚」。阶乘的公式就可以表示为 f(n) &#61; n * f(n-1), 因此&#xff0c;阶乘的递归程序代码就可以写成这样&#xff0c;如下&#xff1a;

int factorial (int n){
    if(n&#61;&#61;1){
      return 1;
    }
    return n * factorial(n-1);
}

「注意啦」&#xff0c;不是所有递推函数的等价关系都像阶乘这么简单&#xff0c;一下子就能推导出来。需要我们多接触&#xff0c;多积累&#xff0c;多思考&#xff0c;多练习递归题目滴~

leetcode案例分析

来分析一道leetcode递归的经典题目吧~

原题链接在这里哈&#xff1a;https://leetcode-cn.com/problems/invert-binary-tree/

「题目&#xff1a;」 翻转一棵二叉树。

输入&#xff1a;

     4
   /   \
  2     7
 / \   / \
1   3 6   9

输出&#xff1a;

     4
   /   \
  7     2
 / \   / \
9   6 3   1

我们按照以上递归解题的三板斧来&#xff1a;

「1. 定义函数功能」

函数功能(即这个递归原问题是)&#xff0c;给出一颗树&#xff0c;然后翻转它&#xff0c;所以&#xff0c;函数可以定义为&#xff1a;

//翻转一颗二叉树
public TreeNode invertTree(TreeNode root) {
}

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val &#61; x; }
 * }
 */

「2.寻找递归终止条件」

这棵树什么时候不用翻转呢&#xff1f;当然是当前节点为null或者当前节点为叶子节点的时候啦。因此&#xff0c;加上终止条件就是&#xff1a;

//翻转一颗二叉树
public TreeNode invertTree(TreeNode root) {
    if(root&#61;&#61;null || (root.left &#61;&#61;null && root.right &#61;&#61;null)){
       return root;
    }
}

「3. 递推函数的等价关系式」

原问题之你要翻转一颗树&#xff0c;是不是可以拆分为子问题&#xff0c;分别翻转它的左子树和右子树&#xff1f;子问题之翻转它的左子树&#xff0c;是不是又可以拆分为&#xff0c;翻转它左子树的左子树以及它左子树的右子树&#xff1f;然后一直翻转到叶子节点为止。嗯&#xff0c;看图理解一下咯~065bfca2d1fb9a22189f2f8f0c957701.png

首先&#xff0c;你要翻转根节点为4的树&#xff0c;就需要「翻转它的左子树(根节点为2)和右子树(根节点为7)」。这就是递归的「递」的过程啦596e2e0305614784d3449a07d8f05f31.png

然后呢&#xff0c;根节点为2的树&#xff0c;不是叶子节点&#xff0c;你需要继续「翻转它的左子树(根节点为1)和右子树(根节点为3)」。因为节点1和3都是「叶子节点」了&#xff0c;所以就返回啦。这也是递归的「递」的过程~

32f2f02d44318189e477fde1b5e1cd09.png

同理&#xff0c;根节点为7的树&#xff0c;也不是叶子节点&#xff0c;你需要翻转「它的左子树(根节点为6)和右子树(根节点为9)」。因为节点6和9都是叶子节点了&#xff0c;所以也返回啦。

b56cec84d48707957757b2429cd621d8.png

左子树(根节点为2)和右子树(根节点为7)都被翻转完后&#xff0c;这几个步骤就「归来」&#xff0c;即递归的归过程&#xff0c;翻转树的任务就完成了~

c0d6d16b7271866997b8b0b3d9c1bf29.png

显然&#xff0c;「递推关系式」就是&#xff1a;

invertTree(root)&#61; invertTree(root.left) &#43; invertTree(root.right);

于是&#xff0c;很容易可以得出以下代码&#xff1a;

//翻转一颗二叉树
public TreeNode invertTree(TreeNode root) {
    if(root&#61;&#61;null || (root.left &#61;&#61;null && root.right &#61;&#61;null){
       return root;
    }
    //翻转左子树
    TreeNode left &#61; invertTree(root.left);
    //翻转右子树
    TreeNode right&#61; invertTree(root.right);
}

这里代码有个地方需要注意&#xff0c;翻转完一棵树的左右子树&#xff0c;还要交换它左右子树的引用位置。

 root.left &#61; right;
 root.right &#61; left;

因此&#xff0c;leetcode这个递归经典题目的「终极解决代码」如下&#xff1a;

class Solution {
    public TreeNode invertTree(TreeNode root) {
         if(root&#61;&#61;null || (root.left &#61;&#61;null && root.right &#61;&#61;null)){
           return root;
         }
         //翻转左子树
         TreeNode left &#61; invertTree(root.left);
         //翻转右子树
         TreeNode right&#61; invertTree(root.right);
         //左右子树交换位置~
         root.left &#61; right;
         root.right &#61; left;
         return root;
    }
}

拿终极解决代码去leetcode提交一下&#xff0c;通过啦~

55e1870197444953d1f30c4c03e86539.png

递归存在的问题

  • 递归调用层级太多&#xff0c;导致栈溢出问题
  • 递归重复计算&#xff0c;导致效率低下

栈溢出问题

  • 每一次函数调用在内存栈中分配空间&#xff0c;而每个进程的栈容量是有限的。
  • 当递归调用的层级太多时&#xff0c;就会超出栈的容量&#xff0c;从而导致调用栈溢出。
  • 其实&#xff0c;我们在前面小节也讨论了&#xff0c;递归过程类似于出栈入栈&#xff0c;如果递归次数过多&#xff0c;栈的深度就需要越深&#xff0c;最后栈容量真的不够咯

「代码例子如下&#xff1a;」

/**
 * 递归栈溢出测试
 */
public class RecursionTest {

    public static void main(String[] args) {
        sum(50000);
    }
    private static int sum(int n) {
        if (n <&#61; 1) {
            return 1;
        }
        return sum(n - 1) &#43; n;
    }
}

「运行结果:」

Exception in thread "main" java.lang.StackOverflowError
 at recursion.RecursionTest.sum(RecursionTest.java:13)

怎么解决这个栈溢出问题&#xff1f;首先需要「优化一下你的递归」&#xff0c;真的需要递归调用这么多次嘛&#xff1f;如果真的需要&#xff0c;先稍微「调大JVM的栈空间内存」&#xff0c;如果还是不行&#xff0c;那就需要弃用递归&#xff0c;「优化为其他方案」咯~

重复计算&#xff0c;导致程序效率低下

我们再来看一道经典的青蛙跳阶问题&#xff1a;一只青蛙一次可以跳上1级台阶&#xff0c;也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。

绝大多数读者朋友&#xff0c;很容易就想到以下递归代码去解决&#xff1a;

class Solution {
    public int numWays(int n) {
    if (n &#61;&#61; 0){
       return 1;
     }
    if(n <&#61; 2){
        return n;
    }
    return numWays(n-1) &#43; numWays(n-2);
    }
}

但是呢&#xff0c;去leetcode提交一下&#xff0c;就有问题啦&#xff0c;超出时间限制了

54bc2f5771fa081d2564a4e83aefec49.png

为什么超时了呢&#xff1f;递归耗时在哪里呢&#xff1f;先画出「递归树」看看&#xff1a;

95c3bd0f5b255ddc6dfab6ac4d921399.png
  • 要计算原问题 f(10)&#xff0c;就需要先计算出子问题 f(9) 和 f(8)
  • 然后要计算 f(9)&#xff0c;又要先算出子问题 f(8) 和 f(7)&#xff0c;以此类推。
  • 一直到 f(2) 和 f(1)&#xff0c;递归树才终止。

我们先来看看这个递归的时间复杂度吧&#xff0c;「递归时间复杂度 &#61; 解决一个子问题时间*子问题个数」

  • 一个子问题时间 &#61;  f(n-1)&#43;f(n-2)&#xff0c;也就是一个加法的操作&#xff0c;所以复杂度是 「O(1)」&#xff1b;
  • 问题个数 &#61; 递归树节点的总数&#xff0c;递归树的总结点 &#61; 2^n-1&#xff0c;所以是复杂度「O(2^n)」

因此&#xff0c;青蛙跳阶&#xff0c;递归解法的时间复杂度 &#61; O(1) * O(2^n) &#61;  O(2^n)&#xff0c;就是指数级别的&#xff0c;爆炸增长的&#xff0c;「如果n比较大的话&#xff0c;超时很正常的了」

回过头来&#xff0c;你仔细观察这颗递归树&#xff0c;你会发现存在「大量重复计算」&#xff0c;比如f(8)被计算了两次&#xff0c;f(7)被重复计算了3次...所以这个递归算法低效的原因&#xff0c;就是存在大量的重复计算&#xff01;

「那么&#xff0c;怎么解决这个问题呢&#xff1f;」

既然存在大量重复计算&#xff0c;那么我们可以先把计算好的答案存下来&#xff0c;即造一个备忘录&#xff0c;等到下次需要的话&#xff0c;先去「备忘录」查一下&#xff0c;如果有&#xff0c;就直接取就好了&#xff0c;备忘录没有才再计算&#xff0c;那就可以省去重新重复计算的耗时啦&#xff01;这就是「带备忘录的解法」

我们来看一下「带备忘录的递归解法」吧~

一般使用一个数组或者一个哈希map充当这个「备忘录」

假设f(10)求解加上「备忘录」&#xff0c;我们再来画一下递归树&#xff1a;

「第一步」&#xff0c;f(10)&#61; f(9) &#43; f(8)&#xff0c;f(9) 和f(8)都需要计算出来&#xff0c;然后再加到备忘录中&#xff0c;如下&#xff1a;

09cfdd031c451419f175b0f873fde706.png

「第二步&#xff0c;」  f(9) &#61; f(8)&#43; f(7)&#xff0c;f(8)&#61; f(7)&#43; f(6), 因为 f(8) 已经在备忘录中啦&#xff0c;所以可以省掉&#xff0c;f(7),f(6)都需要计算出来&#xff0c;加到备忘录中~

cb858d4650e3ddf8efeaea7745799ca9.png

「第三步&#xff0c;」 f(8) &#61; f(7)&#43; f(6),发现f(8)&#xff0c;f(7),f(6)全部都在备忘录上了&#xff0c;所以都可以剪掉。1e58bb13ded6a30867465e229f7d7f60.png

所以呢&#xff0c;用了备忘录递归算法&#xff0c;递归树变成光秃秃的树干咯&#xff0c;如下&#xff1a;ba64c59f8e3a9688c348d64d516c2748.png

带「备忘录」的递归算法&#xff0c;子问题个数&#61;树节点数&#61;n&#xff0c;解决一个子问题还是O(1),所以「带「备忘录」的递归算法的时间复杂度是O(n)」。接下来呢&#xff0c;我们用带「备忘录」的递归算法去撸代码&#xff0c;解决这个青蛙跳阶问题的超时问题咯~&#xff0c;代码如下&#xff1a;

public class Solution {
    //使用哈希map&#xff0c;充当备忘录的作用
    Map tempMap &#61; new HashMap();
    public int numWays(int n) {
        // n &#61; 0 也算1种if (n &#61;&#61; 0) {return 1;
        }if (n <&#61; 2) {return n;
        }
        //先判断有没计算过&#xff0c;即看看备忘录有没有if (tempMap.containsKey(n)) {
            //备忘录有&#xff0c;即计算过&#xff0c;直接返回return tempMap.get(n);
        } else {
            // 备忘录没有&#xff0c;即没有计算过&#xff0c;执行递归计算,并且把结果保存到备忘录map中&#xff0c;对1000000007取余(这个是leetcode题目规定的)
            tempMap.put(n, (numWays(n - 1) &#43; numWays(n - 2)) % 1000000007);return tempMap.get(n);
        }
    }
}

去leetcode提交一下&#xff0c;如图&#xff0c;稳了&#xff1a;

acf75b6b4f84cd8523056074a8af6f14.png

还有没有其他方案解决这个问题呢&#xff1f;只有「带备忘录的递归解法」&#xff1f;其实吧&#xff0c;还可以用「动态规划」去解决

动态规划算法思想怎么解题呢&#xff1f;我们下期继续~ 谢谢阅读~

参考与感谢

  • [一文学会递归解题] (https://mp.weixin.qq.com/s/Hew44D8rdXb3pf8mZGk67w)
  • [动态规划详解] (https://mp.weixin.qq.com/s/1V3aHVonWBEXlNUvK3S28w)
db0b69f9baad989077e5a8cbbd066374.gif

往期推荐

228744c6fddbeb8b99ed7ac8946c270b.png

算法图解&#xff1a;如何用两个栈实现一个队列&#xff1f;

ffad2acbe132d08ead9093422e2d37c1.png

真不错&#xff0c;图解Java中的5大队列&#xff01;

a53be2383be117efaf49d3e42217516e.png

一文详解「队列」&#xff0c;手撸队列的3种方法&#xff01;

关注我&#xff0c;每天陪你进步一点点&#xff01;

68220d5acb8d9e4bda9dc3199523e1de.png




推荐阅读
  • 本文探讨了如何在给定整数N的情况下,找到两个不同的整数a和b,使得它们的和最大,并且满足特定的数学条件。 ... [详细]
  • 本文详细介绍了Java编程语言中的核心概念和常见面试问题,包括集合类、数据结构、线程处理、Java虚拟机(JVM)、HTTP协议以及Git操作等方面的内容。通过深入分析每个主题,帮助读者更好地理解Java的关键特性和最佳实践。 ... [详细]
  • 本文介绍了Java并发库中的阻塞队列(BlockingQueue)及其典型应用场景。通过具体实例,展示了如何利用LinkedBlockingQueue实现线程间高效、安全的数据传递,并结合线程池和原子类优化性能。 ... [详细]
  • 本文详细探讨了KMP算法中next数组的构建及其应用,重点分析了未改良和改良后的next数组在字符串匹配中的作用。通过具体实例和代码实现,帮助读者更好地理解KMP算法的核心原理。 ... [详细]
  • Java 中 Writer flush()方法,示例 ... [详细]
  • Java 中的 BigDecimal pow()方法,示例 ... [详细]
  • 本文详细探讨了Java中的24种设计模式及其应用,并介绍了七大面向对象设计原则。通过创建型、结构型和行为型模式的分类,帮助开发者更好地理解和应用这些模式,提升代码质量和可维护性。 ... [详细]
  • 本文详细介绍了 Dockerfile 的编写方法及其在网络配置中的应用,涵盖基础指令、镜像构建与发布流程,并深入探讨了 Docker 的默认网络、容器互联及自定义网络的实现。 ... [详细]
  • 探讨一个显示数字的故障计算器,它支持两种操作:将当前数字乘以2或减去1。本文将详细介绍如何用最少的操作次数将初始值X转换为目标值Y。 ... [详细]
  • 在金融和会计领域,准确无误地填写票据和结算凭证至关重要。这些文件不仅是支付结算和现金收付的重要依据,还直接关系到交易的安全性和准确性。本文介绍了一种使用C语言实现小写金额转换为大写金额的方法,确保数据的标准化和规范化。 ... [详细]
  • 2023年京东Android面试真题解析与经验分享
    本文由一位拥有6年Android开发经验的工程师撰写,详细解析了京东面试中常见的技术问题。涵盖引用传递、Handler机制、ListView优化、多线程控制及ANR处理等核心知识点。 ... [详细]
  • 本实验主要探讨了二叉排序树(BST)的基本操作,包括创建、查找和删除节点。通过具体实例和代码实现,详细介绍了如何使用递归和非递归方法进行关键字查找,并展示了删除特定节点后的树结构变化。 ... [详细]
  • 构建基于BERT的中文NL2SQL模型:一个简明的基准
    本文探讨了将自然语言转换为SQL语句(NL2SQL)的任务,这是人工智能领域中一项非常实用的研究方向。文章介绍了笔者在公司举办的首届中文NL2SQL挑战赛中的实践,该比赛提供了金融和通用领域的表格数据,并标注了对应的自然语言与SQL语句对,旨在训练准确的NL2SQL模型。 ... [详细]
  • Java编程实践:深入理解方法重载
    本文介绍了Java中方法重载的概念及其应用。通过多个示例,详细讲解了如何在同一类中定义具有相同名称但不同参数列表的方法,以实现更灵活的功能调用。 ... [详细]
  • 探索1000以内的完美数:因数和等于自身
    本文探讨了如何在1000以内找到所有完美数,即一个数的因数(不包括自身)之和等于该数本身。例如,6是一个完美数,因为1 + 2 + 3 = 6。通过编程实现这一过程,可以更好地理解完美数的特性。 ... [详细]
author-avatar
5257wals_220
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有