【前端学算法】04. “树” 的全面讲解

429 阅读8分钟

前端视角学习《数据结构与算法》,包括数据结构的理解,算法解题技巧,Leetcode 经典题解,欢迎讨论!

🐧 前情提要

阅读本章节你会了解到以下内容:

  1. 树的理解
  2. 二叉树的遍历
  3. 二叉树的属性
  4. 二叉树的操作
  5. LeetCode 刷题公式
  6. LeetCode 题目分类

1. 树的理解

(1)数据结构

树是一种数据结构,是由 n 个节点组成的具有层次关系的集合。结构图很像一棵倒置的树,根节点在最上层,叶子节点依次排列在下面逐层。

// 最常用的二叉树的数据结构表示
const TreeNode = function(val){
    this.val = val
    this.left = null
    this.right = null
}

(2)树的特性

  • 每个节点都有有限个子节点,或没有子节点
  • 树中仅有根节点没有父节点
  • 除根节点外,其他节点只有一个父节点
  • 树中没有环路

(3)树的概念

  • 根节点:树中仅有一个根节点,没有父节点的节点被称为根节点 root,理解为树的源头
  • 父节点:一个节点有子节点,则称该节点为其子节点的父节点 parent node(相对概念)
  • 子节点:一个父节点可能同时拥有多个子节点 children node (相对概念)
  • 兄弟节点:相同父节点的节点,之间互称为兄弟节点 sibling node
  • 叶子节点:子树最底层,没有子节点的节点 leaf node
  • 深度:树中的任意节点n,即从根节点到节点n的唯一路径长度,根节点的深度为0
  • 树的深度:从根节点到叶子节点的所有路径中,最长的那条路径的长度 depth
  • 高度:树中的任意节点n,到其叶子节点的最长路径的长度,叶子节点的高度为0 height
  • 节点的度:一个节点含有的子树的个数,称为该节点的度
  • 树的度:一棵树中,所有节点的度的最大值,称为该树的度 level
  • 节点值:当前节点对应的数值 root.val

(4)树的种类

树数据结构中可能种类较多,仅说明以下几个前端经常用到的

  • 二叉树:每个节点最多含有两个子树
    • 平衡二叉树:每个节点的子树高度差不大于1的二叉树
    • 完美二叉树:对于一棵二叉树,除了其最底层节点外,其余各层的节点的数目都达到最大值
  • 二叉搜索树:左子树上的所有节点值,均小于它的根节点值; 右子树上的所有节点值,均大于它的根节点值;
  • N叉树:每个节点含有n个子树

2.树的遍历

对于树来说,最需要掌握的就是对树的遍历,很多延伸都是在遍历的前提下进行的,以下列举常用对树的遍历的方式:

  • 二叉树的前序、中序、后序遍历
  • 二叉树的层序遍历
  • 二叉搜索树的遍历
  • N叉树的前序、后序、层序遍历

(1)二叉树的前序、中序、后序遍历

利用 DFS 深度优先遍历 + 递归,依次遍历左子树、右子树,前序、中序、后序的区别在于,在什么遍历的位置处理填充值

image.png

  • 前序遍历:Pre-Order Traversal 先访问根节点,再访问子树节点
  • 中序遍历:In-Order Traversal 先访问左子树,再访问根节点,最后访问右子树
  • 后序遍历:Post-Order Traversal 先访问子树节点,再访问根节点
const orderTraversal = function(root) {
    let ret = []
    const dfs = function(node) {
        if(root === null) return
        
        /* ret.push(root.val) 前序 */
        root.left && dfs(root.left)
        ret.push(root.val) // 中序
        root.right && dfs(root.right) 
        /* ret.push(root.val) 后序 */
    }
    
    dfs(root)
    return ret
}

(2)二叉树的层序遍历

利用 BFS 广度优先遍历 + 队列先进先出特性;len 记录树中每层节点的数量,curLevel 数组集合记录每层节点的值;分别遍历左子树、右子树依次入队列

image.png

层序遍历:Level-Order Traversal 先访问离根节点最近的节点,逐层访问树

const levelTaversal = function(root) {
    let ret = []
    if(root === null) return ret
    
    let queue = []
    queue.push(root)
    while(queue.length) {
        let len = queue.length
        let curLevel = []
        while(len>0) {
            let node = queue.shift()
            curLevel.push(node.val)
            
            node.left && queue.push(node.left)
            node.right && queue.push(node.right)
            len--
        }
        ret.push(curLevel)
    }
    return ret
}

3. 二叉树的属性

二叉树的属性,包括最小深度、最大深度、树的直径等。以下是解题思路:

(1)二叉树的直径

  • 先理解二叉树直径的概念,任意两个节点路径长度的最大值
  • 涉及到路径,最先想到用 DFS 这种遍历方式
  • 返回值是一个整数,创建 ret 记录路径的最大值
  • 考虑 root === null 时,二叉树的深度为0
  • 依次遍历左右子树,并记录左、右子树的深度
  • 最后返回 +1 算上根节点
