1. 项目概述与核心思路拆解
最近在带学生刷信奥题,碰到一道挺有意思的题目——P13425 [COCI 2020/2021 #1] Bajka。这道题来自克罗地亚信息学竞赛,算是COCI系列里一道经典的字符串处理与动态规划结合的题目。很多刚接触动态规划的同学,一看到字符串和状态转移就有点发怵,觉得抽象。其实这道题的核心思想非常生活化:你可以把它想象成在一个布满字母的“键盘”上,用最少的“步数”敲出一首“歌谣”,每次移动手指都有特定的规则。今天我就带大家用C++,把这道题的思路、实现细节以及我踩过的坑,从头到尾捋一遍。
题目大意是,给你一个N x M的字符网格(可以看作键盘布局),以及一个目标字符串(歌谣)。你的“手指”初始时可以在网格第一行的任意一个与目标字符串第一个字符匹配的位置上。然后,你需要通过移动“手指”,依次“按下”目标字符串的每一个字符。移动规则有三种:1. 留在当前格子(花费0,用于连续相同字符)。2. 向上下左右四个相邻格子移动一格(花费1)。3. 进行一次“跳跃”:如果当前格子正上方或正下方一列(同一列)的某个格子,其字符与当前格子相同,则可以瞬间移动到那里(花费2)。你的目标是,计算出敲出整个目标字符串所需的最小总花费。如果无法完成,则输出-1。
这道题的价值在于,它完美地融合了基础的图论搜索思想(BFS/DFS)和动态规划的状态设计。它不像一些复杂的DP需要艰深的优化,但又能很好地训练我们将实际问题转化为状态和状态转移方程的能力。无论是准备信奥初赛还是想巩固DP基础,这道题都是一个绝佳的练手材料。下面,我们就从最核心的思路设计开始。
1.1 问题抽象与状态定义
拿到题目,第一步永远是抽象。别急着写代码,先在纸上或者脑子里把模型建起来。
网格与字符:一个
N行M列的网格,每个格子一个字符。这就是我们的“舞台”。目标序列:一个长度为
L的字符串S。这是我们要按顺序完成的“乐谱”。状态是什么?动态规划的核心是定义状态。在这题里,我们走到哪一步,以及手指在哪,决定了当前的局面。很自然地,我们可以定义状态
dp[i][pos]。i:表示我们已经成功匹配(按下)了目标字符串S的前i个字符(i从0开始计数,i=0表示还没开始,一个字符都没匹配)。pos:表示在匹配完第i个字符后,我们的“手指”位于网格中的哪个位置。我们需要一个唯一的方式来标识位置,通常用行号r和列号c。但dp数组的维度如果开成dp[L][N][M],在极端情况下(L, N, M最大均为50)就是50*50*50=125,000,状态量是12.5万,完全在可接受范围内。dp[i][r][c]的值:表示匹配完前i个字符,且手指最后停在位置(r, c)时,所花费的最小代价。
初始状态是什么?题目说,开始时手指可以在第一行任意一个字符等于
S[0]的格子上。那么,对于所有满足grid[0][j] == S[0]的列j,dp[0][0][j] = 0。其他所有状态初始化为一个很大的数(代表不可达),比如INF = 0x3f3f3f3f。目标是什么?我们需要匹配完整个字符串
S,即匹配完L个字符(i = L)。最终答案就是所有dp[L][r][c]中的最小值(因为最后手指停在哪都行)。如果这个最小值仍然是INF,说明无法完成,输出 -1。
1.2 状态转移方程推导
定义了状态,下一步就是找出状态之间如何转移。我们已经匹配了前i个字符,手指在(r, c),现在要匹配第i+1个字符S[i](注意下标,第i+1个字符对应S[i],因为i从0开始)。
要匹配S[i],我们的手指必须移动到一个字符等于S[i]的格子上。假设这个目标格子是(nr, nc),并且grid[nr][nc] == S[i]。那么,从(r, c)移动到(nr, nc)的花费是多少?这就要用到题目给出的三种移动方式了。但注意,我们这里计算的是单次移动的花费,而dp[i][r][c]存储的是到达(i, r, c)状态的总花费。所以,状态转移方程可以写成:
dp[i+1][nr][nc] = min(dp[i+1][nr][nc], dp[i][r][c] + cost((r,c) -> (nr,nc)))
其中,cost((r,c) -> (nr,nc))是从旧位置移动到新位置的最小花费。这里就是关键了!我们不能简单地认为相邻移动花费1,跳跃花费2。因为从(r,c)到(nr,nc)可能有多条路径,我们需要的是最小花费。这就变成了一个单源最短路径问题:以(r,c)为起点,到达所有字符等于S[i]的格子(nr, nc)的最短距离(这里的距离就是花费)。
所以,整个DP过程可以这样描述:
- 对于每个已经计算好的状态
dp[i][r][c](它代表了到达这个状态的一种可能方案及其总花费)。 - 以
(r, c)为起点,在网格上进行一次搜索,计算出从(r,c)到网格中所有其他格子的最小移动花费(记为dist[nr][nc])。这个搜索需要涵盖三种移动方式。 - 对于所有满足
grid[nr][nc] == S[i]的格子(nr, nc),我们可以用dp[i][r][c] + dist[nr][nc]去更新dp[i+1][nr][nc]。
那么,如何高效计算dist数组?这就是下一个核心环节。
2. 核心算法实现:BFS 计算移动花费
为什么用BFS(广度优先搜索)?因为我们的移动花费都是非负整数(0, 1, 2),并且BFS的特性保证了当第一次访问到一个节点时,所用的步数(在这里是花费)就是最小的。这完美契合了寻找最小花费路径的需求。
我们需要设计BFS,使其能处理三种移动规则:
- 停留:这其实在状态转移中已经隐含了。如果
(r, c)本身的字符就等于S[i],那么dist[r][c] = 0。在BFS初始化时,起点的距离就是0。 - 四方向移动:上下左右,花费为1。这是BFS的标准操作。
- 跳跃:向正上或正下方寻找同字符格子,花费为2。这是本题的难点。
跳跃规则是:从当前格子(r, c),可以跳到同一列c上,任何一个字符与grid[r][c]相同的格子(kr, c),花费为2。注意,是“可以跳到”,而不是只能跳到相邻的。也就是说,如果同一列上有多个相同字符,你可以花2点代价直接跳到其中任何一个。
在BFS中如何处理这个跳跃?一个直观但低效的方法是:在遍历到每个节点(r, c)时,都向上向下扫描整列,把所有相同字符的格子加入队列,并设置距离为dist[r][c] + 2。但这样会导致大量重复扫描,复杂度可能升高。
一个更高效的做法是预处理。我们可以预先计算出,对于每一列c,字符ch出现在哪些行。这样,当BFS到达某个格子(r, c)时,我们可以立刻知道这一列上所有字符为grid[r][c]的其他位置,然后将它们一次性加入BFS的考虑范围。
BFS实现细节(伪代码思路):
// 假设有一个 vector> same_char_in_col[M][26]; 预处理好的结构 // same_char_in_col[col][ch_idx] 存储了第col列中,字符为 ch_idx 的所有行号。 struct Node { int r, c; }; queue q; vector dist(N, vector(M, INF)); // 初始化,起点 (sr, sc) dist[sr][sc] = 0; q.push({sr, sc}); while (!q.empty()) { auto [r, c] = q.front(); q.pop(); int current_dist = dist[r][c]; char current_char = grid[r][c]; // 1. 四方向移动 (花费+1) for (每个方向 (dr, dc) in {(1,0),(-1,0),(0,1),(0,-1)}) { int nr = r + dr, nc = c + dc; if (位置合法且 dist[nr][nc] > current_dist + 1) { dist[nr][nc] = current_dist + 1; q.push({nr, nc}); } } // 2. 跳跃移动 (花费+2) int col = c; int ch_idx = current_char - 'a'; // 假设只有小写字母 for (int kr : same_char_in_col[col][ch_idx]) { if (kr == r) continue; // 跳过自己 if (dist[kr][col] > current_dist + 2) { dist[kr][col] = current_dist + 2; q.push({kr, col}); } } }这个BFS会计算出从起点(sr, sc)到网格所有点的最小移动花费。注意,BFS的队列要使用queue,因为距离不是固定的1,有1和2两种边权。但由于边权只有1和2,且2>1,使用普通的队列(而不是优先队列)仍然是正确的,这可以看作是一种“0-1 BFS”的变体(边权为1和2),不过普通队列在这里也适用,因为当我们从队列中取出一个节点时,它的距离不一定是最小的,但后续如果发现更小的距离会再次更新并放入队列。虽然可能使某些节点被多次访问,但在本题数据范围内完全可接受。更严谨的做法是使用优先队列(Dijkstra),但代码稍复杂。
实操心得:在信奥竞赛中,如果边权只有小的常数(如1,2),用普通BFS队列通常比优先队列更快,代码也更简单。但一定要清楚其原理,并确认不会因为重复入队导致超时(本题N,M<=50,节点数最多2500,完全没问题)。
3. 完整动态规划流程与代码实现
有了BFS来计算两点间移动花费,整个DP的流程就清晰了。
3.1 预处理
- 读入
N,M,以及N行的网格grid。 - 读入目标字符串
S,长度为L。 - 预处理
same_char_in_col数组,用于加速跳跃操作。 - 初始化三维DP数组
dp,大小为[L+1][N][M],所有值设为INF。dp[0][...][...]表示匹配0个字符的状态。
3.2 初始化第0层状态
遍历网格第一行(r=0)的所有列c,如果grid[0][c] == S[0],则dp[0][0][c] = 0。这表示我们可以从这些位置开始,且尚未花费任何代价。
3.3 状态转移(核心循环)
外层循环i从0到L-1,表示当前已匹配的字符数。 对于每个i,我们遍历所有可能的位置(r, c)。 如果dp[i][r][c]不是INF(即这个状态是可达的),那么我们就以(r, c)为起点,进行一次BFS,得到dist数组。 然后,内层循环遍历网格中所有位置(nr, nc),如果grid[nr][nc] == S[i](注意,这里匹配的是第i个字符,因为我们要从状态i转移到i+1),那么我们就可以用dp[i][r][c] + dist[nr][nc]去更新dp[i+1][nr][nc]。
这里有一个极其关键的优化点!如果对于每一个dp[i][r][c]都做一次全图BFS,复杂度将是O(L * N * M * (N*M)),在50的数据规模下是50*2500*2500,超过3亿,可能超时。我们必须优化。
观察发现,dist数组的计算只依赖于起点(r, c),而与i无关。也就是说,对于同一个起点,无论它在DP的哪一层被用到,计算出的dist都是一样的。因此,我们可以预先计算所有点作为起点时的dist,或者采用一种更巧妙的DP顺序。
一个标准的优化是:我们不是对每个dp[i][r][c]单独BFS,而是在处理同一层i时,将所有当前层的有效状态(r,c)一起考虑。但这需要改变BFS的结构,不太直观。
更简单且有效的做法是:改变DP的转移视角。 我们定义dp[i][r][c]为匹配完前i个字符,且最后一个字符在(r,c)的最小花费。 转移时,我们考虑前一个字符S[i-1]可能在哪里。也就是说,我们需要枚举所有可能的上一个位置(pr, pc),并且grid[pr][pc] == S[i-1],然后计算从(pr, pc)到(r, c)的最小花费cost,那么dp[i][r][c] = min(dp[i-1][pr][pc] + cost)。
这样,问题就变成了:对于每一对字符(S[i-1], S[i]),我们需要知道所有能产出S[i-1]的位置到所有能产出S[i]的位置的最小花费。我们可以预先计算一个花费矩阵min_cost[a][b],其中a和b是网格中的位置索引(例如a = r * M + c)。但这样矩阵大小是(N*M)^2,对于2500个点就是625万,计算和存储都可行。
但还有更优的方案。注意到,我们只关心从字符A的位置到字符B的位置的花费。我们可以这样:
- 预处理:对于网格中的每个位置
p,用BFS计算出它到网格所有其他位置q的最小花费dist[p][q]。这仍然是O((N*M)^2)的BFS,但每个BFS是O(N*M),总复杂度O((N*M)^2)。N*M=2500,2500*2500=6.25e6,再乘以BFS的常数,在竞赛时间限制内(通常1-2秒)是临界的,可能需要优化。 - 实际上,由于
N, M <= 50,N*M=2500,对每个点做一次BFS(O(N*M)),总复杂度O((N*M)^2) = 6.25e6,每个BFS中每个点最多入队几次,常数不大,在C++中通常可以在1秒内完成。这个复杂度是可以接受的。这是最直观、最不易出错的实现方式。
因此,我们选择方案2作为实现基础。
3.4 最终代码框架与细节
#include #include #include #include #include using namespace std; const int INF = 0x3f3f3f3f; const int dx[4] = {1, -1, 0, 0}; const int dy[4] = {0, 0, 1, -1}; int main() { int N, M; cin >> N >> M; vector grid(N); for (int i = 0; i < N; ++i) cin >> grid[i]; string S; cin >> S; int L = S.length(); // 1. 预处理:每个位置到所有位置的最短距离 int totalCells = N * M; vector> dist(totalCells, vector(totalCells, INF)); // 预处理跳跃信息:按列存储每个字符的行号 vector>> sameChar(M, vector>(26)); // sameChar[col][char] for (int r = 0; r < N; ++r) { for (int c = 0; c < M; ++c) { int idx = r * M + c; sameChar[c][grid[r][c] - 'a'].push_back(r); } } // 对每个起点进行BFS for (int sr = 0; sr < N; ++sr) { for (int sc = 0; sc < M; ++sc) { int sIdx = sr * M + sc; vector curDist(N, vector(M, INF)); queue> q; curDist[sr][sc] = 0; q.push({sr, sc}); while (!q.empty()) { auto [r, c] = q.front(); q.pop(); int d = curDist[r][c]; int idx = r * M + c; dist[sIdx][idx] = d; // 记录到所有点的距离 // 四方向移动 for (int dir = 0; dir < 4; ++dir) { int nr = r + dx[dir]; int nc = c + dy[dir]; if (nr >= 0 && nr < N && nc >= 0 && nc < M && curDist[nr][nc] > d + 1) { curDist[nr][nc] = d + 1; q.push({nr, nc}); } } // 跳跃移动 int col = c; char ch = grid[r][c]; for (int kr : sameChar[col][ch - 'a']) { if (kr == r) continue; if (curDist[kr][col] > d + 2) { curDist[kr][col] = d + 2; q.push({kr, col}); } } } } } // 2. DP初始化 // dp[i][pos] 表示匹配完前i个字符,最后在位置pos的最小花费 vector> dp(L + 1, vector(totalCells, INF)); // 初始化i=0:匹配0个字符,手指可以在任何与S[0]匹配的第一行位置,花费0 for (int c = 0; c < M; ++c) { if (grid[0][c] == S[0]) { int pos = 0 * M + c; // 第一行,行号为0 dp[0][pos] = 0; } } // 3. DP转移 for (int i = 0; i < L; ++i) { // i表示已匹配的字符数,接下来要匹配S[i] for (int pos = 0; pos < totalCells; ++pos) { if (dp[i][pos] >= INF) continue; // 状态不可达 int r = pos / M, c = pos % M; // 如果当前位置的字符不是S[i],这个状态理论上不应该存在(除了i=0)。 // 但为了通用性,我们只依赖dist矩阵进行转移。 // 我们需要找到所有下一个字符S[i]的位置npos // 注意:这里容易混淆。dp[i][pos]表示已经匹配了S[0...i-1],现在手指在pos。 // 我们要匹配的是S[i]。所以我们需要找所有字符等于S[i]的位置作为下一个位置。 // 但更准确的描述是:我们从dp[i][pos]转移到dp[i+1][npos],其中grid[npos] == S[i]。 // 所以循环变量i代表“已匹配数”,我们要用它来索引S字符串。 } } // 更清晰的DP循环写法: // dp[i][pos] 表示匹配了前i个字符(S[0...i-1]),手指在pos。 // 初始化:对于所有grid[0][c]==S[0]的位置,dp[1][pos]=0。但这样dp数组要开L+1,索引从1开始。 // 我们调整一下定义,让i表示已匹配的字符数,从0到L。 // 则初始化dp[0][pos]=0 for pos where grid[pos]==S[0]? 不对,匹配0个字符时,手指位置是未定义的。 // 让我们重新定义,这是此类DP常见困惑点。 // 重新定义: // dp[i][pos]: 匹配完**前i个字符**(即S[0], S[1], ..., S[i-1]),且手指位于pos的最小花费。 // i的范围是0到L。当i=0时,表示还没匹配任何字符,此时dp[0][pos]应该为INF,因为还没开始。 // 但是,我们可以虚拟一个“开始前”的状态。更简单的方式是: // 让dp[i][pos]表示**正在匹配第i个字符**(0-indexed),且手指已经位于pos(并且grid[pos]==S[i])时所累积的最小花费。 // 这样初始化:对于所有grid[r][c]==S[0]的位置,dp[0][pos]=0。 // 转移:从dp[i][pos] (匹配S[i]在pos) 转移到 dp[i+1][npos] (匹配S[i+1]在npos),花费为dist[pos][npos]。 // 最终答案:min(dp[L-1][pos]) over all pos。 // 按此思路修正代码: vector> dp(L, vector(totalCells, INF)); // 初始化i=0 for (int r = 0; r < N; ++r) { // 注意:题目说开始时手指可以在第一行任意匹配位置,但示例和逻辑是“匹配第一个字符时,手指必须在与其匹配的格子上”。开始时的位置就是匹配第一个字符的位置。 for (int c = 0; c < M; ++c) { if (grid[r][c] == S[0]) { int pos = r * M + c; dp[0][pos] = 0; } } } for (int i = 0; i < L - 1; ++i) { // i从0到L-2,因为我们要从第i个字符匹配到第i+1个 for (int pos = 0; pos < totalCells; ++pos) { if (dp[i][pos] >= INF) continue; // 对于所有下一个字符S[i+1]所在的位置npos for (int npos = 0; npos < totalCells; ++npos) { int nr = npos / M, nc = npos % M; if (grid[nr][nc] != S[i + 1]) continue; int cost = dist[pos][npos]; if (cost >= INF) continue; // 不可达 dp[i + 1][npos] = min(dp[i + 1][npos], dp[i][pos] + cost); } } } // 4. 获取答案 int ans = INF; for (int pos = 0; pos < totalCells; ++pos) { ans = min(ans, dp[L - 1][pos]); } if (ans >= INF) ans = -1; cout << ans << endl; return 0; }3.5 关键优化与代码调整
上面的代码逻辑正确,但存在一个性能问题:最内层循环遍历了所有npos(最多2500个),而DP层数L最大为50,状态数pos最多2500,那么复杂度是O(L * (N*M)^2),即50 * 2500 * 2500 = 3.125亿,这很可能超时。
我们需要优化内层循环。我们不需要遍历所有npos,只需要遍历那些字符等于S[i+1]的位置。我们可以预处理每个字符在网格中出现的位置列表。
// 预处理每个字符出现的位置 vector> charPositions(26); for (int r = 0; r < N; ++r) { for (int c = 0; c < M; ++c) { charPositions[grid[r][c] - 'a'].push_back(r * M + c); } } // DP转移 for (int i = 0; i < L - 1; ++i) { int nextCharIdx = S[i + 1] - 'a'; const vector& nextPosList = charPositions[nextCharIdx]; for (int pos = 0; pos < totalCells; ++pos) { if (dp[i][pos] >= INF) continue; for (int npos : nextPosList) { int cost = dist[pos][npos]; if (cost >= INF) continue; dp[i + 1][npos] = min(dp[i + 1][npos], dp[i][pos] + cost); } } }假设每个字符平均出现在K个位置,那么内层循环复杂度从O(N*M)降到了O(K)。最坏情况下K = N*M,但平均会好很多。结合L<=50,通常可以AC。
注意事项:预处理
dist矩阵时,dist[a][a]应该为0,代表停留在原地。这在BFS初始化时已经设置。另外,当dist[pos][npos]为INF时,意味着从pos无法到达npos,在转移时应跳过。
4. 常见问题与调试技巧
在实现和调试这道题时,我和学生们遇到了几个典型问题:
4.1 初始化错误
问题:答案总是远大于预期,或者直接是INF。排查:
- 检查
dp[0][...]的初始化。是否只初始化了第一行?题目要求是“开始时手指可以在第一行任意一个匹配S[0]的位置”,但这里的“开始”指的是匹配第一个字符的时候。所以,所有grid[r][c] == S[0]的位置,dp[0][pos]都应该初始化为0吗?不对。题目描述是:“开始时,你的手指在网格顶行的任意一个位置上,该位置的字符与歌曲的第一个字符相同。” 这意味着,匹配第一个字符是没有花费的,因为手指一开始就放在那里了。所以,dp[0][pos]对于所有grid[pos] == S[0]且pos在第一行(r == 0)的位置,应初始化为0。我上面代码中初始化了所有行,这是错误的,应该只初始化第一行。// 正确的初始化 for (int c = 0; c < M; ++c) { if (grid[0][c] == S[0]) { int pos = 0 * M + c; dp[0][pos] = 0; } } - 检查
INF的值是否足够大。0x3f3f3f3f约等于10亿,在本题最大花费(50个字符,每次移动最多2,最多100)远小于此,足够用。但要注意,如果有加法运算,要防止溢出。
4.2 BFS中跳跃处理遗漏或重复
问题:结果比标准答案大,说明某些转移花费算多了。排查:
- 跳跃时,是否跳过了自己?
if (kr == r) continue;这行代码必须有,否则会自己跳到自己,额外花费2,导致错误。 - 跳跃的花费是2,是否在BFS中正确加入了队列?确保
curDist[kr][col] > d + 2才更新和入队。 - 一个更隐蔽的坑:跳跃是瞬间移动到同一列任意一个相同字符的格子,花费固定为2。这意味着,从A跳到B花2,从A跳到C也花2。但在BFS中,如果我们从A先跳到B(花费2),然后从B再跳到C(花费2),那么从A到C就变成了4,这就不符合“瞬间移动”的设定了。因为规则是直接从A跳到C花费2,而不是通过B中转。 我们的BFS实现是否正确处理了这一点?在BFS中,当我们处理节点A时,我们会把所有同列相同字符的节点(B, C, ...)都找出来,并设置距离为
dist[A]+2。如果之后从B又跳转到C,距离会变成dist[B]+2,而dist[B]至少是dist[A]+2,所以dist[C]至少是dist[A]+4,这比直接跳转的2要大,因此不会被更新。所以,我们的写法是正确的,直接跳跃的边权2会先于间接跳跃的边权4被考虑。
4.3 时间复杂度与空间复杂度优化
问题:程序运行超时。排查:
dist矩阵的计算:对2500个点各做一次BFS,每次BFS最多遍历2500个节点,每个节点扩展时,四方向是4次,跳跃最多N次(50次)。所以总操作量大约是2500 * 2500 * (4+50) ≈ 3.4e8。这在2秒的时限内对于C++是非常紧张的,很可能超时。- 优化方案:我们不需要完整的
dist矩阵。在DP转移时,我们只关心从一个字符A的位置到另一个字符B的位置的距离。而且,在每一层DP,我们只关心从当前层所有有效状态所在的位置,到下一个字符所在位置的距离。我们可以采用多源BFS进行优化。- 在DP的每一层
i,我们知道所有有效的pos集合(即dp[i][pos]不是INF的位置)。 - 我们可以从所有这些
pos同时开始BFS(多源BFS),计算出从这些源点到网格所有点的最短距离minDist。 - 然后,对于所有字符等于
S[i+1]的位置npos,用dp[i][pos] + minDist[npos]去更新dp[i+1][npos]。但这里有个问题,dp[i][pos]对于不同的源点pos值不同,我们不能简单地把minDist[npos]加上一个统一的dp[i][pos]。 - 正确的做法是:在BFS时,将
(pos, dp[i][pos])作为源点加入优先队列(因为花费不同)。然后进行Dijkstra算法(因为边权有1和2)。对于每个到达的节点npos,我们得到的是从所有源点出发到它的最小花费,记作minCostTo[npos]。但是,这个minCostTo[npos]已经包含了从某个源点pos出发的dp[i][pos]了吗?没有,我们BFS计算的是移动花费。所以我们需要在更新dp[i+1][npos]时,用的是minCostTo[npos],但minCostTo[npos]是纯移动花费,我们需要加上对应的dp[i][pos]。然而,minCostTo[npos]只记录了最小移动花费,不知道是哪个源点来的。 - 因此,更直接的做法是:对每个有效的
dp[i][pos],我们以其为起点做BFS,但使用优先队列,并且将初始距离设为dp[i][pos]。这样BFS结束后,对于每个npos,我们得到的就是dp[i][pos] + 移动花费的最小值。然后我们用这个值去更新dp[i+1][npos]。但这样还是要做多次BFS。 - 一个巧妙的优化:因为
dp[i][pos]是常数,我们可以将问题转化为:求min_over_pos( dp[i][pos] + dist(pos, npos) )。这可以看作是对每个npos,求它与一组源点pos的“带权距离”的最小值。这可以通过一次多源Dijkstra来完成,其中每个源点pos的初始距离就是dp[i][pos]。这样,我们每一层DP只需要做一次Dijkstra,复杂度降为O(L * N*M log(N*M)),大大提升效率。
- 在DP的每一层
由于篇幅所限,这里不展开多源Dijkstra的代码实现,但它是在竞赛中处理此类问题的标准且高效的方法。对于本题,如果使用最初的O((N*M)^2)预处理距离矩阵的方法能通过,则代码最简单;如果超时,就必须采用每层Dijkstra的优化。
4.4 答案提取错误
问题:程序输出不是-1就是0。排查:
- 最终答案应该是匹配完整个字符串
S的最小花费。我们DP状态dp[i][pos]表示匹配了前i+1个字符(因为i从0开始)后停在pos的花费。所以答案应该是dp[L-1][pos]中的最小值。 - 检查输出语句,是否是
cout << ans << endl;。 - 如果
ans保持为INF,说明没有任何一条路径能完成匹配,应输出-1。
调试建议:
- 使用小规模数据测试,比如2x2网格,短字符串,手动计算预期结果。
- 打印中间状态,例如打印每一层DP完成后,
dp[i]中非INF的值,看看是否按预期转移。 - 重点检查BFS计算出的
dist矩阵是否正确。可以写一个函数,打印从某个特定起点到各点的距离,与手动模拟对比。
这道题从理解题意到完全AC,涉及了问题抽象、状态设计、图论搜索(BFS/Dijkstra)和动态规划的综合应用,对思维和代码能力都是很好的锻炼。最后,再分享一个我个人的习惯:在写这种二维网格的BFS时,我更喜欢将坐标(r, c)编码成一个整数id = r * M + c,在队列和dist数组里都存储id,解码时r = id / M, c = id % M。这样代码更简洁,也减少了定义pair的麻烦。当然,使用pair配合{r, c}在C++17里也很方便,看个人喜好。最重要的是保持逻辑清晰,每一步都知道自己在计算什么。