Luogu P3294 背单词

观前须知

本题解使用 CC BY-NC-SA 4.0 许可
同步发布于 Luogu 题解区。
更好的观看体验 请点这里

笔者的博客主页

正文

Luogu P3294 【SCOI2016】背单词

笔者在刷题的时候看到了这道好题。
花了四十分钟切掉以后,看了一下题解。
发现自己的想法不太一样。
所以想做一篇适合我这样的蒟蒻看的题解。
那么,我们开始吧。

首先:

题意理解

(笔者认为本文最难的一个部分)

给你 \(n\) 个字符串。
要求你找一种这 \(n\) 个字符串的排列使得总花费最小。

规则一:若一个字符串 \(a\) 有一个字符串 \(b\)\(a\) 的后缀,
(这里的后缀在 \(n\) 个字符串中出现过,且不为该串本身,下文同)。
\(b\) 排在 \(a\),则花费增加 \(n^2\)

规则二:若 \(a\) 没有后缀,则花费增加 \(a\) 的排名(即 \(x\) )。

规则三:若一个字符串 \(a\) 的所有后缀都排在 \(a\) 前,
则花费增加 \(a\) 到最近一个 \(a\) 的后缀 \(b\) 的距离(即 \(x-y\) )。  

那么来简化题意
首先,发现原题中的规则二就是规则三的特例,所以不需要额外考虑。
然后,可以发现规则一增加的 \(n^2\) 实在太多了(每个规则二最多也只能增加 \(n\) 的花费)。
所以不能违反规则一。
即要保证所有字符串的后缀一定排在这个字符串的前面。
(一定存在不违反规则一的方案,按照字符串长度从小到大排序就是一种)。  

那么题意已经变为了:
在不违反规则一的情况下,
使规则三的花费和最小。

建模

发现不能违反规则一后,
规则三中的最近一个后缀变为了长度最大的后缀
发现每个字符串要么有唯一的一个长度最大的后缀,要么没有后缀。
这和的结构类似!
那么我们可以建立一棵树,一个节点的父亲就是它的长度最大的后缀。
(也就是 SAM 中的后缀树)。
对于没有后缀的点,我们建立一个虚根(代码中为0号点),作为它们的父亲。
(这里的虚根可以理解为是一个空串,因为空串是每一个字符串的后缀)。

下面给出了一棵后缀树方便大家理解:

a ab ba aab aba ababa bbaab bbbbba
后缀树

建好这棵树后,我们就可以开始贪心了。

贪心

先直接说贪心策略:
在后缀树上按照 dfs 序选点,
且每个节点先走子树小的。

(接下来的证明可以感性理解,建议边想边画图)

首先证明 dfs 序选点是正确的:
对于根节点,它的若干个子节点有若干棵子树。
这里我们考虑其中任意两棵:
我们只需证明,我们要先选完一棵子树,再选择另一棵子树较优。
不妨设先选的子树的树根为 \(x\),后选的子树的树根为 \(y\)
首先考虑把 \(y\) 提前到 \(x\) 的子树选完前。
\(y\) 提前了 \(a\) 个位置。
对于 \(y\) 子树内的第一个子节点,花费增加了 \(a\)
对于插入 \(y\) 后的第一个节点,花费增加了 \(1\)
其余节点花费不变。
继续把 \(y\) 的子树内的节点提前,花费不变。
所以对于根来说,选完一棵子树后再选另一棵是最优的。
递归下去可以证明dfs序选点是最优的。

接下来证明要先走子树小的:
对于一个节点,考虑它的每一个孩子。
发现可以递归处理每一个孩子的子树内的节点,这样只需要考虑每个子树的根节点,
也就是它的每一个孩子,到它本身的距离。
根据上面的结论可得,每个孩子到这个节点的距离就是在遍历到这个孩子前已经走过的节点数,
那么为了距离和最小,显然要已经走过的节点数尽量小,
所以子树小的优先选是最优的。

好的,那么我们的答案就可以由贪心策略算出来了。
欸?你问我是不是少了些什么?
好吧,
最后一部分:

建树

为了建树,我们只需要求出每个节点的父节点,即每个字符串的最长后缀。
我们先根据字符串长度从小到大排序。
那么每个字符串的后缀都在这个字符串前面了。
但是后缀不好做,
所以我们把每个字符串都倒过来变成前缀。
我们把每个字符串依次倒叙插入到 Trie 树 中。
并在每个字符串的终止结点记录编号。
我们可以惊喜地发现:
对于一个字符串的最长后缀(这里已经变为前缀了),
就是在这个字符串在 Trie 树 上的对应路径中,
深度最大的终止节点。
那么我们就能很容易地求出每个节点的父亲,
那么就可以建树了。
(这块讲的比较抽象,建议配着代码食用,或自己 think 一下)。

一些小细节:
用 vector 存树方便 sort。
按照字符串长度排好的顺序其实是后缀树的拓扑逆序,可以直接倒序枚举更新 sz。
因为字符串长度不确定所以要用 string,不能用 scanf 和 char 数组了(悲)。

这份代码最短用时 223ms,拿了个次优解直接开润~。

#include<bits/stdc++.h>

using namespace std;

static constexpr int AwA = 1e5 + 10;
static constexpr int PwP = 6e5 + 10;

int n;
//因为这道题每个字符串长度不确定,所以我只能抛弃我的char数组了(悲)
string s[AwA];
//记录每个节点算出来的父亲
int fa[AwA];
//字典树,id[u]!=0时记录该节点对应的字符串编号
int ch[PwP][26], id[PwP], tot = 1;

vector<int> tr[AwA];
int sz[AwA];
long long ans;

//贪心选点
void Dfs(int u) {
    int cur = 1;
    for (int v: tr[u]) {
        Dfs(v);
        ans += cur;
        cur += sz[v];
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr), cout.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> s[i];
    //根据字符串长度排序
    sort(s + 1, s + n + 1, [](auto &s1, auto &s2) { return s1.size() < s2.size(); });

    int u, p;
    for (int i = 1; i <= n; i++) {
        //如果路径上没有终止节点,即没有后缀,则父亲为虚根0
        fa[i]=0;
        u = 1;
        //倒叙枚举,变后缀为前缀
        for (auto k = s[i].rbegin(); k != s[i].rend(); k++) {
            p = *k - 'a';
            if (!ch[u][p]) ch[u][p] = ++tot;
            u = ch[u][p];
            //遇到终止节点更新父亲
            if (id[u]) fa[i] = id[u];
        }
        //记录终止节点
        id[u] = i;
    }

    //因为父亲串的长度一定小于儿子,所以根据字符串长度排序后为拓扑逆序
    for (int i = n; i; i--) sz[i]++, sz[fa[i]] += sz[i];
    //建树
    for (int i = 1; i <= n; i++) tr[fa[i]].push_back(i);
    //按子树大小排序,方便贪心选择
    //注意0节点也要排序
    auto cmp = [&](int i, int j) { return sz[i] < sz[j]; };
    for (int i = 0; i <= n; i++) sort(tr[i].begin(), tr[i].end(), cmp);
    Dfs(0);
    printf("%lld\n", ans);
    return 0;
}

希望这篇题解能帮助你更好地理解这道很好的贪心题。
完结撒花!~

posted @ 2024-04-03 16:34  Sugar_Cube  阅读(38)  评论(0编辑  收藏  举报