前端算法小结 | 二叉树篇

236 阅读12分钟

写在前面

这是本系列小结的第四篇,今天来讲讲数据结构中的二叉树。

大家如果刚好与我一样正在学(努)习(力)算(刷)法(题),不妨关注下我的专栏: 龙飞的前端算法小结

我们可以一起探讨,一起学习~

前端开(mian)发(shi)需要掌握的几种数据结构

还是先罗列一下前端开发所需要的掌握的数据结构:

  • 数组
  • 队列
  • 链表
  • 树(二叉树)

本文将跟大家一起聊聊最后一种数据结构:二叉树。

二叉树

二叉树(binary tree)是指树中节点的度不大于2的有序树。 二叉树每个节点都有可能存在左右子树,而左右子树也同样是二叉树。

在 JS 中,我们一般用对象来定义二叉树的节点。一个二叉树节点需要有三个属性:

  1. value:当前节点的值
  2. left:当前节点的左子树
  3. right: 当前节点的右子树
class BinaryTree {
    value: string | number;
    left: BinaryTree | null = null;
    right: BinaryTree | null = null;
    
    constructor (value: string | number) {
        this.value = value;
    }
}

二叉树的遍历

在二叉树的考题中,出现频率最高的必然是下面几种遍历:

  • 前序遍历:根节点 → 左子树 → 右子树
  • 中序遍历:左子树 → 根节点 → 右子树
  • 后续遍历:左子树 → 右子树 → 根节点
  • 层序遍历:逐层从左到右遍历

image.png

对于这样一颗二叉树,它的四种遍历结果分别是:

  • 前序遍历:A B B1 B2 C C2
  • 中序遍历:B1 B B2 A C C2
  • 后续遍历:B1 B2 B C2 C A
  • 层序遍历:A B C B1 B2 C2

真题解析

二叉树遍历系列

二叉树的前序遍历

输入: root = [1,null,2,3]
输出: [1,2,3]
输入: root = [1,2]
输出: [1,2]
输入: root = [1,null,2]
输出: [1,2]

对于二叉树的遍历,一般我们需要采用 递归 or 迭代 来实现。

先来看看前序遍历的递归实现:

递归最重要的就是要找到 递归表达式 以及 递归终止条件,二叉树的前中后三种遍历就非常满足递归的条件。

对于前序遍历:

  • 表达式:先根节点,再左子树,再右子树
  • 终止条件:当节点为 null 时,停止递归
function preorderTraversal(root: TreeNode | null): number[] {
    if (!root) {
        return[];
    }

    return [root.val, ...preorderTraversal(root.left), ...preorderTraversal(root.right)];
};

二叉树前序遍历的迭代实现需要借助栈来完成:

利用栈先进后出的特性,我们先把右子树入栈,再把左子树入栈。然后再将栈顶元素出栈,并将其值记录到结果中即可。

function preorderTraversal(root: TreeNode | null): number[] {
    const stack: TreeNode[] = [];
    const res: number[] = [];

    if (root) {
        stack.push(root);
    }

    while (stack.length) {
        const node = stack.pop();
        res.push(node.val);
        
        node.right && stack.push(node.right);
        
        node.left && stack.push(node.left);
    }

    return res;
};

二叉树的中序遍历

输入: root = [1,null,2,3]
输出: [1,3,2]

递归实现的思路与前序遍历一样:

  • 表达式:先左子树,再根节点,再右子树
  • 终止条件:当节点为 null 时,停止递归
function inorderTraversal(root: TreeNode | null): number[] {
    if (!root) {
        return [];
    }

    return [...inorderTraversal(root.left), root.val, ...inorderTraversal(root.right)]
};

迭代实现同样需要借助栈来实现:

  1. 需要从最后一个左节点开始记录结果,因此如果节点存在左子树,则入栈
  2. 如果不存在左子树,则记录到结果中
  3. 如果同时也不存在右子树,则指向栈顶的节点,继续循环
function inorderTraversal(root: TreeNode | null): number[] {
    const stack: TreeNode[] = [];
    const res: number[] = [];

    while (stack.length || root) {
        if (root.left) {
            stack.push(root);
            root = root.left;
        } else {
            res.push(root.val);

            if (root.right) {
                root = root.right;
            } else {
                root = stack.pop();
                root && (root.left = null);
            }
        }
    }

    return res;
};

