Day 7 - 哈希与 KMP

字符串哈希

定义

我们定义一个把字符串映射到整数的函数 \(f\),这个 \(f\) 称为是 \(\text{Hash}\) 函数。

我们希望这个函数 \(f\) 可以方便地帮我们判断两个字符串是否相等。

Hash 的思想

\(\text{Hash}\) 的核心思想在于,将输入映射到一个值域较小、可以方便比较的范围。

这里的「值域较小」在不同情况下意义不同。

在 哈希表中,值域需要小到能够接受线性的空间与时间复杂度。

在字符串哈希中,值域需要小到能够快速比较(\(10^9\)\(10^{18}\) 都是可以快速比较的)。

同时,为了降低哈希冲突率,值域也不能太小。

性质

具体来说,哈希函数最重要的性质可以概括为下面两条:

  1. \(\text{Hash}\) 函数值不一样的时候,两个字符串一定不一样;

  2. \(\text{Hash}\) 函数值一样的时候,两个字符串不一定一样(但有大概率一样,且我们当然希望它们总是一样的)。

我们将 \(\text{Hash}\) 函数值一样但原字符串不一样的现象称为哈希碰撞。

解释

我们需要关注的是什么?

时间复杂度和 \(\text{Hash}\) 的准确率。

通常我们采用的是多项式 \(\text{Hash}\) 的方法,对于一个长度为 \(l\) 的字符串 \(s\) 来说,我们可以这样定义多项式 \(\text{Hash}\) 函数:\(f(s) = \sum_{i=1}^{l} s[i] \times b^{l-i} \pmod M\)。例如,对于字符串 \(xyz\),其哈希函数值为 \(xb^2+yb+z\)

特别要说明的是,也有很多人使用的是另一种 \(\text{Hash}\) 函数的定义,即 \(f(s) = \sum_{i=1}^{l} s[i] \times b^{i-1} \pmod M\),这种定义下,同样的字符串 \(xyz\) 的哈希值就变为了 \(x+yb+zb^2\) 了。

显然,上面这两种哈希函数的定义函数都是可行的,但二者在之后会讲到的计算子串哈希值时所用的计算式是不同的,因此千万注意 不要弄混了这两种不同的 \(\text{Hash}\) 方式

由于前者的 \(\text{Hash}\) 定义计算更简便、使用人数更多、且可以类比为一个 \(b\) 进制数来帮助理解,所以本文下面所将要讨论的都是使用 \(f(s) = \sum_{i=1}^{l} s[i] \times b^{l-i} \pmod M\) 来定义的 \(\text{Hash}\) 函数。

下面讲一下如何选择 \(M\) 和计算哈希碰撞的概率。

这里 \(M\) 需要选择一个素数(至少要比最大的字符要大),\(b\) 可以任意选择。

如果我们用未知数 \(x\) 替代 \(b\),那么 \(f(s)\) 实际上是多项式环 \(\mathbb{Z}_M[x]\) 上的一个多项式。考虑两个不同的字符串 \(s,t\),有 \(f(s)=f(t)\)。我们记 \(h(x)=f(s)-f(t)=\sum_{i=1}^l(s[i]-t[i])x^{l-i}\pmod M\),其中 \(l=\max(|s|,|t|)\)。可以发现 \(h(x)\) 是一个 \(l-1\) 阶的非零多项式。

如果 \(s\)\(t\)\(x=b\) 的情况下哈希碰撞,则 \(b\)\(h(x)\) 的一个根。由于 \(h(x)\)\(\mathbb{Z}_M\) 是一个域(等价于 \(M\) 是一个素数,这也是为什么 \(M\) 要选择素数的原因)的时候,最多有 \(l-1\) 个根,如果我们保证 \(b\) 是从 \([0,M)\) 之间均匀随机选取的,那么 \(f(s)\)\(f(t)\) 碰撞的概率可以估计为 \(\frac{l-1}{M}\)。简单验算一下,可以发现如果两个字符串长度都是 \(1\) 的时候,哈希碰撞的概率为 \(\frac{1-1}{M}=0\),此时不可能发生碰撞。

实现

参考代码:(效率低下的版本,实际使用时一般不会这么写)

using std::string;

const int M = 1e9 + 7;
const int B = 233;

typedef long long ll;

int get_hash(const string& s) {
    int res = 0;
    for (int i = 0; i < s.size(); ++i) {
    res = ((ll)res * B + s[i]) % M;
    }
    return res;
}

bool cmp(const string& s, const string& t) {
    return get_hash(s) == get_hash(t);
}

Hash 的分析与改进

错误率

假定哈希函数将字符串随机地映射到大小为 \(M\) 的值域中,总共有 \(n\) 个不同的字符串,那么未出现碰撞的概率是 \(\prod_{i = 0}^{n-1} \frac{M-i}{M}\)(第 \(i\) 次进行哈希时,有 \(\frac{M-i}{M}\) 的概率不会发生碰撞)。在随机数据下,若 \(M=10^9 + 7\)\(n=10^6\),未出现碰撞的概率是极低的。

所以,进行字符串哈希时,经常会对两个大质数分别取模,这样的话哈希函数的值域就能扩大到两者之积,错误率就非常小了。

多次询问子串哈希

单次计算一个字符串的哈希值复杂度是 \(O(n)\),其中 \(n\) 为串长,与暴力匹配没有区别,如果需要多次询问一个字符串的子串的哈希值,每次重新计算效率非常低下。

