状压 DP 用位掩码记录已用串集合并以结尾串为附加状态,转移按最大后缀-前缀重合补尾拼接,终点按长度与字典序取最小。
OJ: roj
题目 ID: 1483
难度:提高
标签:动态规划状压DP字符串python
创建: 2026-09-30 13:58
更新: 2026-10-07 12:15
形式化题目
给定
样例:ABCD、BCDABC。ABCD 的后缀 BCD 与 BCDABC 的前缀 BCD 重合,先写 ABCD 再补上没写过的 ABC,得到 ABCDABC——它同时包含两个给定串,且比首尾直接相接的 ABCDBCDABC 更短。
正解
思路
朴素想法:枚举全排列。 相邻两串按"最大重合"拼接(后一串与前一串重合的前缀不用再写),
再看这个枚举浪费在哪:拼接进行到中途,后续结果只取决于已经用了哪些串和最后一个串是谁,与已用串之间各自的先后顺序无关——无数个排列在同样的"中点"上重复计算。于是把中点做成 DP 状态:
- 用
位掩码 表示哪些串已经包含(第 位为 1 表示 已用),再记结尾串下标 ; - 状态数
, 时只有 个,完全可行。
转移。 单串自己就是起点:
其中
为什么这样拼一定不劣于任意可行串? 对任意可行的
贪心取的是所有可行重合里最大的,
终点取字典序最小。 状态里直接存字符串本身,比较键写成 (长度, 字符串),先比长度、等长比字典序。这样做是安全的:到达同一 (长度, 字典序) 取最小。
预处理:删掉被包含的串。 若
下面用样例走一遍。补尾表:
| 接法 | 最大重合 | 补尾 |
|---|---|---|
ABCD → BCDABC |
BCD(3 个字符) |
ABC |
BCDABC → ABCD |
ABC(3 个字符) |
D |
DP 状态与转移(记 ABCD,BCDABC):
| 状态 (mask, 结尾) | 来源 | 拼接过程 | 得到的母串(长度) |
|---|---|---|---|
| 起点 | — | ABCD(4) |
|
| 起点 | — | BCDABC(6) |
|
| 前缀 |
ABCD + ABC |
ABCDABC(7) |
|
| 前缀 |
BCDABC + D |
BCDABCD(7) |
两个终点状态长度都是 7,按字典序 ABCDABC < BCDABCD,故输出 ABCDABC。观察重点:每个状态只保留一个最优串;转移的"接续者"视角使得 BCD 这段重合被吃掉,这正是全排列拼接里同一段信息的去重。
代码
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-09-30 13:59
# update_at: 2026-09-30 13:59
import sys
from functools import cache
def get_tail(s: str, t: str) -> str:
"""t 接在 s 后面时需要补写的尾巴:先吃掉 t 能与 s 后缀重合的最长前缀。"""
for k in range(min(len(s), len(t)), 0, -1):
if s[-k:] == t[:k]:
return t[k:]
return t
@cache
def best(mask: int, j: int, strs: tuple[str, ...], tail: tuple[tuple[str, ...], ...]) -> str:
"""包含 mask 中所有串、以 strs[j] 结尾的最短母串,同长取字典序最小。
状态里"结尾是哪个串"必须记录:新串接在哪里,取决于当前结尾与它的重合。
"""
if mask == 1 << j:
return strs[j]
prev = mask ^ (1 << j)
# 只考虑把 j 接在某个已有串后(prev >> i & 1 即 i 属于前驱集合);
# 更短的前缀一定更优,等长时公共尾巴不改变字典序比较结果
return min(
(best(prev, i, strs, tail) + tail[i][j] for i in range(len(strs)) if prev >> i & 1),
key=lambda s: (len(s), s),
)
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
n = int(next(data))
words = [next(data).decode() for _ in range(n)]
# 被别的串完全包含的串删掉:母串含住大串就自动含住了它,留着只会白花状态
uniq = list(dict.fromkeys(words))
strs = tuple(s for s in uniq if not any(s in t for t in uniq if t != s))
# 补尾表:tail[i][j] = 把 strs[j] 接到 strs[i] 后面还需要写的部分
tail = tuple(tuple(get_tail(s, t) for t in strs) for s in strs)
full = (1 << len(strs)) - 1 # 全集掩码:所有串都已包含的终点状态
print(min(
(best(full, j, strs, tail) for j in range(len(strs))),
key=lambda s: (len(s), s),
))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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-05 03:50
* update_at: 2026-10-05 03:50
*/
// roj 1483 最短母串(HNOI 2006):状压 DP,状态记录「已用串集合 + 结尾串」。
#include <iostream>
#include <string>
using namespace std;
typedef long long ll;
const int MAXN = 12;
const int MAXMASK = 1 << MAXN; // n 最大 12,掩码总数 4096
int n; // 去重并删掉被包含串之后,真正参与 DP 的串数
string str_list[MAXN]; // str_list[i] 表示第 i 个串(下标从 0 开始)
// tail_add[i][j] = 把 str_list[j] 接到 str_list[i] 后面时还需要补写的尾巴,
// 也就是 str_list[j] 去掉与 str_list[i] 后缀重合的最长前缀后剩下的部分。
string tail_add[MAXN][MAXN];
// dp[mask][j] = 已经包含 mask 中所有串、且以 str_list[j] 结尾的最短母串,
// 长度相同时保留字典序更小的那个;has_dp 标记该状态是否已经求出。
string dp[MAXMASK][MAXN];
bool has_dp[MAXMASK][MAXN];
// 比较两个母串的优劣:先比长度,长度相等再比字典序。
bool better(const string &a, const string &b) {
if (a.size() != b.size()) {
return a.size() < b.size();
}
return a < b;
}
// 求把 t 接到 s 后面需要补写的尾巴:先找最大的重合长度 k,
// 使 s 的后 k 个字符等于 t 的前 k 个字符,这段被吃掉,只写 t[k:]。
string get_tail(const string &s, const string &t) {
ll limit = s.size() < t.size() ? s.size() : t.size();
for (ll k = limit; k >= 1; k--) {
if (s.compare(s.size() - k, k, t, 0, k) == 0) {
return t.substr(k);
}
}
return t;
}
// 读入并预处理:重复串只留一个,被别的串完全包含的串直接删掉
// (母串含住了大串就自动含住了它,留着只会白白增加状态)。
void read_input() {
ll total;
cin >> total;
string raw_list[MAXN];
int raw_cnt = 0;
for (ll i = 0; i < total; i++) {
string s;
cin >> s;
bool repeated = false;
for (int j = 0; j < raw_cnt; j++) {
if (raw_list[j] == s) {
repeated = true;
break;
}
}
if (!repeated) {
raw_list[raw_cnt] = s;
raw_cnt++;
}
}
for (int i = 0; i < raw_cnt; i++) {
bool contained = false;
for (int j = 0; j < raw_cnt; j++) {
if (i == j) {
continue;
}
if (raw_list[j].find(raw_list[i]) != string::npos) {
contained = true;
break;
}
}
if (!contained) {
str_list[n] = raw_list[i];
n++;
}
}
}
void solve() {
read_input();
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
tail_add[i][j] = get_tail(str_list[i], str_list[j]);
}
}
// 单串自己就是一个起点状态。
for (int i = 0; i < n; i++) {
int mask = 1 << i;
dp[mask][i] = str_list[i];
has_dp[mask][i] = true;
}
// 按掩码从小到大递推:接上新串后掩码只会变大,所以顺序枚举即可。
for (int mask = 1; mask < (1 << n); mask++) {
for (int j = 0; j < n; j++) {
if (!has_dp[mask][j]) {
continue;
}
for (int k = 0; k < n; k++) {
if ((mask >> k) & 1) {
continue;
}
int next_mask = mask | (1 << k);
string cand = dp[mask][j] + tail_add[j][k];
if (!has_dp[next_mask][k] || better(cand, dp[next_mask][k])) {
dp[next_mask][k] = cand;
has_dp[next_mask][k] = true;
}
}
}
}
int full = (1 << n) - 1;
int best_end = -1;
for (int j = 0; j < n; j++) {
if (!has_dp[full][j]) {
continue;
}
if (best_end == -1 || better(dp[full][j], dp[full][best_end])) {
best_end = j;
}
}
cout << dp[full][best_end] << endl;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
solve();
return 0;
}get_tail 实现补尾表;best 是带 @cache 的记忆化 DP,min(..., key=lambda s: (len(s), s)) 同时落实"最短优先、等长取字典序";solve 只做读入、去重删被包含串、建表和终点取值。strs 与 tail 都建成 tuple,才能当 @cache 的可哈希参数。
复杂度
- 时间:
。状态数 ,每状态枚举 个前驱,每次拼接与比较 , 。实际因删冗余串和长度短路远小于上界。 - 空间:
,每个状态缓存一个字符串。
本题 OJ 限制 1000ms / 128MB;真实测试数据 10 组全部通过,最慢 0.061s、峰值 27.3MB,Python 侧无 TLE/MLE 风险。
总结
- 最短母串 = 枚举串的排列 + 相邻最大重合;把"已用集合 + 结尾串"做成状压 DP 状态,把
降到 。 - 转移靠补尾表吃掉重合:只追加新串没写过的后半截,代价只依赖前一个串,这正是状态里只需记"结尾是谁"的原因。
- 字典序 tie-break 不需要单独重建:字符串直接存进状态,按
(长度, 字典序)取 min;等长前缀共享未来尾巴,比较结果不受公共尾影响。 - 预处理先删被包含串,既正确又省状态;
、 的规模下整套做法在 Python 里也远快于时限。