把机器人遍历记录切成每个节点的连续块:f[l][r] 记区间成块方案数,枚举首子块后的根记录位置 k 乘法切分,按奇数长度递推,O(|S|^3) 区间 DP。
OJ: roj
题目 ID: 3147
难度:提高
标签:动态规划区间DP树python
创建: 2026-10-01 20:55
更新: 2026-10-10 11:30
形式化题目
给定一棵有根树:节点的孩子之间有顺序,每个节点涂一种颜色。机器人从根深度优先遍历,每进入一个房间(首次进入或从子节点返回都算)记录它的颜色,退出时不再记录,得到颜色序列
设树有
样例 ABABABA(根(孩子1, 孩子2, …)(孩子从左到右、括号内是颜色),产生该序列的 5 棵树为:
| 方案 | 树形(括号表示) | 结构说明 |
|---|---|---|
| 1 | A(B, B, B) |
根 A 有三个叶子孩子 B |
| 2 | A(B, B(A)) |
两个孩子,第二个孩子还有一个 A 孙子 |
| 3 | A(B(A), B) |
两个孩子,第一个孩子还有一个 A 孙子 |
| 4 | A(B(A, A)) |
唯一孩子 B 有两个 A 孙子 |
| 5 | A(B(A(B))) |
一条 A-B-A-B 链 |
每棵树的遍历序列都恰好是 ABABABA,例如方案 4 A(B(A, A)):进入 A、进入 B、进入第一个孩子 A、返回 B、进入第二个孩子 A、返回 B、返回 A,记下 A B A B A B A。数出 5 棵就是样例答案;本文要算的正是"不枚举树也能得到这个 5"。
暴力解法
思路
按题意直接来:先由
- 枚举全部
节点的有序(平面)有根树——数量是 Catalan 数 ; - 枚举全部
种节点着色; - 对每棵(树,着色)模拟机器人遍历,逐位比对序列,吻合则计数加一。
思路天然正确,它也正是本文的对拍基准:验证阶段用它对 04-correctness-and-edge-cases.md)。但规模双重爆炸:Catalan 数与指数同时相乘,
代码
本层不单独给出 brute.cpp:任务约定本题不写 brute/不对拍,暴力与正解没有可共用的输入输出结构,单独放一份只做"枚举+模拟"的文件不会带来新信息;上面的枚举步骤加样例表已经完整描述了这一层的全部行为;比对结果见上文思路末尾与过程文档 04-correctness-and-edge-cases.md。
复杂度
枚举总数
| 房间数 |
3 | 5 | 10 | 30 | 150 |
|---|---|---|---|---|---|
| 树 × 着色方案数 | 16 | 1 344 |
瓶颈
瓶颈不只是"枚举太多",更在于验证零复用:每次都"造整棵树 → 从头模拟整段序列",相邻两次试错哪怕前半段完全相同也要整段重来。
要消除它,就要在序列上找到可以切开、彼此独立的边界,让"前半段怎么合法"和"后半段怎么合法"分别计数、再乘起来——这正是正解的出发点。
正解
思路
第一步:把序列按节点切成连续块。
固定节点
块(c) := c (子块, c) (子块, c) ... (子块, c)
↑进入 ↑每个孩子遍历完返回 u 都记一次,直到走完全部孩子三条直接推论:叶子的块是单元素 [c];块首尾颜色都是
第二步:块的每一段都是连续区间。
子块是序列上的连续区间——子问题只由两个端点决定,这就是区间 DP 需要的封闭性。定义
第三步:按"第一个孩子"劈开,得到转移。
设
正确性(不重不漏):对区间长度归纳。任取产生
递推顺序:
样例 ABABABA 的完整 DP 表(行 = 起点 — 为 problem-analysis-workspace/viz_render.py 教学版复算):
| l \ r | 0 A |
1 B |
2 A |
3 B |
4 A |
5 B |
6 A |
|---|---|---|---|---|---|---|---|
0 A |
1 | 0 | 1 | 0 | 2 | 0 | 5 |
1 B |
— | 1 | 0 | 1 | 0 | 2 | 0 |
2 A |
— | — | 1 | 0 | 1 | 0 | 2 |
3 B |
— | — | — | 1 | 0 | 1 | 0 |
4 A |
— | — | — | — | 1 | 0 | 1 |
5 B |
— | — | — | — | — | 1 | 0 |
6 A |
— | — | — | — | — | — | 1 |
要观察三点:对角线全是 1(单房间自成一块);非零格全落在首尾同色的奇数长度区间上(
( A):首子块× 余下 → 贡献 2 ( A):首子块× 余下 → 贡献 1 ( A):首子块× 余下 → 贡献 2 - 合计
,与样例输出一致。
代码
dp[i][j] 即 span 递推;if text[i] != text[j]: continue 实现首尾同色过滤;内层 for k in range(i + 2, j + 1) 枚举切分点,text[k] == root 过滤,left[k - 1] * dp[k][j] 就是 left = dp[i + 1] 只是行引用别名,省掉内层一层索引);每格写回前 % MOD(题面模数
/**
* 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 11:30
* update_at: 2026-10-10 11:30
*/
// 金字塔:区间 DP,s[i..j] 单独作为一棵子树的方案数。
#include <cstdio>
#include <cstring>
typedef long long ll;
const ll MOD = 1000000000LL;
const ll MAXN = 305;
char text[MAXN];
ll dp[MAXN][MAXN];
int main() {
if (scanf("%s", text) != 1) {
printf("0\n");
return 0;
}
ll n = (ll)strlen(text);
// dp[i][j]:s[i..j] 单独作为一棵子树(连同它的遍历序列)的方案数
for (ll i = 0; i < n; i++) dp[i][i] = 1; // 单个房间,进出各记一次
for (ll span = 3; span <= n; span += 2) {
for (ll i = 0; i + span - 1 < n; i++) {
ll j = i + span - 1;
if (text[i] != text[j]) continue; // 进入与退出记录的颜色必须都是根色
char root = text[i];
ll total = 0;
// 切分点 k:第一棵子树占 s[i+1..k-1],剩下的 s[k..j] 是同一层的其它子树
for (ll k = i + 2; k <= j; k++) {
if (text[k] == root) {
total = (total + (dp[i + 1][k - 1] % MOD) * (dp[k][j] % MOD)) % MOD;
}
}
dp[i][j] = total % MOD;
}
}
printf("%lld\n", n ? dp[0][n - 1] : 0);
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 20:55
# update_at: 2026-10-01 20:55
import sys
MOD = 10**9
def solve() -> None:
text = sys.stdin.buffer.read().strip().decode()
n = len(text)
# dp[i][j]:s[i..j] 单独作为一棵子树(连同它的遍历序列)的方案数。
# 长度偶数时不可能自成一棵子树(进入/退出成对),初值全为 0。
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = 1 # 单个房间,进出各记一次
for span in range(3, n + 1, 2):
for i in range(n - span + 1):
j = i + span - 1
if text[i] != text[j]:
continue # 进入与退出记录的颜色必须都是根色
root = text[i]
left = dp[i + 1] # 剥掉根之后的内部序列,行下标固定,先取出来省一层索引
total = 0
# 切分点 k:第一棵子树占 s[i+1..k-1],它自身的根色也是 root;
# 剩下 s[k..j] 是同一层的其它子树(可以有多棵),由 dp[k][j] 收口。
for k in range(i + 2, j + 1):
if text[k] == root:
total += left[k - 1] * dp[k][j]
dp[i][j] = total % MOD
print(dp[0][n - 1] if n else 0)
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:状态数为奇数长度区间约
个,每状态枚举 个切分点,合计 ; 时最内层乘加约 次(大量 被同色过滤跳过),实测最慢用例 0.143 s(时限 1000 ms)。 - 空间复杂度:
的 dp表,个整数,实测峰值 16.0 MB(限制 128 MB)。 - 验证记录:真实数据 25 组全部通过(
verify.sh 3147:PASS=25 / FAIL=0,最慢 0.143 s);另有上文暴力全枚举的 198 序列比对与样例复算,均一致。
总结
本题从"枚举树"到"区间 DP"的关键是一条结构性观察:遍历记录会按节点聚成连续的块,块满足文法
- 块首尾同色、长度为奇数——先筛掉大批不可能的区间;
- 子块是连续区间——子问题只由端点
决定, 个状态足够; - 块内按第一个孩子劈开——首子块
与剩余 同型、独立,乘法合并、对 求和,得到 的区间 DP。
最危险的坑是颜色碰撞:孩子可以和根同色,返回根的记录与子块内记录无法靠颜色区分,所以切分点必须全部枚举、交给 DP 判定,而不是贪心找"下一个同色位"。答案对
图示解析
从建模到答案的主线
这张图串起本题从读入序列到得到答案的推理链:
输入颜色序列 S(机器人 DFS 序,长度 m = 2n - 1)
|
v
关键观察:每个节点的记录聚成连续块,文法 c (子块, c)*
| 首尾同色、长度为奇数;整段序列 = 根的块
v
区间 DP:f[l][r] = s[l..r] 恰为一个完整块的方案数
| 按第一个孩子劈开:f[l][r] = sum f[l+1][k-1] * f[k][r]
| 同色过滤 s[k]=s[l],依赖区间更短 -> 按奇数长度递推
v
答案 f[0][m-1](mod 10^9) O(m^3) 时间、O(m^2) 空间顺着箭头看:每个节点都是"一条记录 + 若干(子块, 记录)对",这决定了状态只需两个端点;切分点