一般采取的方法是对整个字符串先预处理出每个前缀的哈希值,将哈希值看成一个 \(b\) 进制的数对 \(M\) 取模的结果,这样的话每次就能快速求出子串的哈希了:

\(f_i(s)\) 表示 \(f(s[1..i])\),即原串长度为 \(i\) 的前缀的哈希值,那么按照定义有 \(f_i(s)=s[1]\cdot b^{i-1}+s[2]\cdot b^{i-2}+\dots+s[i-1]\cdot b+s[i]\)

现在,我们想要用类似前缀和的方式快速求出 \(f(s[l..r])\),按照定义有字符串 \(s[l..r]\) 的哈希值为 \(f(s[l..r])=s[l]\cdot b^{r-l}+s[l+1]\cdot b^{r-l-1}+\dots+s[r-1]\cdot b+s[r]\)

对比观察上述两个式子,我们发现 \(f(s[l..r])=f_r(s)-f_{l-1}(s) \times b^{r-l+1}\) 成立(可以手动代入验证一下),因此我们用这个式子就可以快速得到子串的哈希值。其中 \(b^{r-l+1}\) 可以 \(O(n)\) 的预处理出来然后 \(O(1)\) 的回答每次询问(当然也可以快速幂 \(O(\log n)\) 的回答每次询问)。

Hash 的应用

字符串匹配

求出模式串的哈希值后,求出文本串每个长度为模式串长度的子串的哈希值,分别与模式串的哈希值比较即可。

允许 \(k\) 次失配的字符串匹配

问题:给定长为 \(n\) 的源串 \(s\),以及长度为 \(m\) 的模式串 \(p\),要求查找源串中有多少子串与模式串匹配。\(s'\)\(s\) 匹配,当且仅当 \(s'\)\(s\) 长度相同,且最多有 \(k\) 个位置字符不同。其中 \(1\leq n,m\leq 10^6\)\(0\leq k\leq 5\)

这道题无法使用 \(\text{KMP}\) 解决,但是可以通过哈希 + 二分来解决。