二叉树的后序遍历

输入: root = [1,null,2,3]
输出: [3,2,1]

同样的,递归实现如下:

  • 表达式:先左子树,再右子树,再根节点
  • 终止条件:当节点为 null 时,停止递归
function postorderTraversal(root: TreeNode | null): number[] {
    if (!root) {
        return [];
    }

    return [...postorderTraversal(root.left), ...postorderTraversal(root.right), root.val]
};

后序遍历的迭代实现其实跟前序遍历几乎一样,只不过它的顺序常规的遍历顺序刚好是相反的,因此我们还是可以利用栈来保存节点,只是在保存结果的时候,从头部插入新的结果值。

function postorderTraversal(root: TreeNode | null): number[] {
    const stack: TreeNode[] = [root];
    const res: number[] = [];

    while (stack.length) {
        const node = stack.pop();
        
        node.left && stack.push(node.left);
        node.right && stack.push(node.right);

        res.unshift(node.val);
    }

    return res;
};

二叉树的层序遍历 (medium)

输入: root = [3,9,20,null,null,15,7]
输出: [[3],[9,20],[15,7]]

层序遍历与前面三种遍历不同,不适合用递归,只能通过迭代来实现。

由于题目要求的结果是个二维数组,因此我们需要在遍历的过程中,记录每一层的数据。

可以借助队列来实现:

function levelOrder(root: TreeNode | null): number[][] {
    const queue: TreeNode[] = [root];
    const res: number[][] = [];
    let level: number = 0;

    while(queue.length && root) {
        res[level] = [];
        let levelNums: number = queue.length;

        while(levelNums) {
            const node: TreeNode = queue.shift();
            res[level].push(node.val);

            node.left && queue.push(node.left);
            node.right && queue.push(node.right);

            levelNums--;
        }

        level++;
    }

    return res;
};

二叉树的锯齿形层序遍历 (medium)

给你二叉树的根节点 root ,返回其节点值的 锯齿形层序遍历 。(即先从左往>右,再从右往左进行下一层遍历,以此类推,层与层之间交替进行)。

输入: root = [3,9,20,null,null,15,7]
输出: [[3],[20,9],[15,7]]

这道题是上一题的一个变种。学会了上一题,这道题的思路就很简单了。

虽然题目说的是层层交替遍历,但实际上我们还是照常从左到右按顺序遍历,只是在插入结果时判断当前层是从左到右插入(push)还是从右到左插入(unshift)即可。

function zigzagLevelOrder(root: TreeNode | null): number[][] {
    const queue: TreeNode[] = [root];
    const res: number[][] = [];
    let level: number = 0;

    let isFromLeft: boolean = false;

    while(queue.length && root) {
        res[level] = [];
        let levelNums: number = queue.length;

        isFromLeft = !isFromLeft;

        while(levelNums) {
            const node: TreeNode = queue.shift();

            if (isFromLeft) {
                res[level].push(node.val);
            } else {
                res[level].unshift(node.val);
            }

            node.left && queue.push(node.left);
            node.right && queue.push(node.right);

            levelNums--;
        }

        level++;
    }

    return res;
};

特殊二叉树系列

对称二叉树

image.png

输入: root = [1,2,2,3,4,4,3]
输出: true

image.png

输入:root = [1,2,2,null,3,null,3]
输出:false

这道题非常适合使用递归实现:

  • 从根节点开始,判断左右子树是否相等
  • 如果相等,再判断左子树的左和右子树的右,以及左子树的右和右子树的左是否相等
  • 如果都为空,则视为相等
function isSymmetric(root: TreeNode | null): boolean {
    if (!root) {
        return true;
    }

    return dfs(root.left, root.right);
};

function dfs (left: TreeNode | null, right: TreeNode | null): boolean {
    if (!left && !right) {
        return true;
    }

    if (!left || !right) {
        return false;
    }

    if (left.val !== right.val) {
        return false;
    }

    return dfs(left.left, right.right) && dfs(left.right, right.left);
}

