ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从单向BFS到双向BFS:算法优化实战与性能对比分析

从单向BFS到双向BFS:算法优化实战与性能对比分析 1. 项目概述从单向BFS到双向BFS的思维跃迁“字串变换”这个问题乍一看就是个典型的字符串搜索问题给你一个起始串A、一个目标串B以及若干条形如abc-xyz的替换规则问能否在有限步内将A变成B并求最少步数。很多人的第一反应就是标准的BFS广度优先搜索从起点开始每一步应用所有可能的规则生成新状态直到找到目标。这个思路完全正确也是解决此类问题的基石。然而当我在AcWing上刷到这道题并看到它被归入“算法提高课”和“双向广搜”专题时我就知道事情没那么简单。果不其然用朴素的单向BFS一提交直接TLE超时。问题出在哪在于搜索空间会随着步数呈指数级膨胀。假设每条规则平均能在当前字符串中匹配到2个位置有6条规则那么每一步分支因子可能就是12。搜索10步状态数就可能达到12^10这个天文数字单向BFS的队列根本撑不住。这时“双向广搜”就登场了。它不是一个全新的算法而是对经典BFS的一次精妙优化。其核心思想是“两头堵”不仅从起点A开始正向搜索同时也从终点B开始反向搜索。两边的搜索“波浪”在中间某处相遇时路径就找到了。这样做能极大减少需要探索的状态总数。为什么因为搜索树的节点数量是随着深度指数级增长的。从起点和终点同时搜索相当于将巨大的指数爆炸“拦腰截断”。假设最优解需要10步单向BFS需要探索深度为10的整棵树而双向BFS每边只需要探索深度大约为5的树两棵深度为5的树节点数之和远小于一棵深度为10的树。这个优化在状态空间庞大的问题中效果是颠覆性的。接下来我们就从最基础的单向BFS实现开始一步步拆解如何将其升级为高效的双向BFS并搞定AcWing 190这道经典题目。2. 核心思路与数据结构选型2.1 问题建模与搜索状态定义首先我们必须把问题抽象成一个清晰的图论模型。在这个问题里节点State每一个可能的字符串就是一个状态节点。边Transition应用一条可用的替换规则将当前字符串变为一个新字符串这个过程就构成了一条有向边。由于规则可以正向使用a-b表示把子串a替换为b在双向BFS中也需要反向使用即把子串b替换回a所以边实际上是双向可通的我们把它视为无向边来处理搜索。目标找到从节点A到节点B的最短路径最少应用规则的次数。搜索的核心就是状态扩展。给定一个字符串s我们需要遍历所有规则。对于每条规则(src, dst)我们需要在s中找出所有可以匹配src子串的位置并在每个位置进行替换从而生成一系列新字符串。这个过程需要用到字符串的find函数并且要注意find的起始位置要不断后移以找到所有匹配。2.2 单向BFS的框架与瓶颈我们先回顾单向BFS的标准写法这是理解一切的基础。#include iostream #include queue #include unordered_map #include string using namespace std; int bfs_one_way(string A, string B, vectorpairstring, string rules) { if (A B) return 0; queuestring q; unordered_mapstring, int dist; // 记录到达每个状态的最短步数 q.push(A); dist[A] 0; while (!q.empty()) { string t q.front(); q.pop(); int current_dist dist[t]; // 扩展当前状态t for (auto rule : rules) { string src rule.first, dst rule.second; // 在t中寻找所有src出现的位置 for (int pos t.find(src); pos ! -1; pos t.find(src, pos 1)) { // 生成新状态 string next t.substr(0, pos) dst t.substr(pos src.size()); // 如果新状态超过长度限制或已访问过则跳过 if (next.size() B.size() || dist.count(next)) continue; if (next B) return current_dist 1; // 找到目标 dist[next] current_dist 1; q.push(next); } } } return -1; // 未找到 }这个框架清晰明了但它有一个致命弱点搜索是盲目的、对称的。它从起点开始像水波一样一圈圈向外扩散直到碰到终点。在状态空间巨大时这个“圆圈”会变得非常大消耗大量时间和内存。这就是我们需要双向BFS的根本原因。2.3 双向BFS的工作原理与优势双向BFS建立两个搜索队列和两个距离字典q_a,dist_a: 从起点A开始的正向搜索。q_b,dist_b: 从终点B开始的反向搜索。每一轮迭代我们选择当前节点数较少的那一边进行扩展这是一种常见的优化旨在平衡两边的搜索进度。扩展一个节点时生成所有可能的下一个状态。关键来了当从一个方向生成的新状态next在另一个方向的dist字典中已经存在时说明两条搜索路径相遇了。此时总步数就是dist_a[current] 1 dist_b[next]。这个“相遇检查”是双向BFS的灵魂。它把寻找“到达终点”这个目标转化为了寻找“状态在两边都被访问”这个条件从而将搜索深度减半。数据结构选型心得queuestring用于BFS是标准操作先进先出保证最短路径。unordered_mapstring, int这是本题性能的关键。我们需要快速查询一个字符串状态是否被访问过以及其对应的步数。unordered_map基于哈希表平均O(1)的查找和插入复杂度远优于map的O(log n)。考虑到状态数可能很多且字符串作为键哈希表的性能优势非常明显。这里有一个重要细节在C中标准库已经为std::string提供了特化的哈希函数可以直接使用。如果你自己定义的结构体作为键就需要手动定义哈希函数。3. 双向BFS的详细实现与代码解析理解了原理我们来看AcWing 190. 字串变换的具体实现。题目有几个关键约束最多10步字符串长度不超过20规则最多6条。这直接提示我们超过10步就算不可达双向BFS每边最多扩展5层。3.1 算法流程与步骤拆解初始化读入起始串A、目标串B和所有规则。为正向和反向搜索分别初始化队列和距离字典。将A加入q_adist_a[A]0将B加入q_bdist_b[B]0。循环扩展只要两个队列都不空且总步数未超限例如10步就继续。选择扩展方向比较q_a和q_b的当前大小选择节点数少的那一边进行扩展。这是为了平衡两边的搜索广度避免一边搜得太深而另一边还没动这是一种有效的启发式优化。单层扩展对选中的队列处理其当前层的所有节点注意是“一层”而不是一个这保证了步数的准确性。对于队列中的每个节点t遍历所有规则正向搜索用原规则反向搜索需要用反向规则。在字符串t中寻找规则源子串的所有出现位置。在每个位置进行替换生成新字符串next。剪枝如果next长度超过目标串B的长度题目隐含约束变换中字符串长度可能增长则跳过。相遇检查如果next在对方的距离字典中存在则找到最短路径。路径长度为dist_当前[t] 1 dist_对方[next]。如果next在己方的距离字典中已存在说明已以更短步数访问过跳过BFS特性保证第一次访问是最短的。否则记录距离将next加入当前队列。返回结果如果相遇返回步数如果循环结束仍未相遇返回不可达。3.2 核心代码实现与注释以下是结合了上述思路的C实现。代码中包含了正向扩展和反向扩展的统一处理函数。#include iostream #include algorithm #include queue #include unordered_map #include string using namespace std; const int N 6; int n; // 规则数 string A, B; string a[N], b[N]; // 规则数组a[i]-b[i] // 扩展函数对队列q进行一层扩展距离字典是da另一个距离字典是db // 使用规则数组ra和rb (对于正向扩展raa, rbb; 对于反向扩展rab, rba) int extend(queuestring q, unordered_mapstring, int da, unordered_mapstring, int db, string ra[], string rb[]) { // 取出当前层的所有元素进行扩展 int d da[q.front()]; // 当前层的距离 while (q.size() da[q.front()] d) { auto t q.front(); q.pop(); // 枚举所有规则 for (int i 0; i n; i) { // 在字符串t中寻找所有可以应用规则的位置 for (int pos 0; pos t.size(); pos) { // 检查从pos开始是否能匹配规则源子串ra[i] if (t.substr(pos, ra[i].size()) ! ra[i]) continue; // 生成新状态 string next t.substr(0, pos) rb[i] t.substr(pos ra[i].size()); // 剪枝字符串长度限制根据题意目标串B的长度是一个参考上限 if (next.size() B.size()) continue; // 如果新状态在另一个方向已被访问则相遇 if (db.count(next)) return da[t] 1 db[next]; // 如果新状态在本方向已访问跳过 if (da.count(next)) continue; // 记录距离加入队列 da[next] da[t] 1; q.push(next); } } } return -1; // 本次扩展未相遇 } int bfs() { if (A B) return 0; queuestring qa, qb; unordered_mapstring, int da, db; qa.push(A); da[A] 0; qb.push(B); db[B] 0; int step 0; // 限制总步数题目要求最多10步 while (qa.size() qb.size() step 10) { int t; // 优先扩展节点数少的一边以平衡搜索 if (qa.size() qb.size()) { t extend(qa, da, db, a, b); // 正向扩展 } else { t extend(qb, db, da, b, a); // 反向扩展注意参数顺序 } if (t ! -1) return t; // 相遇则返回总步数 step; } return -1; } int main() { cin A B; while (cin a[n] b[n]) n; int ans bfs(); if (ans -1) puts(NO ANSWER!); else cout ans endl; return 0; }3.3 关键细节与避坑指南一层扩展 vs 单个节点扩展extend函数中的while循环da[q.front()] d是精髓。它保证了每次调用只扩展当前距离的所有节点即“一层”。这是计算正确步数的基础。如果改成每次只弹出一个节点就返回步数逻辑会混乱。规则的方向性在extend函数中参数ra[]和rb[]代表本次扩展所使用的规则。对于从A出发的正向扩展规则是a-b所以传入a, b。对于从B出发的反向扩展规则应该是b-a即反向替换所以传入b, a。这个对应关系千万不能错。相遇判断的逻辑if (db.count(next)) return da[t] 1 db[next];这行代码是双向BFS的核心。da[t]是当前状态t在己方的步数1是走到新状态next的这一步db[next]是next状态在对方早已被访问时的步数。三者之和就是总路径长。剪枝优化if (next.size() B.size()) continue;这是一个非常有效的可行性剪枝。因为我们的目标串是B如果变换过程中产生的字符串长度已经超过了B的长度那么它无论如何也不可能通过缩短变换变成B规则是替换可能变长也可能变短但题目数据中通常无意义的增长会导致搜索爆炸。这是一个基于题目特征的优化。步数限制题目要求最多10步所以在主循环中加入了step 10的条件。注意这里的step可以理解为两边扩展的“轮数”的一个上界估算更精确的约束需要在扩展函数内部判断da[t]或db[t]是否超过5。4. 性能对比与扩展思考为了直观感受双向BFS的威力我们可以做一个简单的理论对比。假设每个状态平均有b个分支分支因子最短路径长度为d。单向BFS需要探索的节点总数约为O(b^d)。当d10,b6时这个数字是6^10约6000万实际由于字符串匹配和剪枝会少很多但依然庞大。双向BFS每边只需要探索深度约为d/2。需要探索的节点总数约为O(2 * b^(d/2))。同样条件下约为2 * 6^5 2 * 7776 ≈ 15552。两者相差了四个数量级这就是为什么单向BFS超时而双向BFS能轻松通过的原因。关于unordered_map的进一步优化 在极端情况下字符串数量很多unordered_map的哈希冲突可能会影响性能。一个进阶优化是使用双端队列deque配合自定义哈希或者使用开放寻址法的哈希数组来模拟dist字典。但对于本题的数据范围unordered_map已经完全足够。这里分享一个心得在竞赛中unordered_map的默认哈希函数对于字符串有时可能不够快如果遇到卡常可以尝试传入自定义哈希函数例如使用std::hashstd::string_view或者简单的BKDR哈希。struct StringHash { size_t operator()(const string s) const { size_t hash 0; for (char c : s) { hash hash * 131 c; // BKDR哈希常数 } return hash; } }; // 使用unordered_mapstring, int, StringHash da, db;双向BFS的适用场景 并不是所有BFS问题都适合双向。它适用于知道明确的起点和终点。状态空间巨大单向搜索容易超时或超内存。状态转移是可逆的或者可以定义明确的反向转移规则如本题。 常见的应用场景包括八数码问题如果可解、单词接龙、某些状态压缩的最短路问题。最后这道题给我的最大启示是优化算法有时不是去发明新东西而是改变看待问题的角度。从起点单向搜索到起点终点双向对搜这个思维的转变带来的性能提升是质的飞跃。在遇到搜索“爆炸”的问题时不妨多问一句“终点明确吗能反向搜吗”
返回列表