枚举所有可能匹配的子串,假设现在枚举的子串为 \(s'\),通过哈希 + 二分可以快速找到 \(s'\)\(p\) 第一个不同的位置。之后将 \(s'\)\(p\) 在这个失配位置及之前的部分删除掉,继续查找下一个失配位置。这样的过程最多发生 \(k\) 次。

总的时间复杂度为 \(O(m+kn\log_2m)\)

最长回文子串

二分答案,判断是否可行时枚举回文中心(对称轴),哈希判断两侧是否相等。需要分别预处理正着和倒着的哈希值。时间复杂度 \(O(n\log n)\)

这个问题可以使用 \(\text{manacher}\) 算法在 \(O(n)\) 的时间内解决。

通过哈希同样可以 \(O(n)\) 解决这个问题,具体方法就是记 \(R_i\) 表示以 \(i\) 作为结尾的最长回文的长度,那么答案就是 \(\max_{i=1}^nR_i\)。考虑到 \(R_i\leq R_{i-1}+2\),因此我们只需要暴力从 \(R_{i-1}+2\) 开始递减,直到找到第一个回文即可。记变量 \(z\) 表示当前枚举的 \(R_i\),初始时为 \(0\),则 \(z\) 在每次 \(i\) 增大的时候都会增大 \(2\),之后每次暴力循环都会减少 \(1\),故暴力循环最多发生 \(2n\) 次,总的时间复杂度为 \(O(n)\)

最长公共子字符串

问题:给定 \(m\) 个总长不超过 \(n\) 的非空字符串,查找所有字符串的最长公共子字符串,如果有多个,任意输出其中一个。其中 \(1\leq m, n\leq 10^6\)

很显然如果存在长度为 \(k\) 的最长公共子字符串,那么 \(k-1\) 的公共子字符串也必定存在。因此我们可以二分最长公共子字符串的长度。假设现在的长度为 \(k\)check(k) 的逻辑为我们将所有所有字符串的长度为 \(k\) 的子串分别进行哈希,将哈希值放入 \(n\) 个哈希表中存储。之后求交集即可。

时间复杂度为 \(O(m+n\log n)\)

确定字符串中不同子字符串的数量

问题:给定长为 \(n\) 的字符串,仅由小写英文字母组成,查找该字符串中不同子串的数量。

为了解决这个问题,我们遍历了所有长度为 \(l=1,\cdots ,n\) 的子串。对于每个长度为 \(l\),我们将其 \(\text{Hash}\) 值乘以相同的 \(b\) 的幂次方,并存入一个数组中。数组中不同元素的数量等于字符串中长度不同的子串的数量,并此数字将添加到最终答案中。

为了方便起见,我们将使用 \(h [i]\) 作为 \(\text{Hash}\) 的前缀字符,并定义 \(h[0]=0\)

参考代码:

int count_unique_substrings(string const& s) {
    int n = s.size();

    const int b = 31;
    const int m = 1e9 + 9;
    vector<long long> b_pow(n);
    b_pow[0] = 1;
    for (int i = 1; i < n; i++) b_pow[i] = (b_pow[i - 1] * b) % m;

    vector<long long> h(n + 1, 0);
    for (int i = 0; i < n; i++)
    h[i + 1] = (h[i] + (s[i] - 'a' + 1) * b_pow[i]) % m;

    int cnt = 0;
    for (int l = 1; l <= n; l++) {
    set<long long> hs;
    for (int i = 0; i <= n - l; i++) {
        long long cur_h = (h[i + l] + m - h[i]) % m;
        cur_h = (cur_h * b_pow[n - i - 1]) % m;
        hs.insert(cur_h);
    }
    cnt += hs.size();
    }
    return cnt;
}

例题

CF1200E Compress Words

给你若干个字符串,答案串初始为空。第 \(i\) 步将第 \(i\) 个字符串加到答案串的后面,但是尽量地去掉重复部分(即去掉一个最长的、是原答案串的后缀、也是第 \(i\) 个串的前缀的字符串),求最后得到的字符串。

字符串个数不超过 \(10^5\),总长不超过 \(10^6\)

题解:

每次需要求最长的、是原答案串的后缀、也是第 \(i\) 个串的前缀的字符串。枚举这个串的长度,哈希比较即可。

当然,这道题也可以使用 \(\text{KMP}\) 算法解决。

参考代码:

#include <bits/stdc++.h>
using namespace std;

const int L = 1e6 + 5;
const int HASH_CNT = 2;

int hashBase[HASH_CNT] = {29, 31};
int hashMod[HASH_CNT] = {int(1e9 + 9), 998244353};

struct StringWithHash {
  char s[L];
  int ls;
  int hsh[HASH_CNT][L];
  int pwMod[HASH_CNT][L];

  void init() {  // 初始化
    ls = 0;
    for (int i = 0; i < HASH_CNT; ++i) {
      hsh[i][0] = 0;
      pwMod[i][0] = 1;
    }
  }

  StringWithHash() { init(); }

  void extend(char c) {
    s[++ls] = c;                          // 记录字符数和每一个字符
    for (int i = 0; i < HASH_CNT; ++i) {  // 双哈希的预处理
      pwMod[i][ls] =
          1ll * pwMod[i][ls - 1] * hashBase[i] % hashMod[i];  // 得到b^ls
      hsh[i][ls] = (1ll * hsh[i][ls - 1] * hashBase[i] + c) % hashMod[i];
    }
  }

  vector<int> getHash(int l, int r) {  // 得到哈希值
    vector<int> res(HASH_CNT, 0);
    for (int i = 0; i < HASH_CNT; ++i) {
      int t =
          (hsh[i][r] - 1ll * hsh[i][l - 1] * pwMod[i][r - l + 1]) % hashMod[i];
      t = (t + hashMod[i]) % hashMod[i];
      res[i] = t;
    }
    return res;
  }
};

bool equal(const vector<int> &h1, const vector<int> &h2) {
  assert(h1.size() == h2.size());
  for (unsigned i = 0; i < h1.size(); i++)
    if (h1[i] != h2[i]) return false;
  return true;
}

int n;
StringWithHash s, t;
char str[L];

void work() {
  int len = strlen(str);  // 取字符串长度
  t.init();
  for (int j = 0; j < len; ++j) t.extend(str[j]);
  int d = 0;
  for (int j = min(len, s.ls); j >= 1; --j) {
    if (equal(t.getHash(1, j), s.getHash(s.ls - j + 1, s.ls))) {  // 比较哈希值
      d = j;
      break;
    }
  }
  for (int j = d; j < len; ++j) s.extend(str[j]);  // 更新答案数组
}

int main() {
  scanf("%d", &n);
  for (int i = 1; i <= n; ++i) {
    scanf("%s", str);
    work();
  }
  printf("%s\n", s.s + 1);
  return 0;
}

部分内容译自博文 строковый хеш 与其英文翻译版 String Hashing。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。

KMP 算法

字符串前缀和后缀定义

关于字符串前缀、真前缀,后缀、真后缀的定义详见字符串基础。

前缀函数

定义

给定一个长度为 \(n\) 的字符串 \(s\),其 前缀函数 被定义为一个长度为 \(n\) 的数组 \(\pi\)
其中 \(\pi[i]\) 的定义是:

  1. 如果子串 \(s[0\dots i]\) 有一对相等的真前缀与真后缀:\(s[0\dots k-1]\)\(s[i - (k - 1) \dots i]\),那么 \(\pi[i]\) 就是这个相等的真前缀(或者真后缀,因为它们相等)的长度,也就是 \(\pi[i]=k\)
  2. 如果不止有一对相等的,那么 \(\pi[i]\) 就是其中最长的那一对的长度;
  3. 如果没有相等的,那么 \(\pi[i]=0\)

简单来说 \(\pi[i]\) 就是,子串 \(s[0\dots i]\) 最长的相等的真前缀与真后缀的长度。

用数学语言描述如下:

\[\pi[i] = \max_{k = 0 \dots i}\{k: s[0 \dots k - 1] = s[i - (k - 1) \dots i]\} \]

特别地,规定 \(\pi[0]=0\)

过程

举例来说,对于字符串 abcabcd

\(\pi[0]=0\),因为 a 没有真前缀和真后缀,根据规定为 0

\(\pi[1]=0\),因为 ab 无相等的真前缀和真后缀

\(\pi[2]=0\),因为 abc 无相等的真前缀和真后缀

\(\pi[3]=1\),因为 abca 只有一对相等的真前缀和真后缀:a,长度为 1

\(\pi[4]=2\),因为 abcab 相等的真前缀和真后缀只有 ab,长度为 2

\(\pi[5]=3\),因为 abcabc 相等的真前缀和真后缀只有 abc,长度为 3

\(\pi[6]=0\),因为 abcabcd 无相等的真前缀和真后缀

同理可以计算字符串 aabaaab 的前缀函数为 \([0, 1, 0, 1, 2, 2, 3]\)

计算前缀函数的朴素算法

过程

一个直接按照定义计算前缀函数的算法流程:

  • 在一个循环中以 \(i = 1\to n - 1\) 的顺序计算前缀函数 \(\pi[i]\) 的值(\(\pi[0]\) 被赋值为 \(0\))。
  • 为了计算当前的前缀函数值 \(\pi[i]\),我们令变量 \(j\) 从最大的真前缀长度 \(i\) 开始尝试。
  • 如果当前长度下真前缀和真后缀相等,则此时长度为 \(\pi[i]\),否则令 j 自减 1,继续匹配,直到 \(j=0\)
  • 如果 \(j = 0\) 并且仍没有任何一次匹配,则置 \(\pi[i] = 0\) 并移至下一个下标 \(i + 1\)

具体实现如下:

// 注:
// string substr (size_t pos = 0, size_t len = npos) const;
vector<int> prefix_function(string s) {
    int n = (int)s.length();
    vector<int> pi(n);
    for (int i = 1; i < n; i++)
    for (int j = i; j >= 0; j--)
        if (s.substr(0, j) == s.substr(i - j + 1, j)) {
        pi[i] = j;
        break;
        }
    return pi;
}

显见该算法的时间复杂度为 \(O(n^3)\),具有很大的改进空间。

计算前缀函数的高效算法

第一个优化

第一个重要的观察是 相邻的前缀函数值至多增加 \(1\)

参照下图所示,只需如此考虑:当取一个尽可能大的 \(\pi[i+1]\) 时,必然要求新增的 \(s[i+1]\) 也与之对应的字符匹配,即 \(s[i+1]=s[\pi[i]]\), 此时 \(\pi[i+1] = \pi[i]+1\)

\[\underbrace{\overbrace{s_0 ~ s_1 ~ s_2}^{\pi[i] = 3} ~ s_3}_{\pi[i+1] = 4} ~ \dots ~ \underbrace{\overbrace{s_{i-2} ~ s_{i-1} ~ s_{i}}^{\pi[i] = 3} ~ s_{i+1}}_{\pi[i+1] = 4} \]

所以当移动到下一个位置时,前缀函数的值要么增加一,要么维持不变,要么减少。

此时的改进的算法为:

vector<int> prefix_function(string s) {
    int n = (int)s.length();
    vector<int> pi(n);
    for (int i = 1; i < n; i++)
    for (int j = pi[i - 1] + 1; j >= 0; j--)  // improved: j=i => j=pi[i-1]+1
        if (s.substr(0, j) == s.substr(i - j + 1, j)) {
        pi[i] = j;
        break;
        }
    return pi;
}

在这个初步改进的算法中,在计算每个 \(\pi[i]\) 时,最好的情况是第一次字符串比较就完成了匹配,也就是说基础的字符串比较次数是 n-1 次。

而由于存在 j = pi[i-1]+1pi[0]=0)对于最大字符串比较次数的限制,可以看出每次只有在最好情况才会为字符串比较次数的上限积累 1,而每次超过一次的字符串比较消耗的是之后次数的增长空间。

