广度优先搜索(BFS)
何时使用广度优先搜索
当你需要逐层探索节点或在无权图中找到最短路径时使用BFS。虽然最常用于层序遍历,但它也可以适用于:
- 层序树遍历(经典用例)
- 无权图中的最短路径(由于逐层探索保证最优)
- 查找距离起点为k的所有节点
- 检查图是否为二分图(可以将节点分成两组,其中同组内没有两个节点相连 - 通过在交替层中为节点着色完成)
- 网页爬虫(在深入之前探索相同深度的链接)
- 社交网络分析(查找n度分离内的连接)
- 任何需要在移动更远之前探索当前"距离"的所有可能性的问题
示例问题
公司组织架构图:给定一个表示为树的公司层次结构,按层次打印所有员工以了解汇报结构。
CEO
/ \
CTO CFO
/ \ \
Dev1 Dev2 Acc1
Level 0: CEO
Level 1: CTO, CFO
Level 2: Dev1, Dev2, Acc1
这清楚地显示了管理层 - 首先是CEO的所有直接下属,然后是他们的所有直接下属,依此类推。
最重要的洞察
BFS在任何距离d+1的节点之前探索距离d的所有节点 - 通过使用队列(FIFO),我们保证节点按照它们被发现的确切顺序处理,这自动提供了逐层探索和无权图中的最短路径。
心智笔记:算法的天才之处在于队列的FIFO特性。当你发现一个节点的邻居时,它们进入队列的后面。这意味着当前层的所有节点在下一层的任何节点之前被处理。就像逐排填满剧院一样 - 你不能在第2排完全满之前开始第3排。
决策框架
在每个节点,你面临一个简单的决策:
- 处理当前节点(标记为已访问)
- 将所有未访问的邻居添加到队列中以供将来探索
队列强制执行顺序 - 你不决定何时处理节点,FIFO结构为你做这件事。
代码
class TreeNode {
constructor(val) {
this.val = val;
this.left = null;
this.right = null;
}
}
function bfs(root) {
// 处理空树
if (root == null) {
return;
}
// 使用根节点初始化队列
const queue = [root];
let level = 0;
// 逐层处理节点
while (queue.length > 0) {
// 捕获当前层的大小 - 这是关键!
const levelSize = queue.length;
const currentLevelNodes = [];
// 处理当前层的所有节点
for (let i = 0; i < levelSize; i++) {
// 决策框架:处理当前节点
const currentNode = queue.shift();
currentLevelNodes.push(currentNode.val);
// 决策框架:将未访问的邻居(子节点)添加到队列
if (currentNode.left != null) {
queue.push(currentNode.left);
}
if (currentNode.right != null) {
queue.push(currentNode.right);
}
}
// 输出当前层
console.log(`Level ${level}: ${currentLevelNodes.join(", ")}`);
level++;
}
}
// 使用公司组织架构图的示例
const ceo = new TreeNode("CEO");
const cto = new TreeNode("CTO");
const cfo = new TreeNode("CFO");
const dev1 = new TreeNode("Dev1");
const dev2 = new TreeNode("Dev2");
const acc1 = new TreeNode("Acc1");
ceo.left = cto;
ceo.right = cfo;
cto.left = dev1;
cto.right = dev2;
cfo.right = acc1;
bfs(ceo);
// 输出:
// Level 0: CEO
// Level 1: CTO, CFO
// Level 2: Dev1, Dev2, Acc1
疑虑消除
常见疑虑:"为什么我不能只使用DFS(深度优先搜索)来遍历树?反正最终会访问所有节点吗?什么使BFS对层序遍历特别?"
这是一个自然的问题,因为BFS和DFS都访问所有节点。让我们看看为什么BFS对某些问题至关重要。
为什么你可能认为DFS可以工作
考虑我们的公司组织架构图。使用DFS,你可能会想:
- "我会先深入:CEO → CTO → Dev1,然后回溯"
- "最终我会访问所有节点,所以有什么区别?"
- "我不能只是跟踪深度并按级别分组节点吗?"
为什么这种想法错过了关键点
关键认识:BFS不仅仅访问所有节点 - 它以特定顺序访问它们,提供独特的保证!
让我们比较BFS与DFS的遍历顺序:
BFS顺序:CEO → CTO → CFO → Dev1 → Dev2 → Acc1
(第0层的所有节点,然后第1层的所有节点,然后第2层的所有节点)
DFS顺序:CEO → CTO → Dev1 → Dev2 → CFO → Acc1
(先深入,在层之间跳跃)
具体例子:查找最短路径
想象在无权图中从CEO到Acc1查找最短路径:
使用BFS:
步骤1:处理CEO(距离0)
步骤2:处理CTO、CFO(距离1) - 找到CFO!
步骤3:处理CFO的子节点 - 在距离2找到Acc1!
使用DFS(可能走更长的路径):
步骤1:处理CEO
步骤2:处理CTO(向左走)
步骤3:处理Dev1(向左更深)
步骤4:回溯到CTO
步骤5:处理Dev2
步骤6:一直回溯到CEO
步骤7:最后处理CFO
步骤8:处理Acc1
DFS可能会找到一个节点,但不能保证是通过最短路径!
为什么队列是必不可少的
BFS中的队列就像一个"代际跟踪器":
- 第0代(根):队列中只有CEO
- 第1代(子代):CTO和CFO添加到队列
- 第2代(孙代):Dev1、Dev2、Acc1添加到队列
FIFO属性确保:
- 所有第1代节点在任何第2代节点之前处理
- 这在DFS的栈(LIFO)或递归调用栈中是不可能的
层大小技巧
BFS中启用逐层处理的关键行:
const levelSize = queue.length; // 当前层大小的快照
这精确地捕获当前层有多少个节点。即使我们在处理时向队列添加新节点,我们也确切知道一层何时结束,下一层何时开始。
为什么这保证了正确性:
- 当我们开始处理一层时,队列只包含该层的节点
- 当我们处理每个节点时,我们将其子节点(下一层)添加到队列
- 但我们只处理
levelSize
个节点,所以我们在当前层完成时准确停止 - 队列现在只包含下一层的节点
这种优雅的机制是为什么BFS可以保证层序遍历和最短路径的原因,这是DFS由于其深度优先的性质根本无法提供的东西。