把一次涂色看成一段连续区间,用区间 DP 让左右端点相同的字符共用一笔涂色,转移枚举拆分点,时间 $O(n^3)$。
OJ: luogu
题目 ID: P4170
难度:提高+/省选-
标签:动态规划区间dp字符串
创建: 2026-06-19 18:55
更新: 2026-10-24 12:05
形式化题目
给定长度为
样例
暴力解法
思路
题目给的不是"目标串长什么样",而是"反复选区间涂色"这个过程,所以最直接的做法就是照着过程搜索。
把一个状态记成一块木板:每个位置要么还是空白(没涂过),要么已经涂了某个颜色。从"整块空白"出发,每次枚举一段连续区间 [l, r] 和一种颜色 c,把这段刷成 c,就得到一个新状态。问"最少刷几次能到目标串",就是在这个状态图上从起点到终点的最短路,用 BFS 一层层扩展即可。
这里有一个容易被忽略、但对暴力正确性很关键的点:已经涂对的位置也允许继续被覆盖。题面允许后涂覆盖前涂,所以搜索时不能把"已经等于
代码
// 小数据暴力:直接模拟"反复选区间涂色"的过程,不做任何区间 DP 推导。
// 状态是一块木板('.' 表示还没涂过的位置),从全 '.' 出发,每次挑一段连续区间
// 涂上一个颜色,广度优先搜索最少需要几步才能把每个位置都涂成目标颜色。
// 已经和目标一致的位置允许继续被覆盖,最后再被更正回来,这正是题目允许的操作。
#include <bits/stdc++.h>
using namespace std;
static string target;
// 状态编码:每位一个字符,'.' 表示未涂。用字符串哈希表去重。
int bfs() {
int n = (int)target.size();
string start(n, '.');
unordered_map<string, int> dist;
queue<string> q;
dist[start] = 0;
q.push(start);
auto done = [&](const string &state) {
for (int i = 0; i < n; ++i) {
if (state[i] != target[i]) {
return false;
}
}
return true;
};
while (!q.empty()) {
string cur = q.front();
q.pop();
int d = dist[cur];
if (done(cur)) {
return d;
}
// 枚举所有区间 [l, r] 和所有颜色,暴力扩展。
for (int l = 0; l < n; ++l) {
for (int r = l; r < n; ++r) {
for (char c : target) {
string nxt = cur;
bool changed = false;
for (int i = l; i <= r; ++i) {
if (nxt[i] != c) {
changed = true;
}
nxt[i] = c;
}
if (!changed) {
continue;
}
if (dist.count(nxt)) {
continue;
}
dist[nxt] = d + 1;
q.push(nxt);
}
}
}
}
return -1;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> target;
// 去重相邻相同字符不影响答案(一次涂色可以顺手带上),但这里连这一步都不做,
// 保证暴力完全独立于正解的推理。
cout << bfs() << '\n';
return 0;
}复杂度与瓶颈
设状态数为
更本质的问题是:这种搜索把"涂色顺序"完整地展开了,但题目只问最少次数,很多不同的涂色顺序其实对应同一批区间的同一组结果。只要能把"顺序"这个无关信息折叠掉,状态数就能从指数级降到
正解
关键观察
搜索之所以爆炸,是因为它在枚举"先涂哪段、后涂哪段"。换一个视角:最终看得到的只有每一笔涂色的区间,顺序只决定谁覆盖谁。
于是从“位置
- 方案 A:位置
是单独被"照顾"的,它最后被一笔只涉及它自己的涂色确定下来。之后 怎么涂与它无关,因为 和右边不再共用任何一笔。代价是 。 - 方案 B:位置
与右边某个位置 共用同一笔涂色( )。既然这一笔在 和 上都留下了颜色 ,而中间 的最终颜色和 不同,这一笔必须先把 刷成 ,再让中间部分被后续涂色盖掉。于是这一笔可以"吸收"进 的最优方案里:先按 的方案处理 ,并保证 上那笔向左延伸到 ,中间 则完全独立地刷成 。
这两类方案覆盖了
下面这张表用样例 R 与哪些位置配对:
| 位置 | 候选代价 | 说明 | ||
|---|---|---|---|---|
| 单独成笔 | 无 | — | R 自己刷一笔,右边独立处理 |
|
| 与 5 号共用 | 是 | 第一笔 GBG |
第二行的写法解释了样例 GGG,再补一笔蓝),那一次跨过
思路
把上面的观察写成区间 DP。设
- 边界:
,单个位置刷一笔。 - 转移:先用"
单独成笔"打底 ,再枚举 使 ,用 更新(当 时中间区间为空,取 )。 - 遍历顺序:所有转移都用到了更短的区间,所以按区间长度从小到大枚举,最后答案就是
。
与"最后一步"直觉相呼应,还有一条常用的等价写法:
小规模状态表(样例
| 1 | 2 | 3 | 3 | 3 | |
| — | 1 | 2 | 2 | 3 | |
| — | — | 1 | 2 | 3 | |
| — | — | — | 1 | 2 | |
| — | — | — | — | 1 |
读法:第 R 在 R 若单独成笔,右侧还剩两笔要做。
代码
#include <bits/stdc++.h>
using namespace std;
static int dp[55][55];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s;
cin >> s;
int n = (int)s.size();
for (int i = 0; i < n; ++i) {
dp[i][i] = 1;
}
// dp[l][r] 表示把目标子串 s[l..r] 涂出来的最少次数。
for (int len = 2; len <= n; ++len) {
for (int l = 0; l + len - 1 < n; ++l) {
int r = l + len - 1;
dp[l][r] = dp[l + 1][r] + 1;
for (int k = l + 1; k <= r; ++k) {
if (s[k] != s[l]) {
continue;
}
int left = (k == l + 1 ? 0 : dp[l + 1][k - 1]);
dp[l][r] = min(dp[l][r], left + dp[k][r]);
}
}
}
cout << dp[0][n - 1] << '\n';
return 0;
}复杂度
状态数
总结
这题的关键不是"模拟涂色顺序",而是注意到:最终只看得见每一笔涂色的区间,顺序信息对答案没有用。把最左端字符的归属拆成"单独成笔"和"与右边同色字符共用一笔"两种,顺序就被折叠进区间里的递归,状态数从指数级降到
这也是区间 DP 的常见套路:先盯住区间端点,再问"端点上的这一笔能不能和内部某处合并"。能和内部同色位置合并时,就把区间拆成"中间"和"右边"两段分别递归。
图示解析
这张一图流把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。