由此我们可以得出字符串比较次数最多的一种情况:至少 1 次字符串比较次数的消耗和最多 n-2 次比较次数的积累,此时字符串比较次数为 n-1 + n-2 = 2n-3

可见经过此次优化,计算前缀函数只需要进行 \(O(n)\) 次字符串比较,总复杂度降为了 \(O(n^2)\)

第二个优化

在第一个优化中,我们讨论了计算 \(\pi[i+1]\) 时的最好情况:\(s[i+1]=s[\pi[i]]\),此时 \(\pi[i+1] = \pi[i]+1\)。现在让我们沿着这个思路走得更远一点:讨论当 \(s[i+1] \neq s[\pi[i]]\) 时如何跳转。

失配时,我们希望找到对于子串 \(s[0\dots i]\),仅次于 \(\pi[i]\) 的第二长度 \(j\),使得在位置 \(i\) 的前缀性质仍得以保持,也即 \(s[0 \dots j - 1] = s[i - j + 1 \dots i]\)

\[\overbrace{\underbrace{s_0 ~ s_1}_j ~ s_2 ~ s_3}^{\pi[i]} ~ \dots ~ \overbrace{s_{i-3} ~ s_{i-2} ~ \underbrace{s_{i-1} ~ s_{i}}_j}^{\pi[i]} ~ s_{i+1} \]

如果我们找到了这样的长度 \(j\),那么仅需要再次比较 \(s[i + 1]\)\(s[j]\)。如果它们相等,那么就有 \(\pi[i + 1] = j + 1\)。否则,我们需要找到子串 \(s[0\dots i]\) 仅次于 \(j\) 的第二长度 \(j^{(2)}\),使得前缀性质得以保持,如此反复,直到 \(j = 0\)。如果 \(s[i + 1] \neq s[0]\),则 \(\pi[i + 1] = 0\)

