写在前面
这是本系列小结的第四篇,今天来讲讲数据结构中的二叉树。
大家如果刚好与我一样正在学(努)习(力)算(刷)法(题),不妨关注下我的专栏: 龙飞的前端算法小结
我们可以一起探讨,一起学习~
前端开(mian)发(shi)需要掌握的几种数据结构
还是先罗列一下前端开发所需要的掌握的数据结构:
- 数组
- 栈
- 队列
- 链表
- 树(二叉树)
本文将跟大家一起聊聊最后一种数据结构:二叉树。
二叉树
二叉树(binary tree)是指树中节点的度不大于2的有序树。 二叉树每个节点都有可能存在左右子树,而左右子树也同样是二叉树。
在 JS 中,我们一般用对象来定义二叉树的节点。一个二叉树节点需要有三个属性:
- value:当前节点的值
- left:当前节点的左子树
- right: 当前节点的右子树
class BinaryTree {
value: string | number;
left: BinaryTree | null = null;
right: BinaryTree | null = null;
constructor (value: string | number) {
this.value = value;
}
}
二叉树的遍历
在二叉树的考题中,出现频率最高的必然是下面几种遍历:
- 前序遍历:根节点 → 左子树 → 右子树
- 中序遍历:左子树 → 根节点 → 右子树
- 后续遍历:左子树 → 右子树 → 根节点
- 层序遍历:逐层从左到右遍历
对于这样一颗二叉树,它的四种遍历结果分别是:
- 前序遍历: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)]
};
迭代实现同样需要借助栈来实现:
- 需要从最后一个左节点开始记录结果,因此如果节点存在左子树,则入栈
- 如果不存在左子树,则记录到结果中
- 如果同时也不存在右子树,则指向栈顶的节点,继续循环
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;
};
特殊二叉树系列
对称二叉树
输入: root = [1,2,2,3,4,4,3]
输出: true
输入: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
,翻转这棵二叉树,并返回其根节点。
输入: 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 的节点将直接作为新二叉树的节点。
返回合并后的二叉树。
注意: 合并过程必须从两个树的根节点开始。
输入: 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)
二叉搜索树(也叫二叉排序树)是一种特殊的二叉树,它的定义如下:
- 左子树不为空,那么左子树上所有节点的值均小于根节点的值
- 右子树不为空,那么右子树上所有节点的值都大于根节点的值
- 左右子树也均满足二叉搜索树
输入: root = [2,1,3]
输出: true
输入: 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
这道题可以分为三种情况:
- 无根节点时,最小深度为 0
- 当根节点无左子树 或者无右子树时,最小深度为
1 + 左 + 右
(左右必有至少一个为 0) - 当根节点同时存在左右子树时,最小深度为 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 。 计算从根节点到叶节点生成的 所有数字之和
输入:root = [1,2,3]
输出:25
解释:
从根到叶子节点路径 1->2 代表数字 12
从根到叶子节点路径 1->3 代表数字 13
因此,数字总和 = 12 + 13 = 25
思路其实非常简单,我们需要层层遍历二叉树,因此需要记录一个 temp value,代表上一层相加后的值。
遍历分为三种情况:
- 根节点为 null,则返回 0
- 如果不为 null,那么 temp value 需要先乘 10(因为是上一层的结果),然后再加上当前层的 root.value
- 如果左右子树都不存在,则返回 temp value
- 否则分别递归左右子树,并返回相加后的结果
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)
给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出:3
解释:节点 5 和节点 1 的最近公共祖先是节点 3 。
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出:5
解释:节点 5 和节点 4 的最近公共祖先是节点 5 。
因为根据定义最近公共祖先节点可以为节点本身。
我们需要对二叉树进行遍历:
- 如果 p / q 是根节点,那么最小公共祖先肯定就是根节点了
- 如果不是,则分别遍历左右子树
- 如果 p 和 q 分别在左右子树上,那么最小公共祖先就是根节点
- 如果 p 和 q 都不在左子树上,那么最小公共节点存在于右子树
- 右子树同理
- 如果都 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)
输入: root = [1,2,3]
输出: 6
解释: 最优路径是 2 -> 1 -> 3 ,路径和为 2 + 1 + 3 = 6
输入: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% 以上面试官最爱出的二叉树相关题目。