正文
在前端中确实用到不少与树相关的的知识,比方说 DOM 树,Diff 算法,包括原型链其实都算是树,学会树,其实对于学这些知识还是有比较大的帮助的,当然我们学算法还是得考虑面试,而树恰好也是一个大重点 – 起码在前端而言;
主要原因在于,树它华而不实,比较下里巴人,需要抽象但是又能把图画出来不至于让你毫无头绪,简单而言就是看上去很厉害,但实际上也很接地气,俗称比较一般
;要知道做前端的面试算法,考的不就是你有么得主动学习能力,抽象能力等,但是考虑到参差不齐的前端娱乐圈,考得难吧可能就全是漏网之鱼了,所以既要筛选出鱼,但是又不能难度过大,树就是那个比较适中的,所以赶紧刷起来吧朋友们;
这里本来是要遵照 3:5:2 难度来刷,预计刷个30题就差不多,但是实际中等题刷得欲罢不能,难题是欲仙欲死,容易题是味如嚼蜡,所以 XDM 担待一下。选题主要是那个男人精选的例题以及 Leetcode 中 HOT 题和字节专题,总的来说代表性还是够的,刷完应该大概或许能够应付一下树这方面的算法了。
如果觉得有那么点帮助,请点个赞留个言,点赞超过10个就更新下一part;好吧,即便不过也会更新,就是这么臭不要脸,大家伙加油吧,欧力给!!
二叉树的遍历
递归遍历
- 递归的时候前中后序都能直接处理完了
- 递归是前中后序遍历最简单也是最容易出理解的方法,不懂的画个图就好了
迭代遍历 – 双色标记法
- 使用颜色标记节点状态,新节点为白色,已经访问的节点为灰色 – 可以用数字或者其他任意标签标示
- 如果遇到的节点是白色,则标记为灰色,然后将右节点,自身,左节点一次入栈 – 中序遍历
- 如果遇到的节点是灰色的,则将节点输出
- 注意这里是用 stack 栈来存储的,所以是后进先出,所以如果是中序遍历,左 - 中 - 右 ,那么在插入栈的时候要反过来 右 - 中 - 左
按照那个男人的指示,正常我们就用递归做就好,就好像我们做非排序题排序的时候,sort 一下就好了,但是一旦面试官问到用另外的迭代方式的时候,我们再套个模板,会比记住多个迭代写法要简单,毕竟内存容量有限,而后续遍历的迭代写法确实挺坑的,能省一点内存就省一点吧
144. 二叉树的前序遍历
// 144. 二叉树的前序遍历
/** * @分析 -- 递归 */
var preorderTraversal = function (root) {
const ret = [];
const recursion = (root) => {
if (!root) return;
ret.push(root.val);
recursion(root.left);
recursion(root.right);
};
recursion(root);
return ret;
};
/** * @分析 -- 迭代 -- 双色标记法 * 1. 使用颜色标记节点状态,新节点为白色,已经访问的节点为灰色 -- 可以用数字或者其他任意标签标示 * 2. 如果遇到的节点是白色,则标记为灰色,然后将右节点,自身,左节点一次入栈 -- 中序遍历 * 3. 如果遇到的节点是灰色的,则将节点输出 * 4. 注意这里是用 stack 栈来存储的,所以是后进先出,这里是前序遍历,中 - 左 - 右 ,那么在插入栈的时候要反过来 右 - 左 - 中 */
var preorderTraversal = function (root) {
const ret = [];
const stack = [];
stack.push([root, 0]); // 0 是白色未处理的,1 是灰色处理过的
while (stack.length) {
const [root, color] = stack.pop();
if (root) {
if (color === 0) {
// 遇到白球,则插入 -- 前序
stack.push([root.right, 0]);
stack.push([root.left, 0]);
stack.push([root, 1]);
} else {
// 遇到灰球,则收网
ret.push(root.val);
}
}
}
return ret;
};
1.94 二叉树的中序遍历
// 94. 二叉树的中序遍历
/** * @分析 * 1. 递归的时候前中后序都能直接处理完了 * 2. 递归是前中后序遍历最简单也是最容易出理解的方法,不懂的画个图就好了 */
var inorderTraversal = function(root) {
const ret = []
const recursion = root => {
if(!root) return
recursion(root.left)
// 这里是中序,所以在两个递归之间,如果是前序就在前面,后序就在后面
ret.push(root.val)
recursion(root.right)
}
recursion(root)
return ret
};
/** * @分析 -- 迭代 -- 双色标记法 * 1. 使用颜色标记节点状态,新节点为白色,已经访问的节点为灰色 -- 可以用数字或者其他任意标签标示 * 2. 如果遇到的节点是白色,则标记为灰色,然后将右节点,自身,左节点一次入栈 -- 中序遍历 * 3. 如果遇到的节点是灰色的,则将节点输出 * 4. 注意这里是用 stack 栈来存储的,所以是后进先出,所以如果是中序遍历,左 - 中 - 右 ,那么在插入栈的时候要反过来 右 - 中 - 左 */
var inorderTraversal = function(root) {
const ret = []
const stack = []
stack.push([root,0]) // 0 是白色未处理的,1 是灰色处理过的
while(stack.length) {
const [root,color] = stack.pop()
if(root){
if(color === 0){
// 遇到白球,则插入 -- 中序遍历
stack.push([root.right,0])
stack.push([root,1])
stack.push([root.left,0])
}else{
// 遇到灰球,则收网
ret.push(root.val)
}
}
}
return ret
};
145. 二叉树的后序遍历
// 145. 二叉树的后序遍历
/** * @分析 -- 递归 */
var postorderTraversal = function(root) {
const ret = []
const dfs = (root) => {
if(!root) return
dfs(root.left)
dfs(root.right)
ret.push(root.val)
}
dfs(root)
return ret
};
/** * @分析 -- 迭代 -- 双色球 */
var postorderTraversal = function(root) {
const ret = []
const stack = []
stack.push([root,0])
while(stack.length){
const [root,color] = stack.pop()
if(root) {
if(color === 0){
stack.push([root,1])
stack.push([root.right,0])
stack.push([root.left,0])
}else{
ret.push(root.val)
}
}
}
return ret
}
刷题过程一些疑惑点
自顶向下(前序遍历)和自低向上(后续遍历)
这两个名词在很多讲树的题解中经常会出现,而这与我们遍历树求值到底关联点在哪里,慢慢刷题之后我发现,虽然 dfs 有三种形式,但在抽象到具体题目的时候,其实是属于不同的方法的。
对于前序遍历
而言,就是先获取到根节点的信息,然后做了一定编码后,再向下遍历,这种遍历方式就是所谓的 自顶向下
的思维,我们从根节点开始,可以携带一定的信息,再继续往下遍历时,先处理,得到临时性结果,给顶层的节点作为信息;
对于自顶向下
的遍历而已,遍历到根节点,就处理结束所有的节点,也相应的得到预期结果了,所以一般使用前序遍历
方法解题的,都会声明一个全局变量,然后遍历完之后,返回这个值.
例子:563. 二叉树的坡度
分析
1. 自底向上返回子树值之和,然后求出对应的坡度,累加起来即可.
2. 需要注意的是,左右子树的累加值大小不确定,需要用绝对值
3. 时间复杂度 ${
O(N)}$
var findTilt = function (root) {
let ret = 0;
const recursion = (root) => {
if (!root) return 0;
const left = recursion(root.left);
const right = recursion(root.right);
ret += Math.abs(left - right);
return left + right + root.val;
};
recursion(root);
return ret;
};
对于后序遍历
而言,是想遍历到叶子节点,然后再向上去处理根节点,也就是所谓的 自底向上
;
实际上,自底向上
是一种递归的方法,先递
到叶子节点,处理完返回一定的值,再归
回来,后续的处理都是根据子树的值作为入参的,所以不要被 遍历
迷惑,后续遍历
可不是遍历完就结束了,那才刚刚开始呢。
所以后面为了区分,在处理自底向上
题目的时候,函数名字都不再使用 dfs,而是直接使用 recursion ;
参考视频:传送门
例子:
判断遍历到边界,什么在叶子节点处判断,什么时候直接跑到 null 返回?
先来解释一下,在做 dfs 遍历的时候,我们需要遍历到叶子节点,然后做最终的处理,有的题目我们看到的是判 null
时返回 null/0 等;有的时候我们直接判断是否叶子节点,if(!root.left && !root.right)
;
这是在刷题过程中感觉忒迷惑的地方,在最开始的时候,我喜欢使用 null ,因为它写的更少,而且顺便把根节点为空的边界也做了,最近刷的时候我开始觉得判断节点
会更稳妥一点,而且不用做更深的处理,直到我再写👆上面的文字时,有那么一点想法
在我们使用自底向上
的时候,因为需要从子节点中 return 值,这个时候即便是 null 也是有用
的,所以使用 null
基本是 OK 的。
例子: 104. 二叉树的最大深度
/** * 1. 自顶向下,带个层数参数,判定为叶子节点就进行最大值判断 */
var maxDepth = function (root) {
if (!root) return 0;
let ret = 0;
const dfs = (root, depth) => {
if (root.left) dfs(root.left, depth + 1);
if (root.right) dfs(root.right, depth + 1);
ret = Math.max(ret, depth);
return;
};
dfs(root, 1);
return ret;
};
// 自低向上
var maxDepth = function (root) {
const dfs = (root) => {
if (!root) return 0;
return Math.max(dfs(root.left), dfs(root.right))+1;
};
return dfs(root);
};
而在一些携带数据,自顶向下
求值的题目中,如果跑到 null
才结束遍历,就比较容易出现重复计算的错误,而且由于不需要获取 return 值,这个时候我建议是使用判断节点
的方法。
例子:1022. 从根到叶的二进制数之和
/** * @分析 * 1. 自顶向下求出每一条路当前对应的数字,保存在入参中 * 2. 在叶子节点处将值累加起来即可 * 3. 需要注意的是,要在叶子节点就处理,而不是在 null 的时候处理,不然会重复计算 */
var sumRootToLeaf = function(root) {
// if(!root) return 0 //题目已知节点是 1-1000
let ret = 0
const dfs = (root,sum) => {
const temp = (sum<<1) + root.val
if(!root.left && !root.right){
ret +=temp
return
}
if(root.left) dfs(root.left,temp)
if(root.right) dfs(root.right,temp)
}
dfs(root,0)
return ret
};
简单题
101. 对称二叉树
分析
- 对称二叉树,其实是要求是否镜像对齐,所以递归过程至少需要两个根节点,然后 dfs 主要就是判断是否是对称的两棵树
- 这里是自顶向下分配相互比较的子树节点 left 和 right,然后再自底向上的返回最终结果
- 在某一次 dfs 中,如果比较双方都是 null,那么证明比较双方是对称的;如果出现只有一方有值,或者双方有值但是值不一样的时候,返回 false;
- 每次递归都是左右外层构成比较,左右内层构成比较
- 时间复杂度: O(h), 其中 h 是树的高度
// 101. 对称二叉树
var isSymmetric = function (root) {
if (!root) return false;
const dfs = (left, right) => {
if (!left && !right) return true;
if (!left ||