Hello, 各位勇敢的小伙伴, 大家好, 我是你们的嘴强王者小五, 身体健康, 脑子没病.
本人有丰富的脱发技巧, 能让你一跃成为资深大咖.
一看就会一写就废是本人的主旨, 菜到抠脚是本人的特点, 卑微中透着一丝丝刚强, 傻人有傻福是对我最大的安慰.
欢迎来到
小五的算法系列之Hello,树先生.
前言
此系列文章以《算法图解》和《学习JavaScript算法》两书为核心,其余资料为辅助,并佐以笔者愚见所成。力求以简单、趣味的语言带大家领略这算法世界的奇妙。
树对于前端而言简直无处不在,DOM树、CSSOM树、级联选择器、嵌套路由等等;其特殊形态二叉树也在算法的面试中占据了非常重要的一环;接下来我们就分别进入这两所奇特的殿堂,来探寻他们的故事。
建议前置阅读:深度优先遍历与广度优先遍历
前端与树
DOM树
<html>
<head>
<title>标题</title>
<style>...</style>
</head>
<body>
<h1>Hello world !</h1>
<div>
<p>内容1</p>
<p>内容2</p>
</div>
<ul>
<li></li>
<li></li>
<li></li>
</ul>
</body>
</html>
树形结构如下:
CSSOM树
#div1 .c .d {}
.f .c .d {}
.a .c .e {}
#div1 .f {}
.c .d {}
树形结构如下:
可以看出,css是从右向左进行解析的
-
减少嵌套,降低选择器的深度有助于css的查找
-
通配符、标签选择器也会逐层向上查找,建议采用类选择器代替
特殊的树 -- 二叉树
各个节点的度不超过2的树即为二叉树
为了便于阅读,这里列举一些树的名词解释(不要细化概念,知道大体意思即可,建议扫读):
根节点:
-
图中 root 节点;
-
树顶端的节点称为根节点,其没有父节点;
父节点:
-
图中 A 为 A1 的父节点;
-
若一个节点含有子节点,则这个节点称为其子节点的父节点;
子节点:
-
图中 A1 为 A 的子节点;
-
若一个节点含有父节点,则这个节点为其父节点的子节点(概念中子节点是除根节点和叶子节点外的节点,本文不做区分);
兄弟节点:
-
图中 A1、A2 互为兄弟节点;
-
具有相同父节点的节点称为兄弟节点;
叶子节点:
-
图中 A1、A2、B1、B2 为叶子节点;
-
没有子节点的节点(度为0的节点)即为叶子节点;
子树:
-
图中 A、A1、A2 为左子树,B、B1、B2 为右子树;
-
子节点及其后代组成的树即为子树,二叉树中左侧的子树为左子树,右侧的子树为右子树;
度:
-
图中 root 节点的度为2,A1 节点的度为0
-
一个节点含有的子树的个数称为该节点的度;一颗树中,最大的节点的度为该树的度;
层:
-
图中树为3层结构
-
从根节点开始,根节点为第一层,其子节点为第二层,以此类推;
深度、高度:
-
根深叶高
-
节点到根的距离为深度,节点到叶的距离为高度;
树结构定义:
下文的树均沿用该结构
interface TreeNode {
val: String | Number,
left: TreeNode,
}
先序遍历
遍历顺序 -> 根左右
先序遍历结果如下:A -> B -> D -> E -> C -> G -> F
🌲 递归解法:
按照其遍历顺序 根左右 递归即可;
-
推入当前节点的值
-
递归调用左子树
-
递归调用右子树
代码如下:
const preorder = root => {
if (!root) return [];
const result = [];
const dfs = (root) => {
if (!root) return;
result.push(root.val);
dfs(root.left);
dfs(root.right);
}
dfs(root);
return result;
};
🌲 迭代解法:
我们看这个遍历过程:
-
A -> B -> D
-
回退到 B
-
B -> E
-
回退到 B
-
回退到 A
-
A -> C -> G
-
回退到 C
-
C -> F
看过上一篇文章的朋友应该知道,这就是一个深度优先遍历的过程,而深度优先遍历的本质是栈。
那这个问题就转变为了:
-
A入栈
-
A出栈
-
C、B入栈
-
B出栈
-
E、D入栈
-
D出栈
-
E出栈
-
C出栈
-
F、G入栈
-
G出栈
-
F出栈
代码实现如下:
const preorder = root => {
if (!root) return [];
const stack = [];
const result = [];
stack.push(root);
while (stack.length) {
const top = stack.pop();
result.push(top.val);
top.right && stack.push(top.right);
top.left && stack.push(top.left);
}
return result;
};
后序遍历
遍历顺序 -> 左右根
后序遍历结果如下:D -> E -> B -> G -> F -> C -> A
🌲 递归解法:
按照其遍历顺序 左右根 递归即可,代码如下:
const postorder = root => {
if (!root) return [];
const result = [];
const dfs = (root) => {
if (!root) return;
root.left && dfs(root.left);
root.right && dfs(root.right);
result.push(root.val);
}
dfs(root);
return result;
};
🌲 迭代解法:
我们不妨想象一下,把图中箭头全部反过来,是不是就和先序遍历联系起来了:
-
A入栈
-
A出栈
-
B、C入栈
-
C出栈
-
G、F入栈
-
F出栈
-
G出栈
-
B出栈
-
D、E入栈
-
E出栈
-
D出栈
怎么实现这个逆过程呢?有朋友会说可以将结果数组翻转,当然可以,但怎么能省去这次遍历过程呢?push 对应 的 unshilft 会帮我们解决这个问题,其代码如下:
const postorder = root => {
if (!root) return [];
const stack = [];
const result = [];
stack.push(root);
while (stack.length) {
const top = stack.pop();
result.unshift(top.val);
top.left && stack.push(top.left);
top.right && stack.push(top.right);
}
return result;
};
中序遍历
遍历顺序 -> 左根右
中序遍历结果如下:D -> B -> E -> A -> G -> C -> F
🌲 递归解法:
按照其遍历顺序 左根右 递归即可,代码如下:
const inorder = root => {
if (!root) return [];
const result = [];
const dfs = (root) => {
if (!root) return;
root.left && dfs(root.left);
result.push(root.val);
root.right && dfs(root.right);
}
dfs(root);
return result;
};
🌲 迭代解法:
中序遍历为左根右的顺序,故思路如下:当前节点一路向左入栈,到叶子节点后依次出栈,若其有右子树,入栈右子树,反复循环;值得注意的是,左子树用完后记得清空,避免重复入栈。
过程如下:
-
A入栈
-
B、D入栈,D出栈
-
B出栈,E入栈
-
E出栈
-
A出栈,C入栈
-
G入栈,G出栈
-
C出栈,F入栈
-
F出栈
代码如下:
const inorder = root => {
if (!root) return [];
const stack = [];
const result = [];
stack.push(root);
let cur = root;
while (stack.length) {
while (cur.left) {
cur = cur.left;
stack.push(cur);
}
cur = stack.pop();
cur.left = null;
result.push(cur.val);
if (cur.right) {
cur = cur.right;
stack.push(cur);
};
}
return result;
};
层序遍历
层序遍历结果如下:A -> B -> C -> D -> E -> G -> F
标准的广度优先遍历,采用队列,详细可见上篇文章广度优先部分,代码如下:
const levelOrder = root => {
const queue = [];
const result = [];
queue.push(root);
while (queue.length) {
const head = queue.shift();
result.push(head.val);
if (top.left) queue.push(head.left);
if (top.right) queue.push(head.right);
}
return result;
}
二叉搜索树
二叉搜索树(BST)是一种有序的二叉树,其需满足 左节点 <= 根节点 <= 右节点;
查询
🌲 最大值和最小值
如图,有序的特点使我们不断向左查询即可找到最小值,不断向右查询即可找到最大值
const min = root => {
if (!root) return null;
let cur = root;
while (cur.left) {
cur = cur.left;
}
return cur.val;
}
🌲 任意值
如图,目标值大于当前节点值,向右走;目标值小于当前节点值,向左走;
const searchBST = (root, val) => {
let cur = root;
while (cur) {
if (val === cur.val) return cur;
else if (val > cur.val) cur = cur.right;
else cur = cur.left;
}
return null;
};
添加
和查找任意值如出一辙;目标值大于当前节点值,向右走;目标值小于当前节点值,向左走;走到叶子节点时,根据其与叶子节点的比对,插入相应位置。
const insertIntoBST = (root, val) => {
if (!root) return new TreeNode(val);
let cur = root;
while (cur) {
if (val > cur.val) {
if (cur.right) {
cur = cur.right;
} else {
cur.right = new TreeNode(val);
break;
}
} else {
if (cur.left) {
cur = cur.left;
} else {
cur.left = new TreeNode(val);
break;
}
}
}
return root;
};
删除
-
第一步,找到需要删除的节点
-
若删除节点为叶子节点,直接置为空
- 若删除节点仅有左子树或仅有右子树,则其前置节点的next等于其后置节点即可
- 若其两侧均有子树,则找寻其左子树的最大值或右子树的最小值,替换并删除找到的叶子节点
const deleteNode = (root, key) => {
if (!root) return null;
if (key > root.val) {
root.right = deleteNode(root.right, key);
return root;
}
if (key < root.val) {
root.left = deleteNode(root.left, key);
return root;
}
if (key === root.val) {
if (!root.left && !root.right) return null;
if (root.left && root.right) {
let cur = root.left;
while (cur.right) {
cur = cur.right;
};
root.val = cur.val;
root.left = deleteNode(root.left, root.val);
return root;
}
if (!root.left || !root.right) return root.left || root.right;
}
};
平衡二叉树
平衡二叉树(AVL)是一种特殊的二叉搜索树,其任意节点的左右子树的高度差都不大于1;
平衡二叉树是对二叉搜索树的一种优化,其解决二叉搜索树可能存在某条边过深的性能问题;
判定平衡二叉树
平衡二叉树的特点为树上每个节点左右子树高度差不大于一,每个节点均要判断代表着需要递归,高度差不大于1即判定条件。
当前节点高度为其左右节点高度的最大值加1
代码如下:
const isBalanced = (root) => {
let flag = true;
const dfs = (root) => {
if (!root) return 0;
const left = dfs(root.left);
const right = dfs(root.right);
if (Math.abs(left - right) > 1) {
flag = false;
}
return Math.max(left, right) + 1;
}
dfs(root);
return flag;
};
构造平衡二叉树
给定一颗二叉搜索树,将其构造成平衡二叉树
二叉树的中序遍历是有序的,可取其中序遍历后的值做二分处理,生成平衡二叉树,如下图:
代码如下:
const balanceBST = (root) => {
const mid = [];
const midSearch = (root) => {
root.left && midSearch(root.left);
mid.push(root.val);
root.right && midSearch(root.right);
}
midSearch(root);
const createAVL = (arr) => {
const mid = Math.floor(arr.length / 2);
const leftArr = arr.slice(0, mid);
const rightArr = arr.slice(mid + 1, arr.length);
let left = null;
let right = null;
if (leftArr.length) left = createAVL(leftArr);
if (rightArr.length) right = createAVL(rightArr);
return new TreeNode(arr[mid], left, right);
}
return createAVL(mid);
};
小试牛刀
LeetCode 111. 二叉树的最小深度
👺 题目描述
给定一个二叉树,找出其最小深度。最小深度是从根节点到最近叶子节点的最短路径上的节点数量。
👺 题目分析
逐层查找 -- 广度优先遍历
边界值 -- 叶子节点
代码如下:
const minDepth = (root) => {
if (!root) return 0;
const result = [];
let deep = 0;
result.push(root);
while (result.length) {
deep++;
const len = result.length;
for (let i = 0; i < len; i++) {
const head = result.shift();
head.left && result.push(head.left);
head.right && result.push(head.right);
if (!head.left && !head.right) return deep;
}
}
return deep;
};
LeetCode 112. 路径总和
👺 题目描述
给你二叉树的根节点 root 和一个表示目标和的整数 targetSum。判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和 targetSum。如果存在,返回 true;否则,返回 false。
👺 题目分析
路径求和 -- 深度优先遍历
触发条件 -- 叶子节点
触发值 -- targetSum === 路径总和
代码实现如下:
const hasPathSum = (root, targetSum) => {
if (!root) return false;
let flag = false;
const dfs = (root, sum) => {
sum = sum + root.val;
if (!root.left && !root.right && sum === targetSum) flag = true;
root.left && dfs(root.left, sum);
root.right && dfs(root.right, sum);
}
dfs(root, 0);
return flag;
};
LeetCode 226. 翻转二叉树
👺 题目描述
给你一棵二叉树的根节点 root,翻转这棵二叉树,并返回其根节点。
👺 题目分析
递归二叉树,将树的左右节点互换即可
const invertTree = (root) => {
if (!root) return null;
const dfs = (root) => {
const left = root.left;
root.left = root.right;
root.right = left;
root.left && dfs(root.left);
root.right && dfs(root.right);
}
dfs(root);
return root;
};
后记
🔗 本系列其它文章链接: