题单 · 习题解析

洛谷 P4170 [CQOI2007] 涂色

★★ 两端同色能省一次(写成 f[l+1][r-1]+1 的最小反例只要 AAA);★★ 参照物不是另一份 DP,是在「木板状态」图上跑 BFS

原题:洛谷 P4170出自 第 26 章 区间 DP:石子合并 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P4170,日期见页头。两边不一致时信原站。

题目描述

假设你有一条长度为 n 的木板,初始时没有涂过任何颜色。 你希望把它的 n 个单位长度涂上目标颜色,用一个长度为 n 的字符串表示这个目标。

每次你可以把一段连续的木板涂成一个给定的颜色,后涂的颜色覆盖先涂的颜色

例如,对于目标字符串 RGBGR,一种涂色方式为:第一次把木板涂成 RRRRR, 第二次涂成 RGGGR,第三次涂成 RGBGR,达到目标。

用尽量少的涂色次数达到目标。

输入格式

输入仅一行,包含一个长度为 n 的字符串,即涂色目标。 字符串中的每个字符都是一个大写字母,不同的字母代表不同颜色,相同的字母代表相同颜色。

输出格式

仅一行,包含一个数,即最少的涂色次数。

说明/提示

40% 的数据满足 1 ≤ n ≤ 10

100% 的数据满足 1 ≤ n ≤ 50

输入输出样例(一)

输入

AAAAA

输出

1

五格全是 A —— 一刷刷完,1 次。

★ 这一组样例把本页两个错法全挡住了(没特判打 5、特判写错打 3)。

输入输出样例(二)

输入

RGBGR

输出

3

RGBGR:先整条 R,再中间 G,最后中间 B3 次。

⚠ 而这一组只挡住了「没特判」那一个(打 5)—— 「特判写错」那一版在这儿照样打 3。 ⇒ 第 16 章 P1074 那条:官方给了几组就跑几组,它们不是同一件事的重复。

1第一版:照石子合并的手感,纯按区间划分

