程序员必备的基本算法:递归详解-程序员宅基地

技术标签: 算法  java  编程语言  leetcode  数据结构  

前言

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

  • 什么是递归?

  • 递归的特点

  • 递归与栈的关系

  • 递归应用场景

  • 递归解题思路

  • leetcode案例分析

  • 递归可能存在的问题以及解决方案

「公众号:捡田螺的小男孩」

什么是递归?

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

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

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

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

递归的特点

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

  • 自身调用:原问题可以分解为子问题,子问题和原问题的求解方法是一致的,即都是调用自身的同一个函数。

  • 终止条件:递归必须有一个终止的条件,即不能无限循环地调用本身。

结合以上demo代码例子,看下递归的特点:

递归与栈的关系

其实,递归的过程,可以理解为出入栈的过程的,这个比喻呢,只是为了方便读者朋友更好理解递归哈。以上代码例子计算sum(n=3)的出入栈图如下:

为了更容易理解一些,我们来看一下 函数sum(n=5)的递归执行过程,如下:

  • 计算sum(5)时,先sum(5)入栈,然后原问题sum(5)拆分为子问题sum(4),再入栈,直到终止条件sum(n=1)=1,就开始出栈。

  • sum(1)出栈后,sum(2)开始出栈,接着sum(3)。

  • 最后呢,sum(1)就是后进先出,sum(5)是先进后出,因此递归过程可以理解为栈出入过程啦~

递归的经典应用场景

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

  • 阶乘问题

  • 二叉树深度

  • 汉诺塔问题

  • 斐波那契数列

  • 快速排序、归并排序(分治算法体现递归)

  • 遍历文件,解析xml文件

递归解题思路

解决递归问题一般就三步曲,分别是:

  • 第一步,定义函数功能

  • 第二步,寻找递归终止条件

  • 第二步,递推函数的等价关系式

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

1.定义函数功能

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

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

}
2.寻找递归终止条件

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

//n的阶乘(n为大于0的自然数)
int factorial (int n){
    if(n==1){
      return 1;
    }
}
3.递推函数的等价关系式

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

int factorial (int n){
    if(n==1){
      return 1;
    }
    return n * factorial(n-1);
}

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

leetcode案例分析

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

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

「题目:」 翻转一棵二叉树。

输入:

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

输出:

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

我们按照以上递归解题的三板斧来:

「1. 定义函数功能」

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

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

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

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

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

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

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

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

首先,你要翻转根节点为4的树,就需要「翻转它的左子树(根节点为2)和右子树(根节点为7)」。这就是递归的「递」的过程啦

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

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

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

显然,「递推关系式」就是:

invertTree(root)= invertTree(root.left) + invertTree(root.right);

于是,很容易可以得出以下代码:

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

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

 root.left = right;
 root.right = left;

因此,leetcode这个递归经典题目的「终极解决代码」如下:

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

拿终极解决代码去leetcode提交一下,通过啦~

递归存在的问题

  • 递归调用层级太多,导致栈溢出问题

  • 递归重复计算,导致效率低下

栈溢出问题
  • 每一次函数调用在内存栈中分配空间,而每个进程的栈容量是有限的。

  • 当递归调用的层级太多时,就会超出栈的容量,从而导致调用栈溢出。

  • 其实,我们在前面小节也讨论了,递归过程类似于出栈入栈,如果递归次数过多,栈的深度就需要越深,最后栈容量真的不够咯

「代码例子如下:」

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

    public static void main(String[] args) {
        sum(50000);
    }
    private static int sum(int n) {
        if (n <= 1) {
            return 1;
        }
        return sum(n - 1) + n;
    }
}

「运行结果:」

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

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

重复计算,导致程序效率低下

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

绝大多数读者朋友,很容易就想到以下递归代码去解决:

class Solution {
    public int numWays(int n) {
    if (n == 0){
       return 1;
     }
    if(n <= 2){
        return n;
    }
    return numWays(n-1) + numWays(n-2);
    }
}

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

