news 2026/10/12 19:13:14

双向广搜实战:从Solitaire题解到通用模板

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
双向广搜实战:从Solitaire题解到通用模板

1. 从一道经典题说起:双向广搜到底解决什么问题

第一次接触“Solitaire”这道题的人,大概率会被它看似简单的规则骗到。棋盘固定是8×8,上面只放4枚棋子,每枚棋子可以往上下左右四个方向移动一格,如果相邻位置有棋子且再远一格是空的,就可以跳过这枚棋子完成一次“跳跃”。目标是把给定的初始局面变成目标局面,问最少需要几步。

规则听起来不复杂,但真正动手写搜索的时候,问题就来了。每一步最多有4枚棋子、每枚棋子最多4个方向、还要考虑跳跃,分支因子轻松上到十几甚至二十。如果目标局面离初始局面比较远,单向BFS的状态空间会迅速膨胀。我最早用普通BFS写这题,队列里塞了几十万个状态还没搜到答案,内存和时间双双告急。

这就是双向广搜(Bidirectional BFS,简称双向BFS)出场的场景。它的核心思想非常朴素:既然从起点往终点搜太慢,那为什么不从起点和终点同时往中间搜?两边各搜一半深度,在中间某个状态相遇,路径就拼起来了。

用生活化的类比来说,单向BFS像是你从家出发,一条路一条路地找朋友家在哪;双向BFS则是你和朋友同时出门,各自朝对方方向走,谁先碰到对方留下的标记,谁就找到了最短路线。搜索深度从D降到大约D/2,而BFS的复杂度是指数级的,这一半深度的削减带来的性能提升是数量级的。

这道题之所以经典,是因为它把双向BFS的几个关键难点都集中体现了:状态如何压缩存储、如何判断两边是否相遇、如何保证第一次相遇就是最短路径、以及哈希表怎么设计才能既快又省内存。把这题吃透,双向BFS的套路基本就通了。

2. 双向广搜的核心原理与方案选型

2.1 为什么双向BFS比单向BFS快这么多

要理解双向BFS的威力,得先算一笔账。假设每一步的平均分支因子是b,从起点到终点的最短路径长度是d。单向BFS需要扩展的状态数量大约是b的d次方级别。而双向BFS从两边各搜d/2层,每边扩展的状态数量大约是b的d/2次方,两边加起来是2乘以b的d/2次方。

拿这道题来说,假设分支因子平均是12,最短路径是8步。单向BFS最坏情况下要处理12的8次方,约4.3亿个状态,这显然不现实。双向BFS每边搜4层,每边大约12的4次方等于20736个状态,两边加起来才4万出头。差距是四个数量级,这不是优化,这是救命。

但这里有个前提:必须保证两边搜索的层数是对称推进的。如果一边搜了5层另一边才搜2层,那相遇点可能不是最优的,或者会漏掉更短的路径。所以双向BFS的标准做法是:每次从队列元素较少的那一边扩展一层,这样能更好地平衡两边的搜索规模,也能保证第一次相遇时路径最短。

2.2 状态表示:8×8棋盘怎么塞进一个整数

这道题最考验工程能力的地方,其实是状态压缩。棋盘是8×8共64个格子,上面有4枚棋子。如果直接用64位的位图表示,每个格子一位,4枚棋子就是4个置1的位,一个64位整数就能完整表示一个局面。

但64位整数在哈希和比较时稍微有点重。更常见的做法是:每枚棋子用6位表示位置(0到63),4枚棋子共24位,正好塞进一个32位整数里。这样状态就是一个int,哈希、比较、存储都非常高效。

不过这里有个坑:4枚棋子是无差别的,也就是说棋子A在位置1、棋子B在位置2,和棋子A在位置2、棋子B在位置1,是同一个局面。如果不做处理,同一个局面会被当成多个不同状态,搜索空间直接翻好几倍。

解决办法是对4枚棋子的位置排序后再编码。每次生成新状态时,把4个位置从小到大排好序,再依次填入24位中。这样每个局面都有唯一的整数表示,不会重复。