p4170NoSkip.cpp✗ 第一版:没有「两端同色」那一条
// ✗ P4170 的第一版:**没有那个特判**,老老实实按区间划分。
//
// f[l][r] = min over k of ( f[l][k] + f[k+1][r] )
//
// 这是从[石子合并](/sol/p1775/)那儿原样搬过来的手感 —— 但它漏掉了这道题的全部门道:
// **一刷可以跨过中间那些「后来会被盖掉」的格子**,所以两端同色时不该各算一次。
//
// ★ 方向能先说死:它只是**少了一种更省的拼法** ⇒ 答案**恒 ≥ 正解**。
// ★ 官方那两组样例**都挡住了它**(AAAAA 打 5、RGBGR 打 5)——
// 因为它其实退化成了「一格一刷」:答案恒等于长度 n。页面上把这条也钉成了断言。
#include <bits/stdc++.h>
using namespace std;
int main() {
char buf[64];
if (scanf("%63s", buf) != 1) return 0;
string s = buf;
int n = (int)s.size();
vector<vector<int>> f(n + 2, vector<int>(n + 2, 0));
for (int i = 1; i <= n; i++) f[i][i] = 1;
for (int len = 2; len <= n; len++)
for (int l = 1; l + len - 1 <= n; l++) {
int r = l + len - 1;
int best = INT_MAX;
for (int k = l; k < r; k++) best = min(best, f[l][k] + f[k + 1][r]);
f[l][r] = best; // ✗ 没有「两端同色」那一条
}
printf("%d\n", f[1][n]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 它算了什么:恒等于串长 n(一格一刷)
300 轮:它的答案 = 串长 n 300 / 300
它 ≥ 正解 300 / 300
被抓 202 / 300

⇒ 因为纯区间划分里,任何一刷都不能跨过分界线 —— 于是拆到最后只能一格一刷。说清楚它算了什么,它的所有表现就都是推论。

2★★ 关键的一步:两端颜色相同时,可以省下一次

★ 转移只有两行
    s[l] == s[r]  ⇒  f[l][r] = min( f[l+1][r], f[l][r−1] )
    否则          ⇒  f[l][r] = min over k of ( f[l][k] + f[k+1][r] )

为什么第一行成立s[l] == s[r] 时,总存在一个最优方案让这两格 在同一次涂色里被盖住 —— 把盖住 l 的那一刷向右延到 r 不会变差, 因为后涂的会覆盖先涂的,多刷到的地方都会被后来的盖掉。 ⇒ 那一刷既服务了 l 也服务了 r,代价只算一次; 剩下的问题就是「把 l(或 r)当成免费搭上的那一格」。

p4170.cpp★ 这一版就能 AC
// P4170 [CQOI2007] 涂色 —— ★ 这一版就能 AC。
//
// f[l][r] = 把 [l, r] 这一段涂成目标的样子,最少要涂几次。
//
// ★★ 关键的一步:**两端颜色相同时,可以省下一次。**
//
// s[l] == s[r] ⇒ f[l][r] = min( f[l+1][r], f[l][r-1] )
// 否则 ⇒ f[l][r] = min over k of ( f[l][k] + f[k+1][r] )
//
// 为什么第一行成立:`s[l] == s[r]` 时,总存在一个最优方案,
// 让这两格**在同一次涂色里被盖住**(把盖住 l 的那一刷向右延到 r 不会变差 ——
// 后面涂的会覆盖先涂的,多刷到的地方都会被后来的盖掉)。
// ⇒ 于是「这一刷」既服务了 l 也服务了 r,代价只算一次,
// 剩下的就是把 l(或 r)当成已经免费搭上的那一格。
//
// ⚠ 注意不能写成 `f[l+1][r-1] + 1`(见 p4170Inner.cpp):那等于**强行规定**
// 这一刷只从 l 刷到 r 一整段、里面的事全部另算,会白花次数。
//
// 规模:`n ≤ 50` ⇒ 转移不到 2 万次,随便跑。
#include <bits/stdc++.h>
using namespace std;
int main() {
char buf[64];
if (scanf("%63s", buf) != 1) return 0;
string s = buf;
int n = (int)s.size();
vector<vector<int>> f(n + 2, vector<int>(n + 2, 0));
for (int i = 1; i <= n; i++) f[i][i] = 1;
for (int len = 2; len <= n; len++)
for (int l = 1; l + len - 1 <= n; l++) {
int r = l + len - 1;
if (s[l - 1] == s[r - 1]) {
f[l][r] = min(f[l + 1][r], f[l][r - 1]); // ★ 两端同色,省一次
} else {
int best = INT_MAX;
for (int k = l; k < r; k++) best = min(best, f[l][k] + f[k + 1][r]);
f[l][r] = best;
}
}
printf("%d\n", f[1][n]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3★★ 那个特判最容易写错的样子:`f[l+1][r-1] + 1`

「两端同色,那就先把 [l, r] 整段刷成这个颜色,再去解决中间」—— 听着很顺, 而且它是一个合法的方案,所以答案恒 ≥ 正解。

p4170Inner.cpp✗ 把那一刷的范围钉死了
// ✗ P4170 的第二版:特判写成了 `f[l+1][r-1] + 1`。
//
// 「两端同色,那就先把 [l, r] 整段刷成这个颜色,再去解决中间」—— 听着很顺,
// 而且它**是一个合法的方案**,所以答案恒 ≥ 正解。
//
// ⚠ 它错在**把那一刷的范围钉死了**。正确的写法 `min(f[l+1][r], f[l][r-1])` 说的是:
// 那一刷只要**盖住 l 和 r**就行,中间刷到哪儿、刷了几段,都由子问题自己安排。
//
// ★ 最小的反例只要三个字符:`AAA` —— 正解 1(一刷刷完),而这一版算成 **2**。
// (`f[2][2] + 1 = 2`:它非要「先刷整段、再单独处理中间那一格」。)
// ⇒ 官方两组样例都挡住了它(AAAAA 打 3、RGBGR 打 3)。
#include <bits/stdc++.h>
using namespace std;
int main() {
char buf[64];
if (scanf("%63s", buf) != 1) return 0;
string s = buf;
int n = (int)s.size();
vector<vector<int>> f(n + 2, vector<int>(n + 2, 0));
for (int i = 1; i <= n; i++) f[i][i] = 1;
for (int len = 2; len <= n; len++)
for (int l = 1; l + len - 1 <= n; l++) {
int r = l + len - 1;
int best = INT_MAX;
for (int k = l; k < r; k++) best = min(best, f[l][k] + f[k + 1][r]);
if (s[l - 1] == s[r - 1]) best = min(best, f[l + 1][r - 1] + 1); // ✗ 钉死了那一刷
f[l][r] = best;
}
printf("%d\n", f[1][n]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 最小反例只要三个字符
最小反例 AAA —— 正解 1,它算成 2
它 ≥ 正解 300 / 300
300 轮被抓 67

⚠ 它错在把那一刷的范围钉死了。正确写法 min(f[l+1][r], f[l][r−1]) 说的是: 那一刷只要盖住 lr 就行 —— 中间刷到哪儿、刷了几段,由子问题自己安排

4★★ 参照物不是另一份 DP,是在「木板状态」这张图上跑 BFS

★ 把「状态」当点,把「一次涂色」当边

起点是「整块木板都没涂过」,一步 = 选一段区间 + 一个颜色刷下去,终点是目标串。 BFS 的层数就是最少涂色次数 —— 这条路和区间 DP 一个字都不共享

300 轮:正解 vs 状态图 BFS 不一致 0 轮

⚠ 状态数是 (颜色数+1)ⁿ,所以只能用在很小的数据上(生成器压到 n ≤ 6、3 种颜色 ⇒ 至多 4⁶ = 4096 个状态)。 ★ 而「小」在这道题上不是凑合n = 6、三色已经足够造出 AABAA 这种 「两端同色、中间夹一段」的形状 —— 那正是这道题全部的门道 (第 6 章 P1719 那条:小本身就是覆盖能力)。 ★ 和第 15 章 P1379 八数码 是同一个套路。

p4170Bfs.cpp参照物:状态图 BFS(300 轮不一致 0 轮)

5★★ 一条可以自己验的性质:把连续相同的字符压成一个,答案不变

300 轮(n ≤ 30):压缩前后答案相同 300 / 300
其中真的被压短了的 270 / 300

⇒ 因为「一段连续的同色」只可能被同一刷盖出来,多出来的那几格既不增加也不减少工作量。 ★ 这条性质不是为了优化n ≤ 50 根本不需要),它的价值是: 给正解找一条和它自己无关的自检(和第 11 章 P1966 那条同一个动作)。

6★ 规模:这道题的门槛完全不在复杂度上

顶格 n = 50 ⇒ 转移 20 225
40% 那一档 n ≤ 10 出题人给的暴力档(BFS 参照物也就能跑到这个规模)
★ 平均答案随颜色数涨 2 色 7,3 色 9,8 色 15,26 色 19n ≤ 50,各 100 组)

7度量程序和生成器

p4170Count.cpp度量程序(本页所有数字都出自它)
p4170Gen.cpp数据生成器

8一页纸

★★ 关键的一步 两端同色 ⇒ f[l][r] = min(f[l+1][r], f[l][r−1])(那一刷同时服务两端)
第一版:没特判 恒等于串长 n(300/300)—— 纯区间划分下任何一刷都不能跨过分界线
★★ 特判写成 f[l+1][r−1]+1 恒 ≥ 正解;最小反例只要 AAA(1 vs 2);被抓 67/300
★ 两组官方样例 第一组两个错法全挡住,第二组只挡住一个 ⇒ 给了几组就跑几组
★★ 参照物 不是另一份 DP,是在木板状态图上 BFS(和 DP 一个字不共享,300 轮 0 不一致)
★★ 自检的性质 压掉连续重复,答案不变(300/300,其中 270 轮真的变短)
规模 顶格 n = 50 只有 20 225 次转移 —— 这道题考的全是那一条特判