翻转二叉树

给你一棵二叉树的根节点 root ,翻转这棵二叉树,并返回其根节点。

image.png

输入: root = [4,2,7,1,3,6,9]
输出: [4,7,2,9,6,3,1]

翻转二叉树,其实就是将每个节点的左右子树进行对换。非常适合用递归来实现:

先暂存左子树,然后将左子树更改为右子树,最后再将右子树更改为暂存的左子树即可。

function invertTree(root: TreeNode | null): TreeNode | null {
    if (!root) {
        return root;
    }

    const tmpNode: TreeNode = root.left;
    root.left = invertTree(root.right);
    root.right = invertTree(tmpNode);

    return root;
};

合并二叉树

给你两棵二叉树: root1 和 root2 。

想象一下,当你将其中一棵覆盖到另一棵之上时,两棵树上的一些节点将会重叠(而另一些不会)。你需要将这两棵树合并成一棵新二叉树。合并的规则是:如果两个节点重叠,那么将这两个节点的值相加作为合并后节点的新值;否则,不为 null 的节点将直接作为新二叉树的节点。

返回合并后的二叉树。

注意: 合并过程必须从两个树的根节点开始。

image.png

输入: root1 = [1,3,2,5], root2 = [2,1,3,null,4,null,7]
输出: [3,4,5,5,4,null,7]

想象一下如果现在要求的是合并两个数组,那么我们的思路将会是遍历两个数组,然后元素一一合并。

合并两个二叉树也是同样的原理。而遍历二叉树的方法也很多,我们使用最简单的前序遍历即可。

function mergeTrees(root1: TreeNode | null, root2: TreeNode | null): TreeNode | null {
    if (!root1) {
        return root2;
    }
    if (!root2) {
        return root1;
    }
    
    // 题目没要求不能改变原 root,因此直接覆盖 root1 即可
    root1.val = root1.val + root2.val;
    root1.left = mergeTrees(root1.left, root2.left);
    root1.right = mergeTrees(root1.right, root2.right);

    return root1;

    // 如果题目有要求不能改变原 root
    // 那就需要新创建一颗树
    // const newTreeNode: TreeNode = new TreeNode(root1.val + root2.val);
    // newTreeNode.left = mergeTrees(root1.left, root2.left);
    // newTreeNode.right = mergeTrees(root1.right, root2.right);
    // return newTreeNode;
};

验证二叉搜索树(medium)

二叉搜索树(也叫二叉排序树)是一种特殊的二叉树,它的定义如下:

  • 左子树不为空,那么左子树上所有节点的值均小于根节点的值
  • 右子树不为空,那么右子树上所有节点的值都大于根节点的值
  • 左右子树也均满足二叉搜索树

image.png

输入: root = [2,1,3]
输出: true

image.png

输入: root = [5,1,4,null,null,3,6]
输出: false
解释: 根节点的值是 5 ,但是右子节点的值是 4

二叉搜索树需要从左到右进行比较,这其实跟二叉树的中序遍历很类似。

因此我们可以先对二叉树进行一遍中序遍历,记录每个节点的值到一个数组中,然后再判断这个数组是否是升序。如果是,则为二叉搜索树,否则则不是二叉搜索树。

function isValidBST(root: TreeNode | null): boolean {
    const values: number[] = getValues(root);

    for (let i = 1; i < values.length; i++) {
        if (values[i-1] >= values[i]) {
            return false;
        }
    }

    return true;
};

function getValues (root: TreeNode | null): number[] {
    if (!root) {
        return [];
    }

    return [...getValues(root.left), root.val, ...getValues(root.right)];
}

这道题还有另外一个解题思路:

依旧是利用中序遍历的思路,不过我们需要记录前一个节点的值。

并判断当前节点是否大于前一个节点即可。同样的,需要递归左右子树进行相同的操作。

let preNodeValue: number = Number.MIN_SAFE_INTEGER;
function isValidBST(root: TreeNode | null): boolean {
   if (!root) {
       return true;
   }

   if (!isValidBST(root.left)) {
       return false;
   }

    if (root.val <= preNodeValue) {
        return false;
    }

    preNodeValue = root.val;

    return isValidBST(root.right);
};