观察上图可以发现,因为 \(s[0\dots \pi[i]-1] = s[i-\pi[i]+1\dots i]\),所以对于 \(s[0\dots i]\) 的第二长度 \(j\),有这样的性质:

\[s[0 \dots j - 1] = s[i - j + 1 \dots i]= s[\pi[i]-j\dots \pi[i]-1] \]

也就是说 \(j\) 等价于子串 \(s[\pi[i]-1]\) 的前缀函数值,即 \(j=\pi[\pi[i]-1]\)。同理,次于 \(j\) 的第二长度等价于 \(s[j-1]\) 的前缀函数值,\(j^{(2)}=\pi[j-1]\)

显然我们可以得到一个关于 \(j\) 的状态转移方程:\(j^{(n)}=\pi[j^{(n-1)}-1], \ \ (j^{(n-1)}>0)\)

最终算法

所以最终我们可以构建一个不需要进行任何字符串比较,并且只进行 \(O(n)\) 次操作的算法。

而且该算法的实现出人意料的短且直观:

实现:

vector<int> prefix_function(string s) {
    int n = (int)s.length();
    vector<int> pi(n);
    for (int i = 1; i < n; i++) {
    int j = pi[i - 1];
    while (j > 0 && s[i] != s[j]) j = pi[j - 1];
    if (s[i] == s[j]) j++;
    pi[i] = j;
    }
    return pi;
}

这是一个 在线 算法,即其当数据到达时处理它——举例来说,你可以一个字符一个字符的读取字符串,立即处理它们以计算出每个字符的前缀函数值。该算法仍然需要存储字符串本身以及先前计算过的前缀函数值,但如果我们已经预先知道该字符串前缀函数的最大可能取值 \(M\),那么我们仅需要存储该字符串的前 \(M + 1\) 个字符以及对应的前缀函数值。

应用

在字符串中查找子串:Knuth–Morris–Pratt 算法

该算法由 \(\text{Knuth}\)\(\text{Pratt}\)\(\text{Morris}\)\(1977\) 年共同发布[1]

该任务是前缀函数的一个典型应用。

过程

给定一个文本 \(t\) 和一个字符串 \(s\),我们尝试找到并展示 \(s\)\(t\) 中的所有出现(occurrence)。

为了简便起见,我们用 \(n\) 表示字符串 \(s\) 的长度,用 \(m\) 表示文本 \(t\) 的长度。

