相邻同色木块先压成颜色段,再用「区间 [i,j] + 段 j 右边还要接上几个同色块 k」做区间 DP:段 j 要么不再牵涉区间内别的段,要么与左边的同色段合并。
OJ: roj
题目 ID: 3173
难度:提高+/省选-
标签:动态规划区间DP记忆化搜索python
创建: 2026-10-01 23:03
更新: 2026-10-10 13:00
形式化题目
一列
一次操作:选一个木块,把它以及与它相邻且同色的所有木块一起删除;这次删掉
求把
例如 1 2 2 2 2 3 3 3 1 的最高分是 2 和 3 个 3 分别删掉(谁先谁后都行,1 靠拢后一起删(1 在左端、一个 1 在右端,它们必须等中间的木块清空后才能合到一起。
为什么不能贪心:每次删掉当前最长的一段是错的。1 1 2 1 里贪心先删 11 得 2,让三个 1 靠拢,得
正解
思路
第一步:把相邻同色的木块压成一段
相邻同色的木块永远不该分开删——若某次删了左边
样例 1 2 2 2 2 3 3 3 1 压成
第二步:状态里必须记“右边挂了多少同色木块”
一次删除一定是若干个整段的并集,这些段在删除的那一刻两两相邻,说明它们之间的段都已经被提前删空了。现在固定一个区间
- 现在还在考虑的段是哪一段连续区间
; - 段
那一次删除还会额外带上右边多少个同色木块 。
中间那些被提前删掉的段只贡献一个分数,细节不再重要——这就是重复子问题的来源,也是朴素搜索(把整条序列当状态)指数爆炸的原因。
定义
也就是说,段
第三步:只处理最右边的段 j
每次盯着区间右端的段
转移一:段
转移二:段
其中
用样例看这个 DP
样例压成 [1,2,3,1],所以只有段 3 有“前面的同色段”:
各段的
| 段 |
0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 颜色 | 1 | 2 | 3 | 1 |
| 1 | 4 | 3 | 1 | |
| 0 | 1 | 2 | 3 | |
|---|---|---|---|---|
| 0 | 1 | 17 | 26 | 29 |
| 1 | 16 | 25 | 26 | |
| 2 | 9 | 10 | ||
| 3 | 1 |
读表的顺序是从左上到右下(区间由短到长)。最关键的最后一格:
- 转移一:
; - 转移二(
): 。
1 一起删”;2 和 3 个 3)独立解决的分数。两者相加就是样例答案。
再看
为什么用记忆化搜索而不是递推
递推当然可以(按区间长度递增,每个区间维护一张 i、j、k 的遍历顺序并为每个区间开数组。关键在于 functools.cache 让递归自己决定访问顺序,既不用给 k 定维,代码也短得多。
EARLIER[j] 存的是 p < i 就可以直接 break,不必扫完整个列表。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-10 13:00
* update_at: 2026-10-10 13:00
*/
// 消除木块:区间 DP,先把同色相邻块压成颜色段
#include <cstdio>
#include <vector>
using namespace std;
typedef long long ll;
int seg_cnt; // 颜色段个数
vector<int> seg_size; // 第 i 段里有几个木块
vector<vector<int> > earlier; // earlier[j]:第 j 段前面同色的段号,从近到远严格递减
vector<int> memo; // f(i,j,k) 的记忆化表,-1 表示未算
int dim; // memo 每一维的大小(可用的 k 上界 + 2)
int idx(int i, int j, int k) {
return (i * dim + j) * dim + k;
}
// 消掉颜色段 i..j 的最高分;段 j 这一次消除还会额外带上右边 k 个同色木块
int f(int i, int j, int k) {
int& res = memo[idx(i, j, k)];
if (res >= 0) {
return res;
}
if (i == j) {
int t = seg_size[j] + k;
res = t * t;
return res;
}
res = f(i, j - 1, 0) + (seg_size[j] + k) * (seg_size[j] + k); // 段 j 单独消
for (int t = 0; t < (int)earlier[j].size(); t++) {
int p = earlier[j][t];
if (p < i) {
break; // earlier[j] 递减,再往前都越界了
}
// 段 j 这次消除还包含左边的同色段 p:中间那截被夹住,接不到外面的木块
int tail = (p == j - 1) ? 0 : f(p + 1, j - 1, 0);
int cand = f(i, p, k + seg_size[j]) + tail;
if (cand > res) {
res = cand;
}
}
return res;
}
// 一组数据的最高分
int score(vector<int>& colors) {
seg_cnt = 0;
seg_size.clear();
earlier.clear();
vector<vector<int> > seen; // 每个颜色已经出现过的段号,递增
int previous = 0; // 颜色 ∈ [1,n],哨兵 0 保证第一块不并进上一段
for (int t = 0; t < (int)colors.size(); t++) {
int value = colors[t];
if (previous == value) { // 与上一块同色:并进当前段
seg_size[seg_cnt - 1]++;
continue;
}
if ((int)seen.size() <= value) {
seen.resize(value + 1);
}
vector<int> rev;
for (int q = (int)seen[value].size() - 1; q >= 0; q--) {
rev.push_back(seen[value][q]);
}
earlier.push_back(rev);
seen[value].push_back(seg_cnt);
seg_size.push_back(1);
seg_cnt++;
previous = value;
}
dim = (int)colors.size() + 2;
memo.assign((seg_cnt + 1) * dim * dim, -1);
return f(0, seg_cnt - 1, 0);
}
int main() {
int T;
if (scanf("%d", &T) != 1) {
return 0;
}
for (int tc = 1; tc <= T; tc++) {
int n;
scanf("%d", &n);
vector<int> colors(n);
for (int i = 0; i < n; i++) {
scanf("%d", &colors[i]);
}
printf("Case %d: %d\n", tc, score(colors));
}
return 0;
}#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 23:03
# update_at: 2026-10-01 23:20
import sys
from functools import cache
SIZE: list[int] = [] # SIZE[i]:第 i 个颜色段里有几个木块
EARLIER: list[tuple[int, ...]] = [] # EARLIER[i]:第 i 段前面同色的段号,从近到远严格递减
@cache
def f(i: int, j: int, k: int) -> int:
"""消掉颜色段 i..j 的最高分,其中段 j 那一次消除还会额外带上它右边的 k 个同色木块。
也就是说,段 j 最终是和 (SIZE[j] + k) 个同色木块一起消掉的。k 只可能由"更右边的段
被留到最后与 j 合并"产生,因此它一定是若干整段的木块总数。
读的是模块级的 SIZE / EARLIER,换一组数据前必须先调用 f.cache_clear()。
"""
if i == j:
return (SIZE[j] + k) ** 2
res = f(i, j - 1, 0) + (SIZE[j] + k) ** 2 # 段 j 的这次消除不牵涉 i..j-1 的任何段
for p in EARLIER[j]:
if p < i:
break # EARLIER[j] 递减,再往前都越界了
# 段 j 的这次消除还包含左边的同色段 p:中间那截被 p 和 j 夹住,接不到外面的木块
tail = 0 if p == j - 1 else f(p + 1, j - 1, 0)
cand = f(i, p, k + SIZE[j]) + tail # 段 p 右边因此挂上 SIZE[j] + k 块
if cand > res:
res = cand
return res
def score(colors: list[int]) -> int:
"""一组数据的最高分:先把相邻同色木块压成颜色段,再求 f(0, m-1, 0)。"""
global SIZE, EARLIER
size: list[int] = []
earlier: list[tuple[int, ...]] = []
seen: dict[int, list[int]] = {} # 每个颜色已经出现过的段号,递增
previous = 0 # 颜色 ∈ [1, n],哨兵 0 保证第一块不并进上一段
for value in colors:
if previous == value: # 与上一块同色:并进当前段,它俩分开消只会更差
size[-1] += 1
continue
earlier.append(tuple(reversed(seen.get(value, ()))))
seen.setdefault(value, []).append(len(size))
size.append(1)
previous = value
SIZE, EARLIER = size, earlier
best = f(0, len(size) - 1, 0)
f.cache_clear() # 缓存是模块级的,不清会让多组数据的内存累加
return best
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
T = next(data)
out: list[str] = []
for tc in range(1, T + 1):
n = next(data)
out.append(f"Case {tc}: {score([next(data) for _ in range(n)])}")
print("\n".join(out))
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:状态是三元组
,区间 个、 的可达取值最多 种,状态数上界 ;每个状态枚举 中 的段,最坏 个,因此最坏 ——这正是本题经典三维区间 DP 的阶,没有退化。实际访问量远小于上界:单组 、颜色数约 8 的数据只访问约 13 万个状态,均摊每个状态只枚举约 6 个候选段。 - 空间复杂度:
个整数。实现上把 f放在模块级并读取全局SIZE/EARLIER,每组数据算完立即f.cache_clear();若改用闭包捕获每组数据,上一组的缓存会被函数对象一直持着,多组数据下峰值内存会成倍上涨。
总结
本题有两个必须想清楚的点。
一是压缩。 相邻同色木块一定一起删,把
二是状态的第三维。 只记区间
转移时注意一件事:段
图示解析
这张图串起从输入到答案的主线:
输入:n 个木块
`- 相邻同色必然一起消 -> 压成 m 个颜色段 s[0..m-1]
`- 一次删除 = 若干个整段的并集,中间的段已被删空
`- 状态 f(i, j, k):段 i..j 的最优分,段 j 那一次消除还会带上右边 k 块同色木块
|- 段 j 单独消 -> f(i, j-1, 0) + (s_j + k)^2
`- 段 j 去和同色的段 p 合并 -> f(i, p, k+s_j) + f(p+1, j-1, 0)
`- 中间那截两头被堵,k 只能取 0接下来把样例
`- f(0,3,0) = 29 段3 的那次消除拉上左边的同色段0
|- f(0,0,1) = 4 ① 段0 的右边接上段3:1 + 1 = 2 块 -> 2² = 4
`- f(1,2,0) = 25 ② 中间被夹住的那截(k = 0)
|- 段2 不再牵涉区间内别的段 3 块 -> 3² = 9
`- f(1,1,0) = 16 段1:4 块 -> 4² = 16读图时关注三点:状态名里的两个数字是它管辖的段区间,第三个是段

