树,包括图,在遍历时都存在两种方式:深度优先遍历和广度优先遍历。 树,一定有一个根节点,而图,没有根节点,但图中的任意节点都可以作为根节点使用(当然该节点一定要有边,否则没有意义)
树的遍历
树的深度优先遍历
核心思想:优先访问子节点而非兄弟节点,直到子节点的递归调用栈清空,再访问兄弟节点。
- 访问当前节点
- 将当前节点的children作为子树的根节点递归访问
使用递归的方式
// 传入树的根节点,开始深度优先遍历,每个节点的子节点用children数组来表示
function dfs(root) {
// 访问节点数据
console.log(root.val);
if (root.children.length > 0) {
for(let i = 0; i < root.children.length; i ++) {
dfs(root.children[i]);
}
// 或者使用root.children.forEach(dfs)来替换上面的for循环
}
}
树的广度优先遍历
核心思想:优先访问兄弟节点,为了保证访问顺序,使用队列来存储要访问的节点。
- 新建队列,将根节点入队
- 队列出队,访问出队节点
- 将出队节点的children依次入队
- 重复2和3,直到队列为空
使用队列的方式
// 传入树的根节点,开始深度优先遍历
function bfs(root) {
const queue = [];
queue.push(root);
while(queue.length > 0) {
const node = queue.shift();
console.log(node.val);
node.children && node.children.forEach(child => queue.push(child))
}
}
在打包模块时,可以使用广度优先遍历从入口文件触发,构建模块的依赖图谱。
图的遍历
图与树的遍历很类似,但最明显的一点在于,树是有指向性(方向性)的,而无向图没有方向性,因此在遍历无向图时,需要额外的操作就是标记已访问的节点(可以使用一个集合来标记已访问过的节点),防止节点出现循环访问的情况(即节点的访问形成一个圈变为死循环)。
另外,作为遍历算法的起始点,不同的起始点可能导致最后遍历到的节点情况不同,这与图本身有关。
图的深度优先遍历
- 访问当前节点
- 将当前节点的未访问的相邻节点递归访问 如图所示
使用递归的方式
// 这里的图使用邻接表的方式来表示
const graph = {
0: [1, 2], // 节点0与节点1,节点2相邻
1: [2],
2: [0, 3],
3: [3]
}
// 存储已访问的节点的集合
const visited = new Set()
// 传入图的任意节点,开始深度优先遍历
function dfs(node) {
// 访问节点数据
console.log(node);
// 加入已访问的节点集合
visited.add(node)
graph[node].forEach(n => {
// 如果没有访问过该节点,则递归访问该节点
if (!visited.has(n)) {
dfs(n);
}
})
}
图的广度优先遍历
核心思想:优先访问兄弟节点,为了保证访问顺序,使用队列来存储要访问的节点。
- 新建队列,将根节点入队
- 队列出队,访问出队节点
- 将出队节点的没有被访问过的相邻节点依次入队
- 重复2和3,直到队列为空
使用队列的方式
// 存储已访问的节点的集合
const visited = new Set()
// 传入树的根节点,开始深度优先遍历
function bfs(node) {
const queue = [];
queue.push(node);
visited.add(node);
while(queue.length > 0) {
const n = queue.shift();
console.log(n);
// 加入已访问集合的代码放在这里不合适,因为某个节点可能是其他几个节点的相邻节点,只要该节点没有出队被加入已访问集合,可能会被多次加入待访问的队列
// visited.add(n);
graph[n].forEach(c => {
if (!visited.has(c)) {
queue.push(c);
// 加入待访问队列中,同时加入已访问队列里,因为待访问队列可以说是100%要访问到
visited.add(c);
}
})
}
}
二叉树的先中后序遍历
二叉树的子节点使用left和right来指定,而不在使用children来指定。
特点
- 先、中、后指的是根节点的访问顺序
- 左子树一定在右子树之前访问
递归型的遍历
先(前)序遍历
- 访问根节点
- 对左子树先(前)序遍历
- 对右子树先(前)序遍历
// 先序遍历
function preOrder(root) {
// 递归终止条件
if (!root) return;
console.log(root.val);
preOrder(root.left);
preOrder(root.right);
}
中序遍历
- 对左子树中序遍历
- 访问根节点
- 对右子树中序遍历
// 中序遍历
function inOrder(root) {
if (!root) return;
inOrder(root.left);
console.log(root.val);
inOrder(root.right);
}
后序遍历
- 对左子树后序遍历
- 对右子树后序遍历
- 访问根节点
// 后序遍历
function postOrder(root) {
if (!root) return;
postOrder(root.left);
postOrder(root.right);
console.log(root.val)
}
非递归型的遍历
所有的递归都可以用栈来代替,因为递归的底层就是使用栈来实现的。
先序遍历
- 访问根节点
- 对左子树先序遍历
- 对右子树先序遍历
function preOrder(root) {
if (!root) {return;}
const stack = [];
stack.push(root);
while(stack.length > 0) {
// 出栈
const node = stack.pop();
console.log(node.val);
// 将左右子树入栈, 先让右子树入栈,再让左子树入栈,因为我们要先访问左子树,再访问右子树
if (node.right) {
stack.push(node.right);
}
if (node.left) {
stack.push(node.left);
}
}
}
中序遍历
- 对左子树中序遍历
- 访问根节点
- 对右子树中序遍历
中序遍历的顺序跟React Fiber树的构建过程很相似
// 中序遍历
function inOrder(root) {
if (!root) {return;}
const stack = [];
let p = root;
while(p || stack.length > 0) {
while(p) {
// 先将左子树不断入栈
stack.push(p);
p = p.left;
}
// 所有左节点都入栈后,开始出栈访问
const node = stack.pop();
console.log(node.val);
// 访问完栈顶左节点后,将其右节点入栈
p = node.right;
}
}
后序遍历
- 对左子树后序遍历
- 对右子树后序遍历
- 访问根节点
后序遍历是左 -> 右 -> 根,前序遍历是 根 -> 左 -> 右,因此将前序遍历改造为 根 -> 右 -> 左,然后利用栈的先进后出,逆序访问即可。 需要两个栈:
- 递归栈:模拟遍历过程中的递归函数调用
- 访问栈:存储访问节点,在递归栈中节点出栈时,将节点存储到访问栈中,提供最终的节点访问顺序。
// 后序遍历
function postOrder(root) {
if (!root) {return;}
const stack = [];
const outPutStack = [];
stack.push(root);
// 对前序遍历进行改造,前序遍历是 根 -> 左 -> 右,后续遍历是左 -> 右 -> 根,
// 将前序遍历改造成 根 -> 右 -> 左,然后逆序输出访问即可
while(stack.length > 0) {
// 出栈
const node = stack.pop();
// 加入到访问栈中
outPutStack.push(node)
// 前序遍历改造,先将左子树入栈,再将右子树入栈, 根 -> 右 -> 左
if (node.left) {
stack.push(node.left);
}
if (node.right) {
stack.push(node.right);
}
}
while(outPutStack.length > 0) {
const node = outPutStack.pop();
console.log(node.val);
}
}