[字符串学习笔记] 2. 前缀函数与 KMP 算法

2.1. 前缀函数的定义

对于字符串 \(s\),若存在一个字符串 \(t\) 使得 \(t\) 既是 \(s\)真前缀,也是 \(s\)真后缀,称 \(t\)\(s\) 的 Border 之一,即 \(t \in \operatorname{Border}(s)\)

\(s\)前缀函数 为一个长度为 \(|s|\) 的有序序列 \(\pi\)\(\pi[i]\) 被定义为 \(\max\limits_{t \in \operatorname{Border}(s[0 \ldots i])} |t|\)特别地\(\pi[0] = 0\)

例如,\(\texttt{ababab}\) 的前缀函数为 \([0, 0, 1, 2, 3, 4]\)

2.2. 前缀函数的实现

2.2.1. 朴素实现

直接按照上式模拟即可。考虑到 substr() 的时间复杂度,总复杂度为 \(\Theta(n^3)\)

2.2.2. 基于前缀函数值增长的优化

首先注意到,\(\pi[i + 1] \leq \pi[i] + 1\)

若想最大化 \(\pi[i + 1]\),则需让新字符 \(s[i + 1] = s[\pi[i]]\)

\[\small \overbrace{s[0] ~ s[1] \ldots s[\pi[i] - 1]}^{\pi[i]} ~ {\color{#9f3} s[\pi[i]]} \\ \overbrace{s[i - \pi[i] + 1] \ldots s[i - 1] ~ s[i]}^{\pi[i]} ~ {\color{#9f3} s[i + 1]} \]

只有\(s[\pi[i]] = s[i + 1]\) 时,\(\pi[i + 1] = \pi[i] + 1\),否则 \(\pi[i + 1]\) 一定 \(\leq \pi[i]\)

在实现中,枚举 \(\pi[i + 1]\) 最大值的 \(j\) 不需要\(i + 1\) 开始,而是从 \(\pi[i] + 1\) 开始。

时间复杂度 \(\Theta(n^2)\)

2.2.3. 基于前缀函数值非增长的优化

本节讨论 \(s[\pi[i]] \neq s[i + 1]\) 的情况。

\(\pi_k[i]\) 代表 \(\operatorname{Border}(s[0 \ldots i])\)\(k\) 长的 字符串的长度。下图是 \(k = 2\) 时的例子:

\[\small \underbrace{{\color{#f85} \overbrace{s[0] ~ s[1] \ldots s[\pi_2[i] - 1]}^{\pi_2[i]}} ~ {\color{#9f3} s[\pi_2[i]]} \ldots s[\pi[i] - 1]}_{\pi[i]} \\ \underbrace{s[i - \pi[i] + 1] \ldots {\color{#f85} \overbrace{s[i - \pi_2[i] + 1] \ldots s[i - 1] ~ s[i]}^{\pi_2[i]}}}_{\pi[i]} ~ {\color{#9f3} s[i + 1]} \]

在找到长度 \(\pi_k[i]\) 时,再次比较 \(s[\pi_k[i]]\)\(s[i + 1]\)

同理,也 只有 \(s[\pi_k[i]] = s[i + 1]\) 时,\(\pi_k[i + 1] = \pi_k[i] + 1\),否则,还需要找到长度 仅次于 \(\pi_k[i]\)\(\pi_{k + 1}[i]\),使 Border 的性质保留。

如此往复,若一直循环到 \(\pi_{k_{\max}} = 0\) 时还是失配(\(s[0] \neq s[i + 1]\)),说明 \(s[0 \ldots i + 1]\) 不存在 Border,\(\pi[i + 1] = 0\)

根据定义,有

\[s[0 \ldots \pi_2[i] - 1] = s[i - \pi_2[i] + 1 \ldots i] \]

而因为 \(s[i - \pi_2[i] + 1 \ldots i]\)\(s[i - \pi[i] + 1 \ldots i]\) 的后 \(\pi_2[i]\) 位,而 \(s[0 \ldots \pi[i] - 1] = s[i - \pi[i] + 1 \ldots i]\),所以同样取 \(s[0 \ldots \pi[i] - 1]\) 的后 \(\pi_2[i]\) 位:

\[s[0 \ldots \pi_2[i] - 1] = s[\pi[i] - \pi_2[i] \ldots \pi[i] - 1] \]

可以发现,上式的两个子串是子串 \(s[0 \ldots \pi[i] - 1]\) 的一个 Border。而因为 \(\pi_2[i]\) 仅次于 \(\pi[i]\),这两个子串必定是 \(s[0 \ldots \pi[i] - 1]\)最长 Border,\(\pi_2[i]\) 相当于 \(\pi[\pi[i] - 1]\)

同理,若代入 \(k = 3\) 可得到 \(\pi_3[i] = \pi[\pi_2[i] - 1]\)

因此,得出递推式 \(\pi_k[i] = \pi[\pi_{k - 1}[i] - 1]\)\(\pi_{k - 1}[i] > 0\)

2.2.4. 线性算法实现

时间复杂度 \(\Theta(|s|)\)

vector<int> getpi(string s) {
    vector<int> pi(s.size()); // pi[0] = 0
    for (int i = 1; i < s.size(); i++) {
        pi[i] = pi[i - 1];
        while (pi[i] != 0 && s[i] != s[pi[i]])
            pi[i] = pi[pi[i] - 1];
        if (s[i] == s[pi[i]]) pi[i]++;
    }
    return pi;
}

例题 CF126B Password

题解

假设 \(t\)\(s\) 中间出现时 右端点\(i\),显然有

\[s[0 \ldots |t| - 1] = s[i - |t| + 1 \ldots i] = s[|s| - |t| + 1 \ldots |s| - 1] \]

可以发现,

  • \(s[0 \ldots |t| - 1] = s[i - |t| + 1 \ldots i]\)\(s[0 \ldots i]\) 的 Border。
  • \(s[0 \ldots |t| - 1] = s[|s| - |t| + 1 \ldots |s| - 1]\)\(s[0 \ldots |s| - 1]\) 的 Border。

于是,问题转变为找到一个 最长的 子串 \(t\),满足 \(t\) 既是 \(s[0 \ldots i]\) 的 Border,又是 \(s[0 \ldots |s| - 1]\) 的 Border。

根据题意,\(0 < i < |s| - 1\),因此 \(|t| \leq \max\limits_{0 < i < |s| - 1} \pi[i]\)。令其为 \(\pi_{\max}\)

基于前缀函数值增长的优化 一节中,已经讨论了递推出第 \(k\) 长的 Border 的方法。在本题中,从 \(\pi[|s| - 1]\) 开始,用该方法推出 第一个 \(\leq \pi_{max}\) 的 Border 长度,也就相当于 最大的 \(|t|\)

时间复杂度 \(\Theta(|s|)\)。参考代码:

signed main() {
    cin >> s;
    vector<int> pi = getpi(s);
    int p = pi[s.size() - 1], pi_max = 0;
    for (int i = 1; i < pi.size() - 1; i++)
        pi_max = max(pi_max, pi[i]);
    while (p > pi_max)
        p = pi[p - 1];
    if (p)
        cout << s.substr(0, p);
    else
        cout << "Just a legend";
}

例题 SP34020 ADAPET - Ada and Pet

题解

\(s\) 的前 \(\pi[|s| - 1]\) 位与后 \(\pi[|s| - 1]\) 相等,那么在拼接 \(n\)\(s\) 时就可以去除重叠的 \((n - 1) \times \pi[|s| - 1]\) 个字符。

参考代码:

void solve(int tc) {
    cin >> s >> n;
    vector<int> pi = getpi(s);
    cout << s.size() * n - pi[s.size() - 1] * (n - 1) << '\n';
}

2.3. 字符串匹配:Knuth-Morris-Pratt 算法

给定模式串 \(t\) 与待匹配串 \(s\),找出 \(t\)\(s\) 中出现的所有位置。默认 \(|t| < |s|\)

构造字符串 \(p = t + \texttt \# + s\)(接下来的所有定义中,\(\texttt \#\)\(s, t\) 字符集中未出现的 分隔符)。

考虑分隔符 \(\texttt \#\)\(p\) 的前缀函数 \(\pi[i]\) 的意义。

由于分隔符的存在,必定有 \(\pi[i] \leq |t|\)。仅有 \(i \geq 2 \cdot |t|\)\(\pi[i]\) 才有可能 \(= |t|\),此时有 \(p[0 \ldots |t| - 1] = p[i - |t| + 1 \ldots i]\)

根据构造 \(p\) 的定义,\(p[0 \ldots |t| - 1]\) 就是 \(t\) 原串,而 \(p[i - |t| + 1 \ldots i]\) 也就是 \(t\)\(s\)对应的 出现位置。不难发现,\(t\) 出现的下标对应 \(i - 2 \cdot |t|\)

时间复杂度 \(\Theta(|p|) = \Theta(|s| + |t|)\)

vector<int> KMP(string s, string t) {
    string p = t + '#' + s;
    vector<int> pi = getpi(p), res;
    for (int i = t.size() * 2; i < p.size(); i++)
        if (pi[i] == t.size())
            res.push_back(i - t.size() * 2);
    return res;
}

例题 CF471D MUH and Cube Walls

题解

由于判断 \(b\)\(a\) 的某一段形状是否相同时,只关注 相对的高度变化。可以利用差分来维护两个相近高度的变化。

具体地,先计算 \(b[1] \sim b[|b| - 1]\) 的差分值,再从 \(a[1]\) 尝试 KMP 匹配。这里将整数数组当作字符串,而分隔符可以取一个不可能在差分数组中出现的值。

参考代码:

int n, m;

vector<int> getpi(vector<int> s) {
    vector<int> pi(s.size()); // pi[0] = 0
    for (int i = 1; i < s.size(); i++) {
        pi[i] = pi[i - 1];
        while (pi[i] != 0 && s[i] != s[pi[i]])
            pi[i] = pi[pi[i] - 1];
        if (s[i] == s[pi[i]]) pi[i]++;
    }
    return pi;
}

int KMP(vector<int> s, vector<int> t) {
    vector<int> p = t;
    p.push_back(2e9); // 分隔符
    p.insert(p.end(), s.begin() + 1, s.end());
    int occ = 0;
    vector<int> pi = getpi(p), res;
    for (int i = t.size() * 2; i < p.size(); i++)
        if (pi[i] == t.size())
            occ++;
    return occ;
}

signed main() {
    cin >> n >> m;
    vector<int> a(n), b(m);
    for (int &i : a)
        cin >> i;
    for (int &i : b)
        cin >> i;
    adjacent_difference(a.begin(), a.end(), a.begin());
    adjacent_difference(b.begin(), b.end(), b.begin());
    b.erase(b.begin());
    cout << KMP(a, b);
}

例题 UVA12467 Secret Word

题解

该题运用了类似 KMP 的思想。

此处定义 \(\operatorname{rev}(p)\)\(p\) 的逆序串。

类似地,因为 Secret Word \(t\) 的逆序 \(\operatorname{rev}(t)\)\(s\)前缀,构造 \(p = s + \texttt \# + \operatorname{rev}(s)\)

与 KMP 算法相同,考虑分隔符 \(\texttt \#\)\(p\) 的前缀函数 \(\pi[i]\) 的意义。

根据定义,有

\[p[0 \ldots \pi[i] - 1] = p[i - \pi[i] + 1 \ldots i] \]

代入 \(s\) 中,则有:

\[\begin{aligned} s[0 \ldots \pi[i] - 1] &= \operatorname{rev}(s)[i - |s| - \pi[i] \ldots i - |s| - 1] \\ &= \operatorname{rev}(s[2 \cdot |s| - i \ldots 2 \cdot |s| - i + \pi[i] - 1]) \end{aligned} \]

\(t\)\(s[2 \cdot |s| - i \ldots 2 \cdot |s| - i + \pi[i] - 1]\) 时,\(\operatorname{rev}(t)\) 显然就是 \(s\) 的前缀。

此时已经满足了两点要求:

  1. \(t\)\(s\) 的非空子串;
  2. \(\operatorname{rev}(t)\)\(s\) 的前缀。

由于 \(\pi[i]\)最长 Border 的长度,也就一定是 左端点\(2 \cdot |s| - i\) 且符合条件的 最长 子串的长度。因此,只需要求出 \(\pi[i]\)最大值 即可。

时间复杂度 \(\Theta(|s|)\)。参考代码:

void solve() {
    cin >> s;
    string rev_s = s;
    reverse(rev_s.begin(), rev_s.end());
    string p = s + '#' + rev_s;
    vector<int> pi = getpi(p);
    int maxlen = 0;
    for (int i = rev_s.size() + 1; i < pi.size(); i++)
        maxlen = max(maxlen, pi[i]);
    for (int i = maxlen - 1; i >= 0; i--)
        cout << s[i];
    cout << '\n';
}

例题 CF25E Test

题解

考虑这个问题的弱化版:

给定字符串 \(s_1, s_2\),求最短的字符串 \(t\) 使得 \(s_1, s_2\) 都是 \(t\) 的子串。

首先考虑包含关系:

  • 如果 \(s_1\)\(s_2\) 的子串,\(t = s_2\)
  • 如果 \(s_2\)\(s_1\) 的子串,\(t = s_1\)

若不存在包含关系,假设 \(s_1\)\(t\) 中的出现位置 严格小于 \(s_2\)

通过计算 \(s_2 + \texttt \# + s_1\) 的前缀函数,去除中间重叠的部分。例如,当 \(s_1 = \texttt{ab} \texttt{\color{#9f3}cab}\)\(s_2 = \texttt{\color{#9f3}cab} \texttt{abc}\) 的情况,则 \(t = \texttt{ab} \texttt{\color{#9f3}cab} \texttt{abc}\)

同理,题目要求查询 \(s_1, s_2, s_3\) 三个字符串,也就是 \(s_1, s_2\) 的结果与 \(s_3\) 再次处理。而由于 \(s_1, s_2, s_3\) 可以以不同的顺序出现,枚举其所有排列即可。

string concat(string s, string t) {
    if (KMP(s, t)) return s;
    if (KMP(t, s)) return t;
    string p = t + '#' + s;
    vector<int> pi = getpi(p);
    return s + t.substr(pi[p.size() - 1]);
}

signed main() {
    cin >> s[0] >> s[1] >> s[2];
    sort(s, s + 3);
    do
        res = min(res, (int)concat(concat(s[0], s[1]), s[2]).size());
    while (next_permutation(s, s + 3));
    cout << res;
}

例题 CF1200E Compress Words

题解

按照题意模拟。

同上题,先设立答案串 \(\mathit{res}\),构造字符串 \(p = s + \texttt \# + \mathit{res}\),计算前缀函数并去除重叠部分。

考虑优化。由于 \(\pi[|p| - 1] < |s|\),只保留 \(\mathit{res}\) 的后 \(|s|\) 个字符并不影响答案。

参考代码:

signed main() {
    cin >> n >> s;
    res = s;
    for (int i = 2; i <= n; i++) {
        cin >> s;
        int l = min(res.size(), s.size());
        string p = s + '#' + res.substr(res.size() - l);
        vector<int> pi = getpi(p);
        res += s.substr(pi[p.size() - 1]);
    }
    cout << res;
}

本题也可以使用 Hash 完成。

2.4. 字符串循环

如果字符串 \(s\)\(k ~ (k \mid s)\) 个子串 \(t\) 首尾拼接而成,则 \(s\)\(t\) 的循环之一。

在已知 \(s\) 的情况下,定义 长度最小\(t\)\(t_{\min}\)

由于 \(s\)\(t_{\min}\) 的循环,其前缀函数值有 特殊的规律

易证,\(\forall |t_{\min}| \leq i \leq |s| - 1\),有 \(\pi[i] = i - |t_{\min}| + 1\)。以 \(\texttt{abaabaaba}\) 为例,

\[\small \begin{matrix} i &0 &1 &2 &3 &4 &5 &6 &7 &8 \\ s[i] &\texttt a &\texttt b &\texttt a &\texttt a &\texttt b &\texttt a &\texttt a &\texttt b &\texttt a \\ \pi[i] &0 &0 &1 &\color{#9f3} 1 &\color{#9f3} 2 &\color{#9f3} 3 &\color{#9f3} 4 &\color{#9f3} 5 &\color{#9f3} 6 \end{matrix} \]

可以看到,

\[\pi[|s| - 1] = |s| - |t_{\min}| \]

\[|t_{\min}| = |s| - \pi[|s| - 1] \]

但是,如果 \((|s| - \pi[|s| - 1]) \nmid |s|\),易证 不存在 \(t \neq s\) 使得 \(s\)\(t\) 的循环。此时 \(t\) 唯一的可能就是 \(s\) 本身。

例题 UVA10298 Power Strings

题解

模版题。参考代码:

void solve() {
    cin >> s;
    int n = s.size();
    vector<int> pi = getpi(s);
    int cycle_len = n - pi[n - 1];
    if (n % cycle_len == 0)
        cout << n / cycle_len << '\n';
    else
        cout << 1 << '\n';
}

习题

参见 前缀函数与 KMP 习题

posted @ 2024-06-22 12:42  Carrot-Meow~  阅读(228)  评论(0)    收藏  举报