金字塔

GitHub跳转原题关系图返回列表

把机器人遍历记录切成每个节点的连续块:f[l][r] 记区间成块方案数,枚举首子块后的根记录位置 k 乘法切分,按奇数长度递推,O(|S|^3) 区间 DP。

OJ: roj

题目 ID: 3147

难度:提高

标签:动态规划区间DP树python

创建: 2026-10-01 20:55

更新: 2026-10-10 11:30

形式化题目

给定一棵有根树:节点的孩子之间有顺序,每个节点涂一种颜色。机器人从根深度优先遍历,每进入一个房间(首次进入或从子节点返回都算)记录它的颜色,退出时不再记录,得到颜色序列 SS。问:有多少种不同的(有序、带色的)树结构,其遍历序列恰好等于 SS?答案对 10910^9 取模。

设树有 nn 个节点:进入根 1 次、每条边向下走 1 次,共 n+(n−1)=2n−1n + (n-1) = 2n-1 次记录,所以 ∣S∣=2n−1|S| = 2n-1 必为奇数,n=(∣S∣+1)/2n = (|S|+1)/2。等价地,每个节点 uu 在序列中出现 1+deg⁡+(u)1 + \deg^+(u) 次(deg⁡+\deg^+ 为孩子数):进入记 1 次,每从一个孩子返回再记 1 次。

