New but Nostalgic Problem

New but Nostalgic Problem

trie树,链式前向星

问题重述

给定 \(n\) 个字符串 \(w_1, w_2, \dots, w_n\),选出恰好 \(k\) 个字符串,令 \(S\) 为选出的下标集合,\(|S|=k\)。定义

\[v = \max_{i \in S, j \in S, i \neq j} \operatorname{lcp}(w_i, w_j) \]

其中 \(\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. 先给每个分支各选 1 个,这样用掉 \(c\) 个名额。
  2. 剩下 \(r = m - c\) 个额外名额,需要分配给某些分支。
  3. 优先把额外名额分配给字典序最小的分支。

分配顺序:

  • 首先看结束分支(如果存在)。
    结束分支对应的字符串就是 \(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)\),继续看下一个子节点。

重复上述过程,直到某个节点满足 \(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{总长度})\),非常节省。


注意事项

  1. 重复字符串:它们会在同一个结束节点,end_cnt[u] 会大于 1。
    如果额外名额落在结束分支,答案就是当前前缀。
  2. 空字符串:如果根节点就满足 \(k \le\) 分支数,答案为空串,输出 EMPTY。
  3. 不是简单地把 a 选完再选 b:
    正确做法是:先每个分支选 1 个,保证最大 LCP 尽量浅;
    多出来的名额再从字典序最小的分支开始加。
    如果直接选完 a 再选 b,可能导致所有字符串都集中在 a 分支,最大 LCP 会变得很长。
  4. 进入分支后新的 \(m\):
    因为之前已经给这个分支算了 1 个基础名额,所以进入后需要选的总数是 \(r + 1\),而不是 \(r\)。
  5. 链式前向星:用 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;
}
posted @ 2026-11-03 22:02  thenrify  阅读(3)  评论(0)    收藏  举报