为什么超时了呢?递归耗时在哪里呢?先画出「递归树」看看:

  • 要计算原问题 f(10),就需要先计算出子问题 f(9) 和 f(8)

  • 然后要计算 f(9),又要先算出子问题 f(8) 和 f(7),以此类推。

  • 一直到 f(2) 和 f(1),递归树才终止。

我们先来看看这个递归的时间复杂度吧,「递归时间复杂度 = 解决一个子问题时间*子问题个数」

  • 一个子问题时间 =  f(n-1)+f(n-2),也就是一个加法的操作,所以复杂度是 「O(1)」

  • 问题个数 = 递归树节点的总数,递归树的总结点 = 2^n-1,所以是复杂度「O(2^n)」

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

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

「那么,怎么解决这个问题呢?」

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

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

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

假设f(10)求解加上「备忘录」,我们再来画一下递归树:

「第一步」,f(10)= f(9) + f(8),f(9) 和f(8)都需要计算出来,然后再加到备忘录中,如下:

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

「第三步,」 f(8) = f(7)+ f(6),发现f(8),f(7),f(6)全部都在备忘录上了,所以都可以剪掉。

所以呢,用了备忘录递归算法,递归树变成光秃秃的树干咯,如下:

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

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

去leetcode提交一下,如图,稳了:

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

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