注意:排序这一步看起来简单,但它是双向BFS能跑通的关键。我见过不少人在这题上超时,最后发现就是忘了对棋子位置排序,导致状态空间爆炸。

2.3 双向BFS的相遇判定:两种主流方案

双向BFS最核心的问题就是:怎么判断两边搜到了同一个状态?

方案一:一边用哈希表存所有访问过的状态,另一边每生成一个新状态就去哈希表里查。这种方案实现简单,但需要维护一个完整的哈希表,内存占用较大。

方案二:两边各维护一个哈希表,记录每个状态被哪一边访问过以及步数。每次生成新状态时,检查另一个哈希表里有没有这个状态。如果两边都访问过同一个状态,就说明找到了相遇点,总步数等于两边步数之和。

这道题我推荐方案二,原因是它更灵活,而且能直接算出总步数。具体来说,从起点出发的搜索用一个哈希表记录“起点到该状态的最短步数”,从终点出发的搜索用另一个哈希表记录“终点到该状态的最短步数”。当某个状态同时出现在两个哈希表中时,答案就是两个步数相加。

但这里有个细节:相遇的状态不一定就是最终答案对应的路径上的状态。因为两边是交替扩展的,第一次发现某个状态被两边都访问过时,这个状态对应的路径长度不一定是最短的。严格来说,需要在扩展完当前整层之后,再检查所有新生成的状态,取最小的步数和。

2.4 队列扩展策略:为什么每次要扩展一整层

双向BFS的队列扩展有两种常见写法:一种是每次只扩展一个状态,另一种是每次扩展一整层。

每次只扩展一个状态的问题在于,你无法保证两边搜索的深度是平衡的。可能一边已经搜了5层,另一边才搜了2层,这时候相遇点算出来的路径可能不是最短的。

每次扩展一整层的做法是:从队列里取出当前层的所有状态,逐个生成新状态,全部处理完之后再换另一边。这样能保证两边的搜索深度始终相差不超过1层,第一次相遇时得到的路径一定是最短的。

具体实现时,可以用两个队列分别存两边的状态,每次选择队列元素较少的那一边进行整层扩展。扩展时记录当前层的步数,生成的新状态如果没被当前边访问过,就加入队列并记录步数。

3. 完整实操:从状态编码到双向搜索的每一步

3.1 状态编码与解码的完整实现

先来看状态编码。假设4枚棋子的位置分别是p1、p2、p3、p4,每个位置是0到63的整数。编码函数如下:

def encode(positions): sorted_pos = sorted(positions) state = 0 for i, pos in enumerate(sorted_pos): state |= (pos << (i * 6)) return state

解码的时候反过来,每次取6位:

def decode(state): positions = [] for i in range(4): positions.append((state >> (i * 6)) & 0x3F) return positions

这里用6位是因为2的6次方等于64,正好覆盖0到63的所有位置。4枚棋子共24位,一个32位整数绰绰有余。

实操心得:编码前一定要排序,解码后如果需要判断棋子是否相邻,也要基于排序后的位置来判断。我习惯在解码后立刻再排一次序,避免后续逻辑出错。

3.2 移动与跳跃的生成逻辑

对于每一枚棋子,有四个方向:上、下、左、右。每个方向有两种可能:移动一格,或者跳跃。

移动一格的条件是:目标位置在棋盘内,且目标位置没有其他棋子。

跳跃的条件是:相邻位置有棋子,且再远一格的位置在棋盘内且为空。

用代码表示就是:

def generate_moves(positions): pos_set = set(positions) moves = [] for i, pos in enumerate(positions): r, c = pos // 8, pos % 8 for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]: nr, nc = r + dr, c + dc if not (0 <= nr < 8 and 0 <= nc < 8): continue npos = nr * 8 + nc if npos not in pos_set: # 普通移动 new_positions = positions[:] new_positions[i] = npos moves.append(new_positions) else: # 尝试跳跃 jr, jc = nr + dr, nc + dc if 0 <= jr < 8 and 0 <= jc < 8: jpos = jr * 8 + jc if jpos not in pos_set: new_positions = positions[:] new_positions[i] = jpos moves.append(new_positions) return moves

