前言
当有人问你二叉树的表示和遍历 , 我们也许都能抬手就敲 , 就算不知道 , 看几分钟代码 , 也能迅速记忆 ,一顿操作猛如虎 , 代码也可以写得有模有样 。
但却缺乏思考 .
一旦在面试中 , 面试官问你为什么这样写 ,就可能表现不佳 , 即使可以说出一点 , 我们也可能只是在玩概念 , 没有深度 。
脱离了高中的应试教育 , 在大学,我自己批给自己的自由 (懂得都懂) , 让我更有时间去思考 , 而非简陋的记忆(水课考试速成还是 ,哈哈🤡) .
思想无价而永恒 , 记忆机械而短暂 , 让我们一起做一个有思想灵魂的代码人 !
面试题
面试官 : 请用 js 代码表示这颗树
A
/ \
B C
/ \ / \
D E F G
leetcode 上不需要你表示一棵树 ,导致很多人 , 都不知道怎么写 , 然而 , 学过数据结构 , 并且有一定的思考 , 也可以较快学会 。
关于一颗二叉树的表示 , 我们可以根据节点之间的关系 :
父节点 为 i , 则左节点为 2i +1 , 右节点为 2i + 2
所以一颗二叉树 ,可以利用父子关系 , 在数组中通过下标计算来表示每个节点的位置 , 在数学关系上 , 形成一颗树 。
但是 , 却失去了二叉树本身的结构 。
而我们实际开发中 , 常用链表的形式来表示二叉树 , 因为一个节点有左节点,右节点 , 不就是链表中 : 一个元素节点 , 有左指针 , 右指针吗 ?
我们对上面这颗树进行抽象 。
可是 js 有指针吗 ?
js 中没有指针 , 但是 js 有引用类型 ! 某种程度上类似于指针的概念 。
在 js 中 , 对象就是一个引用类型 , 变量储存在栈内存中 , 值储存在堆内存中 .
由于栈内存中储存堆内存的地址 , 所以我们可以理解为"变量" 指向 "值" ;
说到这里 , 还是不能表示一颗树出来 , 你还要理解树的递归性 , 树的结构本身就是递归圣体 ,
在不断的递归着这个结构 :
根节点
/ \
左节点 右节点
比如下面的图 :
对于整棵树 , 根节点 A , 左节点 B ,右节点 C
而对于左子树 , 根节点为 B , 左节点为 D , 右节点为 E
对于右子树 , 根节点为 C , 左节点为 F , 右节点为 G
A
/ \
B C
/ \ / \
D E F G
所以我们需要三个变量来分别表示 : val (根节点) , left (左节点) , right (右节点) 。
请看下面这段代码 :
const root = {
val: 'A',
left: {
val: 'B',
left: {
val: 'D'
},
right: {
val: 'E'
}
},
right: {
val: 'C',
left: {
val: 'F'
},
right: {
val: 'G'
}
}
}
面试官 : 请遍历这颗树
我对 dsf 和 bfs 的理解
遍历这颗树 , 我们要明白 , 树的遍历方式有什么 ?
- 深度优先遍历 (dfs) : 俗称"爆搜" , 指每条路死劲搜 , 直到搜到结果或者越界了 , 才沿原路返回(回溯) , 回到之前原点 , 再换条没走过的路 , 继续死劲搜 , 终止条件同上 , 如此递归 . (这句话后面是突破点 )
- 前序遍历
- 中序遍历
- 后序遍历
- 广度优先遍历 (bfs): 一层一层搜 , 一圈一圈搜 。
- 层序遍历
深度优先遍历
三种遍历的统一思考
所谓 ,前序 , 后序 , 中序遍历 都是以根节点的遍历顺序为标准的 , 比如
前序遍历 : 中 -> 左 -> 右 ("中" 指根节点 , "左"指左节点 , "右"指右节点)
中序遍历 : 左 -> 中 -> 右
后序遍历 : 左 -> 右 -> 中
那么如何实现呢 ?
我们可以利用递归 , 上文提到 , 树的递归 , 其实是在递归这样的结构 :
根节点
/ \
左节点 右节点
那么遍历呢 ? (举前序遍历为例)
前序遍历就是在递归如下遍历顺序
- 中 -> 左 -> 右 ("中" 指根节点)
1)前序遍历先输出根节点
function preOrder(root) {
console.log(root.val); // 中
}
2)之后输出左节点
那么再次调用 preOrder() , 只不过此时 root = root.left (左节点)
function preOrder(root) {
console.log(root.val); // 中
preOrder(root.left); // 左
}
3)同理 : 输出右节点也是调用 preOrder() , 传入右节点
function preOrder(root) {
console.log(root.val); // 中
preOrder(root.left); // 左
preOrder(root.right); // 右
}
至此 , 递归体完成 , 即不断递归"中 , 左 , 右"
可是什么时候终止 ?
这里的终止 , 是单层递归的终止 , 比如我上面对深度优先遍历的解释
指每条路死劲搜 , 直到搜到结果或者越界了 , 才沿原路返回 , 回到之前没搜过的点 , 再换条路 , 继续死劲搜 , 终止条件同上 , 如此递归 .
你想象你在一个十字路口 , 你有四条路可选 , 你不知道哪条路的终点有你的梦想 , 所以你只能爆搜, 四条都走一遍。
走第一条路 , 到终点 , 原路返回(回溯) , 回到十字路口 , 由于第一条路走过了 , 所以不会再走。
同理 , 第二 ,三 ,四条路 ,都是这样搜 , 每次都会回到十字路口 , 终止条件就是走到终点 , 只是结束了一条路的探索 , 也就是我上面说的 , 结束单层递归 ,当四条路都搜完了 , 回到十字路口 , 发现无路可走(都走过了) , 此时 , 梦想破裂 , 人生绝望 , 人生苦海的递归结束 。
类比到树 :
节点为空就像每条路的终点, 即单层递归的终点 , 当所有节点访问完 , 即当所有路走完的时候 ,才是彻底结束递归 。
同样以 前序遍历 为例 :
十字路口 : A
第一条路 : 左子树 BDE
A
/ \
B C
/ \ / \
D E F G
function preOrder(root) {
if (!root) return;
console.log(root.val); // 中
preOrder(root.left); // 左
preOrder(root.right); // 右
}
看上面代码 , 首先输出 A
之后 执行函数preOrder(root.left);// 左 (此时 root.left = B)
该函数在遍历左子树 BDE (大家可以跟着代码流走一走) , 当遍历到 E 的时候 , E左节点为 NULL , 结束该层递归 。
此时 preOrder(root.left);函数结束
控制台输出 : A B D E
接下来要执行
preOrder(root.right);// 右
第二条路 : 右子树 CFG
A
/ \
B C
/ \ / \
D E F G
function preOrder(root) {
if (!root) return;
console.log(root.val); // 中
preOrder(root.left); // 左
preOrder(root.right); // 右
}
第一条路走到终点后 , 回到原点(A) , 继续执行 preOrder(root.right); // 右这里的 root.rigth = C , A的右节点。
而该函数是在遍历右子树 , 控制台会继续输出 , C F G , 当走到 G 的时候 , G左节点为 NULL , 终止该层递归 .
两条路走完后 , 所有节点都被访问 , 这才是彻底结束递归。
先序遍历
A
/ \
B C
/ \ / \
D E F G
function preOrder(root) {
if (!root) return;
console.log(root.val); // 中
preOrder(root.left); // 左
preOrder(root.right); // 右
}
中序遍历
A
/ \
B C
/ \ / \
D E F G
function midOrder(root) {
if (!root) return;
midOrder(root.left); // 左
console.log(root.val); // 中
midOrder(root.right); // 右
}
后序遍历
A
/ \
B C
/ \ / \
D E F G
function postOrder(root) {
if (!root) return;
postOrder(root.left); // 左
postOrder(root.right); // 右
console.log(root.val); // 中
}
广度优先遍历
层序遍历
顾名思义 , 就是一层一层的遍历 , 比如以下树的遍历 ;
A
/ \
B C
/ \ / \
D E F G
应当输出 :
A B C D E F G
这个怎么想 ?
也许有人上来就告诉你 , 这个要用队列 ,可是为什么要用到队列呢 ? 不知道大家想过没有 ,
在之前我也没有想过 , 但写这篇文章的时候 , 看着标题 : 思考 > 撸码 , 我不禁 push 自己来思考这个问题 。
我说说我的理解 :
首先要思考一下两点
- 为什么要额外添加一个数据结构 ?
- 这个数据结构又为什么是队列 ?
还是看这个图 :
A
/ \
B C
/ \ / \
D E F G
我们可以发现 , 对于树的节点而言 , 它的底层是一个有着左右指针的节点 , 这也就表明了 ,一个树的节点 , 只能访问它的左节点和右节点,
比如通过 B 可以访问 DE 节点 , 但是不能访问到同一级的节点 C , 也就是说 , 如果只是单纯的遍历树的每一个节点 , 我们不能得到与当前遍历节点同一级的节点 ,比如 B C , 要得到 B C , 就必须要在遍历到 A的 时候 ,就赶紧用小本本记录下来 , 错过了这个村 ,就没有这个店了 ! 这个小本本就是某种数据结构 , 比如数组 , 队列 ,栈 ……
用数组吗 ? 用栈 ? 用队列 ?
到底如何选择 ?
首先 , 数组肯定不是 , 因为元素要动态迭代 , 比如
第一层 , 存 A 就行
第二层 , 存 B C ,A 不要了
这个过程就恰好是队列的思想 , A 出队列 , B C 入队列 , 就得到了第二层元素
同理 , 第三层 , 就把第二层的元素BC踢出 , 把第三层DEFG元素入队 , 这样就得到第三元素 ,要注意的是 , 第三层元素是第二层的子节点 , 所以踢出第二层元素的时候 , 注意榨取它的剩余价值 , 把它的子节点元素记录下来 。
我画个图 ,来更好表达我上面的话 :
A
/ \
B C
/ \ / \
D E F G
function levelOrder(root) {
if (!root) return [];
const result = [];
const queue = [root]; // 作为整棵树的根节点入队
while (queue.length) {
const current = queue.shift(); //看成父节点出队列(实际可能是一颗树)
if (current.left) queue.push(current.left); //看成左边子节点入队 (实际左子树入队)
if (current.right) queue.push(current.right); //看成右边子节点入队 (实际右子树入队)
result.push(current.val); // 记录出队的节点,经过上面的三行代码,一定是一层一层的出队,然后入队
}
return result;
}
const result = levelOrder(root)
result.forEach(item =>{
process.stdout.write(item+" ")
})
总结
思想的厚度 >> 撸码 , 在大学 , 给自己批假 , 撸码闲暇之际 , 用来思考 , 有时候真的很有趣 !
我是一个在校大学生 , 欢迎大家一起交流 !