☰
回文最短路径:双端BFS状态设计与AtCoder ABC394E详解
2026/10/10 4:16:19 网站建设 项目流程

老实说,第一次看到这个题号的时候,我愣了一下。AT_abc394_e,也就是 [ABC394E] Palindromic Shortest Path,来自 AtCoder Beginner Contest 394 的 E 题。这类题最吸引人的地方在于:它把“最短路”和“回文串”两个看似毫不相干的东西揉到了一起。你不能再无脑跑 Dijkstra,也不能直接套 Floyd,而是要从“回文串天生需要两端对称”这个直觉出发,重新设计状态和转移。这篇文章我会完整讲清楚:题目在问什么、为什么普通最短路失效、BFS 状态怎么设计、代码怎么写、有哪些坑,以及再往深一层还能怎么想。不管是刚接触图论 BFS 的新手,还是想刷 AtCoder 的选手,都能从中拿到一套直接可用的思路。

1. 题目到底在问什么:回文约束如何改变最短路

1.1 输入输出与数据规模

题目给一个 N×N 的矩阵,N 不超过 80。第 i 行第 j 列如果是一个小写字母 a~z,表示存在一条从 i 到 j 的、带这个字母标签的有向边;如果是字符 -,表示没有这条边。

注意这里的关键点:这是一个有向图。即使第 i 行第 j 列和第 j 行第 i 列都有边,它们也是两条独立的边,标签还可能不一样。每条边的“长度”都是 1,但我们要的不是普通最短路,而是要求所有有序点对 (i, j) 之间,最短的“回文路径”的长度。回文路径的意思是:把这条路径上经过的所有边上的字母依次取出来,连成一个字符串,这个字符串正着读和倒着读是一样的。

比如有一条路径 i → a → b → a → j,边上的字母依次是 x, y, x,那么整条路径就是回文的。输出是一个 N×N 的矩阵,第 i 行第 j 列表示答案,如果无解就输出 -1。

数据规模 N≤80 是一个非常明确的信号:O(N³) 级别甚至 O(N⁴) 级别的算法都可能卡着时限通过,但这道题真正的难点不在常数,而在状态设计。

1.2 为什么不能直接跑 Dijkstra

很多人一看到“最短路径”四个字,下意识就掏出 Dijkstra 或者 Floyd。但这道题里,回文这个约束彻底破坏了最短路的贪心结构。

普通最短路里,从起点到某个中间点的最短路径可以直接作为整体最短路径的前缀。但回文不一样:一条回文路径的前缀和后缀必须镜像对应,前半段的走向会限制后半段的走向。你不能只从起点往终点方向扩展,因为路径的“后半段”是由终点往回看的。

换句话说,回文串的核心是对称,对称意味着两端同时决定中间。最短路问题的单向扩展方式在这里天然不适用。

此时出现了一个非常自然的直觉:既然回文要两端对称,那我们就让路径从两端同时长出来。每次在左边补一个字母 c,右边也补一个字母 c,这样字符串仍然保持回文。这就是这道题最核心的思维转换:从“单端扩展”变成“双端扩展”。

1.3 这题的考点和定位

从算法训练的角度看,这道题综合了三个东西:图上的 BFS、区间 DP 式的“两端收缩”思想、以及带标签有向图的建图技巧。

光会 BFS 模板不够,你得意识到这里 BFS 的“状态”不是一个点,而是一个点对;光会字符串处理也不够,你得把回文串的递归性质“两边各去掉一个相同字母后仍是回文”搬到图上。

这道题的定位更像是一道思维题:它没有考复杂的数据结构,但考你能不能从题目条件里抽出正确的状态定义。AC 率低不是因为这题代码难写,而是因为状态转移方向容易想反。

2. 核心状态设计与转移方程推导

2.1 把路径切成两端:回文的递推本质

假设存在一条从 u 到 v 的回文路径 P,路径上的字符串是 s,且长度大于等于 2。因为 s 是回文,所以 s 的第一个字符和最后一个字符相等,记为 c。把首尾字符都删掉,中间剩下的字符串 s' 仍然是从某个点 x 到某个点 y 的路径,而且 s' 本身还是回文。

这里的关键是:删掉首尾字符后,起点变成了谁,终点变成了谁?如果原来路径是 u → ... → x --c--> ...? 需要小心。

更准确的描述是:一个从 u 到 v 的长度为 L 的回文路径,左边第一条边是 u → p 且字母为 c,右边最后一条边是 q → v 且字母也为 c,那么从 p 到 q 之间存在一条长度为 L-2 的回文路径。反过来,如果已经知道从 p 到 q 存在长度为 L-2 的回文路径,并且存在一条边 u → p 标着 c,一条边 q → v 也标着 c,那么我们可以把这两条边“垫”到回文路径的两端,得到一条从 u 到 v 的、长度为 L 的回文路径。