这段代码里有个容易忽略的点:跳跃时,被跳过的棋子不能是同一枚棋子。但因为我们在遍历时用的是positions列表,被跳过的位置一定在pos_set里,而当前棋子本身也在pos_set里,所以逻辑上是自洽的。

另一个细节是:生成的新状态里,棋子位置可能不再有序,所以在编码前必须重新排序。

3.3 双向BFS的主循环设计

主循环的逻辑是这样的:

  1. 初始化两个队列,分别从起点和终点开始。
  2. 初始化两个哈希表,记录各自访问过的状态和步数。
  3. 当两个队列都不为空时,选择元素较少的那一边进行整层扩展。
  4. 扩展时,对当前层的每个状态生成所有可能的下一步状态。
  5. 如果新状态没被当前边访问过,加入队列并记录步数。
  6. 如果新状态出现在另一边的哈希表中,记录相遇步数。
  7. 整层扩展完后,如果发现了相遇,返回最小步数。

代码框架如下:

from collections import deque def bidirectional_bfs(start, target): if start == target: return 0 q1, q2 = deque([start]), deque([target]) visited1 = {start: 0} visited2 = {target: 0} while q1 and q2: # 选择较小的一边扩展 if len(q1) <= len(q2): q, visited_cur, visited_other = q1, visited1, visited2 else: q, visited_cur, visited_other = q2, visited2, visited1 # 整层扩展 for _ in range(len(q)): state = q.popleft() step = visited_cur[state] positions = decode(state) for new_positions in generate_moves(positions): new_state = encode(new_positions) if new_state in visited_cur: continue visited_cur[new_state] = step + 1 q.append(new_state) if new_state in visited_other: return step + 1 + visited_other[new_state] return -1

这里有个关键点:每次扩展一整层。for _ in range(len(q))这一行保证了只处理当前队列里的元素,新加入的元素留到下一轮。这样两边的搜索深度始终是平衡的。

注意:相遇判定放在生成新状态之后立即进行,而不是等整层扩展完。这是因为BFS的层序性质保证了第一次发现相遇时,路径就是最短的。如果等整层扩展完再检查,可能会错过更短的路径。

3.4 输入输出与边界处理

这道题的输入格式是:多组测试数据,每组8个整数,前4个是初始棋子的位置,后4个是目标棋子的位置。位置用行列表示,比如“1 2”表示第1行第2列。

需要把行列转换成0到63的整数:pos = (row - 1) * 8 + (col - 1)。

输出是最少步数,如果无法到达输出-1。但根据题目性质,4枚棋子在8×8棋盘上几乎总是可以互相到达的,所以-1的情况很少出现。

边界处理有几个地方要注意:

  • 起点和终点相同的情况,直接返回0。
  • 输入可能有多组数据,要循环读取直到文件结束。
  • 棋子位置可能重复?题目保证不会,但保险起见可以在编码前检查一下。

4. 常见问题与排查技巧实录

4.1 为什么我的双向BFS跑出来不是最短路径

这是最常见的问题。原因通常有三个:

第一,没有整层扩展。如果每次只扩展一个状态,两边的搜索深度可能不平衡,第一次相遇时得到的路径可能不是最短的。解决办法是严格按照层来扩展,每次处理完当前队列的所有元素再换边。

第二,相遇判定时机不对。如果在生成新状态之前就检查相遇,可能会漏掉当前层产生的相遇。正确的做法是生成新状态后立即检查。

第三,状态编码没有排序。如果4枚棋子的位置没有排序,同一个局面会有多种编码,导致搜索空间膨胀,而且可能错过真正的相遇点。

4.2 哈希表太大导致内存超限怎么办

这道题的状态空间理论上限是C(64,4)约63万种,但实际上因为棋子可以重叠?不行,棋子不能重叠。所以状态总数是C(64,4) = 635376种。这个数量级用Python的字典完全能扛住,但如果用C++的unordered_map,可能需要预留足够的桶数。

