C++图搜索算法精讲:BFS、DFS与双向BFS实战指南
1. 项目概述:为什么图搜索是算法工程师的必修课
如果你正在学习数据结构与算法,或者准备技术面试,那么“图搜索”这个概念你一定绕不过去。它不仅是LeetCode上的高频考点,更是解决无数实际工程问题的核心工具。从社交网络的好友推荐、地图软件的最短路径规划,到编译器依赖分析、网络爬虫的页面抓取策略,背后都离不开图搜索算法的身影。今天,我们不谈那些高深莫测的理论,就从一个一线开发者的视角,用C++这把“瑞士军刀”,把图搜索领域最经典、最实用的三个算法——广度优先搜索(BFS)、深度优先搜索(DFS)和它们的进阶版“双向BFS”,给你掰开了、揉碎了讲清楚。
我见过太多初学者,对着算法书上的伪代码和复杂的数学符号一头雾水。也见过一些有经验的开发者,能写出BFS的代码,却说不清队列里到底存的是什么,更不明白在什么场景下该用BFS而不是DFS。这篇内容,就是来解决这些问题的。我会假设你已经有基本的C++语法基础(比如会用vector、queue这些STL容器),然后带你从零开始,一步步实现这三个算法。更重要的是,我会分享在实际编码中,如何根据问题的“味道”来选择合适的算法,如何设计数据结构来高效地表示图,以及调试这些算法时那些教科书上不会写的“坑”。
我们的目标很明确:不只是让你看懂代码,而是让你真正理解算法背后的思想,并能自信地在面试或项目中运用它们。无论你是正在刷题的学生,还是想巩固基础的工程师,这篇文章都将是一份值得你反复查阅的实战指南。让我们暂时忘掉那些抽象的定义,直接进入代码的世界,看看这三个“剑客”究竟是如何工作的。
2. 基础准备:如何用C++优雅地表示一张图
在动手写搜索算法之前,我们得先解决一个更根本的问题:在C++里,怎么表示“图”这个数据结构?这就像打仗前得先有张地图一样重要。图主要由两部分构成:顶点(Vertex或Node)和边(Edge)。顶点的表示通常很简单,用从0开始的连续整数编号就行,这样我们可以直接用数组或向量来索引。难点在于边的表示,它决定了我们后续搜索的效率。主流的表示方法有两种:邻接矩阵和邻接表。
邻接矩阵是一个二维数组(比如vector<vector<int>>),matrix[i][j]的值表示顶点i到顶点j的边信息(例如,1表示连通,0表示不连通,或者存储权重)。它的优点是查询任意两个顶点是否相邻非常快,是O(1)的时间复杂度。但缺点也极其明显:当图的顶点很多(比如上万个),而边相对稀疏时,这个矩阵将浪费巨大的内存空间(空间复杂度O(V²))。想象一下一个社交网络,有一万个用户,但平均每个用户只关注了100个人,那么矩阵里将有上亿个元素,其中绝大部分都是0,这显然是无法接受的。
因此,在绝大多数涉及搜索的算法题和实际场景中,我们更倾向于使用邻接表。邻接表的本质是一个数组,数组的每个元素是一个链表(或动态数组),这个链表里存储了该顶点的所有邻居顶点。在C++中,我们可以用vector<vector<int>> adjList来完美实现。adjList[i]这个向量里,就存放了所有与顶点i直接相连的顶点编号。
#include <iostream> #include <vector> using namespace std; class Graph { private: int V; // 顶点数 vector<vector<int>> adj; // 邻接表 public: // 构造函数,初始化顶点数和空的邻接表 Graph(int vertices) : V(vertices), adj(vertices) {} // 添加一条从顶点u到顶点v的边(无向图) void addEdge(int u, int v) { adj[u].push_back(v); adj[v].push_back(u); // 如果是无向图,需要添加两次 } // 获取顶点v的所有邻居 const vector<int>& getNeighbors(int v) const { return adj[v]; } // 获取图的顶点数 int getNumVertices() const { return V; } };为什么选择vector<vector<int>>而不是list<int>*这样的指针数组?原因在于缓存友好性和易用性。vector的数据在内存中是连续存储的,遍历adj[i]里所有邻居时,CPU缓存命中率会更高,速度更快。同时,vector的动态扩容特性也让我们省去了手动管理内存的麻烦。当然,如果图的规模固定且已知,使用定长数组(如vector<int> adj[MAX_V])在性能上可能略有优势,但灵活性稍差。对于算法竞赛和面试,vector<vector<int>>是通用且推荐的选择。
这里有一个非常重要的实操心得:在初始化Graph对象时,务必在构造函数里用adj(vertices)来预分配好外层向量的大小。如果你写成vector<vector<int>> adj;然后在addEdge里才去resize,或者直接对adj[u]进行push_back,当u超过当前adj大小时,程序就会发生未定义行为(通常是段错误)。这是新手常踩的一个坑。
另一个注意事项是关于有向图和无向图。上面的addEdge函数默认实现的是无向图,即添加一条边(u, v)等价于添加了两条有向边u->v和v->u。如果你处理的是有向图(比如表示任务依赖关系),那么只需要执行adj[u].push_back(v)这一句即可。在解题时,一定要先看清题目对图的定义,这是方向性错误,一旦错了,整个搜索结果就全乱了。
3. 广度优先搜索(BFS):层层递进的搜索策略
现在,我们有了图的表示,可以请出第一位“剑客”:广度优先搜索(BFS)。你可以把BFS想象成一场“涟漪式”的探索。假设你站在一个池塘(起点)边扔下一颗石子,水波会一圈一圈地向外均匀扩散。BFS就是这样,它从起点开始,先访问所有距离为1的邻居(第一圈),再访问所有距离为2的邻居(第二圈),以此类推。这种特性使得BFS天然适合求解最短路径问题(在边权为1的图中)。
BFS的核心数据结构是队列(Queue)。队列“先进先出”的特性,完美契合了“先访问的顶点,其未访问的邻居也优先被访问”这一逻辑。算法流程可以概括为以下几步:
- 将起点放入队列,并标记为已访问。
- 当队列不为空时,取出队首顶点
u。 - 遍历
u的所有未访问邻居v,将v标记为已访问并入队。 - 重复步骤2-3,直到队列为空或找到目标。
下面是一个标准的BFS模板代码,它计算从起点s到所有其他顶点的最短距离(边数):
#include <queue> #include <vector> using namespace std; vector<int> bfs(const Graph& graph, int start) { int V = graph.getNumVertices(); vector<int> distance(V, -1); // 存储最短距离,-1表示不可达 vector<bool> visited(V, false); // 访问标记数组 queue<int> q; // 初始化起点 distance[start] = 0; visited[start] = true; q.push(start); while (!q.empty()) { int u = q.front(); q.pop(); // 遍历u的所有邻居 for (int v : graph.getNeighbors(u)) { if (!visited[v]) { visited[v] = true; distance[v] = distance[u] + 1; // 距离递增 q.push(v); } } } return distance; }这段代码有几个关键点需要深入理解:
distance数组的妙用:它同时承担了记录距离和判断是否首次访问的双重职责(通过初始值-1)。当distance[v] == -1时,说明v尚未被访问。这是一种常见且高效的空间优化技巧,省去了单独的visited数组。但为了逻辑更清晰,示例中我仍然保留了visited数组。- 访问标记的时机:一定要在顶点入队时就将其标记为已访问(
visited[v] = true),而不是在出队时。为什么?想象一下,顶点A和B有一个共同的邻居C。A先将C入队但未标记,接着B又看到了未访问的C,会再次将C入队。这样队列中就会出现两个C,导致重复访问和计算错误,甚至可能使队列无限增长。这是BFS实现中最经典的错误之一。 - 队列里存的是什么?队列里存储的不仅仅是顶点编号,更隐含着“搜索前沿”的状态。每个出队的顶点,都代表着搜索边界向外推进了一步。
让我们看一个具体的应用场景:LeetCode 752. 打开转盘锁。你有一个四个圆形拨轮的转盘锁,每次只能将一个拨轮向上或向下转动一格,同时有一些“死亡数字”组合不能触碰。问从“0000”转到目标数字target,最少需要转动多少次?这本质上就是一个BFS求最短路径的问题。每个状态(如“0000”)是一个顶点,转动一次得到的新状态就是它的邻居顶点(最多8个,因为每个拨轮有两个方向)。deadends列表里的状态就是不能被访问的顶点。用上述BFS模板,稍作修改(判断是否为目标、跳过死亡数字)就能高效解决。
注意:在类似转盘锁这种状态空间搜索问题中,状态(顶点)通常不是简单的整数,而是字符串、数组或自定义结构。这时,我们需要用
unordered_set或unordered_map来替代visited数组,以实现O(1)时间复杂度的查找。同时,生成邻居状态的函数也会比简单的graph.getNeighbors更复杂。
4. 深度优先搜索(DFS):一条路走到黑的探索精神
与BFS的“广撒网”不同,深度优先搜索(DFS)的策略是“一条道走到黑”。它从起点开始,沿着一条路径一直深入下去,直到这条路径走到尽头(没有未访问的邻居),然后回溯到上一个分岔点,选择另一条未探索的路径继续深入。这种特性使得DFS非常适合处理需要遍历所有可能情况的问题,比如图的连通分量检测、拓扑排序、寻找环路、回溯算法等。
DFS的实现有两种经典方式:递归和显式栈迭代。递归写法依靠函数调用栈,代码简洁直观,是表达DFS逻辑最自然的方式。
#include <vector> using namespace std; void dfsRecursive(const Graph& graph, int u, vector<bool>& visited) { // 访问顶点u(这里可以是任何操作,比如打印、记录路径等) // cout << u << " "; visited[u] = true; // 递归地访问所有未访问的邻居 for (int v : graph.getNeighbors(u)) { if (!visited[v]) { dfsRecursive(graph, v, visited); } } } // 封装函数,从起点开始DFS遍历整个连通分量 void dfs(const Graph& graph, int start) { vector<bool> visited(graph.getNumVertices(), false); dfsRecursive(graph, start, visited); }递归DFS虽然简洁,但在图非常大或者深度很深时,有栈溢出的风险。这时,我们可以使用显式的栈(Stack)来模拟递归过程,也就是迭代版DFS:
#include <stack> #include <vector> using namespace std; void dfsIterative(const Graph& graph, int start) { int V = graph.getNumVertices(); vector<bool> visited(V, false); stack<int> s; s.push(start); // 注意:迭代法中,我们选择在入栈时标记,还是出栈时标记? // 为了和BFS对比,以及避免同一顶点多次入栈,通常在入栈时标记。 visited[start] = true; while (!s.empty()) { int u = s.top(); s.pop(); // 对u进行处理,例如输出 // cout << u << " "; // 将u的未访问邻居入栈 // 注意:栈是后进先出,为了保持和递归类似的遍历顺序(比如都优先遍历第一个邻居), // 有时需要将邻居逆序入栈。但这对许多问题(如仅判断连通性)不影响结果。 for (int v : graph.getNeighbors(u)) { if (!visited[v]) { visited[v] = true; // 入栈前标记 s.push(v); } } } }递归与迭代的选择:递归DFS逻辑清晰,适合深度不大或问题本身适合递归分解(如回溯)的场景。迭代DFS更安全,不会栈溢出,并且有时可以通过调整入栈顺序来控制遍历行为。在面试中,如果面试官没有特别要求,使用递归通常更快捷;但如果他提到“图可能很深”,那么主动提出可以用迭代栈实现,会是一个加分项。
DFS的核心应用:寻找连通分量。在无向图中,一个连通分量是最大的、任意两点间有路径相连的顶点子集。利用DFS可以轻松找出所有连通分量,因为一次DFS遍历所能到达的所有顶点,就构成一个连通分量。
vector<vector<int>> findConnectedComponents(const Graph& graph) { int V = graph.getNumVertices(); vector<bool> visited(V, false); vector<vector<int>> components; for (int i = 0; i < V; ++i) { if (!visited[i]) { vector<int> component; // 需要一个能收集遍历结果的DFS函数 dfsForComponent(graph, i, visited, component); components.push_back(component); } } return components; } // 需要实现一个将遍历节点加入component的DFS函数这里有一个重要的注意事项:对于有向图,DFS遍历的结果顺序有特殊意义。如果我们在递归DFS返回时,将顶点压入一个列表,那么这个列表的逆序,就是该图的一个拓扑排序(如果图是有向无环图的话)。这是解决任务调度、依赖解析类问题的关键。
DFS的“坑”:在处理大规模图时,递归DFS最怕的就是深度过大导致栈溢出。我曾经在解决一个棋盘类搜索问题时,递归深度达到了几千层,直接导致了程序崩溃。解决方案就是改用迭代栈,或者尝试用BFS(如果问题允许)。另一个常见错误是在回溯算法中忘记“恢复状态”。DFS在探索一条路径时,可能会修改一些全局或共享的状态(比如当前路径列表),当这条路径探索完毕回溯时,必须将这些状态恢复原样,否则会影响其他路径的探索。这不是图DFS独有的,但在涉及状态修改的DFS中至关重要。
5. 双向BFS:当起点和终点都明确时的搜索加速器
BFS和DFS是基础,但在一些特定场景下,我们可以做得更聪明。想象一下,你要在一个巨大的社交网络中,寻找两个用户之间的最短关联路径。从其中一个人开始BFS,可能需要探索非常庞大的圈子才能碰到另一个人。但如果你同时从两个人开始,分别向外进行BFS探索,那么当两个搜索的“前沿”相遇时,路径就找到了。这就是双向BFS的核心思想。
双向BFS能大幅提升搜索效率,尤其是在搜索空间呈指数级增长时(比如单词接龙、滑块拼图等问题)。从起点和终点同时开始的搜索,会将搜索的“半径”减半。理论上,在最理想的情况下,如果分支因子是b,最短路径长度是L,那么单向BFS需要探索大约 b^L 个节点,而双向BFS只需要探索大约 2 * b^(L/2) 个节点。当b和L较大时,这个优化是指数级的。
实现双向BFS,我们需要维护两个队列(queueA,queueB)和两个访问记录(visitedA,visitedB)。visited记录不仅标记是否访问过,通常还会记录该顶点是从哪一端搜索过来的,以及距离起点的步数。
#include <queue> #include <vector> #include <unordered_map> using namespace std; int bidirectionalBFS(const Graph& graph, int start, int target) { if (start == target) return 0; // 使用哈希表来记录访问状态和距离,方便快速查找相遇点 unordered_map<int, int> visitedA, visitedB; // key: 顶点, value: 距离起/终点的步数 queue<int> qA, qB; // 初始化 visitedA[start] = 0; visitedB[target] = 0; qA.push(start); qB.push(target); while (!qA.empty() && !qB.empty()) { // 每次选择节点数较少的一端进行扩展,这是一种优化,平衡两端的搜索进度 int distance = -1; // 扩展A端 distance = expandQueue(graph, qA, visitedA, visitedB); if (distance != -1) return distance; // 扩展B端 distance = expandQueue(graph, qB, visitedB, visitedA); if (distance != -1) return distance; } return -1; // 未连通 } int expandQueue(const Graph& graph, queue<int>& q, unordered_map<int, int>& visitedThis, unordered_map<int, int>& visitedOther) { int size = q.size(); for (int i = 0; i < size; ++i) { int u = q.front(); q.pop(); int currentDist = visitedThis[u]; for (int v : graph.getNeighbors(u)) { if (visitedThis.find(v) != visitedThis.end()) { continue; // 已在本侧被访问过 } // 关键检查:如果这个节点已经在另一侧被访问过,说明相遇了! if (visitedOther.find(v) != visitedOther.end()) { int otherDist = visitedOther[v]; return currentDist + 1 + otherDist; // 总距离 = A端距离 + 当前边 + B端距离 } // 否则,标记并加入本侧队列 visitedThis[v] = currentDist + 1; q.push(v); } } return -1; // 本轮扩展未相遇 }双向BFS的实现要点与技巧:
- 相遇判断:这是核心。当从一端扩展到一个新节点
v时,不仅检查它是否在本端的visited中,更要检查它是否在另一端的visited中。如果在,则路径连通,总长度是两端距离之和加1(连接v的那条边)。 - 轮流扩展与优化:代码中每次只扩展一层(通过
for (int i = 0; i < size; ++i)循环控制),然后切换另一端。更优的策略是每次选择当前节点数更少的那一端进行扩展,这可以更快地让两端搜索范围接近,从而尽早相遇。上面的expandQueue函数被设计成可以处理任意一端。 - 数据结构选择:由于顶点可能不是连续整数,或者为了快速查找,我们使用
unordered_map来替代vector作为visited记录。visitedThis[u]的值记录了从本侧起点到u的距离。 - 终止条件:任一队列为空时,如果还未相遇,说明起点和终点不连通。
一个经典的应用场景是“单词接龙”问题(LeetCode 127)。给定一个起始单词、一个结束单词和一个单词列表,每次只能改变一个字母,找出从起始词到结束词的最短转换序列长度。单词列表可以构成一个图,每个单词是节点,相差一个字母的单词之间有边。单词列表通常很大,使用单向BFS可能会超时,而双向BFS则可以显著加速。
注意:双向BFS并非万能。它要求起点和终点都明确已知。在那些只知起点、终点未知(如寻找任意一个解)的问题中,双向BFS就无法应用。同时,实现双向BFS的代码复杂度高于单向BFS,在状态空间不大时,优势可能不明显,甚至因为额外的哈希表操作而更慢。所以,选用前要先判断问题是否适合。
6. 三大算法对比与实战选型指南
学完了三位“剑客”的招式,是时候来一场“华山论剑”,看看它们各自的优劣和适用场景了。选择哪种算法,往往取决于问题的具体“味道”。
1. BFS (广度优先搜索)
- 核心特征:使用队列,按层遍历。
- 时间复杂度:O(V + E),其中V是顶点数,E是边数。每个顶点和每条边都被访问一次。
- 空间复杂度:O(V),在最坏情况下(如星型图),队列需要存储所有顶点。
- 适用场景:
- 无权图的最短路径:这是BFS的“杀手锏”。因为它按层遍历,第一次访问到某个节点时的路径,一定是边数最少的路径。
- 层级遍历或扩散问题:如社交网络中的N度好友、腐烂的橘子(LeetCode 994)、岛屿数量(也可以用DFS)等。
- 判断二分图:通过交替染色和BFS遍历,可以高效判断。
- 不适用场景:需要遍历所有路径或状态的问题(如排列组合),BFS的空间消耗可能过大。
2. DFS (深度优先搜索)
- 核心特征:使用栈(递归或显式),一条路走到底再回溯。
- 时间复杂度:O(V + E),同样访问所有顶点和边。
- 空间复杂度:O(H),其中H是图的最大深度。递归DFS取决于调用栈深度,迭代DFS取决于显式栈的大小。在树或链状图上,空间复杂度可能远小于BFS。
- 适用场景:
- 遍历所有路径/方案:如回溯算法、排列组合、求所有连通分量。
- 拓扑排序:对有向无环图进行排序。
- 检测环路:在图中寻找环。
- 解决“可达性”问题:判断两点是否连通(不关心最短路径时)。
- 不适用场景:求解最短路径(除非遍历所有路径后比较,但效率极低)。在深度可能极大的图中,递归DFS有栈溢出风险。
3. 双向BFS
- 核心特征:从起点和终点同时开始BFS,相遇时停止。
- 时间复杂度:最坏情况仍是O(V+E),但平均情况,尤其是解在中间层时,远快于单向BFS。
- 空间复杂度:O(b^(d/2)),其中b是分支因子,d是最短路径长度。通常优于单向BFS的O(b^d)。
- 适用场景:
- 起点和终点明确的最短路径问题,且搜索空间巨大。如单词接龙、滑块拼图(8-puzzle)等。
- 不适用场景:终点未知,或图本身很小,双向BFS的优化效果不明显,反而增加实现复杂度。
为了更直观,我们可以用一个表格来总结:
| 特性 | BFS | DFS | 双向BFS |
|---|---|---|---|
| 数据结构 | 队列 (Queue) | 栈 (Stack/递归) | 两个队列 |
| 遍历顺序 | 层级遍历 | 深度优先 | 双向层级遍历 |
| 解的性质 | 最优解(最短路径) | 不一定最优(最先找到的) | 最优解(最短路径) |
| 空间开销 | 较大,O(V) | 较小,O(H) | 中等,通常小于单向BFS |
| 经典应用 | 最短路径、扩散问题 | 连通性、拓扑排序、回溯 | 已知起终点的最短路径 |
实战选型心法: 当你拿到一个问题时,可以问自己以下几个问题:
- 问题目标是什么?找最短路径? -> 优先考虑BFS或双向BFS。遍历所有可能? -> DFS。
- 图有多大,深度可能有多深?图巨大且深度可能很深 -> 谨慎使用递归DFS,考虑迭代DFS或BFS。起点终点明确且路径可能很长 -> 强烈考虑双向BFS。
- 需要记录路径吗?如果需要输出具体路径,无论是BFS还是DFS,都需要在访问节点时,记录其“前驱节点”(从哪个节点来的),最后从终点反向回溯即可。这是一个通用的技巧。
- 有特殊约束吗?比如“每次移动代价不同”(加权图),那么普通的BFS就不适用了,需要升级为Dijkstra算法或A*算法。这超出了本文范围,但它是图搜索算法家族中的重要成员。
记住,没有最好的算法,只有最适合当前场景的算法。很多时候,在面试中,面试官期待你不仅能写出代码,更能清晰地说出为什么选择这个算法,以及它的时间和空间复杂度是多少。这才是真正理解了算法思想的表现。
7. 常见问题排查与性能优化技巧
即便理解了算法原理,在亲手实现时,依然会遇到各种稀奇古怪的问题。下面我整理了一些在实现图搜索算法时最常见的“坑”和对应的排查技巧,以及一些提升性能的实战心得。
问题1:程序陷入死循环或栈溢出。
- 可能原因:这是最经典的问题,几乎百分之百是因为访问标记(visited)设置错误。
- 对于BFS:没有在节点入队时立即标记为已访问,导致同一个节点被多次加入队列。
- 对于递归DFS:图中有环,但没有
visited数组,或者递归函数没有终止条件(比如在遍历邻居时没有判断visited),导致无限递归。
- 排查与解决:
- 首先,确保你的
visited数组或集合被正确初始化。 - 对于BFS,在
q.push(v)之后,紧跟着visited[v] = true。 - 对于DFS,在递归函数入口或迭代栈的入栈操作后,立即标记当前节点。
- 可以在循环或递归开始时打印当前节点和
visited状态,这是最直接的调试方法。
- 首先,确保你的
问题2:BFS结果不是最短路径。
- 可能原因:
- 使用了DFS,或者BFS实现有误(比如错误地使用了栈)。
- 图的边有权重,而普通BFS只适用于边权为1(或相等)的无权图最短路径。如果边权不同,需要使用Dijkstra算法。
distance数组更新逻辑错误。距离应该是父节点距离+1,如果你错误地用了其他值,或者重复更新了更长的距离,就会出错。
- 排查与解决:
- 再次确认你实现的是BFS(队列)。
- 检查
distance[v] = distance[u] + 1这行代码是否在发现未访问邻居v时执行。 - 对于有权图,立刻停止使用BFS,转用更合适的算法。
问题3:DFS递归深度太大,导致“段错误”或“栈溢出”。
- 可能原因:图深度极深(比如一条长链),递归调用层次太多,耗尽了系统为程序分配的调用栈空间。
- 排查与解决:
- 改用迭代DFS:使用显式的
stack<int>来模拟递归过程,系统的堆空间通常比栈空间大得多。 - 尝试BFS:如果问题不要求必须DFS,换用BFS可能直接避免深度问题。
- 调整系统栈大小(不推荐):在某些编译环境或操作系统中可以设置,但这不是通用的解决方案,且不利于代码移植。
- 改用迭代DFS:使用显式的
问题4:双向BFS没有正确相遇,或者计算的距离不对。
- 可能原因:
- 相遇点判断逻辑错误:检查
expandQueue函数中,发现v在visitedOther中存在时,计算总距离的公式是否正确。必须是distA + 1 + distB。 - 两端距离记录错误:确保
visitedA和visitedB中记录的距离是从各自起点出发的步数。在扩展时,新节点的距离是当前节点距离+1。 - 初始状态处理不当:起点和终点相同的情况需要单独处理(直接返回0)。
- 相遇点判断逻辑错误:检查
- 排查与解决:
- 在扩展队列时,打印出当前扩展的节点、距离以及两个
visited映射的内容,可以非常清晰地看到搜索是如何推进以及在哪里相遇的。 - 用一个非常小的图(比如3个节点的链)手动模拟算法过程,是最有效的调试方法。
- 在扩展队列时,打印出当前扩展的节点、距离以及两个
性能优化技巧:
- 数据结构的选择:
visited标记:如果顶点编号是连续的整数,优先使用vector<bool>或vector<int>,其访问速度远快于unordered_set。如果顶点是字符串或其他复杂类型,则必须使用unordered_set。- 队列/栈:使用STL的
queue和stack即可,它们默认由deque实现,性能足够好。在极端性能要求下,可以用vector模拟队列(维护头尾指针),但代码复杂度会增加。
- 提前终止:无论是BFS还是DFS,一旦找到目标解,立即
return或break,避免无谓的后续搜索。 - 双向BFS的扩展优化:如前所述,每次选择节点数更少的那一端进行扩展,可以更快相遇。这需要你维护两个队列的大小并做比较。
- 状态压缩:在一些搜索问题中(如棋盘状态),顶点可能是一个复杂结构。直接将其作为
unordered_set的key可能效率很低。如果可能,将其压缩为一个整数(比如位运算)或一个字符串,可以大幅提升哈希和比较的速度。 - 避免重复计算:在生成邻居状态时,可能会有重复或无效状态。在入队/入栈前进行有效性判断(比如是否越界、是否满足条件),比生成所有邻居再过滤,效率更高。
调试算法就像破案,需要耐心和逻辑。最笨但最有效的方法就是“打印大法”。把关键变量(当前节点、队列内容、visited数组)在每一步都打印出来,跟着程序的逻辑走一遍,绝大多数错误都会无所遁形。