参考与感谢

  • [一文学会递归解题] (https://mp.weixin.qq.com/s/Hew44D8rdXb3pf8mZGk67w)

  • [动态规划详解] (https://mp.weixin.qq.com/s/1V3aHVonWBEXlNUvK3S28w)

个人公众号

  • 更多干货,关注公众号

  • 回复pdf,获取学习电子书

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weiwenhou/article/details/109088914

智能推荐

我们研究了853场世界杯比赛,发现了这几条稳赔不赚的竞猜攻略-程序员宅基地

文章浏览阅读4.3w次,点赞9次,收藏50次。本文转载自大数据(ID:hzdashuju)导读:世界杯开赛以来,据数据叔不完全统计,身边有66.6%的小伙伴上了天台,其中22.2%的人买了阿根廷赢、22.2%的人买了..._足球概率教你稳赚不赔

invalidate方法源码追踪_private void invalidatelocalcallingidentitycach-程序员宅基地

文章浏览阅读508次。 首先调用public void invalidate() { invalidate(true); }调用invalidete 带参数的方法 void invalidate(boolean invalidateCache) { invalidateInternal(0, 0, mRight - mLeft, mBottom - mTop, invalidateCach..._private void invalidatelocalcallingidentitycach

OpenSSL简介-程序员宅基地

文章浏览阅读5.6w次,点赞30次,收藏209次。基本概念 1. openssl是一个安全套接字层密码库,囊括主要的密码算法、常用密钥、证书封装管理功能及实现ssl协议。OpenSSL整个软件包大概可以分成三个主要的功能部分:SSL协议库libssl、应用程序命令工具以及密码算法库libcrypto。 2. SSL:Secure Socket Layer,安全套接字层协议,分为SSLv2和SSLv3两个版本,TSL在SSL3.0基础之上提出的..._openssl

Android Camera1-Camera2-CameraView和CameraX使用_camera2view-程序员宅基地

文章浏览阅读5.1k次,点赞8次,收藏48次。基础知识Android Framework提供Camera API来实现拍照与录制视频的功能,目前Android有三类API,Camera 此类是用于控制设备相机的旧版 API,现已弃用,在Android5.0以下使用 Camera2 此软件包是用于控制设备相机的主要 API,Android5.0以上使用 CameraX 基于Camera 2 API封装,简化了开发流程,并增加生命周期控制相关开发类android.hardware.camera2 控制相机的核心API,使用它可以实_camera2view

python图像处理,float[0.0, 1.0]转uint8[0, 255],cv2.imwrite()图像全黑问题_python 浮点转换成uint8-程序员宅基地

文章浏览阅读1.5w次,点赞20次,收藏38次。代码一、问题描述二、解决方法先上代码img*=250.0img = img.astype(np.uint8)一、问题描述cv2.imshow()能显示图像,但cv2.imwrite()保存的图片是近乎全黑的imwrite()print(img),部分截图如下查看图片数组的 数据类型。print(img.dtype)现在的格式是float32,本应该要是0-255的整数才行,..._python 浮点转换成uint8

关于grid安装的一些错误_make sure that xdpyinfo-程序员宅基地

文章浏览阅读2.5k次。1、This application requires X displayERROR: Unable to verify the graphical display setup. This application requires X display. Make sure that xdpyinfo exist under PATH variable.解决方式:oracle安装图形显示依赖X display,需安装xorg-x11-utils包,按以下方式安装后验证。[root@grid1-dah_make sure that xdpyinfo

随便推点

[OpenCV实战]7 距离变换-细化字符轮廓&&查找物体质心_距离变换质心-程序员宅基地

文章浏览阅读206次。https://blog.csdn.net/chaihuimin/article/details/76724714_距离变换质心

STM32 开发板代码移植到核心板串口乱码-程序员宅基地

文章浏览阅读138次。  为了改这个bug,我花了一天的时间……只能怪自己,没学好时钟这一块的库函数和宏407 的标准库里 默认 使用 25M 的外部晶振  而我的是 8M 的外部晶振,所以自行写了一个宏  然后 串口 就好使了,怎么看自己的开发版晶震是多少  如果没有写可以去查一下型号真就自己太菜了,第一时间没有考虑到 时钟  电赛不易,诸君共勉!..._核心板可以用开发板的代码吗

Python+Selenium实现最E网自动填卷-程序员宅基地

文章浏览阅读2.8k次。Python实现最E网10积分自动做题 朋友推荐的最E网调查问卷,刚开始做挺开心的,后面发现老是要自己点选项,总感觉特别浪费时间,填的想吐,长的调查需要做半个小时多,还有可能失败。于是我将重心放到了10积分的小调查了,又快又没坑,但总无脑做感觉巨没意思,正好楼主是个程序员,喜欢研究些辅助脚本。这时候又到了我大Python出马的时候了,作为21世纪现代人,当然得来点智能的了,废话说多了,开搞。..._最e网

springboot集成redis序列化配置_springbootredis序列化-程序员宅基地

文章浏览阅读2.3k次。1.当我们的springboot集成了redis,然后通过springboot程序往redis设置信息,做缓存使用,如果没有使用序列化的时候,也是可以set进去的,不过是这样子的库里的值:value信息:看到的都是这样子的,为什么是这样子呢?大白话说,就是你放在程序里的话,翻译完,咱们看不懂了,作为程序员,我们叫做这个值没有做系列化设置,导致编译完,计算机以自己的形式保存,取值是能取出来的,但是看着就是不爽,那么下面我来做序列号设置,以及一些缓存的设置,配置信息如下:@Config_springbootredis序列化

socket(AF_INET, SOCK_RAW, IPPROTO_IP)的意思_socket(af_inet, sock_dgram, ipproto_ip)-程序员宅基地

文章浏览阅读3.9k次,点赞2次,收藏8次。这是include/linux/in.h里的定义:/* Standard well-defined IP protocols. */enum {IPPROTO_IP = 0, /* Dummy protocol for TCP */IPPROTO_ICMP = 1, /* Internet Control Message Protocol */IPPROTO_IGM_socket(af_inet, sock_dgram, ipproto_ip)

TOPO DN 解析_dc=bj,subnetwork=1,managedelement=sn-程序员宅基地

文章浏览阅读2.2k次。有一个算法,是将大量TOPO DN实例解析成结构形式,并根据DN获取相应的数据。本人感觉值得分享,并供大家讨论与优化。_dc=bj,subnetwork=1,managedelement=sn

推荐文章

热门文章

相关标签