热门标签 | 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




推荐阅读
  • 本文介绍了Java并发库中的阻塞队列(BlockingQueue)及其典型应用场景。通过具体实例,展示了如何利用LinkedBlockingQueue实现线程间高效、安全的数据传递,并结合线程池和原子类优化性能。 ... [详细]
  • 本文详细介绍了Java编程语言中的核心概念和常见面试问题,包括集合类、数据结构、线程处理、Java虚拟机(JVM)、HTTP协议以及Git操作等方面的内容。通过深入分析每个主题,帮助读者更好地理解Java的关键特性和最佳实践。 ... [详细]
  • 本文将介绍如何编写一些有趣的VBScript脚本,这些脚本可以在朋友之间进行无害的恶作剧。通过简单的代码示例,帮助您了解VBScript的基本语法和功能。 ... [详细]
  • 1:有如下一段程序:packagea.b.c;publicclassTest{privatestaticinti0;publicintgetNext(){return ... [详细]
  • MQTT技术周报:硬件连接与协议解析
    本周开发笔记重点介绍了在新项目中使用MQTT协议进行硬件连接的技术细节,涵盖其特性、原理及实现步骤。 ... [详细]
  • 本文详细介绍了如何构建一个高效的UI管理系统,集中处理UI页面的打开、关闭、层级管理和页面跳转等问题。通过UIManager统一管理外部切换逻辑,实现功能逻辑分散化和代码复用,支持多人协作开发。 ... [详细]
  • 本文介绍了如何在C#中启动一个应用程序,并通过枚举窗口来获取其主窗口句柄。当使用Process类启动程序时,我们通常只能获得进程的句柄,而主窗口句柄可能为0。因此,我们需要使用API函数和回调机制来准确获取主窗口句柄。 ... [详细]
  • 本文详细解析了Python中的os和sys模块,介绍了它们的功能、常用方法及其在实际编程中的应用。 ... [详细]
  • 本文探讨了 Objective-C 中的一些重要语法特性,包括 goto 语句、块(block)的使用、访问修饰符以及属性管理等。通过实例代码和详细解释,帮助开发者更好地理解和应用这些特性。 ... [详细]
  • 本文探讨了如何在给定整数N的情况下,找到两个不同的整数a和b,使得它们的和最大,并且满足特定的数学条件。 ... [详细]
  • 本文详细记录了在基于Debian的Deepin 20操作系统上安装MySQL 5.7的具体步骤,包括软件包的选择、依赖项的处理及远程访问权限的配置。 ... [详细]
  • 题目描述:给定n个半开区间[a, b),要求使用两个互不重叠的记录器,求最多可以记录多少个区间。解决方案采用贪心算法,通过排序和遍历实现最优解。 ... [详细]
  • 本文详细介绍了Java中org.eclipse.ui.forms.widgets.ExpandableComposite类的addExpansionListener()方法,并提供了多个实际代码示例,帮助开发者更好地理解和使用该方法。这些示例来源于多个知名开源项目,具有很高的参考价值。 ... [详细]
  • 使用 Azure Service Principal 和 Microsoft Graph API 获取 AAD 用户列表
    本文介绍了一段通用代码示例,该代码不仅能够操作 Azure Active Directory (AAD),还可以通过 Azure Service Principal 的授权访问和管理 Azure 订阅资源。Azure 的架构可以分为两个层级:AAD 和 Subscription。 ... [详细]
  • UNP 第9章:主机名与地址转换
    本章探讨了用于在主机名和数值地址之间进行转换的函数,如gethostbyname和gethostbyaddr。此外,还介绍了getservbyname和getservbyport函数,用于在服务器名和端口号之间进行转换。 ... [详细]
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社区 版权所有