这就是转移的核心规律:在已确定的回文状态两端,同时垫上一条字母相同的边,得到新状态。

2.2 状态定义与初始化

定义 dist[a][b] 表示:从 a 到 b 的最短回文路径长度。

初始状态有两类。

第一类:dist[i][i] = 0。因为空串是回文,从某个点出发走 0 条边回到自己,天然满足条件。这里可能有人会纠结“空串算不算回文”,但按这题的输出约定,如果对角线无解要输出 -1,而标准答案里对角线都是 0,所以空串就是答案。

第二类:对每一条边 i → j,它本身就是一个长度为 1 的字符串,单个字符一定是回文,所以 dist[i][j] 可以初始化为 1。

这里需要额外注意一个细节:如果存在自环边 i → i,理论上从 i 走到 i 的长度可以为 1,但已经被长度 0 严格优于,所以自环边在初始化时必须跳过。

把所有初始状态都塞进队列,BFS 就会帮我们按长度递增的顺序一层一层扩展。

2.3 转移规则的来源:为什么两边同时垫字符

现在假设已经确定了从 a 到 b 有一条最短回文路径,长度是 dist[a][b]。我们想构造更长的回文路径。

为了保持回文性质,新路径必须形如:x --c--> a ... 回文路径 ... b --c--> y。

也就是说,新起点 x 必须满足存在一条边 x → a,且边上字母为 c;新终点 y 必须满足存在一条边 b → y,边上字母也是 c。这里有两个方向需要特别留意:

  • x → a 是“指向 a 的入边”
  • b → y 是“从 b 出发的出边”

所以建图时不能只存正向边,还必须存反向边。这是实现上最容易出错的地方。

转移式写出来就是:

dist[x][y] = dist[a][b] + 2

条件是 dist[a][b] 已知,边 x → a 的字母和边 b → y 的字母相同。

因为每次转移都在两端各加一条边,长度严格增加 2,所以这个过程天然适合 BFS。用队列维护待扩展的状态,每个状态第一次被确定时就是最短距离。

3. BFS 实现:完整步骤与代码解读

3.1 建图:正向表和反向表缺一不可

用邻接矩阵读入数据后,我们要维护两类邻接表。一类是正向表 g[u],记录从 u 出发的所有边,边结构是 (to, char);另一类是反向表 rg[v],记录所有到达 v 的边,边结构是 (from, char)。

为什么要反向表?看转移条件就明白了:扩展状态 (a, b) 时,我们要找的是“以 a 为终点的边”和“以 b 为起点的边”。前者只能靠反向表快速枚举,后者靠正向表快速枚举。

在实现上,我建议直接把边按字母分组存储,也就是 g[u][c] 表示从 u 出发、字母为 c 的边的目标点集合,rg[v][c] 表示到达 v 的、字母为 c 的边的来源点集合。这样做的好处不只是代码清晰,扩展时可以直接避开字母不匹配的边,省掉一层字符比较。后面讲复杂度时你就能看到这个优化多值钱。

3.2 队列初始化与长度单调性

初始化分两步。

第一步,把所有 (i, i) 入队,dist[i][i] = 0。

第二步,扫描所有边,如果 i != j 且矩阵位置不是 -,就把 (i, j) 入队,dist[i][j] = 1。

这里有一个非常容易踩的坑:如果某条边已经存在,但它的目标点对正好是某个已经初始化为更短长度的状态,不能覆盖。具体来说,对角线状态 dist[i][i] = 0 永远不能被自环边更新成 1,所以我一般直接在读入时跳过 i == j 的情况。

BFS 的队列为什么能保证第一次出队的状态就是最短的?因为所有初始状态长度分别为 0 和 1,每次扩展出的新状态长度都是当前长度 +2。队列按入队顺序一批批处理,长度小的状态一定先被扩展。即使队列里同时存在长度为 3 和长度为 5 的状态,也不会出现某个点对先被一个更长的路径确定、后再被更短路径更新的情况。

3.3 参考代码

下面是完整 C++ 实现,这个版本按字母分组存储边,扩展时只遍历相同字母的边。

#include <bits/stdc++.h> using namespace std; const int MAXN = 85; struct Edge { int to; }; vector<Edge> g[MAXN][26], rg[MAXN][26]; int dist[MAXN][MAXN]; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; memset(dist, -1, sizeof(dist)); queue<pair<int, int>> q; for (int i = 0; i < n; i++) { dist[i][i] = 0; q.push({i, i}); } for (int i = 0; i < n; i++) { string s; cin >> s; for (int j = 0; j < n; j++) { char c = s[j]; if (c == '-') continue; if (i == j) continue; int id = c - 'a'; g[i][id].push_back({j}); rg[j][id].push_back({i}); if (dist[i][j] == -1) { dist[i][j] = 1; q.push({i, j}); } } } while (!q.empty()) { auto [a, b] = q.front(); q.pop(); int cur = dist[a][b]; for (int id = 0; id < 26; id++) { for (const auto &e1 : rg[a][id]) { for (const auto &e2 : g[b][id]) { int x = e1.to; int y = e2.to; if (dist[x][y] != -1) continue; dist[x][y] = cur + 2; q.push({x, y}); } } } } for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (j) cout << ' '; cout << dist[i][j]; } cout << '\n'; } return 0; }

