双向广搜-BiDirectional BFS
双向广搜
前言
复习acwing、洛谷算法,提高课的内容,本篇为讲解算法:双向广搜
一、双向广搜
双向广搜其实就是两个bfs,我们知道bfs是一种暴力的做题方法,搜索树长下图所示:

我们会发现搜索树越来越宽,每一层的搜索量增加,如果数据范围很大的话,显然是会TLE的,那么为了避免TLE,我们可以采用双向广搜,即两个bfs,如下图所示:

这个样子就可以省去很多不必要的搜索量,我们在这个基础上可以再次优化,每次只bfs一次,每次bfs那个队列容量少的部分。
二、AcWing 190(洛谷P1032). 字串变换
本题链接:AcWing 190. 字串变换 洛谷P1032. 字串变换
本博客提供本题截图:
本题分析
定义两个队列,分别表示开始和末尾,进行双向广搜,我们每次挑出来含元素少的队列进行bfs,qa表示从起始位置开始的bfs,qb表示的是从结尾开始的bfs,da表示的距离是从起始到当前的距离,db表示的距离是从结尾到当前的距离,extend表示的是扩展的过程,这里需要注意的话如果是对qb执行extend操作的话,是把b变成a
对于extend操作:
if (da.count(state)) continue;
if (db.count(state)) return da[t] + 1 + db[state];
如果state出现在da,那么证明被更新过,直接continue这种情况
如果state出现在db,那么证明已经找到了最后的结果,即双向光搜碰头了,那么就把距离返回给main
AC代码
#include <cstring>
#include <iostream>
#include <algorithm>
#include <unordered_map>
#include <queue>
using namespace std;
const int N = 6;
int n;
string a[N], b[N];
int extend(queue<string>& q, unordered_map<string, int>& da, unordered_map<string, int>& db, string a[], string b[])
{
for (int k = 0, sk = q.size(); k < sk; k ++ )
{
string t = q.front();
q.pop();
for (int i = 0; i < t.size(); i ++ )
for (int j = 0; j < n; j ++ )
if (t.substr(i, a[j].size()) == a[j])
{
string state = t.substr(0, i) + b[j] + t.substr(i + a[j].size());
if (da.count(state)) continue;
if (db.count(state)) return da[t] + 1 + db[state];
da[state] = da[t] + 1;
q.push(state);
}
}
return 11;
}
int bfs(string A, string B)
{
queue<string> qa, qb;
unordered_map<string, int> da, db;
qa.push(A), da[A] = 0;
qb.push(B), db[B] = 0;
while (qa.size() && qb.size())
{
int t;
if (qa.size() <= qb.size()) t = extend(qa, da, db, a, b);
else t= extend(qb, db, da, b, a);
if (t <= 10) return t;
}
return 11;
}
int main()
{
string A, B;
cin >> A >> B;
while (cin >> a[n] >> b[n]) n ++ ;
int step = bfs(A, B);
if (step > 10) puts("NO ANSWER!");
else printf("%d\n", step);
return 0;
}
三、洛谷P1379. 八数码难题
本题链接: 洛谷P1379. 八数码难题 这道题是acwing-179. 八数码的低配版
- 数据结构定义:
target是目标状态。dx和dy数组分别表示四个方向的偏移量。q1和q2是两个队列,分别用于从起点和终点开始搜索。dist1和dist2是两个哈希表,分别记录从起点和终点到每个状态的步数。
extend函数:- 该函数从队列
q中取出状态进行扩展。 - 对于每个状态,找到空格(0)的位置,并尝试向四个方向移动空格。
- 如果新状态已经在
dist中出现过,跳过;如果在other_dist中出现过,说明找到了从起点到终点的路径,返回步数。 - 否则,将新状态加入队列,并更新
dist中的步数。
- 该函数从队列
bfs函数:- 使用两个队列
q1和q2分别从起点和终点开始搜索。 - 使用两个哈希表
dist1和dist2分别记录从起点和终点到每个状态的步数。 - 每次选择元素较少的队列进行扩展,直到找到从起点到终点的路径或队列为空。
- 使用两个队列
main函数:- 输入初始状态。
- 调用
bfs函数计算最短转换步数。 - 输出最短转换步数。
#include <iostream> #include <queue> #include <unordered_map> #include <string> #include <algorithm> using namespace std; // 目标状态 const string target = "123804765"; // 定义四个方向:上、下、左、右 const int dx[4] = {-1, 1, 0, 0}; const int dy[4] = {0, 0, -1, 1}; // 扩展队列的函数,用于从当前队列中取出状态并进行扩展 // q 是要扩展的队列,dist 存储从起点到当前状态的步数,other_dist 存储从另一个方向到当前状态的步数 int extend(queue<string>& q, unordered_map<string, int>& dist, unordered_map<string, int>& other_dist) { int size = q.size(); // 遍历当前队列中的所有状态 for (int i = 0; i < size; i++) { string state = q.front(); q.pop(); // 找到空格(0)的位置 int idx = state.find('0'); int x = idx / 3, y = idx % 3; // 尝试向四个方向移动空格 for (int j = 0; j < 4; j++) { int nx = x + dx[j], ny = y + dy[j]; // 判断新位置是否合法 if (nx >= 0 && nx < 3 && ny >= 0 && ny < 3) { string next_state = state; // 交换空格和相邻棋子的位置 swap(next_state[idx], next_state[nx * 3 + ny]); // 如果新状态已经在 dist 中出现过,跳过 if (dist.count(next_state)) continue; // 如果新状态已经在 other_dist 中出现过,说明找到了从起点到终点的路径 if (other_dist.count(next_state)) return dist[state] + 1 + other_dist[next_state]; // 记录新状态到 dist 中,并更新步数 dist[next_state] = dist[state] + 1; // 将新状态加入队列 q.push(next_state); } } } // 如果没有找到从起点到终点的路径,返回 -1 return -1; } // 双向广度优先搜索函数,用于寻找从初始状态到目标状态的最短转换步数 int bfs(string start) { // 定义两个队列,分别用于从起点和终点开始搜索 queue<string> q1, q2; // 定义两个哈希表,分别记录从起点和终点到每个状态的步数 unordered_map<string, int> dist1, dist2; // 将起点加入队列,并初始化步数为 0 q1.push(start); dist1[start] = 0; // 将终点加入队列,并初始化步数为 0 q2.push(target); dist2[target] = 0; // 当两个队列都不为空时,继续搜索 while (q1.size() && q2.size()) { int t; // 选择队列元素较少的那个进行扩展 if (q1.size() <= q2.size()) t = extend(q1, dist1, dist2); else t = extend(q2, dist2, dist1); // 如果找到了从起点到终点的路径,返回步数 if (t != -1) return t; } // 如果没有找到从起点到终点的路径,返回 -1 return -1; } int main() { string start; cin >> start; // 调用双向广度优先搜索函数,计算最短转换步数 int step = bfs(start); cout << step << endl; return 0; }
四、相关总结:
状态空间的定义
状态空间是指在一个问题中,所有可能的状态所构成的集合。在解决搜索问题时,算法需要在这个状态空间里寻找从初始状态到目标状态的路径。每个状态代表问题在某一时刻的特定情况,而状态之间的转换规则则定义了如何从一个状态转移到另一个状态。
状态空间的具体数量评估或数量级估算
字串替换问题
- 数量评估:字串替换问题的状态空间数量与初始字符串的长度、替换规则的数量以及规则的复杂程度密切相关。假设初始字符串长度为 n,有 m 条替换规则,每条规则替换的子串长度平均为 k。每次替换操作可能会产生多个新的字符串状态。
- 数量级估算:在最坏情况下,状态空间的数量可能呈指数级增长。如果每次替换都能产生新的不同状态,并且可以进行多次替换,那么状态空间的数量级可能达到 \(O(m^{n/k})\)。例如,若初始字符串长度 \(n = 10\),有 \(m = 3\) 条替换规则,平均替换子串长度 \(k = 2\),那么状态空间可能达到 \(3^5 = 243\) 种状态。不过,实际情况中,由于字符串的重复和规则的限制,状态空间可能会小于这个理论值。
八数码问题
- 数量评估:八数码问题是在一个 \(3\times3\) 的棋盘上,有 8 个标有 1 - 8 的棋子和 1 个空格。状态空间的大小就是这些棋子和空格的所有可能排列组合的数量。
- 数量级估算:根据排列组合的知识,n 个不同元素的全排列数为 \(n!\)。在八数码问题中,一共有 9 个位置,相当于对 9 个元素进行全排列,但由于空格的本质是相同的,所以状态空间的大小为 \(9! = 362880\) 种状态。
普通 BFS 和双向 BFS 在不同状态空间下的情况
普通 BFS
使用场景与前提
- 场景
- 状态空间规模较小:当状态空间数量有限且可在合理时间内遍历完时,普通 BFS 是一个简单有效的选择。例如在字串替换问题中,如果字符串较短且替换规则少,或者八数码问题中初始状态和目标状态很接近,普通 BFS 能快速找到解。
- 对时间复杂度要求不苛刻:在一些对时间要求不高的场景,如教学演示、小规模问题求解等,普通 BFS 因其实现简单的特点可优先考虑。
- 前提
- 明确起始状态:在字串替换问题中是初始字符串,在八数码问题中是初始的棋盘布局。
- 有明确的目标状态判断条件:能清晰判断何时达到目标状态,如字串替换为目标字符串,八数码问题达到目标棋盘布局。
优缺点
- 优点
- 实现简单:代码逻辑清晰,只需一个队列存储待扩展的状态,按规则不断扩展新状态并判断是否为目标状态。
- 通用性强:适用于各种能用图表示的搜索问题,只要能定义好状态和状态转移规则即可使用。
- 缺点
- 搜索效率低:随着状态空间增大,搜索节点数量呈指数级增长。在八数码问题中,若从一个随机状态开始搜索到目标状态,可能需要遍历大量状态,导致效率低下。
- 时间复杂度高:最坏情况下时间复杂度为 \(O(b^d)\),其中 b 是分支因子(每个状态平均产生的新状态数量),d 是解的深度。
双向 BFS
使用场景与前提
- 场景
- 状态空间规模大:当状态空间非常大,普通 BFS 搜索效率极低时,双向 BFS 能显著提高搜索效率。如复杂的字串替换规则或八数码问题中初始和目标状态差异大的情况。
- 明确起始和目标状态:问题同时明确起始和目标状态,且两个方向的搜索操作相似时适用。
- 前提
- 能从目标状态反向操作:在字串替换问题中规则需具备一定可逆性(可设计反向搜索逻辑),在八数码问题中空格移动操作反向也适用。
- 状态可有效存储和比较:能高效存储和比较两个方向搜索产生的状态,以便判断是否相遇。
优缺点
- 优点
- 搜索效率高:从起始和目标状态同时搜索,当两个方向相遇时找到最短路径,大大减少搜索节点数量。在八数码问题中可将时间复杂度从 \(O(b^d)\) 降低到接近 \(O(b^{d/2})\)。
- 节省时间:对于大规模状态空间问题,能在更短时间内找到解,尤其解的深度较大时优势明显。
- 缺点
- 实现复杂:需同时维护两个搜索队列和状态记录,处理两个方向搜索相遇情况,代码实现难度大。
- 空间需求大:要同时存储两个方向的搜索状态,空间复杂度相对较高,在状态空间极大时可能导致内存占用过高。

浙公网安备 33010602011771号