- 学术
浅谈棋盘类DP(三)
- @ 2026-1-18 11:39:21

棋盘类 DP (允许改 个字母后字典序最小路径构造类型)
题目描述
给一个 的小写字母矩阵,你最多可以把 不超过 个格子的字母改成任意字母。只能从 走到 ,每步向右或向下;路径经过的字母按顺序拼成长度 的字符串。
问:在最多改 个格子的前提下,能得到的 字典序最小 路径字符串是什么。
输入输出样例
输入 #1
4 2
abcd
bcde
bcad
bcde
输出 #1
aaabcde
输入 #2
5 3
bwwwz
hrhdh
sepsp
sqfaf
ajbvw
输出 #2
aaaepfafw
输入 #3
7 6
ypnxnnp
pnxonpm
nxanpou
xnnpmud
nhtdudu
npmuduh
pmutsnz
输出 #3
aaaaaaadudsnz
解题思路
该题目相比于 浅谈棋盘类DP(二) 中的问题多了一个修改的部分,就需要关注这部分。
首先要求最终的结果 字典序最小 ,正常走的时候可能会生成一个 字典序 ,然后通过修改之后得到一个 字典序 ,在本问题中这两个字典序的长度还是相同的。
字典序最小 可以理解成 像查字典一样比较两个字符串谁更靠前 。
给两个字符串 和 (长度可以相同也可以不同),比较规则是:
- 从左到右找到 第一个不同的位置 。
- 如果 (按字符的大小,比如
'a'<'b'<'c'),那么 的字典序更小;反之 更小。- 如果一直到某个字符串结束都没找到不同字符:
- 那么 更短的那个更小 。例如
"abc" < "abcd"。
例子
"abz"和"aca":第 位不同,'b'<'c',所以"abz" < "aca"(后面不用看了)。"aaa"和"aab":第 位不同,'a'<'b',所以"aaa" < "aab"。"abc"和"abc":完全一样,不分大小。"abc"和"abca":前 位相同,短的"abc"更小。
根据上面的部分很明显可以观察到如果想要让得到的 字典序最小 ,就需要把前面的部分修改了,把这个字典序 从左到右 尽量把字符改成 'a' ,这是解决该问题的 关键小结论 。
因为:
'a'是最小字母;- 字典序只看最早不同位,所以 如果某一位能改成 'a',永远不亏 ;
- 因此在一条给定路径上,用预算把最早的非
'a'尽量改成'a',得到该路径的最小字符串。
由此可得到 暴力解法
- DFS / 回溯枚举所有由
R/D组成的路径(共 条)。- 对每条路径记录经过格子的字符序列 ,把前面最多 个非
'a'改成'a'得到 。- 全局取最小 。
路径数是 ,每条路径处理 。
只能用于很小的 (比如 左右)。
#include <bits/stdc++.h>
#define LOG(FMT...) fprintf(stderr, FMT)
using namespace std;
typedef long long LL;
const int N = 2e3 + 10;
int read(){
int x = 0,f = 1; char ch = getchar();
while (ch < '0' || ch > '9') {if (ch == '-') f = -1; ch = getchar();}
while (ch >= '0' && ch <= '9') {x = x * 10 + ch - '0'; ch = getchar();}
return x * f;
}
int n, k;
char s[N][N];
string Ans;
// 固定一条路径字符串 t:用最多 k 次把最早的非 'a' 改成 'a'
string f(string t) {
int rem = k;
for (char &c : t) {
if (rem > 0 && c != 'a') {
c = 'a';
rem--;
}
}
return t;
}
void dfs(int x, int y, string t) {
// t 已经包含 (x, y) 的字符
if (x == n && y == n) {
string Res = f(t);
if (Ans.empty() || Res < Ans) Ans = Res;
return;
}
if (y + 1 <= n) dfs(x, y + 1, t + s[x][y + 1]); // 右 (x, y + 1)
if (x + 1 <= n) dfs(x + 1, y, t + s[x + 1][y]); // 下 (x + 1, y)
}
inline void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
dfs(1, 1, string(1, s[1][1]));
cout << Ans << "\n";
}
int main() {
int _ = 1;
while (_--) solve();
return 0;
}
既然都已经写暴力 DFS 了,不妨加一个剪枝做个小优化,虽然并没有什么 🥚 用,可以解决 左右的数据。
加上:
- 在线生成改 次后的输出字符串前缀(不用等到叶子再贪)
- 字典序剪枝:一旦当前前缀已经比 的对应前缀更大,就直接砍掉这棵子树
同时再加一个小优化:优先走下一位输出字符更小的分支 ,更快得到一个很小的 ,从而剪枝更猛(猛个 🥚)。
正确性
对任意路径,最优改法 就是把 整条路径上最早出现的 个非 'a' 改成 'a' 。
因此当走到某个前缀时,这个前缀在最终输出串里 已经确定 (以后不会被 未来的修改 影响),所以可以拿来和当前 做字典序比较剪枝。
#include <bits/stdc++.h>
#define LOG(FMT...) fprintf(stderr, FMT)
using namespace std;
typedef long long LL;
const int N = 2e3 + 10;
int read(){
int x = 0,f = 1; char ch = getchar();
while (ch < '0' || ch > '9') {if (ch == '-') f = -1; ch = getchar();}
while (ch >= '0' && ch <= '9') {x = x * 10 + ch - '0'; ch = getchar();}
return x * f;
}
int n, k;
char s[N][N];
string Ans;
char buf[2 * N]; // 当前改 k 次后的输出串前缀(在线构造)
// (x, y) 当前坐标(1 - index)
// rem 还剩多少次把非 'a' 改成 'a' 的机会(只会用于最早出现的非 'a')
// cmp 当前前缀与 Ans 的比较状态:0 = 完全相等,-1 = 已小于,1 = 已大于(1 会被剪掉,不会递归进去)
// len buf 当前长度
void dfs(int x, int y, int rem, int cmp, int len) {
if (x == n && y == n) {
string Res(buf, buf + len);
if (Ans.empty() || Res < Ans) Ans = Res;
return;
}
struct Opt {
int nx, ny, nrem;
char outc;
};
Opt opts[2];
int cnt = 0;
auto add_opt = [&](int nx, int ny) {
char c = s[nx][ny];
Opt o;
o.nx = nx; o.ny = ny;
if (c != 'a' && rem > 0) {
o.outc = 'a';
o.nrem = rem - 1;
} else {
o.outc = c;
o.nrem = rem;
}
opts[cnt++] = o;
};
if (y + 1 <= n) add_opt(x, y + 1); // 右
if (x + 1 <= n) add_opt(x + 1, y); // 下
// 优先扩展下一位输出字符更小的分支,尽快拿到更小 Ans
if (cnt == 2 && (opts[1].outc < opts[0].outc)) swap(opts[0], opts[1]);
for (int i = 0; i < cnt; i++) {
auto o = opts[i];
// 字典序剪枝:比较新加入这一位与 Ans 对应位
int ncmp = cmp;
if (!Ans.empty() && cmp == 0) {
char bc = Ans[len]; // 新字符将放在 buf[len]
if (o.outc > bc) continue; // 前缀已经更大,直接剪掉
if (o.outc < bc) ncmp = -1; // 已经更小,后面无需再和 Ans 精确比对
}
buf[len] = o.outc;
dfs(o.nx, o.ny, o.nrem, ncmp, len + 1);
}
}
inline void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
// 起点也可能被改成 'a'
int rem = k;
char c = s[1][1];
if (c != 'a' && rem > 0) {
buf[0] = 'a';
rem--;
}
else buf[0] = c;
// cmp 初始为 0(因为 Ans 还空,不影响)
dfs(1, 1, rem, 0, 1);
cout << Ans << "\n";
}
int main() {
int _ = 1;
while (_--) solve();
return 0;
}
可以观察到
DFS 是 按路径展开 ;
那么就可以用朴素 DP 把所有到同一个状态 的路径合并 ,只保留其中字典序最小的那条字符串。
这样就不需要枚举 条路径了(依然很暴力,只是因为存字符串的原因暴力而已)。
定义
$$dp_{i,j,t} \text{在所有从 }(1,1)\text{ 到 }(i,j)\text{ 的路径上,选择恰好 }t\text{ 个经过的格子修改其字母后所得字符串中,字典序最小的那个} $$注意:一旦决定 改 某个格子,最优一定改成 'a'(因为 'a' 最小,字典序最优永远不亏)。
初始化
起点两种可能:
-
不改: 。
-
若 且 :改成 , 。
转移
到 只能 从上 / 左 来:
-
不改当前格:从 过来,末尾加 。
-
改当前格为
'a'(只有当 且 才有意义):从 过来,末尾加'a'
每个 取字典序最小者。
最终输出
时间复杂度
-
状态数:
-
但每次更新会复制 / 比较长度 的字符串
⇒ 总体大概 级,大数据必炸 。
#include <bits/stdc++.h>
#define LOG(FMT...) fprintf(stderr, FMT)
using namespace std;
typedef long long LL;
const int N = 50 + 10;
int read(){
int x = 0,f = 1; char ch = getchar();
while (ch < '0' || ch > '9') {if (ch == '-') f = -1; ch = getchar();}
while (ch >= '0' && ch <= '9') {x = x * 10 + ch - '0'; ch = getchar();}
return x * f;
}
int n, k, ok[N][N][N * N]; // ok 标记该状态是否可达
char s[N][N];
string dp[N][N][N * N]; // N = 50 + 10 ,太大会爆炸,因为这里存最小字符串,数据量非常大
inline void upd(string &dst, const string &cand, int &ok) {
if (!ok || cand < dst) {
dst = cand;
ok = 1;
}
}
inline void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
dp[1][1][0] = string(1, s[1][1]);
ok[1][1][0] = 1;
if (k >= 1 && s[1][1] != 'a') {
dp[1][1][1] = "a";
ok[1][1][1] = 1;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (i == 1 && j == 1) continue;
char c = s[i][j];
for (int t = 0; t <= k; t++) {
// 1) 不改当前格:从上 / 左 (同 t) 来,加 c
if (i > 1 && ok[i - 1][j][t]) {
string cand = dp[i - 1][j][t];
cand.push_back(c);
upd(dp[i][j][t], cand, ok[i][j][t]);
}
if (j > 1 && ok[i][j - 1][t]) {
string cand = dp[i][j - 1][t];
cand.push_back(c);
upd(dp[i][j][t], cand, ok[i][j][t]);
}
// 2) 改当前格为 'a':从上 / 左 (t - 1) 来,加 'a'
// 注意:这里不需要 c!='a',因为允许浪费一次修改
if (t >= 1) {
if (i > 1 && ok[i - 1][j][t - 1]) {
string cand = dp[i - 1][j][t - 1];
cand.push_back('a');
upd(dp[i][j][t], cand, ok[i][j][t]);
}
if (j > 1 && ok[i][j - 1][t - 1]) {
string cand = dp[i][j - 1][t - 1];
cand.push_back('a');
upd(dp[i][j][t], cand, ok[i][j][t]);
}
}
}
}
}
string Ans;
bool has = false;
for (int t = 0; t <= k; t++) if (ok[n][n][t]) {
if (!has || dp[n][n][t] < Ans) Ans = dp[n][n][t], has = true;
}
cout << Ans << "\n";
}
int main() {
int _ = 1;
while (_--) solve();
return 0;
}
空间优化
考虑 父指针 + rank(按层压缩) 的方式对空间进行压缩,同 浅谈棋盘类DP(二) 中的方案相同。
- 不再存 的整条字符串本体 。
- 只存它由哪一个父状态转移来(父指针),以及 它在这一层所有字符串中的字典序排名 rank 。
这样比较两个候选就从 比字符串 变成 比二元组 。
需要注意比较虽然时间复杂度降低了,但是每层还需要排序,所以时间复杂度并不能降低多少,只能降低 大量 string 拷贝 的时间复杂度。
空间降低规模
1)朴素 DP 存字符串
朴素版要存 的 整条字符串 ,长度大约是 。
- 状态数: 。
- 每个状态一条长度 的字符串。
所以字符总量级:
而且 std::string 还有额外的对象开销、capacity、分配器碎片等,实际更大。
2)rank + 父指针版:空间复杂度降到多少?
只存每个状态的 少量整数 :
- :int(4B)
- :1B
- :1B
- :2B(用 unsigned short 足够,)
合起来大约 字节 / 状态(再加对齐可能到 )。
状态数还是 ,所以空间:
另外每一层收集的 vec 大小 ,只是临时的。
3)空间节省了多少倍?(最关键结论)
对比两者:
- 朴素字符串版: 。
- rank + 父指针: 。
所以空间 理论上降低了一个 的量级 :
也就是大约省了 倍 。
状态
$$dp_{i,j,t} \text{从 }(1,1)\text{ 走到 }(i,j)\text{,恰好修改 }t\text{ 次后能得到的字典序最小字符串} $$但 不存 本体,只存:
-
:该字符串在长度为 的所有状态字符串中的 字典序排名 (越小越好)
-
:父方向(上 / 左)。
-
:父状态的 。
-
:当前格是否使用一次修改(改成
'a')。
为什么只用 (父rank, 当前字符) 就能比较新字符串
当前层长度为 。任一转移得到的字符串形如:
其中 长度为 ,它在上一层已经有排名 。
对两个候选:
它们字典序比较等价于先比前缀 和 ,若前缀相同再比最后一位。因为 就表示 的字典序大小,所以只要比较二元组:
就等价于比较完整字符串 。
因此我们对每个状态 只需要选一个最小的 签名 :
按层(对角线)做 rank 压缩
同一层是固定 (字符串长度固定为 )。
流程:
- 对本层所有状态 ,用上一层的 选出最小 ,并记录父指针。
- 把本层所有可达状态的 收集起来排序,相同 给同一个 ,不同 key rank 递增。
- 这就是 每层压缩 。
#include <bits/stdc++.h>
#define LOG(FMT...) fprintf(stderr, FMT)
using namespace std;
typedef long long LL;
const int N = 50 + 10, inf = 1e9 + 10;
int read(){
int x = 0,f = 1; char ch = getchar();
while (ch < '0' || ch > '9') {if (ch == '-') f = -1; ch = getchar();}
while (ch >= '0' && ch <= '9') {x = x * 10 + ch - '0'; ch = getchar();}
return x * f;
}
struct Node {
int pr; char ch;
int i, j, t;
bool operator<(const Node& o) const {
if (pr != o.pr) return pr < o.pr;
return ch < o.ch;
}
};
// rk[i][j][t]:该状态字符串在本层的排名(越小越好),不可达为 INF
// 父指针:parDir 0 = 上, 1 = 左;parMod 0 = 不改, 1 = 改成 'a';parT 父的 t
int n, k, rk[N][N][N * N], parT[N][N][N * N];
char s[N][N], parDir[N][N][N * N], parMod[N][N][N * N];
string dp[N][N][N * N];
inline void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
// sum = i + j,sum = 2 是(1, 1),字符串长度 = sum - 1
auto G = [&](int i, int j) -> char {return s[i][j];};
for (int sum = 2; sum <= 2 * n; sum++) {
vector<Node> vec;
// 枚举本层所有 (i,j)
int il = max(1, sum - n);
int ir = min(n, sum - 1);
for (int i = il; i <= ir; i++) {
int j = sum - i;
for (int t = 0; t <= k; t++) {
int bestPr = inf;
char bestCh = '{'; // 比 'z' 大
unsigned char bestDir = 0, bestMod = 0;
int bestPT = 0;
auto relax = [&](int pr, char ch, unsigned char dir, unsigned char mod, int pt) {
if (pr == inf) return;
if (pr < bestPr || (pr == bestPr && ch < bestCh)) {
bestPr = pr; bestCh = ch;
bestDir = dir; bestMod = mod; bestPT = pt;
}
};
if (sum == 2) {
// (1, 1) 基础层:父 rank 视为0(空前缀)
// t = 0:不改 -> g[1][1]
// t >= 1:允许浪费修改,最优就是 'a'
if (t == 0) relax(0, G(1,1), 0, 0, 0);
else relax(0, 'a', 0, 1, 0);
} else {
char c = G(i, j);
// 来自上:不改
if (i > 1) relax(rk[i - 1][j][t], c, 0, 0, t);
// 来自左:不改
if (j > 1) relax(rk[i][j - 1][t], c, 1, 0, t);
// 改当前格为 'a':来自上 / 左且 t >= 1
if (t >= 1) {
if (i > 1) relax(rk[i - 1][j][t - 1], 'a', 0, 1, t - 1);
if (j > 1) relax(rk[i][j - 1][t - 1], 'a', 1, 1, t - 1);
}
}
if (bestPr != inf) {
parDir[i][j][t] = bestDir;
parMod[i][j][t] = bestMod;
parT[i][j][t] = (unsigned short)bestPT;
vec.push_back({bestPr, bestCh, i, j, t});
}
}
}
// 本层 rank 压缩
sort(vec.begin(), vec.end());
int curRank = 0;
int lastPr = -1; char lastCh = 0;
for (auto &nd : vec) {
if (nd.pr != lastPr || nd.ch != lastCh) {
curRank++;
lastPr = nd.pr;
lastCh = nd.ch;
}
rk[nd.i][nd.j][nd.t] = curRank;
}
}
// 选答案:在 (n, n) 取 t <= k 中 rank 最小者
int bestT = -1, bestR = inf;
for (int t = 0; t <= k; t++) {
if (rk[n][n][t] < bestR) {
bestR = rk[n][n][t];
bestT = t;
}
}
// 回溯构造字符串
int L = 2 * n - 1;
string Ans(L, '?');
int i = n, j = n, t = bestT;
int pos = L - 1;
while (!(i == 1 && j == 1)) {
Ans[pos--] = (parMod[i][j][t] ? 'a' : G(i, j));
int pt = parT[i][j][t];
if (parDir[i][j][t] == 0) i--;
else j--;
t = pt;
}
// 起点字符(t > 0 表示我们在(1, 1)也选择了改成a / 浪费修改的那条最优定义)
Ans[0] = (t > 0 ? 'a' : G(1, 1));
cout << Ans << "\n";
}
int main() {
int _ = 1;
while (_--) solve();
return 0;
}
降低时间复杂度
很明显需要将 这一个维度降低下来,虽然前面强调了很多遍了,这里还是在提一下:
字典序最小 = 尽可能让前面的字符越小越好 。
在这个题里,修改一个格子能把字符变成任意字母,但最优永远只会改成 'a'(最小字母)。所以修改的作用只有一种:
把路径前缀里的某些字符变成
'a',从而让前缀更小。
那么策略必然是:
- 先把能变成
'a'的前缀长度尽量拉长(这是字典序最关键的部分);- 一旦
'a'前缀已经无法再延长(否则就更优了),后面就只能在剩下的格子里按字典序最小走(分层贪心)。
因此我们只需要知道:
到每个格子 ,把从起点到这里这段前缀 全变成
'a'最少要改几次。
这就变成一个 二维最短路 / DP 问题了。
总结
令
定义
$$dp_{i,j}=\min\{\text{把某条 }(1,1)\to(i,j)\text{ 路径上所有字符都变成 'a' 所需修改次数}\} $$转移:
$$dp_{i,j} = \min(dp_{i-1,j}, dp_{i,j-1}) + cost(i,j) $$这是 。
然后找能做到的最远步数:
这意味着答案的前缀可以做到 个 'a' 。
分层贪心(BFS)补后缀:只留能达到最小前缀的状态集合
把所有满足
的格子作为当前集合 (它们都能实现最优的 'a' 前缀)。
接下来重复:
-
看 所有后继(右 / 下)的字符,取最小
-
答案加上
-
收集所有字符为 的后继(去重)
-
每个格子最多入集合几次(去重后总体 ),所以这部分也是 。
最终复杂度
-
二维 dp:
-
分层贪心:
总时间:
空间:
#include <bits/stdc++.h>
#define LOG(FMT...) fprintf(stderr, FMT)
#define PII pair<int, int>
#define fi first
#define se second
using namespace std;
typedef long long LL;
const int N = 2e3 + 10, inf = 1e9 + 10;
int read(){
int x = 0,f = 1; char ch = getchar();
while (ch < '0' || ch > '9') {if (ch == '-') f = -1; ch = getchar();}
while (ch >= '0' && ch <= '9') {x = x * 10 + ch - '0'; ch = getchar();}
return x * f;
}
int n, k, dp[N][N], vis[N][N], tim;
char s[N][N];
inline void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
// 1) dp[i][j]: 最少改多少次,使得 (1, 1) -> (i, j) 这段前缀全为 'a'
for (int i = 0; i <= n; i++) dp[i][0] = dp[0][i] = inf;
dp[1][1] = (s[1][1] != 'a');
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (i == 1 && j == 1) continue;
int c = (s[i][j] != 'a');
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + c;
}
}
int best = -1;
for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) if (dp[i][j] <= k) best = max(best, i + j - 2);
string Ans;
vector<PII> cur, nxt;
if (best == -1) {
// 只有当 k = 0 且 s[1][1] != 'a' 才会出现
Ans.push_back(s[1][1]);
cur.push_back({1, 1});
best = 0; // 当前已确定到步数 0
} else {
Ans.assign(best + 1, 'a');
// 收集 best 这条对角线上的所有可行点
for (int i = 1; i <= n; i++) {
int j = (best + 2) - i; // i + j - 2 = best => i + j = best + 2
if (j < 1 || j > n) continue;
if (dp[i][j] <= k) cur.push_back({i, j});
}
}
// 2) 分层贪心补齐剩余字符
tim = 1;
for (int step = best; step < 2 * n - 2; step++) {
char bestc = '{'; // 比 'z' 大
for (auto &p : cur) {
int x = p.fi, y = p.se;
if (x + 1 <= n) bestc = min(bestc, s[x + 1][y]);
if (y + 1 <= n) bestc = min(bestc, s[x][y + 1]);
}
Ans.push_back(bestc);
tim++;
nxt.clear();
for (auto &p : cur) {
int x = p.fi, y = p.se;
if (x + 1 <= n && s[x + 1][y] == bestc && vis[x + 1][y] != tim) {
vis[x + 1][y] = tim;
nxt.push_back({x + 1, y});
}
if (y + 1 <= n && s[x][y + 1] == bestc && vis[x][y + 1] != tim) {
vis[x][y + 1] = tim;
nxt.push_back({x, y + 1});
}
}
cur.swap(nxt);
}
cout << Ans << "\n";
}
int main() {
int _ = 1;
while (_--) solve();
return 0;
}
京公网安备11010802045784号
_Separation WA的一声就哭了