const diameter = function(root) {
    let ret = 0
    const dfs = function(root) {
        if(root === null) return ret
        const left = dfs(root.left)
        const right = dfs(root.right)
        
        ret = Math.max(ret, left + right)
        return Math.max(left, right) + 1
    }
    
    dfs(root)
    return ret
}

(2)二叉树的最大深度

最大深度,意思是从根节点到叶子节点,最长路径上的所有节点数 转换成逻辑语言,理解为左子树的深度、右子树的深度的最大值 + 1根节点

const maxDepth = function(root) {
    if(root === null) return 0
    return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1
}

(3)二叉树的最小深度

最小深度,意识是从根节点到叶子节点,最短路径上的所有节点数 转换成逻辑语言,理解为左子树的深度、右子树的深度的最小值 + 1根节点

const minDepth = function(root) {
    if(root === null) return 0
    
    let queue = [root]
    let depth = 1
    while(queue.length) {
        let len = queue.length
        while(len--) {
            let node = queue.shift()
            if(node.left === null && node.right === null) {
                return depth
            }
            node.left && queue.push(node.left)
            node.right && queue.push(node.right)
        }
        depth++
    }
    return root
}

4. 二叉树的操作

二叉树的操作,包括翻转二叉树、合并二叉树...

(1)翻转二叉树

递归的一般思路是,先找递归的子问题,再考虑终止条件

const invertTree = function(root) {
    if(root === null) return root
    
    [root.left, root.right] = [invertTree(root.right), invertTree(root.left)]
    return root
}

(2)合并二叉树

const mergeTree = function(root1, root2) {
    if(root1 === null) return root2
    if(root2 === null) return root1
    
    let ret = new TreeNode(root1.val + root2.val)
    ret.left = mergeTree(root1.left, root2.left)
    ret.right = mergeTree(root1.right, root2.right)
    
    return ret
}

5. leetcode 刷题公式

以下是做树题目能够用到的最基本的公式。刷题时会发现基本都是这两种公式的变形,先理解树的概念,思考做题时可以用到哪些方式解题。需要注意的是,某类题目刷到一定量时,要停下来总结下相似点,还有什么更好的解题思路么... 而不是一直盲目的追求刷题的数量,刷 500 道题不是目的,遇到题了能解出来,能优化解题思路,才是更重要的

解题思路步骤:

  • 读题,清楚并能转换题目需求,涉及到树的基本概念要理解,不断积累解题思路
  • 考虑是利用 DFS 还是 BFS 解题,区别在于遍历方式不同。有时可能两种都可以解出来,前期用你能理解的解题思路做,能理解挺重要的。随着后期接触的多了,可以深入了解不同的解题思路和方式,选择更高效复杂度更低的
  • 确定之后,把对应的公式默写出来
  • 看返回值,考虑是否需要创建 ret,还是可以直接修改当前树,作为返回值
  • 递归的边界条件,以及 root === null 时返回值
  • 细节处理,具体根据题目要求转换成逻辑代码

(1)DFS 深度优先遍历 + 递归

let ret = []

const dfs = function(root) {
    if(root === null) return 
    
    ret.push(root.val)
    root.left && dfs(root.left)
    root.right && dfs(root.right)
}

dfs(root)
return ret

(2)BFS 广度优先遍历 + 队列

let ret = []
if(root === null) return ret

let queue = [root]
while(queue.length) {
    let len = queue.length
    let curLevel = []
    while(len > 0) {
        let node = queue.shift()
        curLevel.push(root.val)
        node.left && queue.push(node.left)
        node.right && queue.push(node.right)
        len--
    }
}

return ret

6. LeetCode 题目分类

集中分类刷 LeetCode 树相关题目时,可以按照如下建议顺序来做题

  • 二叉树的遍历
    • 94.二叉树的中序遍历
    • 144.二叉树的前序遍历
    • 145.二叉树的后序遍历
    • 102.二叉树的层序遍历 - 自顶向下
    • 107.二叉树的层序遍历II - 自底向上
    • 199.二叉树的层序遍历 - 右视图
    • 513.二叉树的左下角的值 - 左视图
    • 637.二叉树的每层平均值
    • 515.二叉树每层的最大值
    • 429.N叉树的层序遍历
    • 589.N叉树的前序遍历
    • 590.N叉树的后序遍历
    • 98.验证二叉搜索树
    • 99.恢复二叉搜索树
    • 700.二叉搜索树中的搜索
    • 701.二叉搜索树中插入值
    • 230.二叉搜索树中第k小的元素
    • 108.数组转换二叉搜索树
    • 109.链表转换二叉搜索树
    • 112.二叉树的路径总和
    • 113.二叉树的路径总和II
    • 257.二叉树的所有路径
  • 二叉树的操作
    • 100.相同二叉树
    • 101.对称二叉树
    • 226.翻转二叉树
    • 617.合并二叉树
    • 116.填充二叉树
  • 二叉树的属性
    • 111.二叉树的最小深度
    • 104.二叉树的最大深度
    • 543.二叉树的直径
    • 236.二叉树的最近祖先
    • 235.二叉搜索树的最近祖先