形式化题目

给定 nn 个由大写字母组成的字符串 S1,S2,…,SnS_1, S_2, \dots, S_n,求最短的字符串 TT,使每个 SiS_i 都是 TT 的子串;若有多个同样最短的 TT,输出其中字典序最小的一个。

样例:S1=S_1 = ABCD、S2=S_2 = BCDABC。ABCD 的后缀 BCD 与 BCDABC 的前缀 BCD 重合,先写 ABCD 再补上没写过的 ABC,得到 ABCDABC——它同时包含两个给定串,且比首尾直接相接的 ABCDBCDABC 更短。

正解

思路

朴素想法:枚举全排列。 相邻两串按"最大重合"拼接(后一串与前一串重合的前缀不用再写),n!n! 种顺序里取最短。这个做法已经给出了答案的形态,问题只在枚举量:本题 n⩽12n \leqslant 12,12!≈4.8×10812! \approx 4.8 \times 10^8,不可行。

再看这个枚举浪费在哪:拼接进行到中途,后续结果只取决于已经用了哪些串和最后一个串是谁,与已用串之间各自的先后顺序无关——无数个排列在同样的"中点"上重复计算。于是把中点做成 DP 状态:

  • 用 nn 位掩码 mask\text{mask} 表示哪些串已经包含(第 kk 位为 1 表示 SkS_k 已用),再记结尾串下标 jj;
  • 状态数 2n⋅n2^n \cdot n,n=12n = 12 时只有 4096×12=491524096 \times 12 = 49152 个,完全可行。

转移。 单串自己就是起点:dp[1≪i][i]=Sidp[1 \ll i][i] = S_i。一般地,把没用过的串 kk 接到状态 (mask,j)(\text{mask}, j) 后面:

dp[mask∣1≪k][k]=min⁡j∈mask  dp[mask][j]  +  tail(j,k)dp[\text{mask} \mid 1 {\ll} k][k] = \min_{j \in \text{mask}} \; dp[\text{mask}][j] \;+\; \text{tail}(j, k)

其中 tail(j,k)\text{tail}(j,k) 是补尾:找最大的 k′k' 使 SkS_k 的前 k′k' 个字符等于 SjS_j 的后 k′k' 个字符,这段重合被"吃掉",只把 Sk[k′:]S_k[k':] 追加到末尾。补尾表只依赖两个串本身,可以先预处理出来。

为什么这样拼一定不劣于任意可行串? 对任意可行的 TT,把各串按首次出现位置排成一个排列,记相邻两串的起点间隔为 did_i、它们在 TT 中实际搭接的长度为 oio_i。若后一串起点落在前一串内部,两段是 TT 的同一段子串,强制 oi⩾∣Si∣−dio_i \geqslant |S_i| - d_i;若完全分离,di⩾∣Si∣d_i \geqslant |S_i| 让右侧为负、不等式照样成立。而 ∣T∣|T| 不短于从首串起点到末串结尾的距离,故

∣T∣⩾首串起点+∑di+∣Sn∣⩾∑∣Si∣−∑oi.|T| \geqslant \text{首串起点} + \sum d_i + |S_n| \geqslant \sum |S_i| - \sum o_i.

贪心取的是所有可行重合里最大的,gi⩾oig_i \geqslant o_i,于是沿该排列拼出的长度 ∑∣Si∣−∑gi⩽∑∣Si∣−∑oi⩽∣T∣\sum |S_i| - \sum g_i \leqslant \sum |S_i| - \sum o_i \leqslant |T|;而 DP 恰好枚举了这个排列,所以 min⁡dp⩽∣T∣\min dp \leqslant |T|。反方向,每个 DP 状态拼出的串都真实包含了自己的 mask\text{mask} 里所有串(追加补尾时要么新串完整写出、要么它本就是前一串的后缀),所以最小状态就是可行解。两个方向合起来即最优。

终点取字典序最小。 状态里直接存字符串本身,比较键写成 (长度, 字符串),先比长度、等长比字典序。这样做是安全的:到达同一 (mask,j)(\text{mask}, j) 的候选,未来只会追加相同的尾巴(只取决于结尾 jj),长度更短的前缀必然给出更短的最终答案;而两个等长前缀 XX、YY 比较时 ∣X∣=∣Y∣|X| = |Y|,逐字符比较在进入公共尾之前就已分出胜负,X+aX + a 与 Y+aY + a 的字典序和 XX、YY 完全一致。因此局部取 min 不会破坏全局字典序最优。最后在 mask=2n−1\text{mask} = 2^n - 1 的所有结尾里同样按 (长度, 字典序) 取最小。

预处理:删掉被包含的串。 若 SiS_i 是 SjS_j 的子串(i≠ji \neq j),任何含 SjS_j 的母串必然含 SiS_i,把 SiS_i 删掉不会改变答案,还能省下大量状态;重复串也一并去重。

下面用样例走一遍。补尾表:

接法 最大重合 补尾
ABCD → BCDABC BCD(3 个字符) ABC
BCDABC → ABCD ABC(3 个字符) D

DP 状态与转移(记 0=0 = ABCD,1=1 = BCDABC):

状态 (mask, 结尾) 来源 拼接过程 得到的母串(长度)
({0}, 0)(\{0\},\ 0) 起点 — ABCD(4)
({1}, 1)(\{1\},\ 1) 起点 — BCDABC(6)
({0,1}, 1)(\{0,1\},\ 1) 前缀 ({0},0)(\{0\},0) ABCD + ABC ABCDABC(7)
({0,1}, 0)(\{0,1\},\ 0) 前缀 ({1},1)(\{1\},1) BCDABC + D BCDABCD(7)

两个终点状态长度都是 7,按字典序 ABCDABC < BCDABCD,故输出 ABCDABC。观察重点:每个状态只保留一个最优串;转移的"接续者"视角使得 BCD 这段重合被吃掉,这正是全排列拼接里同一段信息的去重。

代码

Python 版:

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++ 版(同一算法):

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-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 的可哈希参数。

复杂度

  • 时间:O(2n⋅n2⋅L)O(2^n \cdot n^2 \cdot L)。状态数 2n⋅n2^n \cdot n,每状态枚举 ⩽n\leqslant n 个前驱,每次拼接与比较 O(L)O(L),L⩽∑∣Si∣⩽600L \leqslant \sum |S_i| \leqslant 600。实际因删冗余串和长度短路远小于上界。
  • 空间:O(2n⋅n⋅L)O(2^n \cdot n \cdot L),每个状态缓存一个字符串。

本题 OJ 限制 1000ms / 128MB;真实测试数据 10 组全部通过,最慢 0.061s、峰值 27.3MB,Python 侧无 TLE/MLE 风险。

总结

  • 最短母串 = 枚举串的排列 + 相邻最大重合;把"已用集合 + 结尾串"做成状压 DP 状态,把 n!n! 降到 2n⋅n2^n \cdot n。
  • 转移靠补尾表吃掉重合:只追加新串没写过的后半截,代价只依赖前一个串,这正是状态里只需记"结尾是谁"的原因。
  • 字典序 tie-break 不需要单独重建:字符串直接存进状态,按 (长度, 字典序) 取 min;等长前缀共享未来尾巴,比较结果不受公共尾影响。
  • 预处理先删被包含串,既正确又省状态;n⩽12n \leqslant 12、∣Si∣⩽50|S_i| \leqslant 50 的规模下整套做法在 Python 里也远快于时限。