构造二叉树系列

构造二叉树也是非常经典的考题系列:通过已知二叉树的前序、中序、后序遍历中的任意两两组合,去构造出这颗二叉树。

我们始终需要记住以下三点:

  • 前序遍历的第一个节点就是二叉树的根节点
  • 后续遍历的最后一个节点就是二叉树的根节点
  • 中序遍历中根节点的左边就是左子树的节点,右边就是右子的节点

从前序与中序遍历序列构造二叉树 (medium)

前提:二叉树中节点的值不重复。

输入: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
输出: [3,9,20,null,null,15,7]

根据前序遍历的特点,preorder[0] 就是根节点。

根据中序遍历的特点,根据 inorder.indexOf(preorder[0]) 就可以找到根节点的 index,从而可以得到

  • 前序遍历中的左子树坐标:[1, index + 1]
  • 前序遍历中的右子树坐标:[index + 1, end]
  • 中序遍历中的左子树坐标:[0, index]
  • 中序遍历中的右子树坐标:[index + 1, end]

再通过递归便可以构造出二叉树:

function buildTree(preorder: number[], inorder: number[]): TreeNode | null {
    if (!preorder.length || !inorder.length) {
        return null;
    }
    
    const newRoot = new TreeNode(preorder[0]);
    const rootIndexInorder: number = inorder.indexOf(preorder[0]);
    newRoot.left = buildTree(preorder.slice(1, rootIndexInorder + 1), inorder.slice(0, rootIndexInorder));
    newRoot.right = buildTree(preorder.slice(rootIndexInorder + 1), inorder.slice(rootIndexInorder + 1));

    return newRoot;
};

从中序与后序遍历序列构造二叉树(medium)

前提:二叉树中节点的值不重复。

输入:inorder = [9,3,15,20,7], postorder = [9,15,7,20,3]
输出:[3,9,20,null,null,15,7]

与前一题的思路一致,后序遍历的最后一个节点postorder[postorder.length - 1]就是根节点。

同理,index 即为 inorder.indexOf(postorder[postorder.length - 1])

  • 后序遍历中的左子树坐标:[0, index]
  • 后序遍历中的右子树坐标:[index, end - 1]
  • 中序遍历中的左子树坐标:[0, index]
  • 中序遍历中的右子树坐标:[index + 1, end]
function buildTree(inorder: number[], postorder: number[]): TreeNode | null {
    if(!inorder.length || !postorder.length) {
        return null;
    }

    const rootIndexInOrder: number = inorder.indexOf(postorder[postorder.length - 1]);
    const newRoot: TreeNode = new TreeNode(postorder[postorder.length - 1]);

    newRoot.left = buildTree(inorder.slice(0, rootIndexInOrder), postorder.slice(0, rootIndexInOrder));
    newRoot.right = buildTree(inorder.slice(rootIndexInOrder + 1), postorder.slice(rootIndexInOrder, postorder.length - 1));

    return newRoot;
};

根据前序和后序遍历构造二叉树(medium)

这道题的考察频率要相对前两道更低一些。因为众所周知已知前序遍历和后序遍历,构造出来的二叉树可能不止一颗。

在这里就不做过多赘述,大家感兴趣可以直接去看题解

二叉树其他系列

最后一 part 来看下二叉树中的其他题型。

二叉树的最小深度

给定一个二叉树,找出其最小深度。

最小深度是从根节点到最近叶子节点的最短路径上的节点数量。

输入: root = [3,9,20,null,null,15,7]
输出: 2
输入: root = [2,null,3,null,4,null,5,null,6]
输出: 5

这道题可以分为三种情况:

  1. 无根节点时,最小深度为 0
  2. 当根节点无左子树 或者无右子树时,最小深度为 1 + 左 + 右(左右必有至少一个为 0)
  3. 当根节点同时存在左右子树时,最小深度为 1 + 左右子树中更小的那个
function minDepth(root: TreeNode | null): number {
    if(!root) {
        return 0;
    }

    const leftMin = minDepth(root.left);
    const rightMin = minDepth(root.right);

    if (!root.left || !root.right) {
        return leftMin + rightMin + 1;
    }

    return Math.min(leftMin, rightMin) + 1;
};