我们构造一个字符串 \(s + \# + t\),其中 \(\#\) 为一个既不出现在 \(s\) 中也不出现在 \(t\) 中的分隔符。接下来计算该字符串的前缀函数。现在考虑该前缀函数除去最开始 \(n + 1\) 个值(即属于字符串 \(s\) 和分隔符的函数值)后其余函数值的意义。根据定义,\(\pi[i]\) 为右端点在 \(i\) 且同时为一个前缀的最长真子串的长度,具体到我们的这种情况下,其值为与 \(s\) 的前缀相同且右端点位于 \(i\) 的最长子串的长度。由于分隔符的存在,该长度不可能超过 \(n\)。而如果等式 \(\pi[i] = n\) 成立,则意味着 \(s\) 完整出现在该位置(即其右端点位于位置 \(i\))。注意该位置的下标是对字符串 \(s + \# + t\) 而言的。

因此如果在某一位置 \(i\)\(\pi[i] = n\) 成立,则字符串 \(s\) 在字符串 \(t\)\(i - (n - 1) - (n + 1) = i - 2n\) 处出现。

正如在前缀函数的计算中已经提到的那样,如果我们知道前缀函数的值永远不超过一特定值,那么我们不需要存储整个字符串以及整个前缀函数,而只需要二者开头的一部分。在我们这种情况下这意味着只需要存储字符串 \(s + \#\) 以及相应的前缀函数值即可。我们可以一次读入字符串 \(t\) 的一个字符并计算当前位置的前缀函数值。

因此 \(\text{Knuth–Morris–Pratt}\) 算法(简称 \(\text{KMP}\) 算法)用 \(O(n + m)\) 的时间以及 \(O(n)\) 的内存解决了该问题。

实现:

vector<int> find_occurrences(string text, string pattern) {
    string cur = pattern + '#' + text;
    int sz1 = text.size(), sz2 = pattern.size();
    vector<int> v;
    vector<int> lps = prefix_function(cur);
    for (int i = sz2 + 1; i <= sz1 + sz2; i++) {
    if (lps[i] == sz2) v.push_back(i - 2 * sz2);
    }
    return v;
}

字符串的周期

对字符串 \(s\)\(0 < p \le |s|\),若 \(s[i] = s[i+p]\) 对所有 \(i \in [0, |s| - p - 1]\) 成立,则称 \(p\)\(s\) 的周期。

对字符串 \(s\)\(0 \le r < |s|\),若 \(s\) 长度为 \(r\) 的前缀和长度为 \(r\) 的后缀相等,就称 \(s\) 长度为 \(r\) 的前缀是 \(s\) 的 border。

\(s\) 有长度为 \(r\)\(\text{border}\) 可以推导出 \(|s|-r\)\(s\) 的周期。

根据前缀函数的定义,可以得到 \(s\) 所有的 \(\text{border}\) 长度,即 \(\pi[n-1],\pi[\pi[n-1]-1], \ldots\)\(^1\)

所以根据前缀函数可以在 \(O(n)\) 的时间内计算出 \(s\) 所有的周期。其中,由于 \(\pi[n-1]\)\(s\) 最长 \(\text{border}\) 的长度,所以 \(n - \pi[n-1]\)\(s\) 的最小周期。

统计每个前缀的出现次数

在该节我们将同时讨论两个问题。给定一个长度为 \(n\) 的字符串 \(s\),在问题的第一个变种中我们希望统计每个前缀 \(s[0 \dots i]\) 在同一个字符串的出现次数,在问题的第二个变种中我们希望统计每个前缀 \(s[0 \dots i]\) 在另一个给定字符串 \(t\) 中的出现次数。

首先让我们来解决第一个问题。考虑位置 \(i\) 的前缀函数值 \(\pi[i]\)。根据定义,其意味着字符串 \(s\) 一个长度为 \(\pi[i]\) 的前缀在位置 \(i\) 出现并以 \(i\) 为右端点,同时不存在一个更长的前缀满足前述定义。与此同时,更短的前缀可能以该位置为右端点。容易看出,我们遇到了在计算前缀函数时已经回答过的问题:给定一个长度为 \(j\) 的前缀,同时其也是一个右端点位于 \(i\) 的后缀,下一个更小的前缀长度 \(k < j\) 是多少?该长度的前缀需同时也是一个右端点为 \(i\) 的后缀。因此以位置 \(i\) 为右端点,有长度为 \(\pi[i]\) 的前缀,有长度为 \(\pi[\pi[i] - 1]\) 的前缀,有长度为 \(\pi[\pi[\pi[i] - 1] - 1]\) 的前缀,等等,直到长度变为 \(0\)。故而我们可以通过下述方式计算答案。

实现:

vector<int> ans(n + 1);
for (int i = 0; i < n; i++) ans[pi[i]]++;
for (int i = n - 1; i > 0; i--) ans[pi[i - 1]] += ans[i];
for (int i = 0; i <= n; i++) ans[i]++;

解释

在上述代码中我们首先统计每个前缀函数值在数组 \(\pi\) 中出现了多少次,然后再计算最后答案:如果我们知道长度为 \(i\) 的前缀出现了恰好 \(\text{ans}[i]\) 次,那么该值必须被叠加至其最长的既是后缀也是前缀的子串的出现次数中。在最后,为了统计原始的前缀,我们对每个结果加 \(1\)

现在考虑第二个问题。我们应用来自 Knuth–Morris–Pratt 的技巧:构造一个字符串 \(s + \# + t\) 并计算其前缀函数。与第一个问题唯一的不同之处在于,我们只关心与字符串 \(t\) 相关的前缀函数值,即 \(i \ge n + 1\)\(\pi[i]\)。有了这些值之后,我们可以同样应用在第一个问题中的算法来解决该问题。

一个字符串中本质不同子串的数目

给定一个长度为 \(n\) 的字符串 \(s\),我们希望计算其本质不同子串的数目。

我们将迭代的解决该问题。换句话说,在知道了当前的本质不同子串的数目的情况下,我们要找出一种在 \(s\) 末尾添加一个字符后重新计算该数目的方法。

\(k\) 为当前 \(s\) 的本质不同子串数量。我们添加一个新的字符 \(c\)\(s\)。显然,会有一些新的子串以字符 \(c\) 结尾。我们希望对这些以该字符结尾且我们之前未曾遇到的子串计数。

构造字符串 \(t = s + c\) 并将其反转得到字符串 \(t^{\sim}\)。现在我们的任务变为计算有多少 \(t^{\sim}\) 的前缀未在 \(t^{\sim}\) 的其余任何地方出现。如果我们计算了 \(t^{\sim}\) 的前缀函数最大值 \(\pi_{\max}\),那么最长的出现在 \(s\) 中的前缀其长度为 \(\pi_{\max}\)。自然的,所有更短的前缀也出现了。

因此,当添加了一个新字符后新出现的子串数目为 \(|s| + 1 - \pi_{\max}\)

所以对于每个添加的字符,我们可以在 \(O(n)\) 的时间内计算新子串的数目,故最终复杂度为 \(O(n^2)\)

值得注意的是,我们也可以重新计算在头部添加一个字符,或者从尾或者头移除一个字符时的本质不同子串数目。

字符串压缩

给定一个长度为 \(n\) 的字符串 \(s\),我们希望找到其最短的「压缩」表示,也即我们希望寻找一个最短的字符串 \(t\),使得 \(s\) 可以被 \(t\) 的一份或多份拷贝的拼接表示。

显然,我们只需要找到 \(t\) 的长度即可。知道了该长度,该问题的答案即为长度为该值的 \(s\) 的前缀。

让我们计算 \(s\) 的前缀函数。通过使用该函数的最后一个值 \(\pi[n - 1]\),我们定义值 \(k = n - \pi[n - 1]\)。我们将证明,如果 \(k\) 整除 \(n\),那么 \(k\) 就是答案,否则不存在一个有效的压缩,故答案为 \(n\)

假定 \(n\) 可被 \(k\) 整除。那么字符串可被划分为长度为 \(k\) 的若干块。根据前缀函数的定义,该字符串长度为 \(n - k\) 的前缀等于其后缀。但是这意味着最后一个块同倒数第二个块相等,并且倒数第二个块同倒数第三个块相等,等等。作为其结果,所有块都是相等的,因此我们可以将字符串 \(s\) 压缩至长度 \(k\)

证明:

诚然,我们仍需证明该值为最优解。实际上,如果有一个比 \(k\) 更小的压缩表示,那么前缀函数的最后一个值 \(\pi[n - 1]\) 必定比 \(n - k\) 要大。因此 \(k\) 就是答案。

现在假设 \(n\) 不可以被 \(k\) 整除,我们将通过反证法证明这意味着答案为 \(n\)[^1]。假设其最小压缩表示 \(r\) 的长度为 \(p\)\(p\) 整除 \(n\)),字符串 \(s\) 被划分为 \(n / p \ge 2\) 块。那么前缀函数的最后一个值 \(\pi[n - 1]\) 必定大于 \(n - p\)(如果等于则 \(n\) 可被 \(k\) 整除),也即其所表示的后缀将部分的覆盖第一个块。现在考虑字符串的第二个块。该块有两种解释:第一种为 \(r_0 r_1 \dots r_{p - 1}\),另一种为 \(r_{p - k} r_{p - k + 1} \dots r_{p - 1} r_0 r_1 \dots r_{p - k - 1}\)。由于两种解释对应同一个字符串,因此可得到 \(p\) 个方程组成的方程组,该方程组可简写为 \(r_{(i + k) \bmod p} = r_{i \bmod p}\),其中 \(\cdot \bmod p\) 表示模 \(p\) 意义下的最小非负剩余。

\[\begin{gathered} \overbrace{r_0 ~ r_1 ~ r_2 ~ r_3 ~ r_4 ~ r_5}^p ~ \overbrace{r_0 ~ r_1 ~ r_2 ~ r_3 ~ r_4 r_5}^p \\ r_0 ~ r_1 ~ r_2 ~ r_3 ~ \underbrace{\overbrace{r_0 ~ r_1 ~ r_2 ~ r_3 ~ r_4 ~ r_5}^p ~ r_0 ~ r_1}_{\pi[11] = 8} \end{gathered} \]

根据扩展欧几里得算法我们可以得到一组 \(x\)\(y\) 使得 \(xk + yp = \gcd(k, p)\)。通过与等式 \(pk - kp = 0\) 适当叠加我们可以得到一组 \(x' > 0\)\(y' < 0\) 使得 \(x'k + y'p = \gcd(k, p)\)。这意味着通过不断应用前述方程组中的方程我们可以得到新的方程组 \(r_{(i + \gcd(k, p)) \bmod p} = r_{i \bmod p}\)

由于 \(\gcd(k, p)\) 整除 \(p\),这意味着 \(\gcd(k, p)\)\(r\) 的一个周期。又因为 \(\pi[n - 1] > n - p\),故有 \(n - \pi[n - 1] = k < p\),所以 \(\gcd(k, p)\) 是一个比 \(p\) 更小的 \(r\) 的周期。因此字符串 \(s\) 有一个长度为 \(\gcd(k, p) < p\) 的压缩表示,同 \(p\) 的最小性矛盾。

综上所述,不存在一个长度小于 \(k\) 的压缩表示,因此答案为 \(k\)

\(^1\): 在俄文版及英文版中该部分证明均疑似有误。本文章中的该部分证明由作者自行添加。

根据前缀函数构建一个自动机

让我们重新回到通过一个分隔符将两个字符串拼接的新字符串。对于字符串 \(s\)\(t\) 我们计算 \(s + \# + t\) 的前缀函数。显然,因为 \(\#\) 是一个分隔符,前缀函数值永远不会超过 \(|s|\)。因此我们只需要存储字符串 \(s + \#\) 和其对应的前缀函数值,之后就可以动态计算对于之后所有字符的前缀函数值:

\[\underbrace{s_0 ~ s_1 ~ \dots ~ s_{n-1} ~ \#}_{\text{need to store}} ~ \underbrace{t_0 ~ t_1 ~ \dots ~ t_{m-1}}_{\text{do not need to store}} \]

实际上在这种情况下,知道 \(t\) 的下一个字符 \(c\) 以及之前位置的前缀函数值便足以计算下一个位置的前缀函数值,而不需要用到任何其它 \(t\) 的字符和对应的前缀函数值。

换句话说,我们可以构造一个 自动机(一个有限状态机):其状态为当前的前缀函数值,而从一个状态到另一个状态的转移则由下一个字符确定。

因此,即使没有字符串 \(t\),我们同样可以应用构造转移表的算法构造一个转移表 \(( \text { old } \pi , c ) \rightarrow \text { new } _ { - } \pi\)

实现:

void compute_automaton(string s, vector<vector<int>>& aut) {
    s += '#';
    int n = s.size();
    vector<int> pi = prefix_function(s);
    aut.assign(n, vector<int>(26));
    for (int i = 0; i < n; i++) {
    for (int c = 0; c < 26; c++) {
        int j = i;
        while (j > 0 && 'a' + c != s[j]) j = pi[j - 1];
        if ('a' + c == s[j]) j++;
        aut[i][c] = j;
    }
    }
}

然而在这种形式下,对于小写字母表,算法的时间复杂度为 \(O(|\Sigma|n^2)\)。注意到我们可以应用动态规划来利用表中已计算过的部分。只要我们从值 \(j\) 变化到 \(\pi[j - 1]\),那么我们实际上在说转移 \((j, c)\) 所到达的状态同转移 \((\pi[j - 1], c)\) 一样,但该答案我们之前已经精确计算过了。

实现:

void compute_automaton(string s, vector<vector<int>>& aut) {
    s += '#';
    int n = s.size();
    vector<int> pi = prefix_function(s);
    aut.assign(n, vector<int>(26));
    for (int i = 0; i < n; i++) {
    for (int c = 0; c < 26; c++) {
        if (i > 0 && 'a' + c != s[i])
        aut[i][c] = aut[pi[i - 1]][c];
        else
        aut[i][c] = i + ('a' + c == s[i]);
    }
    }
}

最终我们可在 \(O(|\Sigma|n)\) 的时间复杂度内构造该自动机。

该自动机在什么时候有用呢?首先,记得大部分时候我们为了一个目的使用字符串 \(s + \# + t\) 的前缀函数:寻找字符串 \(s\) 在字符串 \(t\) 中的所有出现。

因此使用该自动机的最直接的好处是 加速计算字符串 \(s + \# + t\) 的前缀函数

通过构建 \(s + \#\) 的自动机,我们不再需要存储字符串 \(s\) 以及其对应的前缀函数值。所有转移已经在表中计算过了。

但除此以外,还有第二个不那么直接的应用。我们可以在字符串 \(t\)某些通过一些规则构造的巨型字符串 时,使用该自动机加速计算。Gray 字符串,或者一个由一些短的输入串的递归组合所构造的字符串都是这种例子。

出于完整性考虑,我们来解决这样一个问题:给定一个数 \(k \le 10^5\),以及一个长度 \(\le 10^5\) 的字符串 \(s\),我们需要计算 \(s\) 在第 \(k\)\(\text{Gray}\) 字符串中的出现次数。回想起 \(\text{Gray}\) 字符串以下述方式定义:

\[\begin{aligned} g_1 &= \mathtt{a}\\ g_2 &= \mathtt{aba}\\ g_3 &= \mathtt{abacaba}\\ g_4 &= \mathtt{abacabadabacaba} \end{aligned} \]

由于其天文数字般的长度,在这种情况下即使构造字符串 \(t\) 都是不可能的:第 \(k\)\(\text{Gray}\) 字符串有 \(2^k - 1\) 个字符。然而我们可以在仅仅知道开头若干前缀函数值的情况下,有效计算该字符串末尾的前缀函数值。

除了自动机之外,我们同时需要计算值 \(G[i][j]\):在从状态 \(j\) 开始处理 \(g_i\) 后的自动机的状态,以及值 \(K[i][j]\):当从状态 \(j\) 开始处理 \(g_i\) 后,\(s\)\(g_i\) 中的出现次数。实际上 \(K[i][j]\) 为在执行操作时前缀函数取值为 \(|s|\) 的次数。易得问题的答案为 \(K[k][0]\)

我们该如何计算这些值呢?首先根据定义,初始条件为 \(G[0][j] = j\) 以及 \(K[0][j] = 0\)。之后所有值可以通过先前的值以及使用自动机计算得到。为了对某个 \(i\) 计算相应值,回想起字符串 \(g_i\)\(g_{i - 1}\),字母表中第 \(i\) 个字符,以及 \(g_{i - 1}\) 三者拼接而成。因此自动机会途径下列状态:

\[\begin{gathered} \text{mid} = \text{aut}[G[i - 1][j]][i] \\ G[i][j] = G[i - 1][\text{mid}] \end{gathered} \]

\(K[i][j]\) 的值同样可被简单计算。

\[K[i][j] = K[i - 1][j] + [\text{mid} == |s|] + K[i - 1][\text{mid}] \]

其中 \([\cdot]\) 当其中表达式取值为真时值为 \(1\),否则为 \(0\)。综上,我们已经可以解决关于 \(\text{Gray}\) 字符串的问题,以及一大类与之类似的问题。举例来说,应用同样的方法可以解决下列问题:给定一个字符串 \(s\) 以及一些模式 \(t_i\),其中每个模式以下列方式给出:该模式由普通字符组成,当中可能以 \(t_{k}^{\text{cnt}}\) 的形式递归插入先前的字符串,也即在该位置我们必须插入字符串 \(t_k\) \(\text{cnt}\) 次。以下是这些模式的一个例子:

\[\begin{aligned} t_1 &= \mathtt{abdeca} \\ t_2 &= \mathtt{abc} + t_1^{30} + \mathtt{abd} \\ t_3 &= t_2^{50} + t_1^{100} \\ t_4 &= t_2^{10} + t_3^{100} \end{aligned} \]

递归代入会使字符串长度爆炸式增长,他们的长度甚至可以达到 \(100^{100}\) 的数量级。而我们必须找到字符串 \(s\) 在每个字符串中的出现次数。

该问题同样可通过构造前缀函数的自动机解决。同之前一样,我们利用先前计算过的结果对每个模式计算其转移然后相应统计答案即可。

练习题目

参考资料与注释

主要译自博文 Префикс-функция. Алгоритм Кнута-Морриса-Пратта 与其英文翻译版 Prefix function. Knuth–Morris–Pratt algorithm。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。

\(^1\): 金策 - 字符串算法选讲

posted @ 2024-07-14 07:59  So_noSlack  阅读(34)  评论(0编辑  收藏  举报