如果你更习惯 Python,也可以写出完全等价的结构,只是要注意 Python 的循环常数偏大,N=80 时最好也用按字母分组的写法:

from collections import deque n = int(input()) g = [[[] for _ in range(26)] for _ in range(n)] rg = [[[] for _ in range(26)] for _ in range(n)] dist = [[-1] * n for _ in range(n)] q = deque() for i in range(n): dist[i][i] = 0 q.append((i, i)) for i in range(n): s = input().strip() for j, ch in enumerate(s): if ch == '-' or i == j: continue cid = ord(ch) - ord('a') g[i][cid].append(j) rg[j][cid].append(i) if dist[i][j] == -1: dist[i][j] = 1 q.append((i, j)) while q: a, b = q.popleft() cur = dist[a][b] for cid in range(26): for x in rg[a][cid]: for y in g[b][cid]: if dist[x][y] == -1: dist[x][y] = cur + 2 q.append((x, y)) for i in range(n): print(*dist[i])

代码结构并不复杂,但每一步都有它存在的理由。尤其是边按字母分组那一步,绝不是为了炫技,而是为了实打实减少无效枚举。

4. 复杂度分析与实测优化

4.1 最坏复杂度为什么是 O(E²)

如果不按字母分组,直接对所有点对 (a, b) 扩展时各遍历一遍 rg[a] 和 g[b],那么总操作次数是:

Σ(a, b) indeg(a) × outdeg(b)

= (Σa indeg(a)) × (Σb outdeg(b))

= E × E = O(E²)

E 最大是 N(N-1),N=80 时约为 6320,所以 E² 约等于 4×10⁷。这个量级用 C++ 写,两秒内稳稳跑完。Python 如果直接写会很吃力,但也不是完全不能过,关键就看常数怎么省。

4.2 按字母分组到底有没有用

按字母分组后,对于每个状态 (a, b),我们不再同时遍历所有反边和正边,而是逐字母处理。对于字母 c,只遍历 rg[a][c] 和 g[b][c]。

设 cnt_c 表示边上字母为 c 的边数,那么总操作次数变为:

Σ(a, b, c) cnt_rev[a][c] × cnt_fwd[b][c]

= Σc (Σa cnt_rev[a][c]) × (Σb cnt_fwd[b][c])

= Σc cnt_c²

如果 26 个字母分布均匀,cnt_c ≈ 6320 / 26 ≈ 243,那么 Σc cnt_c² ≈ 26 × 243²,只有大约 150 万次操作,比原来少了两个数量级。最坏情况是所有边都标同一个字母,那么复杂度退化回 E²,也就是 4×10⁷。但此时代码内部不再需要比较字符是否相等,常数仍然比朴素双重循环小。

4.3 几个容易忽略的性能细节

第一,队列里存点对可以存成两个 int,不要为了图方便存结构体或者 string,尤其是 Python 里推荐用两个 list 分别存 a 和 b,牺牲一点可读性换速度。

第二,dist 数组用 int 存 -1 和长度,不建议用很大的 INF。用 -1 的好处是判断未访问时直接比较,输出时也直接输出 -1,不需要额外转换。

第三,读入用 ios::sync_with_stdio(false) 和 cin.tie(nullptr),这个虽然是老生常谈,但在 4×10⁷ 次操作的背景下,输入输出优化能省下不少时间。Python 那边则建议用 sys.stdin 一次性读入,而不是反复 input()。

第四,所有初始状态要一次性全部入队,不要边读边扩展。因为 BFS 的正确性依赖队列按长度单调排列,初始化顺序不会影响正确性,但分开写容易漏状态。

5. 常见错误与排查技巧

5.1 自环把 dist[i][i] 覆盖成 1

这是我见过最多人踩的坑。初始化时先设置了 dist[i][i] = 0,然后扫描邻接矩阵,如果遇到 s[i][i] 是字母,有的写法会顺手把 dist[i][i] = 1 并入队,导致答案变成 1。

解决方式有两种。第一种最简单:读入时直接 if (i == j) continue。第二种是保留自环边,但更新时只有新值小于旧值才更新。考虑到从 i 到 i 的空串就是回文,长度 0 必然最优,直接跳过自环没有任何信息损失。

5.2 方向搞反导致样例都过不了

