把折叠串最外层只可能是拼接或重复这一观察落到区间上,用区间 DP 求最短长度,再按决策树还原答案串。
OJ: roj
题目 ID: 3170
难度:提高+/省选-
标签:动态规划区间dp字符串递归
创建: 2026-10-01 22:33
更新: 2026-10-10 13:00
形式化题目
给定长度为
- 单个字符
c是折叠序列,展开为c; - 若
都是折叠序列,则 是折叠序列,展开为二者展开结果的首尾相接; - 若
是折叠序列、整数 ,则 X(S)是折叠序列,展开为的展开结果重复 次。
注意规则 3 里 1(A) 不是合法折叠串。
求展开结果恰为
样例输入 AAAAAAAAAABABABCCD(9(A)3(AB)CCD,长度 A 时,10(A) 与 A9(A) 都是长度为
正解
思路
朴素做法与瓶颈
最直接的做法是按定义递归地构造折叠串:枚举把当前串切成两段的所有位置,再枚举所有可能的循环节长度,取展开后长度最小的那个。可行方案的数量随串长指数增长,
瓶颈其实不在枚举的数量级上,而在决策的层次上:与其枚举折叠串,不如直接问“区间
关键观察:语法树最外层只有两种可能
任何折叠序列都可以看成一棵语法树,内部结点只有两种:
- 拼接结点:由规则 2 产生;
- 重复结点:由规则 3 产生。
于是对于
- 最外层是拼接:存在断点
, 被切成 和 两段,两段各自折叠,总长度等于两段长度之和。两段互不影响,所以各自取最短即可。 - 最外层是重复:存在块长
, 正好是 原样重复 次( , )。此时折叠串形如 X(Q),其中, 是块自己的折叠串。
这样就把“枚举折叠串”换成了“在区间上做决策”,而所有决策都只依赖更短的区间,可以按区间长度从小到大递推。
状态与转移
设
其中
判定“
两个容易写错的点
第一,括号里要放块的最优折叠,而不是块本身。 例如 ABABABAB:
2(ABAB)长,等于原文,没有收益; 2(2(AB))长,才是最优。
所以重复候选的块内代价必须写成
第二,折叠不一定更短,必须和拼接一起取最小值。 规则 3 要求
ABCDEFGHIJKLMNOPQRSTUVWXYZ没有周期性,压根无法折叠;- 10 个
A折成10(A)长,与 A9(A)()打平; - 11 个
A时11(A)长,才严格优于 A10(A)。
因此循环节
用样例走一遍这张表
样例
| 区间 |
子串 | 最优决策 | 折叠串 | |
|---|---|---|---|---|
A |
1 | 单个字符 | A |
|
9 个 A |
4 | 重复, |
9(A) |
|
10 个 A |
5 | 拼接, |
A9(A) |
|
ABABAB |
5 | 重复, |
3(AB) |
|
CCD |
3 | 拼接 | CCD |
|
ABABABCCD |
8 | 拼接, |
3(AB)CCD |
|
| 整个串 | 12 | 拼接, |
9(A)3(AB)CCD |
读表时注意两点:10(A) 与 A9(A) 长度打平,DP 保留了拼接决策,所以答案是 A9(A) 而不是 10(A),两者都是合法最优解;ABABAB 折成 3(AB) 而不是 3(3(A...)) 之类,是因为它的循环节长度只能是 AB 已经最短。
还原答案串
只算出最短长度还不够,要输出具体串,所以 DP 时额外记录两个决策表:
cut[i][j]:拼接决策的断点; rep[i][j]:重复决策的块长, 表示这一段不做整体折叠。
从 rep[i][j] != 0,输出 L/p 加括号,括号里递归展开块 cut[i][j] 把区间拆成两半分别展开,结果相接。两种情形都严格使用更短的区间,递归深度不超过
代码
/**
* 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 <cstring>
#include <string>
using namespace std;
const int MAXN = 512;
char s[MAXN];
int f[MAXN][MAXN]; // f[i][j]:s[i..j] 展开前的最少字符数
int cut[MAXN][MAXN]; // cut[i][j]:拼接时的断点(左半段的右端点)
int rep[MAXN][MAXN]; // rep[i][j]:整体折叠的循环节长度 p,0 表示不折叠
// 按 DP 记录的决策还原 s[i..j] 的最优折叠串:折叠优先,其次按断点拆两半
string unfold(int i, int j) {
if (i == j) {
return string(1, s[i]);
}
int p = rep[i][j];
if (p) {
int times = (j - i + 1) / p;
char buf[16];
sprintf(buf, "%d", times);
return string(buf) + "(" + unfold(i, i + p - 1) + ")";
}
int k = cut[i][j];
return unfold(i, k) + unfold(k + 1, j);
}
int main() {
if (scanf("%500s", s) != 1) {
return 0;
}
int n = strlen(s);
for (int i = 0; i < n; i++) {
f[i][i] = 1;
}
for (int length = 2; length <= n; length++) {
for (int i = 0; i + length - 1 < n; i++) {
int j = i + length - 1;
// 拼接:枚举断点取最优(并列时取最小的断点)
int best = f[i][i] + f[i + 1][j], best_k = i;
for (int k = i + 1; k < j; k++) {
int v = f[i][k] + f[k + 1][j];
if (v < best) {
best = v;
best_k = k;
}
}
f[i][j] = best;
cut[i][j] = best_k;
// 折叠:循环节长度 p 必须整除区间长度,且各块完全相同
for (int p = 1; p <= length / 2; p++) {
if (length % p) {
continue;
}
if (memcmp(s + i, s + i + p, j + 1 - p - i) != 0) {
continue;
}
int digits = 0, t = length / p;
while (t) {
digits++;
t /= 10;
}
int total = f[i][i + p - 1] + digits + 2; // 重复次数 + 一对括号
if (total < f[i][j]) { // 严格更短才改写决策
f[i][j] = total;
rep[i][j] = p;
}
}
}
}
string ans = unfold(0, n - 1);
printf("%s\n", ans.c_str());
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 22:35
# update_at: 2026-10-01 22:35
import sys
def unfold(s: str, i: int, j: int, cut: list[list[int]], rep: list[list[int]]) -> str:
"""按 DP 记录的决策还原 s[i..j] 的最优折叠串:rep 优先,其次按 cut 拆两半。"""
if i == j:
return s[i]
p = rep[i][j]
if p: # 整段是一个短块重复 length/p 次,块内继续递归折叠
cnt = (j - i + 1) // p
return f"{cnt}({unfold(s, i, i + p - 1, cut, rep)})"
k = cut[i][j]
return unfold(s, i, k, cut, rep) + unfold(s, k + 1, j, cut, rep)
def fold(s: str) -> str:
"""区间 DP 求 s 的最短折叠串:先枚举断点拼接,再看整段能否写成 X(...)。"""
n = len(s)
f = [[0] * n for _ in range(n)] # f[i][j]:s[i..j] 展开前的最少字符数
cut = [[-1] * n for _ in range(n)] # 拼接时的断点:左半段的右端点
rep = [[0] * n for _ in range(n)] # 折叠时的块长 p,0 表示这一段不整体折叠
for i in range(n):
f[i][i] = 1 # 单个字母只能是自己
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
# 拼接(定义 2):枚举断点,左右两半各自最优
k = min(range(i, j), key=lambda k: f[i][k] + f[k + 1][j])
f[i][j] = f[i][k] + f[k + 1][j]
cut[i][j] = k
# 折叠(定义 3):枚举块长 p,要求 p 整除 length 且相邻块完全相同;
# 折叠串是 "重复次数(块的最优折叠)",所以要额外付出位数 + 一对括号
for p in range(1, length // 2 + 1):
if length % p or s[i:j + 1 - p] != s[i + p:j + 1]:
continue # 整段每隔 p 个字符与前面相同,才以 p 为循环节
total = f[i][i + p - 1] + len(str(length // p)) + 2
if total < f[i][j]: # 严格更短才改写决策,平手时保留拼接
f[i][j], rep[i][j] = total, p
return unfold(s, 0, n - 1, cut, rep)
def solve() -> None:
s = sys.stdin.read().strip()
print(fold(s))
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:按长度枚举
个区间,每个区间枚举断点 ,主项为 。周期判定只对整除 的 执行,即每个区间约 次( 为约数个数) 的切片比较,总量为 , 时 、 的均值远小于它。整体与标准做法同阶,实测单组数据约 s。 - 空间复杂度:三张
的表(长度、拼接断点、折叠块长),即 。
总结
本题的核心是把“枚举折叠串”转成“枚举区间决策”。一旦意识到折叠串语法树的最外层只可能是拼接或重复,
图示解析
下面这张图概括
flowchart TD S["区间 S[i..j]<br/>f[i][j] 最短折叠长度"] --> A["拼接:切在 k<br/>f[i][k] + f[k+1][j]"] S --> B["重复:块长 p 是循环节<br/>f[i][i+p-1] + len(L/p) + 2"] A --> C["更短的区间"] B --> C C --> D["长度 1 的区间<br/>f[i][i] = 1"]
读图时关注两点:两条边对应折叠串语法树的两种内部结点;两条边都指向“更短的区间”,所以按长度递增递推不需要考虑循环依赖。
再下面用样例的决策树展示 unfold 的还原过程:每个结点标注区间、
[0,17] f=12 拼接,断点 k=8
├── [0,8] f=4 重复 p=1 → 9(A)
└── [9,17] f=8 拼接,断点 k=14
├── [9,14] f=5 重复 p=2 → 3(AB)
└── [15,17] f=3 拼接 → CCD把这棵树的叶子从左到右读出来,就得到 9(A)3(AB)CCD,正好是样例输出。这也说明了为什么必须保存决策表:长度相同的拼接与重复(例如 [0,9] 的 10(A) 与 A9(A))只有靠决策才能还原出确定的串。