BFS 算法解题套路框架
本文讲解的例题
LeetCode | 力扣 | 难度 |
---|---|---|
111. Minimum Depth of Binary Tree | 111. 二叉树的最小深度 | 🟢 |
752. Open the Lock | 752. 打开转盘锁 | 🟠 |
后台有很多人问起 BFS 和 DFS 的框架,今天就来说说吧。
首先,你要说我没写过 BFS 框架,这话没错,今天写个框架你背住就完事儿了。但要是说没写过 DFS 框架,那你还真是说错了,其实 DFS 算法就是回溯算法,我们前文 回溯算法框架套路详解 就写过了,而且写得不是一般得好,建议好好复习,嘿嘿嘿~
BFS 的核心思想应该不难理解的,就是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,我们写 BFS 算法都是用「队列」这种数据结构,每次将一个节点周围的所有节点加入队列。
BFS 相对 DFS 的最主要的区别是:BFS 找到的路径一定是最短的,但代价就是空间复杂度可能比 DFS 大很多,至于为什么,我们后面介绍了框架就很容易看出来了。
本文就由浅入深写两道 BFS 的典型题目,分别是「二叉树的最小高度」和「打开密码锁的最少步数」,手把手教你怎么写 BFS 算法。
一、算法框架
要说框架的话,我们先举例一下 BFS 出现的常见场景好吧,问题的本质就是让你在一幅「图」中找到从起点 start
到终点 target
的最近距离,这个例子听起来很枯燥,但是 BFS 算法问题其实都是在干这个事儿,把枯燥的本质搞清楚了,再去欣赏各种问题的包装才能胸有成竹嘛。
这个广义的描述可以有各种变体,比如走迷宫,有的格子是围墙不能走,从起点到终点的最短距离是多少?如果这个迷宫带「传送门」可以瞬间传送呢?
再比如说两个单词,要求你通过某些替换,把其中一个变成另一个,每次只能替换一个字符,最少要替换几次?
再比如说连连看游戏,两个方块消除的条件不仅仅是图案相同,还得保证两个方块之间的最短连线不能多于两个拐点。你玩连连看,点击两个坐标,游戏是如何判断它俩的最短连线有几个拐点的?
再比如……
净整些花里胡哨的,本质上看这些问题都没啥区别,就是一幅「图」,让你从一个起点,走到终点,问最短路径。这就是 BFS 的本质,框架搞清楚了直接默写就好。
记住下面这个框架就 OK 了:
// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
// 核心数据结构
Queue<Node> q;
// 避免走回头路
Set<Node> visited;
// 将起点加入队列
q.offer(start);
visited.add(start);
while (q not empty) {
int sz = q.size();
// 将当前队列中的所有节点向四周扩散
for (int i = 0; i < sz; i++) {
Node cur = q.poll();
// 划重点:这里判断是否到达终点
if (cur is target)
return step;
// 将 cur 的相邻节点加入队列
for (Node x : cur.adj()) {
if (x not in visited) {
q.offer(x);
visited.add(x);
}
}
}
}
// 如果走到这里,说明在图中没有找到目标节点
}
int BFS(Node start, Node target) {
queue<Node> q;
set<Node> visited;
q.push(start);
visited.insert(start);
while (!q.empty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
Node cur = q.front();
q.pop();
if (cur == target)
return step;
for (Node x : cur.adj()) {
if (visited.count(x) == 0) {
q.push(x);
visited.insert(x);
}
}
}
}
// 如果走到这里,说明在图中没有找到目标节点
}
from typing import List, Set
from collections import deque
class Node:
def __init__(self, val: int):
self.val = val
self.neighbors = []
def BFS(start: Node, target: Node) -> int:
# 核心数据结构
q = deque()
# 避免走回头路
visited = set()
# 将起点加入队列
q.append(start)
visited.add(start)
# 记录扩散的步数
step = 0
while q:
step += 1
size = len(q)
# 将当前队列中的所有节点向四周扩散
for i in range(size):
cur = q.popleft()
# 划重点:这里判断是否到达终点
if cur == target:
return step
# 将cur相邻节点加入队列
for x in cur.neighbors:
if x not in visited:
q.append(x)
visited.add(x)
# 如果走到这里,说明在图中没有找到目标节点
return -1
// 计算从起点 start 到终点 target 的最近距离
func BFS(start Node, target Node) int {
// 核心数据结构
q := make([]Node, 0)
// 避免走回头路
visited := make(map[Node]bool)
// 将起点加入队列
q = append(q, start)
visited[start] = true
for len(q) > 0 {
sz := len(q)
// 将当前队列中的所有节点向四周扩散
for i := 0; i < sz; i++ {
cur := q[0]
q = q[1:]
// 划重点:这里判断是否到达终点
if cur == target {
return step
}
// 将 cur 的相邻节点加入队列
for _, x := range cur.adj() {
if _, ok := visited[x]; !ok {
q = append(q, x)
visited[x] = true
}
}
}
}
// 如果走到这里,说明在图中没有找到目标节点
}
var BFS = function(start, target) {
// 核心数据结构
var q = [];
// 避免走回头路
var visited = new Set();
var step = 0;
// 将起点加入队列
q.push(start);
visited.add(start);
while (q.length > 0) {
var sz = q.length;
// 将当前队列中的所有节点向四周扩散
for (var i = 0; i < sz; i++) {
var cur = q.shift();
// 划重点:这里判断是否到达终点
if (cur == target)
return step;
// 将 cur 的相邻节点加入队列
var adj = cur.adj();
for (var j = 0; j < adj.length; j++) {
var x = adj[j];
if (!visited.has(x)) {
q.push(x);
visited.add(x);
}
}
}
step++;
}
// 如果走到这里,说明在图中没有找到目标节点
}
队列 q
就不说了,BFS 的核心数据结构;cur.adj()
泛指 cur
相邻的节点,比如说二维数组中,cur
上下左右四面的位置就是相邻节点;visited
的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要 visited
。
二、二叉树的最小高度
先来个简单的问题实践一下 BFS 框架吧,判断一棵二叉树的最小高度,这也是力扣第 111 题「二叉树的最小深度」:
111. 二叉树的最小深度 | 力扣 | LeetCode |
给定一个二叉树,找出其最小深度。
最小深度是从根节点到最近叶子节点的最短路径上的节点数量。
说明:叶子节点是指没有子节点的节点。
示例 1:
输入:root = [3,9,20,null,null,15,7] 输出:2
示例 2:
输入:root = [2,null,3,null,4,null,5,null,6] 输出:5
提示:
- 树中节点数的范围在
[0, 105]
内 -1000 <= Node.val <= 1000
怎么套到 BFS 的框架里呢?首先明确一下起点 start
和终点 target
是什么,怎么判断到达了终点?
显然起点就是 root
根节点,终点就是最靠近根节点的那个「叶子节点」嘛,叶子节点就是两个子节点都是 null
的节点:
if (cur.left == null && cur.right == null)
// 到达叶子节点
那么,按照我们上述的框架稍加改造来写解法即可:
class Solution {
public int minDepth(TreeNode root) {
if (root == null) return 0;
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
// root 本身就是一层,depth 初始化为 1
int depth = 1;
while (!q.isEmpty()) {
int sz = q.size();
// 将当前队列中的所有节点向四周扩散
for (int i = 0; i < sz; i++) {
TreeNode cur = q.poll();
// 判断是否到达终点
if (cur.left == null && cur.right == null)
return depth;
// 将 cur 的相邻节点加入队列
if (cur.left != null)
q.offer(cur.left);
if (cur.right != null)
q.offer(cur.right);
}
// 这里增加步数
depth++;
}
return depth;
}
}
class Solution {
public:
int minDepth(TreeNode* root) {
if (root == nullptr) return 0;
queue<TreeNode*> q;
q.push(root);
// root 本身就是一层,depth 初始化为 1
int depth = 1;
while (!q.empty()) {
int sz = q.size();
// 将当前队列中的所有节点向四周扩散
for (int i = 0; i < sz; i++) {
TreeNode* cur = q.front();
q.pop();
// 判断是否到达终点
if (cur->left == nullptr && cur->right == nullptr)
return depth;
// 将 cur 的相邻节点加入队列
if (cur->left != nullptr)
q.push(cur->left);
if (cur->right != nullptr)
q.push(cur->right);
}
// 这里增加步数
depth++;
}
return depth;
}
};
class Solution:
def minDepth(self, root):
if not root:
return 0
queue = deque()
queue.append(root)
# root 本身就是一层,depth 初始化为 1
depth = 1
while queue:
sz = len(queue)
# 将当前队列中的所有节点向四周扩散
for i in range(sz):
cur = queue.popleft()
# 判断是否到达终点
if not cur.left and not cur.right:
return depth
# 将 cur 的相邻节点加入队列
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
# 这里增加步数
depth += 1
return depth
func minDepth(root *TreeNode) int {
if root == nil {
return 0
}
var q []*TreeNode
q = append(q, root)
// root 本身就是一层,depth 初始化为 1
depth := 1
for len(q) != 0 {
sz := len(q) // Get current queue size
// 将当前队列中的所有节点向四周扩散
for i := 0; i < sz; i++ {
cur := q[0] // Get the first element
q = q[1:] // Dequeue
// 判断是否到达终点
if cur.Left == nil && cur.Right == nil {
return depth
}
// 将 cur 的相邻节点加入队列
if cur.Left != nil {
q = append(q, cur.Left)
}
if cur.Right != nil {
q = append(q, cur.Right)
}
}
// Increase the depth at each BFS level
depth++
}
return depth
}
var minDepth = function(root){
if(root == null) return 0;
let queue = [];
queue.push(root);
// root 本身就是一层,depth 初始化为 1
let depth = 1;
while(queue.length != 0){
let size = queue.length;
// 将当前队列中的所有节点向四周扩散
for(let i = 0; i < size; i++){
let cur = queue.shift();
// 判断是否到达终点
if(cur.left == null && cur.right == null)
return depth;
// 将 cur 的相邻节点加入队列
if(cur.left != null)
queue.push(cur.left);
if(cur.right != null)
queue.push(cur.right);
}
// 这里增加步数
depth++;
}
return depth;
}
这里注意这个 while
循环和 for
循环的配合,while
循环控制一层一层往下走,for
循环利用 sz
变量控制从左到右遍历每一层二叉树节点:
这一点很重要,这个形式在普通 BFS 问题中都很常见,但是在 Dijkstra 算法模板框架 中我们修改了这种代码模式,读完并理解本文后你可以去看看 BFS 算法是如何演变成 Dijkstra 算法在加权图中寻找最短路径的。
话说回来,二叉树本身是很简单的数据结构,我想上述代码你应该可以理解的,其实其他复杂问题都是这个框架的变形,再探讨复杂问题之前,我们解答两个问题:
1、为什么 BFS 可以找到最短距离,DFS 不行吗?
首先,你看 BFS 的逻辑,depth
每增加一次,队列中的所有节点都向前迈一步,这保证了第一次到达终点的时候,走的步数是最少的。
DFS 不能找最短路径吗?其实也是可以的,但是时间复杂度相对高很多。你想啊,DFS 实际上是靠递归的堆栈记录走过的路径,你要找到最短路径,肯定得把二叉树中所有树杈都探索完才能对比出最短的路径有多长对不对?而 BFS 借助队列做到一次一步「齐头并进」,是可以在不遍历完整棵树的条件下找到最短距离的。
形象点说,DFS 是线,BFS 是面;DFS 是单打独斗,BFS 是集体行动。这个应该比较容易理解吧。
2、既然 BFS 那么好,为啥 DFS 还要存在?
BFS 可以找到最短距离,但是空间复杂度高,而 DFS 的空间复杂度较低。
还是拿刚才我们处理二叉树问题的例子,假设给你的这个二叉树是满二叉树,节点数为 N
,对于 DFS 算法来说,空间复杂度无非就是递归堆栈,最坏情况下顶多就是树的高度,也就是 。
但是你想想 BFS 算法,队列中每次都会储存着二叉树一层的节点,这样的话最坏情况下空间复杂度应该是树的最底层节点的数量,也就是 N/2
,用 Big O 表示的话也就是 。
由此观之,BFS 还是有代价的,一般来说在找最短路径的时候使用 BFS,其他时候还是 DFS 使用得多一些(主要是递归代码好写)。
好了,现在你对 BFS 了解得足够多了,下面来一道难一点的题目,深化一下框架的理解吧。
三、解开密码锁的最少次数
这是力扣第 752 题「打开转盘锁」,比较有意思:
752. 打开转盘锁 | 力扣 | LeetCode |
你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有10个数字: '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
。每个拨轮可以自由旋转:例如把 '9'
变为 '0'
,'0'
变为 '9'
。每次旋转都只能旋转一个拨轮的一位数字。
锁的初始数字为 '0000'
,一个代表四个拨轮的数字的字符串。
列表 deadends
包含了一组死亡数字,一旦拨轮的数字和列表里的任何一个元素相同,这个锁将会被永久锁定,无法再被旋转。
字符串 target
代表可以解锁的数字,你需要给出解锁需要的最小旋转次数,如果无论如何不能解锁,返回 -1
。
示例 1:
输入:deadends = ["0201","0101","0102","1212","2002"], target = "0202" 输出:6 解释: 可能的移动序列为 "0000" -> "1000" -> "1100" -> "1200" -> "1201" -> "1202" -> "0202"。 注意 "0000" -> "0001" -> "0002" -> "0102" -> "0202" 这样的序列是不能解锁的, 因为当拨动到 "0102" 时这个锁就会被锁定。
示例 2:
输入: deadends = ["8888"], target = "0009" 输出:1 解释:把最后一位反向旋转一次即可 "0000" -> "0009"。
示例 3:
输入: deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"], target = "8888" 输出:-1 解释:无法旋转到目标数字且不被锁定。
提示:
1 <= deadends.length <= 500
deadends[i].length == 4
target.length == 4
target
不在deadends
之中target
和deadends[i]
仅由若干位数字组成
函数签名如下:
int openLock(String[] deadends, String target)
int openLock(vector<string>& deadends, string target)
def openLock(deadends: List[str], target: str) -> int
func openLock(deadends []string, target string) int {}
var openLock = function(deadends, target) {
}
题目中描述的就是我们生活中常见的那种密码锁,如果没有任何约束,最少的拨动次数很好算,就像我们平时开密码锁那样直奔密码拨就行了。
但现在的难点就在于,不能出现 deadends
,应该如何计算出最少的转动次数呢?
第一步,我们不管所有的限制条件,不管 deadends
和 target
的限制,就思考一个问题:如果让你设计一个算法,穷举所有可能的密码组合,你怎么做?
穷举呗,再简单一点,如果你只转一下锁,有几种可能?总共有 4 个位置,每个位置可以向上转,也可以向下转,也就是有 8 种可能对吧。
比如说从 "0000"
开始,转一次,可以穷举出 "1000", "9000", "0100", "0900"...
共 8 种密码。然后,再以这 8 种密码作为基础,对每个密码再转一下,穷举出所有可能...
仔细想想,这就可以抽象成一幅图,每个节点有 8 个相邻的节点,又让你求最短距离,这不就是典型的 BFS 嘛,框架就可以派上用场了,先写出一个「简陋」的 BFS 框架代码再说别的:
// 将 s[j] 向上拨动一次
String plusOne(String s, int j) {
char[] ch = s.toCharArray();
if (ch[j] == '9')
ch[j] = '0';
else
ch[j] += 1;
return new String(ch);
}
// 将 s[i] 向下拨动一次
String minusOne(String s, int j) {
char[] ch = s.toCharArray();
if (ch[j] == '0')
ch[j] = '9';
else
ch[j] -= 1;
return new String(ch);
}
// BFS 框架,打印出所有可能的密码
void BFS(String target) {
Queue<String> q = new LinkedList<>();
q.offer("0000");
while (!q.isEmpty()) {
int sz = q.size();
// 将当前队列中的所有节点向周围扩散
for (int i = 0; i < sz; i++) {
String cur = q.poll();
// 判断是否到达终点
System.out.println(cur);
// 将一个节点的相邻节点加入队列
for (int j = 0; j < 4; j++) {
String up = plusOne(cur, j);
String down = minusOne(cur, j);
q.offer(up);
q.offer(down);
}
}
// 在这里增加步数
}
return;
}
// 将 s[j] 向上拨动一次
string plusOne(string s, int j) {
char ch[s.length()];
strcpy(ch, s.c_str());
if (ch[j] == '9')
ch[j] = '0';
else
ch[j] += 1;
string res(ch);
return res;
}
// 将 s[i] 向下拨动一次
string minusOne(string s, int j) {
char ch[s.length()];
strcpy(ch, s.c_str());
if (ch[j] == '0')
ch[j] = '9';
else
ch[j] -= 1;
string res(ch);
return res;
}
// BFS 框架,打印出所有可能的密码
void BFS(string target) {
queue<string> q;
q.push("0000");
while (!q.empty()) {
int sz = q.size();
// 将当前队列中的所有节点向周围扩散
for (int i = 0; i < sz; i++) {
string cur = q.front(); q.pop();
// 判断是否到达终点
cout << cur << endl;
// 将一个节点的相邻节点加入队列
for (int j = 0; j < 4; j++) {
string up = plusOne(cur, j);
string down = minusOne(cur, j);
q.push(up);
q.push(down);
}
}
// 在这里增加步数
}
return;
}
from typing import List
# 将 s[j] 向上拨动一次
def plusOne(s: str, j: int) -> str:
ch = list(s)
if ch[j] == '9':
ch[j] = '0'
else:
ch[j] = chr(ord(ch[j]) + 1)
return ''.join(ch)
# 将 s[i] 向下拨动一次
def minusOne(s: str, j: int) -> str:
ch = list(s)
if ch[j] == '0':
ch[j] = '9'
else:
ch[j] = chr(ord(ch[j]) - 1)
return ''.join(ch)
# BFS 框架,打印出所有可能的密码
def BFS(target: str) -> None:
q = ['0000']
while len(q) > 0:
sz = len(q)
# 将当前队列中的所有节点向周围扩散
for i in range(sz):
cur = q.pop(0)
# 判断是否到达终点
print(cur)
# 将一个节点的相邻节点加入队列
for j in range(4):
up = plusOne(cur, j)
down = minusOne(cur, j)
q.append(up)
q.append(down)
# 在这里增加步数
return
// 将 s[j] 向上拨动一次
func plusOne(s string, j int) string {
ch := []byte(s)
if ch[j] == '9' {
ch[j] = '0'
} else {
ch[j] += 1
}
return string(ch)
}
// 将 s[i] 向下拨动一次
func minusOne(s string, j int) string {
ch := []byte(s)
if ch[j] == '0' {
ch[j] = '9'
} else {
ch[j] -= 1
}
return string(ch)
}
// BFS 框架,打印出所有可能的密码
func BFS(target string) {
var q []string
q = append(q, "0000")
for len(q) > 0 {
sz := len(q)
// 将当前队列中的所有节点向周围扩散
for i := 0; i < sz; i++ {
cur := q[0]
q = q[1:]
// 判断是否到达终点
fmt.Println(cur)
// 将一个节点的相邻节点加入队列
for j := 0; j < 4; j++ {
up := plusOne(cur, j)
down := minusOne(cur, j)
q = append(q, up, down)
}
}
// 在这里增加步数
}
return
}
// 将 s[j] 向上拨动一次
var plusOne = function(s, j) {
let ch = s.split('');
if(ch[j] == '9') ch[j] = '0';
else ch[j] = String.fromCharCode(ch[j].charCodeAt(0) + 1);
return ch.join('');
};
// 将 s[i] 向下拨动一次
var minusOne = function(s, j) {
let ch = s.split('');
if(ch[j] == '0') ch[j] = '9';
else ch[j] = String.fromCharCode(ch[j].charCodeAt(0) - 1);
return ch.join('');
}
// BFS 框架,打印出所有可能的密码
var BFS = function(target) {
let q = ['0000'];
while(q.length > 0) {
let sz = q.length;
// 将当前队列中的所有节点向周围扩散
for(let i = 0; i < sz; i++) {
let cur = q.shift();
// 判断是否到达终点
console.log(cur);
// 将一个节点的相邻节点加入队列
for(let j = 0; j < 4; j++) {
let up = plusOne(cur, j);
let down = minusOne(cur, j);
q.push(up)
q.push(down)
}
}
// 在这里增加步数
}
return;
}
注
这段代码当然有很多问题,但是我们做算法题肯定不是一蹴而就的,而是从简陋到完美的。不要完美主义,咱要慢慢来,好不。
这段 BFS 代码已经能够穷举所有可能的密码组合了,但是显然不能完成题目,有如下问题需要解决:
1、会走回头路。比如说我们从 "0000"
拨到 "1000"
,但是等从队列拿出 "1000"
时,还会拨出一个 "0000"
,这样的话会产生死循环。
2、没有终止条件,按照题目要求,我们找到 target
就应该结束并返回拨动的次数。
3、没有对 deadends
的处理,按道理这些「死亡密码」是不能出现的,也就是说你遇到这些密码的时候需要跳过。
如果你能够看懂上面那段代码,真得给你鼓掌,只要按照 BFS 框架在对应的位置稍作修改即可修复这些问题:
class Solution {
public int openLock(String[] deadends, String target) {
// 记录需要跳过的死亡密码
Set<String> deads = new HashSet<>();
for (String s : deadends) deads.add(s);
// 记录已经穷举过的密码,防止走回头路
Set<String> visited = new HashSet<>();
Queue<String> q = new LinkedList<>();
// 从起点开始启动广度优先搜索
int step = 0;
q.offer("0000");
visited.add("0000");
while (!q.isEmpty()) {
int sz = q.size();
// 将当前队列中的所有节点向周围扩散
for (int i = 0; i < sz; i++) {
String cur = q.poll();
// 判断是否到达终点
if (deads.contains(cur))
continue;
if (cur.equals(target))
return step;
// 将一个节点的未遍历相邻节点加入队列
for (int j = 0; j < 4; j++) {
String up = plusOne(cur, j);
if (!visited.contains(up)) {
q.offer(up);
visited.add(up);
}
String down = minusOne(cur, j);
if (!visited.contains(down)) {
q.offer(down);
visited.add(down);
}
}
}
// 在这里增加步数
step++;
}
// 如果穷举完都没找到目标密码,那就是找不到了
return -1;
}
// 将 s[j] 向上拨动一次
String plusOne(String s, int j) {
char[] ch = s.toCharArray();
if (ch[j] == '9')
ch[j] = '0';
else
ch[j] += 1;
return new String(ch);
}
// 将 s[i] 向下拨动一次
String minusOne(String s, int j) {
char[] ch = s.toCharArray();
if (ch[j] == '0')
ch[j] = '9';
else
ch[j] -= 1;
return new String(ch);
}
}
class Solution {
public:
int openLock(vector<string>& deadends, string target) {
// 记录需要跳过的死亡密码
unordered_set<string> deads(deadends.begin(), deadends.end());
// 记录已经穷举过的密码,防止走回头路
unordered_set<string> visited;
queue<string> q;
// 从起点开始启动广度优先搜索
int step = 0;
q.push("0000");
visited.insert("0000");
while (!q.empty()) {
int sz = q.size();
// 将当前队列中的所有节点向周围扩散
for (int i = 0; i < sz; i++) {
string cur = q.front(); q.pop();
// 判断是否到达终点
if (deads.count(cur))
continue;
if (cur == target)
return step;
// 将一个节点的未遍历相邻节点加入队列
for (int j = 0; j < 4; j++) {
string up = plusOne(cur, j);
if (!visited.count(up)) {
q.push(up);
visited.insert(up);
}
string down = minusOne(cur, j);
if (!visited.count(down)) {
q.push(down);
visited.insert(down);
}
}
}
// 在这里增加步数
step++;
}
// 如果穷举完都没找到目标密码,那就是找不到了
return -1;
}
// 将 s[j] 向上拨动一次
string plusOne(string s, int j) {
s[j] = s[j] == '9' ? '0' : s[j] + 1;
return s;
}
// 将 s[i] 向下拨动一次
string minusOne(string s, int j) {
s[j] = s[j] == '0' ? '9' : s[j] - 1;
return s;
}
};
class Solution:
def openLock(self, deadends: List[str], target: str) -> int:
# 记录需要跳过的死亡密码
deads = set(deadends)
# 记录已经穷举过的密码,防止走回头路
visited = set()
q = collections.deque()
# 从起点开始启动广度优先搜索
step = 0
q.append("0000")
visited.add("0000")
while q:
sz = len(q)
# 将当前队列中的所有节点向周围扩散
for _ in range(sz):
cur = q.popleft()
# 判断是否到达终点
if cur in deads:
continue
if cur == target:
return step
# 将一个节点的未遍历相邻节点加入队列
for j in range(4):
up = self.plusOne(cur, j)
if up not in visited:
q.append(up)
visited.add(up)
down = self.minusOne(cur, j)
if down not in visited:
q.append(down)
visited.add(down)
# 在这里增加步数
step += 1
# 如果穷举完都没找到目标密码,那就是找不到了
return -1
# 将 s[j] 向上拨动一次
def plusOne(self, s: str, j: int) -> str:
ch = list(s)
if ch[j] == '9':
ch[j] = '0'
else:
ch[j] = str(int(ch[j]) + 1)
return ''.join(ch)
# 将 s[i] 向下拨动一次
def minusOne(self, s: str, j: int) -> str:
ch = list(s)
if ch[j] == '0':
ch[j] = '9'
else:
ch[j] = str(int(ch[j]) - 1)
return ''.join(ch)
func openLock(deadends []string, target string) int {
// 记录需要跳过的死亡密码
deads := make(map[string]bool)
for _, s := range deadends {
deads[s] = true
}
// 记录已经穷举过的密码,防止走回头路
visited := make(map[string]bool)
q := make([]string, 0)
// 从起点开始启动广度优先搜索
step := 0
q = append(q, "0000")
visited["0000"] = true
for len(q) != 0 {
sz := len(q)
// 将当前队列中的所有节点向周围扩散
for i := 0; i < sz; i++ {
cur := q[0]
q = q[1:]
// 判断是否到达终点
if deads[cur] {
continue
}
if cur == target {
return step
}
// 将一个节点的未遍历相邻节点加入队列
for j := 0; j < 4; j++ {
up := plusOne(cur, j)
if !visited[up] {
q = append(q, up)
visited[up] = true
}
down := minusOne(cur, j)
if !visited[down] {
q = append(q, down)
visited[down] = true
}
}
}
// 在这里增加步数
step++
}
// 如果穷举完都没找到目标密码,那就是找不到了
return -1
}
// 将 s[j] 向上拨动一次
func plusOne(s string, j int) string {
ch := []byte(s)
if ch[j] == '9' {
ch[j] = '0'
} else {
ch[j] += 1
}
return string(ch)
}
// 将 s[i] 向下拨动一次
func minusOne(s string, j int) string {
ch := []byte(s)
if ch[j] == '0' {
ch[j] = '9'
} else {
ch[j] -= 1
}
return string(ch)
}
var openLock = function(deadends, target) {
// 记录需要跳过的死亡密码
let deads = new Set(deadends);
// 记录已经穷举过的密码,防止走回头路
let visited = new Set();
let q = [];
// 从起点开始启动广度优先搜索
let step = 0;
q.push("0000");
visited.add("0000");
while (q.length !== 0) {
let sz = q.length;
// 将当前队列中的所有节点向周围扩散
for (let i = 0; i < sz; i++) {
let cur = q.shift();
// 判断是否到达终点
if (deads.has(cur))
continue;
if (cur === target)
return step;
// 将一个节点的未遍历相邻节点加入队列
for (let j = 0; j < 4; j++) {
let up = plusOne(cur, j);
if (!visited.has(up)) {
q.push(up);
visited.add(up);
}
let down = minusOne(cur, j);
if (!visited.has(down)) {
q.push(down);
visited.add(down);
}
}
}
// 在这里增加步数
step++;
}
// 如果穷举完都没找到目标密码,那就是找不到了
return -1;
}
// 将 s[j] 向上拨动一次
var plusOne = function(s, j) {
let ch = s.split('');
if(ch[j] == '9') ch[j] = '0';
else ch[j] = String.fromCharCode(ch[j].charCodeAt(0) + 1);
return ch.join('');
};
// 将 s[i] 向下拨动一次
var minusOne = function(s, j) {
let ch = s.split('');
if(ch[j] == '0') ch[j] = '9';
else ch[j] = String.fromCharCode(ch[j].charCodeAt(0) - 1);
return ch.join('');
}
至此,我们就解决这道题目了。有一个比较小的优化:可以不需要 dead
这个哈希集合,可以直接将这些元素初始化到 visited
集合中,效果是一样的,可能更加优雅一些。
四、双向 BFS 优化
你以为到这里 BFS 算法就结束了?恰恰相反。BFS 算法还有一种稍微高级一点的优化思路:双向 BFS,可以进一步提高算法的效率。
篇幅所限,这里就提一下区别:传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向 BFS 则是从起点和终点同时开始扩散,当两边有交集的时候停止。
为什么这样能够能够提升效率呢?其实从 Big O 表示法分析算法复杂度的话,它俩的最坏复杂度都是 ,但是实际上双向 BFS 确实会快一些,我给你画两张图看一眼就明白了:
图示中的树形结构,如果终点在最底部,按照传统 BFS 算法的策略,会把整棵树的节点都搜索一遍,最后找到 target
;而双向 BFS 其实只遍历了半棵树就出现了交集,也就是找到了最短距离。从这个例子可以直观地感受到,双向 BFS 是要比传统 BFS 高效的。
不过,双向 BFS 也有局限,因为你必须知道终点在哪里。比如我们刚才讨论的二叉树最小高度的问题,你一开始根本就不知道终点在哪里,也就无法使用双向 BFS;但是第二个密码锁的问题,是可以使用双向 BFS 算法来提高效率的,代码稍加修改即可:
class Solution {
public int openLock(String[] deadends, String target) {
Set<String> deads = new HashSet<>();
for (String s : deadends) deads.add(s);
// 用集合不用队列,可以快速判断元素是否存在
Set<String> q1 = new HashSet<>();
Set<String> q2 = new HashSet<>();
Set<String> visited = new HashSet<>();
int step = 0;
q1.add("0000");
q2.add(target);
while (!q1.isEmpty() && !q2.isEmpty()) {
// 哈希集合在遍历的过程中不能修改,用 temp 存储扩散结果
Set<String> temp = new HashSet<>();
// 将 q1 中的所有节点向周围扩散
for (String cur : q1) {
// 判断是否到达终点
if (deads.contains(cur))
continue;
if (q2.contains(cur))
return step;
visited.add(cur);
// 将一个节点的未遍历相邻节点加入集合
for (int j = 0; j < 4; j++) {
String up = plusOne(cur, j);
if (!visited.contains(up))
temp.add(up);
String down = minusOne(cur, j);
if (!visited.contains(down))
temp.add(down);
}
}
// 在这里增加步数
step++;
// temp 相当于 q1
// 这里交换 q1 q2,下一轮 while 就是扩散 q2
q1 = q2;
q2 = temp;
}
return -1;
}
// 将 s[j] 向上拨动一次
String plusOne(String s, int j) {
char[] ch = s.toCharArray();
if (ch[j] == '9')
ch[j] = '0';
else
ch[j] += 1;
return new String(ch);
}
// 将 s[i] 向下拨动一次
String minusOne(String s, int j) {
char[] ch = s.toCharArray();
if (ch[j] == '0')
ch[j] = '9';
else
ch[j] -= 1;
return new String(ch);
}
}
class Solution {
public:
int openLock(vector<string>& deadends, string target) {
unordered_set<string> deads(deadends.begin(), deadends.end());
// 用集合不用队列,可以快速判断元素是否存在
unordered_set<string> q1, q2, visited;
int step = 0;
q1.insert("0000");
q2.insert(target);
while (!q1.empty() && !q2.empty()) {
// 哈希集合在遍历的过程中不能修改,用 temp 存储扩散结果
unordered_set<string> temp;
// 将 q1 中的所有节点向周围扩散
for (const string& cur : q1) {
// 判断是否到达终点
if (deads.count(cur))
continue;
if (q2.count(cur))
return step;
visited.insert(cur);
// 将一个节点的未遍历相邻节点加入集合
for (int j = 0; j < 4; j++) {
string up = plusOne(cur, j);
if (!visited.count(up))
temp.insert(up);
string down = minusOne(cur, j);
if (!visited.count(down))
temp.insert(down);
}
}
// 在这里增加步数
step++;
// temp 相当于 q1
// 这里交换 q1 q2,下一轮 while 就是扩散 q2
q1 = q2;
q2 = temp;
}
return -1;
}
private:
string plusOne(string s, int j) {
if (s[j] == '9')
s[j] = '0';
else
s[j] += 1;
return s;
}
string minusOne(string s, int j) {
if (s[j] == '0')
s[j] = '9';
else
s[j] -= 1;
return s;
}
};
class Solution:
def openLock(self, deadends: list[str], target: str) -> int:
deads = set(deadends)
# 用集合不用队列,可以快速判断元素是否存在
q1 = set()
q2 = set()
visited = set()
step = 0
q1.add("0000")
q2.add(target)
while q1 and q2:
# 哈希集合在遍历的过程中不能修改,用 temp 存储扩散结果
temp = set()
# 将 q1 中的所有节点向周围扩散
for cur in q1:
# 判断是否到达终点
if cur in deads:
continue
if cur in q2:
return step
visited.add(cur)
# 将一个节点的未遍历相邻节点加入集合
for j in range(4):
up = self.plusOne(cur, j)
if up not in visited:
temp.add(up)
down = self.minusOne(cur, j)
if down not in visited:
temp.add(down)
# 在这里增加步数
step += 1
# temp 相当于 q1
# 这里交换 q1 q2,下一轮 while 就是扩散 q2
q1 = q2
q2 = temp
return -1
def plusOne(self, cur: str, j: int) -> str:
ch = list(cur)
if ch[j] == '9':
ch[j] = '0'
else:
ch[j] = str(int(ch[j]) + 1)
return ''.join(ch)
def minusOne(self, cur: str, j: int) -> str:
ch = list(cur)
if ch[j] == '0':
ch[j] = '9'
else:
ch[j] = str(int(ch[j]) - 1)
return ''.join(ch)
import (
"strings"
)
func openLock(deadends []string, target string) int {
deads := make(map[string]bool)
for _, s := range deadends {
deads[s] = true
}
// 用集合不用队列,可以快速判断元素是否存在
q1 := make(map[string]bool)
q2 := make(map[string]bool)
visited := make(map[string]bool)
step := 0
q1["0000"] = true
q2[target] = true
for len(q1) > 0 && len(q2) > 0 {
// 哈希集合在遍历的过程中不能修改,用 temp 存储扩散结果
temp := make(map[string]bool)
// 将 q1 中的所有节点向周围扩散
for cur := range q1 {
// 判断是否到达终点
if deads[cur] {
continue
}
if q2[cur] {
return step
}
visited[cur] = true
// 将一个节点的未遍历相邻节点加入集合
for j := 0; j < 4; j++ {
up := plusOne(cur, j)
if !visited[up] {
temp[up] = true
}
down := minusOne(cur, j)
if !visited[down] {
temp[down] = true
}
}
}
// 在这里增加步数
step++
// temp 相当于 q1
// 这里交换 q1 q2,下一轮 while 就是扩散 q2
q1 = q2
q2 = temp
}
return -1
}
func plusOne(s string, j int) string {
ch := []rune(s)
if ch[j] == '9' {
ch[j] = '0'
} else {
ch[j]++
}
return string(ch)
}
func minusOne(s string, j int) string {
ch := []rune(s)
if ch[j] == '0' {
ch[j] = '9'
} else {
ch[j]--
}
return string(ch)
}
var openLock = function(deadends, target) {
let deads = new Set(deadends);
// 用集合不用队列,可以快速判断元素是否存在
let q1 = new Set(["0000"]);
let q2 = new Set([target]);
let visited = new Set();
let step = 0;
while (q1.size > 0 && q2.size > 0) {
// 哈希集合在遍历的过程中不能修改,用 temp 存储扩散结果
let temp = new Set();
// 将 q1 中的所有节点向周围扩散
for (let cur of q1) {
// 判断是否到达终点
if (deads.has(cur)) {
continue;
}
if (q2.has(cur)) {
return step;
}
visited.add(cur);
// 将一个节点的未遍历相邻节点加入集合
for (let j = 0; j < 4; j++) {
let up = plusOne(cur, j);
if (!visited.has(up)) {
temp.add(up);
}
let down = minusOne(cur, j);
if (!visited.has(down)) {
temp.add(down);
}
}
}
// 在这里增加步数
step++;
// temp 相当于 q1
// 这里交换 q1 q2,下一轮 while 就是扩散 q2
q1 = q2;
q2 = temp;
}
return -1;
};
var plusOne = function(s, j) {
let ch = s.split('');
if(ch[j] == '9') ch[j] = '0';
else ch[j] = String.fromCharCode(ch[j].charCodeAt(0) + 1);
return ch.join('');
};
var minusOne = function(s, j) {
let ch = s.split('');
if(ch[j] == '0') ch[j] = '9';
else ch[j] = String.fromCharCode(ch[j].charCodeAt(0) - 1);
return ch.join('');
}
双向 BFS 还是遵循 BFS 算法框架的,只是不再使用队列,而是使用 HashSet 方便快速判断两个集合是否有交集。
另外的一个技巧点就是 while 循环的最后交换 q1
和 q2
的内容,所以只要默认扩散 q1
就相当于轮流扩散 q1
和 q2
。
其实双向 BFS 还有一个优化,就是在 while 循环开始时做一个判断:
// ...
while (!q1.isEmpty() && !q2.isEmpty()) {
if (q1.size() > q2.size()) {
// 交换 q1 和 q2
temp = q1;
q1 = q2;
q2 = temp;
}
// ...
}
// ...
while (!q1.empty() && !q2.empty()) {
if (q1.size() > q2.size()) {
// 交换 q1 和 q2
queue<int> temp = q1;
q1 = q2;
q2 = temp;
}
// ...
}
# ...
while not q1.empty() and not q2.empty():
if q1.qsize() > q2.qsize():
# 交换 q1 和 q2
temp = q1
q1 = q2
q2 = temp
# ...
// ...
for !q1.isEmpty() && !q2.isEmpty() {
if q1.size() > q2.size() {
// 交换 q1 和 q2
temp := q1
q1 = q2
q2 = temp
}
// ...
}
// ...
while (!q1.isEmpty() && !q2.isEmpty()) {
if (q1.size() > q2.size()) {
// 交换 q1 和 q2
var temp = q1;
q1 = q2;
q2 = temp;
}
// ...
}
为什么这是一个优化呢?
因为按照 BFS 的逻辑,队列(集合)中的元素越多,扩散之后新的队列(集合)中的元素就越多;在双向 BFS 算法中,如果我们每次都选择一个较小的集合进行扩散,那么占用的空间增长速度就会慢一些,效率就会高一些。
不过话说回来,无论传统 BFS 还是双向 BFS,无论做不做优化,从 Big O 衡量标准来看,时间复杂度都是一样的,只能说双向 BFS 是一种 trick,算法运行的速度会相对快一点,掌握不掌握其实都无所谓。最关键的是把 BFS 通用框架记下来,反正所有 BFS 算法都可以用它套出解法。
引用本文的题目
安装 我的 Chrome 刷题插件 点开下列题目可直接查看解题思路: