New but Nostalgic Problem
New but Nostalgic Problem
trie树,链式前向星
问题重述
给定 \(n\) 个字符串 \(w_1, w_2, \dots, w_n\),选出恰好 \(k\) 个字符串,令 \(S\) 为选出的下标集合,\(|S|=k\)。定义
其中 \(\operatorname{lcp}(a,b)\) 表示字符串 \(a\) 和 \(b\) 的最长公共前缀。
要求找到一个集合 \(S\),使得字符串 \(v\) 的字典序最小,并输出这个最优的 \(v\)。
若 \(v\) 为空串,输出 EMPTY。
核心转化
两个字符串的 LCP 就是它们在 Trie 上的最近公共祖先所代表的前缀。
因此问题等价于:
在 Trie 上从根往下走,找到一个尽可能浅、且字典序尽可能小的前缀 \(p\),使得我们能够选出 \(k\) 个字符串,且这些字符串两两之间的 LCP 的最大值恰好是 \(p\)。
为什么是“恰好是 \(p\)”?
因为我们是从根往下走的,当前前缀 \(p\) 是所有候选字符串的公共前缀。
如果能让这些字符串在 \(p\) 的下一层就“分散”到不同分支,那么任意两个来自不同分支的字符串的 LCP 就是 \(p\),而来自同一分支的字符串 LCP 会超过 \(p\)。
所以,要保证最大 LCP 不超过 \(p\),就必须保证每个分支最多只选 1 个字符串。
如果 \(k\) 大于分支数,就必然有某个分支被选了至少 2 个,最大 LCP 就会超过 \(p\),我们必须进入那个分支继续看。
Trie 节点维护的信息
对每个节点 \(u\)(代表一个前缀),记录:
cnt[u]:以 \(u\) 为根的子树中一共有多少个字符串。end_cnt[u]:有多少个字符串恰好结束在节点 \(u\)(即字符串等于当前前缀)。type[u]:节点 \(u\) 的实际子节点个数(字符'a'~'z'),不包含结束分支。
插入字符串时:
- 每经过一个节点,
cnt[u]++。 - 字符串结束时,在末尾节点
end_cnt[u]++。 - 每新建一个字符子节点,
type[父节点]++。
贪心选择过程
从根节点开始,\(u = \text{根}\),\(m = k\),\(path = \text{""}\)。
对于当前节点 \(u\),把它所有的“直接分支”列出来:
- 结束分支:如果
end_cnt[u] > 0,表示有些字符串就是当前前缀 \(path\) 本身。
这个分支的容量是end_cnt[u]。 - 子节点分支:每个存在的子节点 \(v\) 对应一个字符 \(c\),容量是
cnt[v]。
按字符'a'到'z'升序排列。
设分支总数为 \(c\)。
情况 1:\(m \le c\)
我们可以从 \(c\) 个分支中挑 \(m\) 个不同的分支,每个分支只选 1 个字符串。
这样,任意两个字符串的 LCP 最多就是当前前缀 \(path\)。
因为它们在 \(path\) 的下一层就分开了。
所以最大 LCP 就是 \(path\),直接返回 \(path\) 作为答案。
情况 2:\(m > c\)
分支不够分,必须从某些分支中选多个字符串。
为了让最终的答案字典序最小,我们应该:
- 先给每个分支各选 1 个,这样用掉 \(c\) 个名额。
- 剩下 \(r = m - c\) 个额外名额,需要分配给某些分支。
- 优先把额外名额分配给字典序最小的分支。
分配顺序:
-
首先看结束分支(如果存在)。
结束分支对应的字符串就是 \(path\) 本身,字典序最小。
它的额外容量是end_cnt[u] - 1(因为已经选了 1 个)。
如果 \(r \le \text{end\_cnt}[u] - 1\),说明我们可以把额外名额全放在结束分支,这样最大 LCP 仍然是 \(path\),直接返回 \(path\)。
否则,把结束分支选满,\(r \gets r - (\text{end\_cnt}[u] - 1)\)。 -
然后按子节点字符
'a'到'z'的顺序,依次考虑每个子节点 \(v\):- 子节点 \(v\) 的额外容量是
cnt[v] - 1(已经选了 1 个)。 - 如果 \(r \le \text{cnt}[v] - 1\),说明我们可以在该子节点中多选 \(r\) 个,总共在这个子节点中选 \(r + 1\) 个。
此时,最大 LCP 必然会进入这个子节点。
我们更新:- \(path\) 末尾加上该子节点对应的字符;
- \(u \gets v\);
- \(m \gets r + 1\)(因为在这个子节点中总共要选这么多);
- 跳出当前层,继续下一轮循环。
- 如果 \(r > \text{cnt}[v] - 1\),说明这个子节点容量不够,把它选满(即选
cnt[v]个),\(r \gets r - (\text{cnt}[v] - 1)\),继续看下一个子节点。
- 子节点 \(v\) 的额外容量是
重复上述过程,直到某个节点满足 \(m \le c\),此时 \(path\) 就是最优答案。
如果最终 \(path\) 为空字符串,输出 EMPTY。
正确性证明
- 我们希望最大 LCP 的字典序尽可能小。
在 Trie 上,前缀越短、越靠前(字典序越小),答案就越小。 - 如果能在当前节点分散到不同分支,最大 LCP 就停在当前前缀,不会再深入。
所以只要 \(m \le c\),当前前缀就是最优的。 - 如果必须深入,深入哪个分支决定了最终答案的字典序。
为了让答案字典序最小,我们当然希望深入字典序最小的分支。
所以额外名额优先给结束分支,然后给字符最小的子节点,依此类推。 - 进入某个分支后,我们只关心这个分支内部如何继续分散,其他分支已经各选了 1 个,它们之间的 LCP 就是当前前缀,不会再影响最大 LCP。
所以问题递归到该子节点,且需要选的数量变成了 \(r + 1\)。
复杂度分析
- Trie 节点数不超过所有字符串长度之和。
- 每个节点最多 26 个子节点。
- 贪心过程中每个节点最多处理一次,每次遍历 26 个字符。
- 总时间复杂度:\(O(\text{总长度} \times 26)\),空间 \(O(\text{总长度})\)。
若使用链式前向星存储 Trie,内存约为 \(O(\text{总长度})\),非常节省。
注意事项
- 重复字符串:它们会在同一个结束节点,
end_cnt[u]会大于 1。
如果额外名额落在结束分支,答案就是当前前缀。 - 空字符串:如果根节点就满足 \(k \le\) 分支数,答案为空串,输出
EMPTY。 - 不是简单地把 a 选完再选 b:
正确做法是:先每个分支选 1 个,保证最大 LCP 尽量浅;
多出来的名额再从字典序最小的分支开始加。
如果直接选完 a 再选 b,可能导致所有字符串都集中在 a 分支,最大 LCP 会变得很长。 - 进入分支后新的 \(m\):
因为之前已经给这个分支算了 1 个基础名额,所以进入后需要选的总数是 \(r + 1\),而不是 \(r\)。 - 链式前向星:用
head、to、nxt、ech存储稀疏的子节点,内存从 \(O(27 \times \text{节点数})\) 降到 \(O(\text{节点数} + \text{边数})\),避免内存超限和缓存不友好。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 2000005;
int ch[MAXN][27][2];
int cnt[MAXN], type[MAXN];
int id = 0, timer = 1;
void trie_insert(string s) {
int cur = 0;
cnt[cur] ++;
for(int i = 0; i < s.size(); i ++) {
int x = s[i] - 'a' + 1;
if(ch[cur][x][1] < timer) {
type[cur] ++;
ch[cur][x][0] = ++id; ch[cur][x][1] = timer;
cur = id;
cnt[id] = 0; type[id] = 0;
}
else cur = ch[cur][x][0];
cnt[cur] ++;
if(i == s.size() - 1) {
if(ch[cur][0][1] < timer) {
type[cur] ++;
ch[cur][0][1] = timer;
ch[cur][0][0] = ++id;
}
cnt[ch[cur][0][0]] ++;
}
}
}
void solve() {
int n, k; cin >> n >> k;
id = 0; timer ++; type[0] = 0; cnt[0] = 0;
for(int i = 0; i < n; i ++) {
string s; cin >> s;
trie_insert(s);
}
string ans = "";
int cur = 0;
while(true) {
if(type[cur] >= k) break;
if(k == cnt[cur]) {
while(1) {
int j = 26;
for(; j >= 0; j --) {
if(ch[cur][j][1] < timer) continue;
if(cnt[ch[cur][j][0]] >= 2) {
if(j > 0) ans += 'a' + j - 1;
cur = ch[cur][j][0];
break;
}
}
if(j == -1) break;
}
break;
}
int res = 0, tol = type[cur], mx = -1;
for(int i = 0; i <= 26; i ++) {
if(ch[cur][i][1] < timer) continue;
int t = cnt[ch[cur][i][0]] - 1;
if(t == 0) continue;
if(tol + t > k) {
if(k - tol >= 1) mx = i, res = k - tol + 1;
break;
}
tol += t; mx = i; res = t + 1;
}
if(mx == 0) break;
ans += 'a' + mx - 1;
k = res;
cur = ch[cur][mx][0];
}
if(ans == "") cout << "EMPTY\n";
else cout << ans << "\n";
}
int main() {
ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
int _ = 1; cin >> _;
while(_--) solve();
return 0;
}
链式前向星版本,节约内存
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 2000005;
const int MAXE = 2000005;
// 链式前向星代替 ch[MAXN][27][2]
int head[MAXN], htimer[MAXN];
int to[MAXE], nxt[MAXE], ech[MAXE];
int edge;
int cnt[MAXN], type[MAXN];
int id = 0, timer = 1;
int find_child(int u, int c) {
if(htimer[u] != timer) return -1;
for(int e = head[u]; e != -1; e = nxt[e])
if(ech[e] == c) return to[e];
return -1;
}
void add_edge(int u, int v, int c) {
if(htimer[u] != timer) { head[u] = -1; htimer[u] = timer; }
ech[edge] = c;
to[edge] = v;
nxt[edge] = head[u];
head[u] = edge ++;
}
void trie_insert(string s) {
int cur = 0;
cnt[cur] ++;
for(int i = 0; i < s.size(); i ++) {
int x = s[i] - 'a' + 1;
int v = find_child(cur, x);
if(v == -1) {
type[cur] ++;
v = ++ id;
cnt[id] = 0; type[id] = 0;
add_edge(cur, v, x);
}
cur = v;
cnt[cur] ++;
if(i == s.size() - 1) {
int ev = find_child(cur, 0);
if(ev == -1) {
type[cur] ++;
ev = ++ id;
cnt[id] = 0; type[id] = 0;
add_edge(cur, ev, 0);
}
cnt[ev] ++;
}
}
}
void solve() {
int n, k; cin >> n >> k;
id = 0; timer ++; edge = 0;
type[0] = 0; cnt[0] = 0;
head[0] = -1; htimer[0] = timer;
for(int i = 0; i < n; i ++) {
string s; cin >> s;
trie_insert(s);
}
string ans = "";
int cur = 0;
int tmp_ch[27];
while(true) {
// 把当前节点的所有分支收集到 tmp_ch,下标就是字符值
for(int i = 0; i <= 26; i ++) tmp_ch[i] = -1;
if(htimer[cur] == timer) {
for(int e = head[cur]; e != -1; e = nxt[e]) tmp_ch[ech[e]] = to[e];
}
if(type[cur] >= k) break;
if(k == cnt[cur]) {
while(1) {
int j = 26;
for(; j >= 0; j --) {
if(tmp_ch[j] == -1) continue;
if(cnt[tmp_ch[j]] >= 2) {
if(j > 0) ans += 'a' + j - 1;
cur = tmp_ch[j];
break;
}
}
if(j == -1) break;
for(int i = 0; i <= 26; i ++) tmp_ch[i] = -1;
if(htimer[cur] == timer) {
for(int e = head[cur]; e != -1; e = nxt[e]) tmp_ch[ech[e]] = to[e];
}
}
break;
}
int res = 0, tol = type[cur], mx = -1;
for(int i = 0; i <= 26; i ++) {
if(tmp_ch[i] == -1) continue;
int t = cnt[tmp_ch[i]] - 1;
if(t == 0) continue;
if(tol + t > k) {
if(k - tol >= 1) mx = i, res = k - tol + 1;
break;
}
tol += t; mx = i; res = t + 1;
}
if(mx == 0) break;
if(mx > 0) ans += 'a' + mx - 1;
k = res;
cur = tmp_ch[mx];
}
if(ans == "") cout << "EMPTY\n";
else cout << ans << "\n";
}
int main() {
ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
int _ = 1; cin >> _;
while(_--) solve();
return 0;
}

浙公网安备 33010602011771号