白话递归2——由二叉树力扣题总结出来的递归思想

477 阅读9分钟

前言

最近在刷力扣中二叉树相关题目,发现很多问题都需要用递归来解决,或者说很多问题用递归来解决会比较简单。本文主要是结合力扣题目来对递归算法进行总结,轻松写出递归程序。

在上一篇文章中已经对递归过程和递归思想进行了通俗易懂的解释,附有六道简单的算法题目来便于理解,保证一看就会。附上上一篇文章的连接:点我点我!

因为上一篇已经做了详细的解释,本文就不再赘述,直接上题!

刷题

题目的难度会在上一篇的基础上略微增加,但并不算得上难。上一篇只表明了力扣题号,但是没有题目描述,不方便阅读。本文加上了题目要求,不必再去力扣里搜题号了,降低题目阅读难度。

1.平衡二叉树:力扣110题。

题目:给定一个二叉树,判断它是否是高度平衡的二叉树。

先来看力扣上对平衡二叉树的描述:一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1 。

思路:写一个递归来获取二叉树的高度即可。

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */
/**
 * @param {TreeNode} root
 * @return {boolean}
 */
let recursive = (root) => {    // 递归函数,用于获取二叉树高度
    if (!root) return 0        // 边界条件,节点为空则高度为0
    let left = recursive(root.left)    // 获取左子树高度
    let right = recursive(root.right)  // 获取右子树高度
    if (left < 0 || right < 0) return -1
    if (Math.abs(left - right) > 1) return -1   // 不符合平衡二叉树要求的情况
    return Math.max(left, right) + 1    // 获取最大高度,并且加上根节点本身
}

var isBalanced = function(root) {
    return recursive(root) >= 0
};

2.路径总合:力扣112题。

题目:给你二叉树的根节点 root 和一个表示目标和的整数 targetSum ,判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum 。

叶子节点 是指没有子节点的节点。

思路:每向下遍历一个节点,就让targetSum减去这个节点的值,如果遍历到最后一个叶子节点,节点的值和targetSum相等,则正确

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */
/**
 * @param {TreeNode} root
 * @param {number} targetSum
 * @return {boolean}
 */
var hasPathSum = function(root, targetSum) {    // 递归函数,返回true或false
    if (!root) return false    // 边界条件
    // 当左子树和右子树都不存在时,即到达叶子节点时,判断targetSum和节点的值是否相等并返回结果
    if (!root.left && !root.right) return root.val === targetSum
    // 如果仍然存在子节点,则让targetSum减去当前节点的值
    targetSum = targetSum - root.val
    // 左子树或右子树有一个符合‘节点的值和targetSum相等’这个条件,则符合题意,返回true
    return (hasPathSum(root.left, targetSum) || hasPathSum(root.right, targetSum))
};

3.从前序与中序遍历序列构造二叉树:力扣105题。

题目:根据一棵树的前序遍历与中序遍历构造二叉树。

注意:你可以假设树中没有重复的元素。

这个题目我们首先要搞清楚怎样根据前序和中序遍历来重现一颗二叉树,我们先看题目中给出的例子

preorder = [3,9,20,15,7]
inorder = [9,3,15,20,7]

由前序和中序遍历的特点,我们可以轻易的从前序遍历中得到根节点是谁(前序遍历一个集合中的第一个数就是根节点的值),然后根据根节点就能轻易的从中序遍历中得到左子树和右子树的集合(在中序遍历中,根节点的左边就是左子树集合,右边就是右子树集合)。

所以在这个栗子中,preorder中第一个数就是根节点,也就是3。inorder中3的左子树集合是[9],右子树集合是[15,20,7],左子树就一个节点,不做分析。结合preorder我们可以得知右子树的前序遍历是[20,15,7],那么右子树的根节点就是20,结合inorder中右子树的中序遍历可得右子树的左子树集合是[15],右子树的右子树集合是[7],都是一个节点,至此分析完毕,我们也通过前序和中序遍历还原了一个二叉树,如图:

那么根据这个还原过程,我们可以得到一个思路:

思路:前序遍历的第一个就是根节点,对应找到中序遍历中根节点的位置,此时中序遍历中根节点左边就是左子树,右边就是右子树。然后递归重复查找即可。

这个思路可以画出一个图来便于理解:

image-20210407194752108

直接上代码:

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */
/**
 * @param {number[]} preorder
 * @param {number[]} inorder
 * @return {TreeNode}
 */
var buildTree = function(preorder, inorder) {
    if (preorder.length === 0) return null    // 边界条件,此时节点为空
    let flag = 0        // 用于记录中序遍历中根节点的下标
    // 在中序遍历集合中定位到根节点位置
    while (inorder[flag] != preorder[0]) flag++
    // 分别定义出左子树的前序遍历、左子树的中序遍历、右子树的前序遍历、右子树的中序遍历
    let l_pr = [], l_in = [], r_pr = [], r_in = []
    for (let i = 0; i < flag; i++) {    // 获取左子树
        // 左子树在前序遍历集合中是从根节点的后一位开始
        l_pr.push(preorder[i + 1])
        l_in.push(inorder[i])
    }
    // 由上图可得知,在前序和中序集合中,中序遍历的根节点对应的下标右侧都为右子树
    for (let i = flag + 1; i < preorder.length; i++) {    // 获取右子树
        r_pr.push(preorder[i])
        r_in.push(inorder[i])
    }
    let root = new TreeNode(preorder[0])    // new一个节点出来
    root.left = buildTree(l_pr, l_in)    // 获取左子树
    root.right = buildTree(r_pr, r_in)   // 获取右子树
    return root
};