求根节点到叶节点数字之和(medium)

给你一个二叉树的根节点 root ,树中每个节点都存放有一个 0 到 9 之间的数字。 每条从根节点到叶节点的路径都代表一个数字:

例如,从根节点到叶节点的路径 1 -> 2 -> 3 表示数字 123 。 计算从根节点到叶节点生成的 所有数字之和

image.png

输入:root = [1,2,3]
输出:25
解释:
从根到叶子节点路径 1->2 代表数字 12
从根到叶子节点路径 1->3 代表数字 13
因此,数字总和 = 12 + 13 = 25

思路其实非常简单,我们需要层层遍历二叉树,因此需要记录一个 temp value,代表上一层相加后的值。

遍历分为三种情况:

  1. 根节点为 null,则返回 0
  2. 如果不为 null,那么 temp value 需要先乘 10(因为是上一层的结果),然后再加上当前层的 root.value
  3. 如果左右子树都不存在,则返回 temp value
  4. 否则分别递归左右子树,并返回相加后的结果
function sumNumbers(root: TreeNode | null): number {
    return sum(root, 0);
};

function sum(root: TreeNode | null, temp: number): number {
    if (root === null) {
        return 0;
    }

    temp = temp * 10 + root.val;

    if (!root.left && !root.right) {
        return temp;
    }

    return sum(root.left, temp) + sum(root.right, temp);
}

二叉树的最近公共祖先 (medium)

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。

image.png

输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出:3
解释:节点 5 和节点 1 的最近公共祖先是节点 3

image.png

输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出:5
解释:节点 5 和节点 4 的最近公共祖先是节点 5 。
因为根据定义最近公共祖先节点可以为节点本身。

我们需要对二叉树进行遍历:

  1. 如果 p / q 是根节点,那么最小公共祖先肯定就是根节点了
  2. 如果不是,则分别遍历左右子树
  3. 如果 p 和 q 分别在左右子树上,那么最小公共祖先就是根节点
  4. 如果 p 和 q 都不在左子树上,那么最小公共节点存在于右子树
  5. 右子树同理
  6. 如果都 p 和 q 即不在左子树也不在右子树,那么说明没找到,返回 null 即可
function lowestCommonAncestor(root: TreeNode | null, p: TreeNode | null, q: TreeNode | null): TreeNode | null {
    if (!root || root === q || root === p) {
        return root;
    }

    const left: TreeNode = lowestCommonAncestor(root.left, p, q);
    const right: TreeNode = lowestCommonAncestor(root.right, p, q);

    if (left && right) {
        return root;
    }

    if (!left) {
        return right;
    }

    if (!right) {
        return left;
    }

    return null;
};

二叉树中的最大路径和(hard)

image.png

输入: root = [1,2,3]
输出: 6
解释: 最优路径是 2 -> 1 -> 3 ,路径和为 2 + 1 + 3 = 6

image.png

输入:root = [-10,9,20,null,null,15,7]
输出:42
解释:最优路径是 15 -> 20 -> 7 ,路径和为 15 + 20 + 7 = 42
function maxPathSum(root: TreeNode | null): number {
    let max = Number.MIN_SAFE_INTEGER;

    const dfs = (root: TreeNode | null): number => {
        if (!root) {
            return 0;
        }

        const left: number = dfs(root.left);
        const right: number = dfs(root.right);
        const currentMax: number = root.val + left + right;

        max = Math.max(max, currentMax);

        // 如果是负数,则取 0,不影响最终结果
        const innerMax: number = root.val + Math.max(0, left, right);
        return innerMax < 0 ? 0 : innerMax;
    }

    dfs(root);

    return max;
};

写在最后

其实不难发现,二叉树的很多题型都可以用递归去解决。所以我们解题的重点在于如何写好这个递归逻辑:

  • 处理好当前节点的逻辑
  • 设定好递归的终止条件
  • 根据场景递归当前节点的子树

上述提到的遍历系列、特殊系列以及构造系列基本可以覆盖 90% 以上面试官最爱出的二叉树相关题目。