扩展状态 (a, b) 时,需要的是“终点是 a 的边”和“起点是 b 的边”。前者枚举 rg[a],后者枚举 g[b]。有相当一部分人会在初始化和扩展时把 g 和 rg 的用法写反,结果整个 BFS 的方向完全乱掉。

一个很实用的自检办法:随便挑一条长度为 2 的回文路径,比如 x → a 和 b → y 的字母都是 c,那么从 a 到 b 的空串就能扩展出从 x 到 y 的 "cc"。手工推一遍这个流程,如果方向反了,推出来的路径是从 a 到 b 的,而不是从 x 到 y 的,一眼就能发现。

5.3 为什么 dist[x][y] != -1 时可以直接跳过

这个问题问的人不少。有没有可能某个状态先被一条长路径更新,后来又被一条短路径更新?

答案是不会。因为 BFS 队列中的状态按长度单调递增出队。当我们在处理长度为 cur 的状态时,所有长度小于 cur 的状态都已经出队并扩展完毕。如果 dist[x][y] 已经被更新过,那它一定是通过某个长度小于等于 cur 的状态扩展出来的,长度不可能超过 cur + 2。因此当前这条长度为 cur + 2 的候选路径不可能更优,直接跳过完全安全。

这个性质正是“每个状态只入队一次”的正确性基础。明白这一点后,你甚至可以进一步用数组标记代替队列判重,但没必要,dist == -1 本身就是最好的标记。

5.4 输出与 -1 的坑

输出要求每一行之间的数字用空格隔开,最后一个是 -1 时也要正常输出。这个没什么技术含量,但容易在赶时间的时候把换行多打或者少打。建议把输出单独拎出来写一个循环,不要和 BFS 混在一起。

另外,矩阵是对称读入的,但答案不一定对称。因为图本身是有向图,从 i 到 j 可能有回文路径,从 j 到 i 可能没有。输出时千万不要想当然地复用对称值。

6. 更进一步:这类题还能怎么想

6.1 把回文路径理解成双向扩展的字符串匹配

从更高维度看,这题本质上是在一个有向带标签图上,寻找满足“正向字符串等于反向字符串”的路径。dist[a][b] 可以理解为两条字符串的匹配长度:一条从 a 出发,一条从 b 反向出发。每次扩展相当于让两条字符串同时往后各读一个相同字符。

这样理解之后,你会发现这题和自动机上的回文子串匹配是同一个思想。回文串的问题往往都可以转化为“两段字符串逐渐靠拢”的问题,BFS 只是其中一种实现手段。如果题目改成求最长回文路径,那么就要考虑图上的环,问题性质就完全变了。这也是为什么这道题适合作为双端 BFS 类题目的入门题。

6.2 单点对查询时的双向 BFS 剪枝技巧

如果题目只问一个特定点对 (s, t) 的最短回文路径,不需要求出全矩阵答案,我们可以在上述全源 BFS 的基础上做双向剪枝。

具体做法是:从 (s, s) 和 (t, t) 两端同时开始扩展。每次选择一个方向扩展一层,一旦发现某个状态从两端都被访问到,就说明找到了一条回文路径。由于每次扩展长度增加 2,最终拼接时如果两端长度分别为 L1 和 L2,且某个公共状态被两端的路径覆盖,那么总长度就是 L1 + L2。

这个剪枝在最坏情况下不会改变复杂度阶数,但实际数据里往往能减少大量无效状态。我的经验是,如果题目的输入矩阵非常稀疏,双向扩展的效果会非常明显。

6.3 和区间 DP / 马拉车思路的横向对比

其实在想到 BFS 之前,我第一反应是区间 DP。把这个图看作字符串的集合,回文路径的判定天然适合“两端收缩”的区间模型。但区间 DP 要求枚举所有可能的起终点,复杂度通常是 O(N³) 甚至更高,在这题里状态总数 N² 加转移枚举就已经接近极限,DP 并不占优势。

马拉车的思路也有启发,但马拉车依赖单个字符串的前缀信息,在图上无法直接套用。真正与本题神似的反而是“两端同时向外扩展”的对称思想。理解了这一点,以后遇到任何“带条件的路径存在性”问题,都可以先问自己一句:条件是否能被拆成两端同时满足的局部性质?如果是,BFS 状态很可能就是一个二元组。

最后再分享一个我在实际排查中特别喜欢用的方法:样例数据太弱,自己手搓一个 N=3 的全连接图,给每条边编号,把所有 dist 状态按长度分层打出来。如果一个长度为 3 的状态没被推出来,大概率是反向表建错了;如果一个长度为 4 的状态出现了但实际字符串不是回文,大概率是扩展时字母判断写错了。这种小规模暴力验证比对着代码干瞪眼高效得多。做这类图上的双端 BFS,调试的心法就一句话:不要相信直觉,让状态自己把路径讲出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询