样例 S=S= ABABABA(n=4n=4)输出 55。把树写成括号形式 根(孩子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"。

暴力解法

思路

按题意直接来:先由 ∣S∣=2n−1|S|=2n-1 定出房间数 n=(∣S∣+1)/2n=(|S|+1)/2,然后枚举所有可能的金字塔再逐个验证:

  1. 枚举全部 nn 节点的有序(平面)有根树——数量是 Catalan 数 Cn−1C_{n-1};
  2. 枚举全部 2n2^n 种节点着色;
  3. 对每棵(树,着色)模拟机器人遍历,逐位比对序列,吻合则计数加一。

思路天然正确,它也正是本文的对拍基准:验证阶段用它对 n⩽5n \leqslant 5 的全部有序树 × A/B 全着色做了全枚举,生成的 198 个不同序列与正解逐一比对、0 处不一致(记录见过程文档 04-correctness-and-edge-cases.md)。但规模双重爆炸:Catalan 数与指数同时相乘,n=150n=150 时约 1013110^{131}。

代码

本层不单独给出 brute.cpp:任务约定本题不写 brute/不对拍,暴力与正解没有可共用的输入输出结构,单独放一份只做"枚举+模拟"的文件不会带来新信息;上面的枚举步骤加样例表已经完整描述了这一层的全部行为;比对结果见上文思路末尾与过程文档 04-correctness-and-edge-cases.md。

复杂度

枚举总数 Cn−1⋅2nC_{n-1}\cdot 2^{n},每次还要 O(n)O(n) 模拟:

房间数 nn 3 5 10 30 150
树 × 着色方案数 16 1 344 ≈5×106\approx 5\times10^{6} ≈1024\approx 10^{24} ≈10131\approx 10^{131}

n=5n=5 时才一千多种,n=10n=10 已经五百万,到 n=150n=150(∣S∣=299|S|=299)是彻底的天文数字——"整棵枚举、整段验证"这条路在任何语言里都走不通。

瓶颈

瓶颈不只是"枚举太多",更在于验证零复用:每次都"造整棵树 → 从头模拟整段序列",相邻两次试错哪怕前半段完全相同也要整段重来。

要消除它,就要在序列上找到可以切开、彼此独立的边界,让"前半段怎么合法"和"后半段怎么合法"分别计数、再乘起来——这正是正解的出发点。

正解

思路

第一步:把序列按节点切成连续块。

固定节点 uu(颜色 cc):机器人首次进入 uu 时记第一次,之后每从一个孩子返回 uu 又记一次,直到最后一次返回为止——这些记录在序列中是连续的一段,中间只插入以 uu 为根的子遍历。于是每个节点对应一个"块",满足文法:

text
块(c) := c (子块, c) (子块, c) ... (子块, c)
         ↑进入     ↑每个孩子遍历完返回 u 都记一次,直到走完全部孩子

三条直接推论:叶子的块是单元素 [c];块首尾颜色都是 cc;块长度 =1+2×= 1 + 2\times(孩子数)恒为奇数。整个序列就是根的块,所以 S[0]=S[m−1]S[0]=S[m-1]。

第二步:块的每一段都是连续区间。

子块是序列上的连续区间——子问题只由两个端点决定,这就是区间 DP 需要的封闭性。定义

f[l][r]=序列 s[l..r] 恰好构成一个完整块的方案数,答案 f[0][m−1].f[l][r] = \text{序列 } s[l..r] \text{ 恰好构成一个完整块的方案数},\qquad \text{答案 } f[0][m-1].

第三步:按"第一个孩子"劈开,得到转移。

设 s[l..r]s[l..r] 是块,c=s[l]c=s[l]。剥掉开头的根记录后,第一个子块占 [l+1,k−1][l+1, k-1],位置 kk 是"从第一个孩子返回根"的记录(必须 s[k]=cs[k]=c);剩下的 [k,r][k, r] 恰好又是 c (子块,c)∗c\,(\text{子块},c)^* 形态——与块文法完全同型(文法只关心颜色与结构,位置 kk 上那条记录语义上是"进入"还是"返回"无关紧要),所以两半复用同一个 ff 计数、彼此独立:

f[l][r]={1,l=r(单个房间自成一块)0,s[l]≠s[r](块首尾必须同色)∑k=l+2,…,rs[k]=s[l]f[l+1][k−1]⋅f[k][r],其他情况 f[l][r]= \begin{cases} 1, & l = r \quad(\text{单个房间自成一块})\\[2pt] 0, & s[l] \neq s[r] \quad(\text{块首尾必须同色})\\[2pt] \displaystyle\sum_{\substack{k=l+2,\dots,r \\ s[k] = s[l]}} f[l+1][k-1]\cdot f[k][r], & \text{其他情况} \end{cases}

k⩾l+2k \geqslant l+2 是因为首子块 [l+1,k−1][l+1,k-1] 必须非空;条件 s[k]=s[l]s[k]=s[l] 只是必要过滤——孩子颜色可以与根相同,切分是否合法不能靠"找下一个同色位置"判断,而要交给两半的 DP 值自己说话,这也正是不能贪心匹配的原因。

正确性(不重不漏):对区间长度归纳。任取产生 s[l..r]s[l..r] 的子树,其第一个孩子的块有确定终点 kk,两半分别由归纳对应求和中的某一项(不漏);不同 kk 的第一孩子块终点不同,固定 kk 后两半解析由归纳各自唯一,乘积中每对解析拼出的树也不同(不重)。于是求和恰好每棵合法子树计一次,f[0][m−1]f[0][m-1] 即为答案。

递推顺序:f[l+1][k−1]f[l+1][k-1] 长度 k−l−1k-l-1、f[k][r]f[k][r] 长度 r−k+1r-k+1 都严格小于 r−l+1r-l+1,按区间长度从小到大递推即可;合法块长度恒为奇数,只枚举奇数长度,偶数长度区间恒为 0。

样例 S=S= ABABABA 的完整 DP 表(行 = 起点 ll,列 = 终点 rr,单元格 = f[l][r]f[l][r],— 为 l>rl>r 空区间;由题目专用脚本 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(单房间自成一块);非零格全落在首尾同色的奇数长度区间上(f[0][3]f[0][3]、f[0][5]f[0][5] 都是 0);答案直接读右上角 f[0][6]=5f[0][6]=5。最外层 f[0][6]f[0][6] 的切分明细进一步展示乘法结构:

  • k=2k=2(A):首子块 f[1][1]=1f[1][1]=1 × 余下 f[2][6]=2f[2][6]=2 → 贡献 2
  • k=4k=4(A):首子块 f[1][3]=1f[1][3]=1 × 余下 f[4][6]=1f[4][6]=1 → 贡献 1
  • k=6k=6(A):首子块 f[1][5]=2f[1][5]=2 × 余下 f[6][6]=1f[6][6]=1 → 贡献 2
  • 合计 2+1+2=52+1+2=5,与样例输出一致。

代码

dp[i][j] 即 f[i][j]f[i][j],初始化把 f(i,i)=1f(i,i)=1 一次写好;外层按奇数 span 递推;if text[i] != text[j]: continue 实现首尾同色过滤;内层 for k in range(i + 2, j + 1) 枚举切分点,text[k] == root 过滤,left[k - 1] * dp[k][j] 就是 f[i+1][k−1]⋅f[k][j]f[i+1][k-1]\cdot f[k][j](left = dp[i + 1] 只是行引用别名,省掉内层一层索引);每格写回前 % MOD(题面模数 10910^9)。

cpp
/**
 * 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;
}
python
#!/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()

复杂度

  • 时间复杂度:状态数为奇数长度区间约 m2/4m^2/4 个,每状态枚举 O(m)O(m) 个切分点,合计 O(m3)O(m^3);m=300m=300 时最内层乘加约 2×1062\times10^{6} 次(大量 kk 被同色过滤跳过),实测最慢用例 0.143 s(时限 1000 ms)。
  • 空间复杂度:O(m2)O(m^2) 的 dp 表,300×300300\times300 个整数,实测峰值 16.0 MB(限制 128 MB)。
  • 验证记录:真实数据 25 组全部通过(verify.sh 3147:PASS=25 / FAIL=0,最慢 0.143 s);另有上文暴力全枚举的 198 序列比对与样例 55 复算,均一致。

总结

本题从"枚举树"到"区间 DP"的关键是一条结构性观察:遍历记录会按节点聚成连续的块,块满足文法 c (子块,c)∗c\,(\text{子块},c)^*,于是

  1. 块首尾同色、长度为奇数——先筛掉大批不可能的区间;
  2. 子块是连续区间——子问题只由端点 (l,r)(l,r) 决定,O(m2)O(m^2) 个状态足够;
  3. 块内按第一个孩子劈开——首子块 [l+1,k−1][l+1,k-1] 与剩余 [k,r][k,r] 同型、独立,乘法合并、对 kk 求和,得到 O(m3)O(m^3) 的区间 DP。

最危险的坑是颜色碰撞:孩子可以和根同色,返回根的记录与子块内记录无法靠颜色区分,所以切分点必须全部枚举、交给 DP 判定,而不是贪心找"下一个同色位"。答案对 10910^9 取模(不是 109+710^9+7),按区间长度递推保证依赖的更短区间先算好。

图示解析

从建模到答案的主线

这张图串起本题从读入序列到得到答案的推理链:

text
输入颜色序列 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) 空间

顺着箭头看:每个节点都是"一条记录 + 若干(子块, 记录)对",这决定了状态只需两个端点;切分点 kk 把区间劈成互相独立的两半,乘法合并消灭了暴力的重复验证;最后按长度递推把指数级枚举压成三次方。