4.完全二叉树的节点个数:力扣222题。

题目:给你一棵 完全二叉树 的根节点 root ,求出该树的节点个数。

完全二叉树 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。

思路:节点为空返回0,否则就返回左子树的节点数量加右子树的节点数量加1(根节点)

有了思路就很简单了,根据上一篇文章总结出来的递归思想,直接上代码

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */
/**
 * @param {TreeNode} root
 * @return {number}
 */
var countNodes = function(root) {    // 递归函数
    if (!root) return 0    // 边界条件
    // 返回左子树的节点数量加右子树的节点数量加1(根节点)
    return countNodes(root.left) + countNodes(root.right) + 1
};

5.二叉搜索树的第k大节点:力扣剑指offer54题。

题目:给定一棵二叉搜索树,请找出其中第k大的节点。

二叉搜索树:右子树的值都要大于根节点,左子树的值都要小于根节点。

思路一:由于二叉搜索树的特性,我们可以先对二叉树进行中序遍历,得到的一定是个有序数组,获取这个有序数组的倒数第k个值即可

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} root
 * @param {number} k
 * @return {number}
 */
 
// 上篇文章写过的中序遍历,引入简单的递归思想即可
let recursive = (root, arr) => {
    if (!root) return
    recursive(root.left, arr)
    arr.push(root.val)
    recursive(root.right, arr)
    return
}
var kthLargest = function(root, k) {
    let res_arr = []
    recursive(root, res_arr)
    return res_arr[res_arr.length - k]
};

思路二:由于二叉搜索树的性质,左子树<根<右子树,所以可以先获取右子树的节点数(通过上一题的算法),如果k小于或等于右子树节点数量,那么k所代表的值肯定在右子树,如果k比右子树节点数量大1,那么肯定是根节点,如果比右子树节点数量+1还大,那么肯定是左子树,由此规律递归就可以得到第k大节点

这种写法相比上一种会增加递归次数,但本文主要是为了深入理解递归思想,所以一并列了出来。

let getCount = (root) => {    // 上一题的获取二叉树节点数量
    if (!root) return 0
    return getCount(root.left) + getCount(root.right) + 1
}

var kthLargest = function(root, k) {      // 递归函数,目的是返回第k大的节点的val值
    if (!root) return null    // 边界条件
    let r = getCount(root.right)    // 获取右子树的节点数量,用于和k进行比较
    if (k <= r) return kthLargest(root.right, k)  // 此时第k大的节点一定在右子树,返回右子树
    if (k == r + 1) return root.val        // 此时k所代表的的就是根节点
    // 以上条件都不满足,k就在左子树中,返回左子即可。
    // k - r - 1代表k减去根节点和右子树的节点数量后剩余的数量n,此时题目可以理解为在左子树中第n大的数
    return kthLargest(root.left, k - r - 1)
};

6.树的子结构:力扣剑指offer26题。

题目:输入两棵二叉树A和B,判断B是不是A的子结构。(约定空树不是任意一个树的子结构)

B是A的子结构, 即 A中有出现和B相同的结构和节点值。

思路:可以写一个判断两个二叉树是否匹配的函数。在主函数中对a.val和b.val进行对比,相等的话就说明两个树的根节点相等,就可以通过匹配函数对二者进行匹配,不相等就继续向A的左子树和A的右子树寻找

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} A
 * @param {TreeNode} B
 * @return {boolean}
 */
let match = (A, B) => {    // 匹配函数,目的是判断两个二叉树是否匹配
    if (!B) return true    // 边界条件,null可以匹配任何节点
    if (!A || A.val != B.val) return false    // A的根节点的值和B的是否相等
    // 因为是匹配是否相等,所以只有当左子树和右子树都相等,才返回true
    return match(A.left, B.left) && match(A.right, B.right)
}

var isSubStructure = function(A, B) {    // 递归函数,目的是判断二叉树B是否属于二叉树A
    if (!A || !B) return false    // 边界条件
    // 当B的根节点的值和A的相等时,说明B的根节点已经和A节点对上了,可以开始进行匹配了
    // 匹配结果为true时,说明BA的子结构,返回true即可
    if (A.val == B.val && match(A, B)) return true
    // 如果B的根节点的值和A的不等时,B的根节点在二叉树A上还没有对上,就继续沿着A的左右子树向下遍历即可
    return isSubStructure(A.left, B) || isSubStructure(A.right, B)
};

总结

至此,已经通过两篇文章共计12道二叉树算法题来对递归进行了自己的理解,在这里再做一次总结,具体的可以看一下我的上篇白话递归:点我点我!

关于递归的实现逻辑,不用陷入递归的循环中去思考这个递归展开后是什么样,只需要确定我这个递归要实现什么,以及在哪一步实现即可。

在学校里的学习中,老师和书籍都会教我们递归过程如何展开实现,这确实是递归程序的实现方式,但是这样很容易让初学者或者理解能力不好的人(比如说我呜呜呜)陷入到这个过程中,更多的去拆分递归过程,从而没办法很快速地实现一个递归程序。所以有时候我们换一种思路,不去想递归过程展开后如何实现,只需要想我需要这个递归程序干什么就好。

这里是一个前端菜鸟,期望通过记录学习过程的方式来和大家一起成长。欢迎大家留下宝贵的意见,或者找我一起探讨前端的学习之路。