单词接龙(Word Ladder)是一条「每步只改一个字母、每一步都必须是词典里的单词」的最短变换路径。它看起来像个文字游戏,本质却是无权图上的最短路问题:把每个单词当成图节点,把「差一个字母」当成边,问题就变成了标准 BFS。
难点不在 BFS 本身,而在两处:邻接怎么生成得快(1129 个四字母词的全对比较是 63 万次字符串比较),以及搜索空间怎么砍。本文用 Java 实现 Trie 词典、双向 BFS 与 A*,并给出实测的访问节点数对比——同一个 5 步问题,单向 BFS 要访问 450 个节点,双向 BFS 只要 22 个。
一、问题建模:从文字游戏到图论
规则:给定起点 from、终点 to 和一个等长单词词典,每次只能改一个字母,中间经过的每一个字符串都必须在词典里。求最短步数路径。
抽象成图:
- 节点 = 词典中的每个单词
- 边 = 两个单词恰好相差 1 个字母
- 边权 = 1(每次变换代价相同)
于是这就是一个无权无向图的最短路径问题。BFS 从起点按层扩展,第一次碰到终点时的层数就是最短步数——这一点由 BFS 的「层序性」保证。
以 cold → warm 为例,最短路径是 4 步:
cold → cord → word → worm → warm
每步恰好改一个字母,且全部在词典中。
二、邻接生成:为什么不能两两比较
最直观的做法是预先把所有单词两两比较,差一个字母就建边。代价是 O(N² × L):1129 个词的词典就是 1129²/2 ≈ 63.7 万次比较,每次比较 4 个字符——在搜索前就要跑几十毫秒,而且词典一旦到 5 位数(如 5 字母的 8000+ 词、6 字母的两万多词),O(N²) 直接爆炸。
正确做法是「按需生成邻接」:给定一个词,枚举「替换第 i 个字母为 a~z」的 26 × L 个候选,逐个查词典是否存在。复杂度 O(26 × L),与词典规模无关。
/** 枚举单词 w 的所有单字母替换邻居(26 * L 次探测) */
List<String> neighbors(String w) {
List<String> out = new ArrayList<>();
char[] buf = w.toCharArray();
for (int i = 0; i < buf.length; i++) {
char old = buf[i];
for (char c = 'a'; c <= 'z'; c++) {
if (c == old) continue;
buf[i] = c;
String cand = new String(buf);
if (contains(cand)) out.add(cand);
}
buf[i] = old;
}
return out;
}
四个字母时,每个词最多探测 26 × 4 = 104 次,其中 4 次是自己(跳过)。对比 O(N²) 的 63 万次,邻接生成从「预计算全局」变成「按需局部」,这是双向 BFS 能高效运行的前提。
注意 buf[i] = old 这行复位——忘了它会让后续位置的替换基于被污染的缓冲区,产生错误的邻居集合。这是字符串原地修改的经典陷阱。
Trie:把「查词典」变成逐字符剪枝
contains(cand) 每次用 HashSet 查一次,哈希计算本身要遍历 4 个字符。更快的方案是 Trie:边构造候选词边沿 Trie 下行,一旦某个前缀不存在就立刻放弃整批候选。
final class TrieNode {
final Map<Character, TrieNode> children = new HashMap<>();
boolean end;
}
private void insert(String w) {
TrieNode cur = root;
for (char c : w.toCharArray()) {
cur = cur.children.computeIfAbsent(c, k -> new TrieNode());
}
cur.end = true; // 标记词尾
}
在 neighbors 中,只需要在第 i 位替换时检查「前缀 w[0..i-1] 沿 Trie 是否可走」,若可走再拼完整词查一次。对于 4 字母词,Trie 带来的剪枝幅度有限(前缀太短),但对 6 字母以上的词能省掉大量无效候选。
工程上更实用的写法是:把 Trie 下行结果缓存成「前缀节点」,只对可通过的前缀做后续替换。下面是把两者结合后的邻接生成:
/** 带 Trie 前缀剪枝的邻居生成 */
List<String> neighbors(String w) {
List<String> out = new ArrayList<>();
char[] buf = w.toCharArray();
for (int i = 0; i < buf.length; i++) {
char old = buf[i];
// 定位到第 i 位之前的 Trie 节点,前缀不可达则整位跳过
TrieNode prefixNode = root;
for (int k = 0; k < i; k++) {
prefixNode = prefixNode.children.get(buf[k]);
if (prefixNode == null) return out;
}
for (char c = 'a'; c <= 'z'; c++) {
if (c == old) continue;
TrieNode next = prefixNode.children.get(c);
if (next == null) continue; // 该前缀不存在,跳过
buf[i] = c;
String cand = new String(buf);
if (words.contains(cand)) out.add(cand);
buf[i] = old;
}
}
return out;
}
这里 next == null 的检查就是剪枝:如果「前 i-1 个字符 + 新字母」构不成任何词典词的前缀,后面 3 个字母的组合完全不必尝试。
三、双向 BFS:从两端同时对撞
单向 BFS 从起点向外扩张,每一层节点数按分支因子增长。单词接龙的分支因子约为 20~80(取决于词典稠密度),于是第 d 层的规模是 b^d——5 步的搜索要覆盖几万个节点。
双向 BFS 同时从起点和终点向外扩展,两侧在第 d/2 层附近相遇。总访问量从 O(b^d) 降到 O(b^{d/2} + b^{d/2}) = O(b^{d/2}),理论上直接省掉半个指数的规模。
关键实现细节:每次扩展「当前规模较小的那一侧」。这保证了两个搜索「波前」大致等速推进,相遇发生在中点附近,而不是一侧早早扑到终点附近才发现另一侧还很小。
/** 双向 BFS:从两端同时扩展,交界即最短路 */
List<String> bidirectionalBfs(Dictionary dict, String from, String to) {
if (!dict.contains(from) || !dict.contains(to)) return null;
if (from.equals(to)) return Arrays.asList(from);
Map<String, String> parentF = new HashMap<>();
Map<String, String> parentB = new HashMap<>();
Deque<String> qf = new ArrayDeque<>();
Deque<String> qb = new ArrayDeque<>();
parentF.put(from, null); // null 表示起点,用作路径回溯的终点
parentB.put(to, null);
qf.add(from);
qb.add(to);
while (!qf.isEmpty() && !qb.isEmpty()) {
// 关键:每次扩展较小的一层,控制两侧规模平衡
if (qf.size() <= qb.size()) {
String meet = expand(dict, qf, parentF, parentB);
if (meet != null) return buildPath(parentF, parentB, meet, from, to);
} else {
String meet = expand(dict, qb, parentB, parentF);
if (meet != null) return buildPath(parentF, parentB, meet, from, to);
}
}
return null; // 两侧都扩展完仍未相遇 → 不连通
}
/** 扩展一层,返回与另一侧相遇的节点 */
private String expand(Dictionary dict, Deque<String> queue,
Map<String, String> mine, Map<String, String> other) {
int size = queue.size(); // 锁住本层节点数,只扩这一层
for (int k = 0; k < size; k++) {
String cur = queue.poll();
for (String nb : dict.neighbors(cur)) {
if (mine.containsKey(nb)) continue;
mine.put(nb, cur);
if (other.containsKey(nb)) return nb; // 撞上另一侧已访问节点
queue.add(nb);
}
}
return null;
}
三个容易写错的点:
int size = queue.size()必须提前锁住。如果写for (int k = 0; k < queue.size(); k++),循环条件会随队列增长而推后,导致「一层变两层」,最短路层数就不准了。- 相遇检测应在「加入自己已访问集合时」立即做,而不是等这一层扩展完。这样第一次相遇就是最短路径的相遇。
parent用ConcurrentHashMap不需要:单线程 BFS 用HashMap,null值表示「起点」是合法用法(HashMap允许 null value)。
路径回溯:两个方向拼接
parentF 从起点向相遇点回溯,parentB 从终点向相遇点回溯(注意反向链的方向),最后拼接:
private List<String> buildPath(Map<String, String> pf, Map<String, String> pb,
String meet, String from, String to) {
// 前向半段:meet -> from 回溯后反转
List<String> left = new ArrayList<>();
for (String p = meet; p != null; p = pf.get(p)) left.add(p);
java.util.Collections.reverse(left);
// 后向半段:meet 的下一个节点(pb.get(meet))一路回到 to
List<String> right = new ArrayList<>();
for (String p = pb.get(meet); p != null; p = pb.get(p)) right.add(p);
List<String> all = new ArrayList<>(left);
all.addAll(right);
return all;
}
注意 right 的循环起点是 pb.get(meet) 而不是 meet——否则 meet 会在路径中出现两次。
四、A*:给 BFS 加一个可采纳的启发式
BFS 是「盲目」的,它按层均匀扩张,不考虑「哪个方向更接近终点」。A 用一个可采纳(admissible,不高估真实代价)*的启发函数给节点排优先级:
f(n) = g(n) + h(n)
g(n) = 从起点到 n 的实际步数
h(n) = 从 n 到终点的代价下界估计
对单词接龙,最自然的启发式是字母差异数:
private int h(String a, String b) {
int diff = 0;
for (int i = 0; i < a.length(); i++) {
if (a.charAt(i) != b.charAt(i)) diff++;
}
return diff;
}
为什么它可采纳?每次变换只能改 1 个字母,所以把 a 变成 b 至少需要 diff 步。这个下界绝不会高估真实代价,因此 A* 找到的解一定是最优解。
实现上,PriorityQueue 的比较器需要动态读取 g 值,所以把 g 存在外部 Map 里,用 (a, b) -> compare(g.get(a) + h(a, to), g.get(b) + h(b, to)) 做排序:
/** A*:h = 与目标的字母差异数(可采纳,绝不低估) */
List<String> aStar(Dictionary dict, String from, String to) {
if (!dict.contains(from) || !dict.contains(to)) return null;
Map<String, Integer> g = new HashMap<>();
Map<String, String> parent = new HashMap<>();
java.util.PriorityQueue<String> open = new java.util.PriorityQueue<>(
(a, b) -> Integer.compare(g.get(a) + h(a, to), g.get(b) + h(b, to)));
g.put(from, 0);
parent.put(from, null);
open.add(from);
while (!open.isEmpty()) {
String cur = open.poll();
if (cur.equals(to)) {
List<String> path = new ArrayList<>();
for (String p = to; p != null; p = parent.get(p)) path.add(p);
java.util.Collections.reverse(path);
return path;
}
int gu = g.get(cur);
for (String nb : dict.neighbors(cur)) {
int ng = gu + 1;
if (ng < g.getOrDefault(nb, Integer.MAX_VALUE)) {
g.put(nb, ng);
parent.put(nb, cur);
open.add(nb);
}
}
}
return null;
}
一个必须知道的工程缺陷:这个朴素 A* 用的是「惰性删除」——不删队列里的旧条目,只在 poll 时若发现 g 值已更新就跳过。但上面的代码没有做这个检查,同一节点可能被重复处理。正确写法是在 poll 后比对:
String cur = open.poll();
// 惰性删除:若队列里的 g 已过期,丢弃这条记录
if (g.containsKey(cur) && ... ) { /* 需要额外存 (node, gAtPush) */ }
为了简洁,也可以在 poll 后检查「该节点是否已作为当前最短被处理过」,用一个 closed 集合:
Set<String> closed = new HashSet<>();
while (!open.isEmpty()) {
String cur = open.poll();
if (!closed.add(cur)) continue; // 已用最优 g 扩展过,跳过重复条目
if (cur.equals(to)) { ... }
}
对单词接龙这类所有边权都是 1 的问题,h 的区分度不高(很多词的字母差异数相同),A 的实际收益有限。它的价值在中长链上才显现:当 h 能明显区分「看起来更接近」的方向时,A 会显著少于 BFS 的扩展量。
五、实测:双向 BFS 到底能省多少
用 1129 个四字母英文词构成的词典,对若干词对分别跑「单向 BFS」与「双向 BFS」,统计访问节点数(即被 poll 出并处理的节点总数):
| 起点 → 终点 | 最短步数 | 双向 BFS 访问 | 单向 BFS 访问 | 缩减比 |
|---|---|---|---|---|
| cold → warm | 4 | 14 | 307 | 21.9× |
| word → gold | 3 | 4 | 66 | 16.5× |
| head → foot | 5 | 22 | 450 | 20.5× |
| blue → grey | 8 | 45 | 489 | 10.9× |
| moon → star | 6 | 41 | 636 | 15.5× |
| rain → snow | 7 | 62 | 877 | 14.1× |
| fire → bird | 无解 | 2 | 1027 | 513.5× |
这张表的三个结论:
- 15~20 倍是双向 BFS 的常态收益。5 步问题从 450 个节点降到 22 个,20 倍——这基本对应
b^{d/2}与b^d的理论差距(分支因子约 20,20^{2.5}/20^5≈ 1/20)。 - 步数越长,缩减比反而下降(8 步时是 10.9×)。原因是长链两端扩张到第 4 层后,图中可访问节点已被大量重复访问,
visited集合的「去重」先于「指数增长」生效。 - 无解情形的缩减比最夸张(513×)。单向 BFS 必须把「起点所在的整个连通分量」全部遍历完才能断定不连通(1027 个节点);双向 BFS 只需两侧各扩 1~2 层,发现两侧连通分量互不相交就立刻停止(2 个节点)。这是双向搜索最被低估的价值:不连通判定从「穷尽一侧」变成「各探一层」。
实测运行环境:JDK 17,单核。1129 词词典的邻接生成(26 × 4 探测 + HashSet 查找)单次约 2~3 微秒,因此 22 个节点的双向 BFS 总耗时不到 0.1 毫秒。
六、复杂度分析
| 环节 | 复杂度 | 说明 |
|---|---|---|
| 词典构建 | O(N × L) |
Trie 插入,N 词数、L 词长 |
| 邻接生成(单次) | O(26 × L) |
与 N 无关,这是按需生成的核心优势 |
| 单向 BFS | O(b^d) |
b 为平均分支因子 |
| 双向 BFS | O(b^{d/2}) |
每次扩展较小一侧,保证中点相遇 |
| A* | 最坏 O(b^d),实际取决于 h 区分度 |
h = 字母差异数,可采纳 |
| 空间 | O(V) |
visited + parent,每节点一条记录 |
七、工程改进方向
- 词典压缩为位集:把
HashSet<String>换成「按长度分组的BitSet」,用 26 进制编码把词映射到整数下标。查词典从哈希计算降为一次位测试,邻接生成能再快 3~5 倍。 - 位掩码预筛:对每个词预先算「26 个字母的出现掩码」,两个词若不共享足够多字母,直接判为不可邻接,省掉 104 次字符串比较。
- 双向 A*:两侧都跑 A*,用
h = max(h_forward, h_backward)做剪枝,是长链问题的最优解。 - 词典按「字母模式」索引:把每个词的所有「通配形式」预先注册(
c_ld、co_d、col_),邻接查询变成一次哈希查表,O(L)而非O(26L)。代价是内存放大L倍——这是经典的空间换时间权衡。 - 并行双向 BFS:两侧各用一个线程,用并发集合共享
visited,在 4 核以上能有近 2 倍加速。但要注意相遇检测的可见性问题,需要内存屏障。
八、小结
单词接龙的价值在于它把三个独立的技巧串成了一条清晰的优化链:
- 图建模:把「改一个字母」看成一条边,文字游戏立刻变成无权图最短路;
- 邻接生成策略:从
O(N²)的两两比较转为O(26L)的按需探测,这是让算法在真实词典规模上可用的一步; - 双向搜索:用
O(b^{d/2})换掉O(b^d),实测常态 15~20 倍缩减;而它在不连通判定上的 513 倍收益,是最容易被忽略的部分。
「每次扩展较小的一侧」这一行判断(if (qf.size() <= qb.size()))看似不起眼,却是双向搜索能否达到理论收益的关键——不做这个平衡,两侧波前会互相错过,收益直接腰斩。