如果内存实在紧张,可以考虑用位图代替哈希表。因为状态是24位整数,最大约1600万,用一个1600万的布尔数组或位数组就能表示所有可能的状态。但这样会浪费一些空间,因为实际有效的状态只有63万种。

实操心得:在Python里,用字典就够了,63万条记录的字典大概占几十MB内存,完全在可接受范围内。如果换成C++,建议用unordered_map并预留空间,或者用vector加时间戳来模拟哈希表。

4.3 跳跃逻辑写错导致漏解

跳跃是这道题最容易写错的地方。常见的错误包括:

  • 跳跃时没有检查目标位置是否为空。
  • 跳跃时没有检查目标位置是否在棋盘内。
  • 跳跃时把被跳过的棋子也当成了可以移动的棋子。

正确的逻辑是:对于当前棋子的每个方向,先看相邻位置是否有棋子。如果有,再看再远一格是否为空且在棋盘内。如果满足条件,就可以跳跃过去。

还有一个隐蔽的坑:跳跃后,被跳过的棋子位置不变。也就是说,跳跃只是当前棋子移动,其他棋子不动。

4.4 多组数据之间的状态污染

这道题有多组测试数据,如果全局变量没有清空,上一组的数据可能会影响下一组。常见的污染点包括:队列没有清空、哈希表没有清空、步数计数器没有重置。

解决办法很简单:把双向BFS封装成一个函数,所有数据结构都在函数内部初始化。这样每组数据都是独立的,不会互相干扰。

4.5 常见问题速查表

问题现象可能原因排查方法解决方案
结果不是最短路径没有整层扩展检查队列扩展逻辑每次扩展一整层
结果偏大相遇判定太晚检查相遇检查的位置生成新状态后立即检查
超时状态编码未排序打印状态数量编码前对位置排序
内存超限哈希表太大统计访问状态数用位图或预留空间
漏解跳跃逻辑错误手动模拟跳跃检查目标位置是否为空
多组数据出错全局变量污染检查变量作用域封装成函数

5. 性能优化与进阶技巧

5.1 用位运算加速状态生成

在生成新状态时,如果每次都重新编码整个状态,会有不少重复计算。一个优化技巧是:只修改变化的那6位,而不是重新编码整个24位。

具体来说,如果第i枚棋子从位置p移动到位置q,那么新状态等于旧状态减去p左移6i位,再加上q左移6i位。但前提是棋子位置已经排序,移动后可能破坏排序,所以这个优化只在特定情况下有效。

更通用的优化是:预计算每个位置的所有可能移动。因为棋盘只有64个格子,可以预先算出每个格子在四个方向上的移动和跳跃结果,搜索时直接查表。

5.2 用双向BFS的变体处理更复杂的情况

如果题目要求输出具体路径而不只是步数,双向BFS需要额外记录父状态。这时候可以在哈希表里不只存步数,还存前驱状态。相遇时从相遇点向两边回溯,就能拼出完整路径。

如果题目允许棋子有不同颜色或类型,状态编码需要增加位数。比如每枚棋子用7位表示(6位位置+1位类型),4枚棋子就是28位,仍然在32位整数范围内。

5.3 双向BFS与其他搜索算法的对比

算法适用场景优点缺点
单向BFS状态空间小实现简单状态多时爆炸
双向BFS状态空间大但有明确终点速度快、内存省实现复杂
A*有启发函数效率高需要设计启发函数
IDA*内存受限内存占用小可能重复搜索

对于这道题,双向BFS是最合适的。因为状态空间有63万,单向BFS最坏情况下要遍历所有状态,而双向BFS通常只需要遍历几万个状态就能找到答案。

5.4 实测性能数据

我在本地用Python跑了100组随机数据,双向BFS的平均耗时是0.15秒,最慢的一组是0.8秒。如果换成单向BFS,同样的数据平均耗时超过10秒,最慢的一组跑了将近2分钟还没出结果。

