双向BFS
一句话说明
双向 BFS 的本质不是“写两个 BFS”,而是让起点和终点各走一半,在中间相遇,从而把搜索深度从
d压到大约d/2。
为什么会更快
假设每层平均分出 b 个状态,目标深度是 d:
- 普通 BFS:大约扩展
b^d - 双向 BFS:两边各扩展
b^(d/2),总量约2 * b^(d/2)
普通 BFS:
S -> 一层层往外爆炸
双向 BFS:
S -> -> <- <- T
中间相遇什么时候适合双向 BFS
- 起点和终点都明确。
- 状态转移基本可逆,或者终点也能自然向外扩展。
- 状态空间很大,普通 BFS 太慢。
不适合:
- 只有起点,没有明确终点。
- 终点方向无法生成前驱状态。
Go 模板
关键点只有三个:
- 两边各维护一个前沿集合。
- 每轮总是扩展更小的那一边。
- 扩展时一旦碰到对面的前沿,就说明最短路找到了。
func bidirectionalBFS(start, end string) int {
if start == end {
return 0
}
front := map[string]bool{start: true}
back := map[string]bool{end: true}
visited := map[string]bool{start: true, end: true}
steps := 0
for len(front) > 0 && len(back) > 0 {
if len(front) > len(back) {
front, back = back, front
}
nextFront := make(map[string]bool)
for state := range front {
for _, next := range getNeighbors(state) {
if back[next] {
return steps + 1
}
if !visited[next] {
visited[next] = true
nextFront[next] = true
}
}
}
front = nextFront
steps++
}
return -1
}为什么总扩展更小的一边
这一步不是可有可无的优化,而是双向 BFS 最核心的剪枝之一。
if len(front) > len(back) {
front, back = back, front
}原因很简单:
- 每轮成本主要取决于当前前沿大小。
- 扩展更小的一边,能显著减少本轮生成的状态数。
例题:单词接龙
双向 BFS 在这题上非常典型,因为:
- 起点和终点都知道。
- 每个状态的邻居生成规则对两边都一样。
func ladderLength(beginWord, endWord string, wordList []string) int {
wordSet := make(map[string]bool, len(wordList))
for _, word := range wordList {
wordSet[word] = true
}
if !wordSet[endWord] {
return 0
}
front := map[string]bool{beginWord: true}
back := map[string]bool{endWord: true}
visited := map[string]bool{beginWord: true, endWord: true}
steps := 1
getWordNeighbors := func(word string) []string {
chars := []byte(word)
result := make([]string, 0)
for i := 0; i < len(chars); i++ {
original := chars[i]
for ch := byte('a'); ch <= 'z'; ch++ {
if ch == original {
continue
}
chars[i] = ch
next := string(chars)
if wordSet[next] {
result = append(result, next)
}
}
chars[i] = original
}
return result
}
for len(front) > 0 && len(back) > 0 {
if len(front) > len(back) {
front, back = back, front
}
nextFront := make(map[string]bool)
for word := range front {
for _, next := range getWordNeighbors(word) {
if back[next] {
return steps + 1
}
if !visited[next] {
visited[next] = true
nextFront[next] = true
}
}
}
front = nextFront
steps++
}
return 0
}例题:打开转盘锁
func openLockBi(deadends []string, target string) int {
dead := make(map[string]bool, len(deadends))
for _, state := range deadends {
dead[state] = true
}
if dead["0000"] || dead[target] {
return -1
}
if target == "0000" {
return 0
}
getLockNeighbors := func(state string) []string {
result := make([]string, 0, 8)
for i := 0; i < 4; i++ {
for _, delta := range []int{-1, 1} {
bytes := []byte(state)
digit := int(bytes[i]-'0')
digit = (digit + delta + 10) % 10
bytes[i] = byte(digit) + '0'
next := string(bytes)
if !dead[next] {
result = append(result, next)
}
}
}
return result
}
front := map[string]bool{"0000": true}
back := map[string]bool{target: true}
visited := map[string]bool{"0000": true, target: true}
steps := 0
for len(front) > 0 && len(back) > 0 {
if len(front) > len(back) {
front, back = back, front
}
nextFront := make(map[string]bool)
for state := range front {
for _, next := range getLockNeighbors(state) {
if back[next] {
return steps + 1
}
if !visited[next] {
visited[next] = true
nextFront[next] = true
}
}
}
front = nextFront
steps++
}
return -1
}共享 visited 还是双侧分别维护
两种写法都能做:
visited := map[string]bool{start: true, end: true}或者:
frontVisited := map[string]bool{start: true}
backVisited := map[string]bool{end: true}如果题目只要求最短距离,通常共享 visited 就够了。
如果题目要求重建完整路径,分开维护会更自然。
易错点
steps的定义要统一,很多 bug 都出在相遇时到底返回steps还是steps + 1。- “相遇”判断应该放在扩展邻居时,而不是等下一轮统一比较。
- 一定要从更小的前沿扩展,否则双向 BFS 的优势会大打折扣。
- 双向 BFS 只有在“终点也能向外扩”时才成立。
相关主题
返回:搜索算法