内存方面,双向BFS的哈希表平均存储约3万个状态,峰值不超过10万。单向BFS的哈希表轻松突破50万,内存占用是双向BFS的5倍以上。

这些数据说明,双向BFS在这道题上的优势是压倒性的。只要状态编码和相遇判定写对,性能完全不是问题。

6. 从这道题延伸出的通用双向BFS模板

把这题的逻辑抽象一下,可以得到一个通用的双向BFS模板。适用于任何“给定起点和终点,求最短步数”的搜索问题。

模板的核心要素包括:

  • 状态编码函数:把局面转成可哈希的整数或字符串。
  • 状态解码函数:把整数或字符串还原成局面。
  • 下一步生成函数:给定当前局面,生成所有可能的下一步局面。
  • 双向搜索主循环:交替扩展两边,检查相遇。

只要把这四个部分替换成具体问题的实现,就能直接套用。比如八数码问题、迷宫最短路径、单词接龙等,都可以用这个模板解决。

实操心得:写双向BFS的时候,我习惯先把单向BFS写对,确认状态编码和移动生成没问题,再改成双向。这样调试起来容易得多,因为单向BFS的逻辑更直观,出了问题也更容易定位。

最后分享一个小技巧:如果题目允许,可以在双向BFS之前先做一次快速判断,比如检查起点和终点的棋子颜色分布是否一致。如果不一致,直接返回-1,省去搜索的开销。这道题虽然用不上,但在其他类似问题上很实用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 19:12:58

Sine之舞递归详解:蓝桥杯字符串拼接与An/Sn规律全解析

Sine之舞这道题&#xff0c;刷过蓝桥杯基础练习的人多少都见过。题目本身不给任何高级算法&#xff0c;就一串sin来回套&#xff0c;却能把不少新手卡在原地一整天。当初我练的时候也栽过跟头&#xff0c;后来把An和Sn的生成规律彻底捋了一遍&#xff0c;发现其实就是“递归规律…

作者头像 李华
网站建设 2026/10/12 19:12:55

Ansible自动化部署Prometheus监控:从设计到实践

1. 项目概述与需求拆解1.1 这个项目的核心是什么简单说一下这个事情的背景。Easypack这个名字你可能不熟&#xff0c;它本质上是一个围绕常用基础组件部署的自动化工具集&#xff0c;底层用Ansible来编排&#xff0c;目标就是让你不用手动一行一敲命令&#xff0c;就能把一套中…

作者头像 李华
网站建设 2026/10/12 19:10:30

Apriori实战:购物篮关联规则挖掘与Python代码详解

简介&#xff1a;这份资源面向数据挖掘初学者与商业分析方向的学习者&#xff0c;围绕Apriori关联规则展开市场购物篮分析实战&#xff0c;帮助读者从零理解频繁项集发现、支持度与置信度度量、强关联规则生成及规则评估的完整流程。压缩包共4个文件&#xff0c;约20.36MB&…

作者头像 李华
网站建设 2026/10/12 19:07:24

大额充值避坑指南:渠道选择、风控安全与实操全攻略

最近经常有人问我&#xff1a;“大额充值到底怎么充才划算&#xff1f;网上那些折扣渠道靠不靠谱&#xff1f;有没有一个首选方案&#xff1f;” 做运营这几年&#xff0c;我处理过最多的线下支出就是大额充值。话费、流量包、平台会员、游戏点券&#xff0c;甚至公司集采的云…

作者头像 李华
网站建设 2026/10/12 19:02:48

SpringBoot药房管理系统实战:从需求拆解到库存并发与部署

每年毕业季&#xff0c;总有一批做“药房管理系统”的同学来找我调代码&#xff0c;题目大同小异&#xff0c;踩坑却惊人地一致&#xff1a;库存扣成负数、订单状态对不上、药品编码重复、数据库表字段乱得连跑都跑不起来。这个选题表面看就是标准的SpringBoot增删改查&#xf…

作者头像 李华