字符串

2025-10-07T17:42:08+08:00 | 19 分钟阅读 | 更新于 2025-10-07T17:42:08+08:00 | 0 次阅读

@
✅ 内容已复制
字符串

串串是诗,这是诗和远方!

1. 字符串 Hash

1.1 多项式 Hash

1.1.1 定义

多项式 Hash 是字符串 Hash 的基础。具体而言,对于 $a_0,a_1\cdots a_n$,以及固定整数 $b$、模数 $p$,其 Hash 值为:

$$\sum\limits_{i=1}^n a_ib^{n-i}\bmod p$$

对于字符串而言,每个元素 $a_i$ 相当于字符串的字符 $s_i$。

1.1.2 基础操作

查询子串 $[l,r]$ 的 Hash。只需预处理每个前缀的 Hash,设 $h_i=\sum\limits_{j\in[1,i]}a_jb^{n-j}\bmod p$ 表示 $[1,i]$ 的 Hash 值。

对于区间 $[l,r]$,其 Hash 值即为 $(h_r-h_{l-1}\cdot b^{r-l+1}) \bmod p$。

同样的, 字符串 Hash 亦可完成拼接操作。对于字符串 $s_1,s_2$,Hash 值分别为 $h_1,h_2$。那么二者拼接的 Hash 值就为 $(h_1b^{\vert s_2\vert}+h_2)\bmod p$。

1
2
3
4
// 区间 Hash
ull gethash (int l, int r) {
    return (hsh[r] - hsh[l - 1] * pw[r - l + 1] % p + p) % p;
}

1.2 性质

字符串 Hash 实际上是字符串到整数的映射。我们期望的情况下,我们有的字符串集会和数集形成一一映射,但是显然不尽人意。

对于 Hash 值不同的两个字符串, 二者一定不同。而对于具有相同 Hash 值的两个字符串,二者不一定相同。对于具有相同 Hash 值但不相同的两个字符串,叫做「哈希碰撞」。但在不碰撞的情况下,可以实现许多优秀的算法。

1.3 碰撞概率与双模 Hash

1.3.1 双模 Hash

双模 Hash 很简单,就是再取一对 $p$ 和 $b$ 计算。

1.3.2 碰撞概率

大体上说,假设有 $10^5$ 个不同字符串,我们要求每个字符串的 Hash 互不相同,也就是 $\approx 10^{10}$ 对关系。在 $\bmod p$ 意义下,总共有 $p$ 种不同 Hash 值。假设 $p=10^9+7$,那么就是约 $\dfrac{10^5}{10^9+7}\approx 10^{-4}$ 的概率有碰撞。

因此,引入双模 Hash 就可以大大减小碰撞的概率。

1.3.3 注意点

用 $2^{64}$ 作模数,即自然溢出,极有可能被卡。

大量 Hash、$10^7$ 次比较的 Hash 应该用双模。

模数可以常见质数,$b$ 取较大且随机。

1.3.4 卡 Hash

见 OI-Wiki

1.4 小应用

1.4.1 二分哈希

最长公共前缀:一种求 LCP 的小技巧,即把每一位的前缀 Hash 存下来,二分地找最靠后的前缀 Hash 相等的位置。

最长回文子串:存前缀与后缀 Hash,枚举每一位,把这一位当作回文串的中心。然后二分最长相等 Hash 段即可。

最长公共子串:假设 $m$ 个字符串的总长为 $n$,那么考虑二分长度。在 check 时我们只需对每一个长为 $k$ 的子串放在 Hash 内比较即可。

1.4.2 求最小循环周期

假设周期为 $k$,字符串长为 $n$。首先有 $k\mid n$。由于是周期,那么每 $k$ 位都是相等的。即 $S[1\dots k]=S[k+1\dots 2k]=\cdots=S[n-k+1\dots n]$。这个也就能推出 $S[1\dots n-k]=S[k+1\dots n]$,而且这是一个充要条件!

为什么呢,我们把左右两边按照 $k$ 为一段来分,那么 $S[1\dots k]=S[k+1\dots 2k]$,第二段相等为 $S[k+1\dots 2k]=S[2k+1\dots 3k]$。直到 $n$,就能退出上面那个条件了。这也就是 Hash 找字符串循环周期的线性做法。

1.4.3 例题

P3763 [TJOI2017] DNA

给出模式串与文本串,求文本串有多少个子串与模式串最多三位不同。

考虑枚举起始位,然后三次二分即可。

P3498 [POI 2010] KOR-Beads

直接枚举 $k$,然后枚举所有段,对于 $k$ 的段数为 $\left\lfloor \dfrac{n}{k}\right\rfloor$。

总复杂度即为 $\mathcal O(\sum\limits_{k=1}^n \left\lfloor\dfrac{n}{k}\right\rfloor)\approx\mathcal O(\sum\limits_{k=1}^n \dfrac{n}{k})\approx\mathcal O(n\ln n)$。

CF1063F String Journey

给定长度 $\le 3\times 10^5$ 的字符串 $s$,找到最大的 $k$,使得从前往后可以选出 $k$ 个不同子串 $t_1,\cdots,t_k$ 使得 $t_{i+1}$ 是 $t_i$ 的 真子串

从后往前考虑,最后一位长度取 $1$ 必然最优。而若 $\vert t_{k-1} \vert> 2$,那么不如取 $\vert t_{k-1}\vert=2$ 更优。因此,从后往前第 $i$ 个子串,长度为 $i$ 最优。所以答案最大为 $\sqrt {2n}$。

根据这个性质,枚举每个答案 $k$。假设 $k=l+1$,设 $f_{l,i}$ 表示以 $i$ 为首个字符 $k=l$ 的后缀是否可行,在 dp 的过程中不断加入 $f_{l,k}=1$ 的串,然后到 $i$ 判断一下 $[i,i+l-1]$ 或者 $[i+1,i+l]$ 是否出现即可。

注意卡常。$\mathcal O(n\log n)$ 做法要二分 Hash,但没怎么想。

Submisson

2. Manacher

一种在 $\mathcal O(n)$ 的时间内求最长回文串的算法。

2.1 回文串

对于串 $s$,若 $\forall i\in [1,\vert s\vert]$,都有 $s_i=s_{\vert s\vert -i+1}$,则 $s$ 为 回文串

这实际上就是说 $s$ 是关于 $\dfrac {\vert s\vert+1}{2}$ 对称 的。对于奇回文串,其对称中心为中间的字符;对于偶回文串,$\dfrac{\vert s\vert+1}{2}$ 非整数,其对称中心为中间两数的间隙。

因此,奇回文串和偶回文串并不相同。而 Manacher 直接处理的是奇回文串。对于偶回文串,我们可以直接在所有间隙以及头尾间插入间隙字符,这样就可以转化奇回文串。为了统一二者,在奇回文串的空隙与两侧加上空隙字符,这样奇偶性不变,仍然是奇串。

假设回文中心为 $m$,若 $s[m-R+1\dots m+R-1]$ 这一子串是回文的,那么称 $R$ 是 $m$ 的一个 回文半径。 Manacher 所做的,就是找到每一个 $m$ 的 最长回文半径

2.2 算法流程

朴素做法

对于每一个 $m$,枚举半径 $R$。这个很好做,但是 $\mathcal O(n^2)$ 的。

观察 & 优化

设 $R_i$ 表示以 $i$ 为对称中心的最长回文半径,$r$ 为已经计算的,最大的 $m+R_m-1$。

对 $i$ 进行分类讨论:

  • $m<i\le r$:找到 $i$ 关于 $m$ 的对称点 $2m-i$,见下图,根据回文的性质,粉色段前部分与绿色段是对称的。因此,$R_i$ 的大小至少为 $\min(r-i+1,R_{2m-i})$。随后暴力拓展即可。

1

  • $m>r$,那么无法借用先前已有的值,暴力拓展。

这样,就是 Manacher 的整个流程。

为什么时间复杂度是 $\mathcal O(n)$ 呢?

考虑当前的最大的 $r$,这个东西限制了第二个情况的复杂度是 $\mathcal O(n)$。

继续看第一个情况的暴力拓展,若 $R_{2m-i}<r-i+1$,那么 $R_i=R_{2m-i}$,看上面的第二张图,${2m-i}$ 的左侧已经无法匹配,因此对称地 $i+R_{2m-i}$ 的右侧也无法匹配。也就是说每一次暴力拓展必然会使 $r$ 增大。

综上可见,暴力拓展必然会使 $r$ 变大。而 $r$ 最大取到 $n$,所以复杂度是 $\mathcal O(n)$。

这个复杂度同时也证明了一个性质:一个长度为 $n$ 的串本质不同的回文子串数不超过 $n$。

2.3 例题

P3805 【模板】manacher

下面给出代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const int N = 2.2e7 + 10;

int n;
int R[N];
char s[N];

void read () {
    s[n] = '~'; s[n = 1] = '|';
    char ch = getchar ();
    while (ch < 'a' || ch > 'z') ch = getchar ();
    while (ch >= 'a' && ch <= 'z') s[++ n] = ch, s[++ n] = '|', ch = getchar ();
}

int main (void) {

    read (); int ans = 1;
    for (int i = 1, r = 0, m = 0; i <= n; i ++) {
        if (i <= r) R[i] = min (R[2 * m - i], r - i + 1);
        while (s[i - R[i]] == s[i + R[i]]) ++ R[i];
        if (R[i] + i > r) r = R[i] + i - 1, m = i;
        if (R[i] > ans) ans = R[i];
    }
    printf ("%d\n", ans - 1);

    return 0;
}

P4287 [SHOI2011] 双倍回文

求长度为 $4$ 的倍数,且有两个相同回文串拼接成的子串的最长长度。$n\le 5\times 10^5$。

由于一个串本质不同的回文子串个数只有 $\mathcal O(n)$ 个,求出来判断即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int main (void) {
    
    scanf ("%d\n", &n); 
    s[0] = '~'; s[m = 1] = '|';
    for (int i = 1; i <= n; i ++) {
        char ch; scanf ("%c", &ch);
        s[++ m] = ch; s[++ m] = '|';
    } n = m;

    int ans = 0;
    for (int i = 1, r = 0, m = 0; i <= n; i ++) {
        if (i <= r) R[i] = min (r - i + 1, R[2 * m - i]);
        while (s[i + R[i]] == s[i - R[i]]) R[i] ++;
        if (R[i] + i > r) {
            if (i & 1) {
                for (int j = max (r, i + 4); j < R[i] + i; j ++)
                    if (!((j - i) % 4) && R[i - (j - i >> 1)] > j - i >> 1) ans = max (ans, j - i);
            }
            r = R[i] + i - 1, m = i;
        }
    }

    printf ("%d\n", ans);

    return 0;
}

3. Border 与 前缀函数

Border

当一个串 $s$ 同时是 $T$ 的 真前缀 以及是 $T$ 的 真后缀 时,称 $s$ 为串 $T$ 的一个 border。

前缀函数

$\pi(i)$ 表示串 $T[0\dots i]$ 的最长 border 长度。

3.2 计算前缀函数

朴素算法

很显然的一个观察是,$\pi(i+1)\le \pi(i)+1$。对于 $\pi(i)$,只需从 $T[\pi (i-1)+1$] 位向前匹配。代码很容易写:

1
2
3
4
5
6
7
for (int i = 1; i < n; i ++) {
	for (int j = pi[i - 1] + 1; i >= 0; i --) {
		if (T.substr (0, j) == T.substr (i - j + 1, j)) {
			pi[i] = j; break; 
		}
    }
}

考虑字符串比较最多的次数,最优的情况是每次恰好就匹配,次数是 $n-1$。最劣情况下,会比较所有轮。总比较次数仍然是 $\mathcal O(n)$。因此总复杂度为 $\mathcal O(n^2)$。

小优化

注意一个结论:

  • $S[1\dots i]$ 最长 border 长为 $\pi(i)$,次长 border 长度为 $\pi(\pi(i))$。第 $k$ 长 border 为嵌套 $k$ 层的 $\pi (i)$。这一点个人认为是比较显然的。假设最长 border 为 $k=\pi (i)$,存在比 $\pi(\pi (i))$ 长但比 $\pi(i)$ 短的 border,那么必然为 $S[1\dots k]$ 的前缀函数值,这个就是 $\pi(k)=\pi(\pi(i))$,矛盾。所以有这个结论。

根据这个结论,我们要计算 $\pi(i)$,我们只需不断地跳 $j=\pi(i-1)$,看下一位字符是否与 $S_i$ 匹配。匹配了就退出。

代码实现也十分简单,下面为以 $1$ 为首位的字符串。

1
2
3
4
5
for (int i = 2; i <= n; i ++) {
    int j = pi[i - 1];
    while (j && s[j + 1] != s[i]) j = pi[j];
    if (s[j + 1] == s[i]) pi[i] = j + 1;
}

复杂度为 $\mathcal O(n)$。这个其实可以势能分析的,但记住是这个复杂度就好。

3.3 KMP 以及其他应用

KMP

在一个文本串中找到模式串出现的所有位置。

一个很直观的做法,就是将模式串 $S$ 与文本串 $T$ 拼接:$S’=S+\mathbb{#}+T$。然后对 $S’$ 求前缀数组,每当 $\pi(i)=\vert S\vert$ 时就说明匹配成功。

另一个做法,其实和上面的做法大同小异。先求出模式串的前缀数组,然后维护一个 $p$ 表示文本串匹配到模式串的第 $p$ 位,然后仿照着前缀数组的做法做就好了。这一点其实就是对 $T$ 再求一遍前缀数组,不过前缀是 $S$。

周期

若 $p\in [1,\vert s\vert]$ 且 $\forall i\in[1,\vert s\vert-p],s_i=s_{i+p}$,则 $p$ 就是 $s$ 的一个周期。

很容易发现,假设 $\Pi$ 是 $s$ 的 border 集合,那么其对应着一个周期集合 $P$。$T$ 是一个长度为 $k$ 的 border $\iff$ $\vert s\vert -k$ 是 $s$ 的周期。

Weak Periodicity Lemma:若 $p$ 与 $q$ 都是 $s$ 的周期,且 $p+q\le \vert s\vert$,则 $\gcd (p,q)$ 也是 $s$ 的一个周期。

Periodicity Lemma:若 $p$ 与 $q$ 都是 $s$ 的周期,且 $p+q-\gcd (p,q)\le \vert s\vert$,则 $\gcd (p,q)$ 也是 $s$ 的一个周期。

失配树

由于 $\pi(i)<i$,考虑从 $\pi (i)$ 向 $i$ 连边。这样会构成一棵有根树,这棵有根树就是失配树。

失配树有良好的性质,从其构成看来,$u$ 的祖先就是他的一个 border。

P5829 【模板】失配树

求字符串前缀 $u,v$ 的最长公共 border。

就是 $u,v$ 在失配树上的 LCA。

3.4 exKMP/Z-Algorithm

和 KMP 没有关系,反而和 Manacher 很像。

3.4.1 定义与计算方法

定义一个字符串 $s$ 的 Z 函数 $z(i)=\vert\operatorname{lcp}(s,s[i\dots n])\vert$ 。即 $s$ 与 $s[i\dots n]$ 的最长公共前缀长度。称 $[i,i+z(i)-1]$ 为 $i$ 的 匹配段。算法流程中,记录 $r$ 最大的匹配段 $[l,r]$。对当前 $i$ 进行讨论:

  • 若 $i\le r$,由于 $s[1,r-l+1]=s[l,r]$,因而 $s[i,r]=s[1+i-l,r-l+1]$。故 $z(i)\gets\min(z(i-l+1),r-i+1)$,然后暴力匹配。
  • 若 $i>r$,直接暴力匹配。

这和 Manacher 几乎一模一样啊!

时间复杂度也是同样地分析,$\mathcal O(n)$,这里就不赘述了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void exkmp (char* s) {
    n = strlen (s + 1);
    z[1] = n; ans1 = n + 1;
    for (int i = 2, l = 0, r = 0; i <= n; i ++) {
        if (i <= r) z[i] = min (z[1 + i - l], r - i + 1);
        while (z[i] + i <= n && s[z[i] + 1] == s[i + z[i]]) ++ z[i];
        if (i + z[i] > r) r = i + z[i] - 1, l = i;
        ans1 = ans1 ^ ((z[i] + 1) * 1ll * i);
    } 
}

3.4.2 例题

P5410 【模板】扩展 KMP/exKMP(Z 函数)

第二个操作可以直接把 $a$ 接到 $b$ 后面,这里根据 Z 函数的性质算。和 KMP 的匹配是一样的。

1
2
3
4
5
6
7
8
exkmp (a);
m = strlen (b + 1);
for (int i = 1, l = 0, r = 0; i <= m; i ++) {
    if (i <= r) z2[i] = min (r - i + 1, z[i - l + 1]);
    while (z2[i] + i <= m && z2[i] + 1 <= n && a[ z2[i] + 1 ] == b[ z2[i] + i ]) ++ z2[i];
    if (i + z2[i] > r) r = i + z2[i] - 1, l = i;
    ans2 = ans2 ^ ((z2[i] + 1) * 1ll * i);
}

P9576 「TAOI-2」Ciallo~(∠・ω< )⌒★

给定串 $s,t$,求有多少对四元组 $(l,r,l’,r’)$,使删掉 $s$ 的区间 $[l,r]$ 后,$s’[l’:r’]$ 是 $t$。

为了方便叙述,这里的 $l’,r’$ 映射到原字符串的相应位置上。

思考发现,可以将四元组分成两类:

  • 当 $[l’,r’]\bigcap[l,r]=\varnothing$ 时,即选的 $[l’,r’]$ 就是原字符串的子串时。只需要找到所有与 $t$ 匹配的子串,这个贡献就是与子串无交的所有区间数。假设 $i$ 为匹配 $t$ 的左端点,那么贡献就是 $\dfrac {i(i-1)}2+\dfrac {[n-(i+m-1)][n-(i+m-1)+1]}{2}$。
  • 当 $[l,r]\subseteq[l’,r’]$ 时,即 $[l’,r’]$ 是前后拼接后形成 $t$ 时。对于每个位置,维护与 $t$ 匹配的 LCP 以及 LCS(最长公共前/后缀)。若一对 $[l’,r’]$ 是合法的,设 $a_i=\operatorname{LCP}(T,S[i,n]),b_i=\operatorname{LCS}(T,S[1,i])$,则必然有 $a_{l’}+b_{r’}\ge\vert T\vert$ 且 $r’-l’\ge \vert T\vert$。会发现这满足了二维偏序的关系,于是用树状数组来维护。一对合法的 $[l’,r’]$,对答案的贡献就是 $a_{l’}+b_{r’}-\vert T\vert+1$。

![2][/img/string2.png]

这里详细说一下 Case 2 的做法。删除的区间一定不能有端点与 $l’,r’$ 之一重合,否则会出现和 Case 1 算重的情况。这个情况会在 $b_{r’}=m$ 的时候或者 $a_{l’}=m$ 出现,减掉即可。另一方面,我们在树状数组中存 $m-a_i$,并整体向右移一位,这样防止出现 $0$ 的情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <cstring>
#include <iostream>
#include <algorithm>

#define ll long long
#define int long long

using namespace std;

const int N = 4e5 + 10;

struct BIT {
    ll t[N], n;
    void init (int x) { n = x + 1; for (int i = 1; i <= n; i ++) t[i] = 0; }
    int lowbit (int x) { return x & -x; }
    void add (int x, int k) { x ++; for (; x <= n; x += lowbit (x)) t[x] += k; }
    ll qry (int x) {  ++ x; int ret = 0; for (; x > 0; x -= lowbit (x)) ret += t[x]; return ret; }
} cnt, sum;

int z[N], lcp[N], lcs[N];
char s[N], t[N];

void Z (int* z, char* s) {
    int n = strlen (s + 1);
    z[1] = n;
    for (int i = 2, l = 0, r = 0; i <= n; i ++) {
        z[i] = 0;
        if (i <= r) z[i] = min (r - i + 1, z[i - l + 1]);
        while (z[i] + i <= n && s[ z[i] + i ] == s[ z[i] + 1 ]) ++ z[i];
        if (z[i] + i > r) r = z[i] + i - 1, l = i;
    }
}

void getval (char* s, char* t, int* p) {
    int n = strlen (s + 1), m = strlen (t + 1); 
    for (int i = 1, l = 0, r = 0; i <= n; i ++) {
        if (i <= r) p[i] = min (r - i + 1, z[i - l + 1]);
        while (p[i] + i <= n && p[i] + 1 <= m && s[p[i] + i] == t[p[i] + 1]) ++ p[i];
        if (p[i] + i > r) r = p[i] + i - 1, l = i;
    }
} 

ll choose (ll num) {
    return num * 1ll * (num + 1) / 2;
}

signed main (void) {

    scanf ("%s%s", s + 1, t + 1);
    Z (z, t); getval (s, t, lcp); int n = strlen (s + 1), m = strlen (t + 1);
    reverse (t + 1, t + m + 1); reverse (s + 1, s + n + 1); Z (z, t); 
    getval (s, t, lcs);   reverse (lcs + 1, lcs + n + 1);

    ll ans = 0; cnt.init (m + 5), sum.init (m + 5);
    for (int i = 1; i <= n; i ++) if (lcp[i] == m) ans += choose (i - 1) + choose (n - (i + m - 1));
    for (int i = m + 1; i <= n; ++i) {
        cnt.add (m - lcp[i - m], 1);
        sum.add (m - lcp[i - m], lcp[i - m]);
        ans += sum.qry (lcs[i]) + cnt.qry (lcs[i]) * (lcs[i] - m + 1);
        if(lcs[i] == m) ans -= cnt.qry (lcs[i]); 
        ans -= cnt.qry (0);    
    }
    printf ("%lld\n", ans);

    return 0;
}

4. AC 自动机

4.1 前置芝士

4.1.1 确定有限状态自动机(Deterministic Finite Automaton, DFA)

可以理解为,一个只接受 $\Sigma$ 集合元素的一个有向图。有若干个状态和一个起始状态,从前往后扫描每个字符,下一个状态仅由当前状态和当前字符决定。

4.1.2 Trie

4.2 算法流程

AC 自动机(Aho-Corasick Automaton, ACAM)是一个 基于 Trie 的结构,结合 KMP 建立的 DFA。实际上是 Trie 上的自动机。

4.2.1 $fail$ 的构造

考虑一个基础问题:给出文本串 $T$,以及 $n$ 个模式串 $s_1,\cdots,s_n$。求每个模式串在文本串上出现次数。

若 $n=1$,这就是普通的 KMP。我们维护的前缀数组记录了所有前缀的最长 border,对于任意 $i$,我们可以不超过 $\vert\Sigma\vert$ 次就找到其最长 border。

考虑对所有的模式串构建 Trie。类比上述做法,对于每个 Trie 上节点 $u$ 维护 $fail_u$,表示 所有模式串与 $u$ 后缀匹配的最长前缀 的节点编号。可以类比 KMP 的做法,维护 $fail_u$ 指针。假设已经维护了根节点到 $u$ 父节点的所有 $fail_u$,那么就跳到 $fail_{fa{u}}$ 上,检查有无 $s_u$ 这个子节点。有则令 $fail_u\gets son(fail{fa_u})$,无则继续跳 $fail_{fail_{fa_u}}$。显然,根据 border 理论,第一个找到的存在 $s_u$ 的节点就是 $fail_u$。这个“跳”的操作,实则上可以直接优化:对于每个没有 $v$ 子节点的节点 $u$,直接将 $v$ 设为 $fail_u$ 的 $v$ 子节点。这样向上继承,保证每个节点的转移都是 $\mathcal O(1)$,进而总复杂度 $\mathcal O(n\vert\Sigma\vert)$。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int insert (char* s, int id) {
    int p = 0; int n = strlen (s);
    for (int i = 0; i < n; i ++) {
        int now = s[i] - 'a';
        if (!t[p].ch[now]) t[p].ch[now] = ++ cnt;
        p = t[p].ch[now];
    }
    t[p].num ++; 
    t[p].id.push_back (id);
    return p;
}

void build () {
    queue <int> q;
    for (int i = 0; i < 26; i ++) if (t[0].ch[i]) t[ t[0].ch[i] ].nxt = 0, q.push (t[0].ch[i]), t[0].deg ++;
    while (!q.empty ()) {
        int u = q.front (); q.pop ();
        for (int i = 0; i < 26; i ++) {
            int& v = t[u].ch[i];
            if (v) t[v].nxt = t[ t[u].nxt ].ch[i], t[ t[t[u].nxt].ch[i] ].deg ++, q.push (v);
            else   v = t[ t[u].nxt ].ch[i];
        }
    }
}

特别的是,当字符集很大时,这种复杂度可能无法满足要求。可以考虑 可持久化线段树 的优化(TBD)。

4.2.2 匹配

先把文本串塞到 Trie 里。在 Trie 遍历文本串时,不断跳 $fail_u$。经过的所有节点到根节点形成的串,都是文本串的一个子串。直接计数即可。

1
2
3
4
5
6
7
8
9
void Aho_Corasick (char* s) {
    int len = strlen (s), p = 0;
    for (int i = 0; i < len; i ++) {
        int ch = s[i] - 'a'; // 不妨假设为小写字符集
        p = son[p][ch];
        for (int v = p; v; v = fail[v])
            ++ num[v];
    }
}

这个也很好卡,全为相同字符的模式串与文本串,这就退化到了 $\mathcal O(nm)$。

注意一个性质:$fail$ 指针形成的是一个 有向无环图,根据这个性质考虑优化。

4.2.3 匹配的优化

拓扑排序优化

在 Trie 上遍历文本串时,只对 $fail_u$ 进行计数 $num(fail_u)\gets num(fail_u)+1$,并不往上跳。然后对 $fail$ 构成的 DAG 跑拓扑排序。对于 $u$ 指向的点 $v$,$num(v)\gets num(v)+num(u)$。这样复杂度就降到 $\mathcal O(n+m)$。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void toposort () {
    queue <int> q;
    for (int i = 0; i <= cnt; i ++) if (!t[i].deg) q.push (i);
    while (!q.empty ()) {
        int u = q.front (); q.pop ();
        for (auto w : t[u].id) ans[w] = t[u].cnt; // 若存在相同模式串
        int v = t[u].nxt;
        t[v].cnt += t[u].cnt;
        if (!-- t[v].deg) q.push (v);
    }
}

dfs 优化

注意到每个点都只有一个出边,因此这构成一个 树形 结构,称为**「$fail$ 树」**。$fail$ 树的节点 $u$ 的子树,显然是包含 $rt\to u$ 这个串的集合。故可以树形 dp 优化,复杂度为 $\mathcal O(n+m)$,与拓扑优化异曲同工。

4.3 $fail$ 树

前面已经说过,$fail$ 指针的结构构成一棵树,具有机器良好的性质。

4.3.1 性质

  1. 是一棵 有根树
  2. 对于树上节点 $u$,设根节点到 $u$ 构成的串为 $s(u)$。那么有 $ v\in\operatorname{subtree}(u)\iff s(u) \text{ is a suffix of } s(v)$。

根据性质 2,可以找到一个好用的结论:若 $s$ 为 $t$ 的子串,那么 $s$ 必然为 $t$ 某个前缀的后缀

4.3.2 例题

P2414 [NOI2011] 阿狸的打字机

4.4 AC 自动机上 DP

通常见于“存在子串”的一类最优化或者计数问题。

4.4.1 例题

P4052 [JSOI2007] 文本生成器

给出 $n$ 个模式串,求长度为 $m$ 的文本串使得存在至少一个模式串为其子串。$m\le 100,\vert s_i\vert\le 100,n\le60$

考虑补集转化,相当于求长度为 $m$ 的文本串不存在子串为模式串的个数。

设 $f(i,j)$ 表示长度为 $i$,以 Trie 树上 $i$ 节点为结尾的合法串数量。若 $j$ 与其子 $k$ 都合法,那么 $j$ 就可以转移到 $k$。关于合法性,实际上是在跳 fail 指针,$u$ 在 fail 树上到根节点上没有终止节点就是合法的。另外,Trie 上 $u$ 到根的链上点必须都是合法。

容易写出转移方程:$f(i+1,ch[j][k])\gets f(i+1,ch[j][k])+f(i,j)$。

注意最终根节点的贡献,实际上就是不以任何模式串前缀为后缀的贡献。

1
2
3
4
5
dp[0][0] = 1;
for (int i = 0; i < m; i ++) for (int j = 0; j <= cnt; j ++) for (int k = 0; k < 26; k ++)
    if (!bad[ch[j][k]]) (dp[i + 1][ ch[j][k] ] += dp[i][j]) %= p;
int ans = qpow (26, m);
for (int i = 0; i <= cnt; i ++) ans = (ans - dp[m][i] + p) % p;

ABC419F All Included

给出 $n\le 8$ 个长度 $\le 10$ 的模式串 $s_i$,求长度为 $L\le100$ 的文本串,使所有模式串都在文本串内出现的文本串的数量。

设 $f(i,S,k)$ 表示填了前 $i$ 位,已经出现过的文本串集合为 $S$,当前在 Trie 树上的节点为 $k$ 的文本串数量。直接填下一位,即 $f(i+1,S\cup num_j,ch(k,j))\gets f(i,S,j)$,其中 $num_j$ 表示 Trie 上节点 $j$ 包含的模式串集合。

Submission

P2292 [HNOI2004] L 语言

给出 $n\le 20$ 个单词 $\vert s_i\vert\le2$0,$m$ 次询问,每次给出一个文本串 $\vert t\vert \le 2\times 10^6$,求 $t$ 最长前缀使前缀为单词组成。

每一次的询问显然无关。设 $f_i$ 表示 $t[1,i]$ 是否可以作为合法前缀,当 $f_i=1$,且 $t[i+1,j]\in S$ 时 $f_j=1$。考虑如何对 $t[i+1,j]\in S$ 进行刻画。

对于每个前缀,可以维护一个集合 $msk$,第 $i$ 位表示从第 $i$ 位的后缀是一个单词。在计算文本串 $t$ 是否合法的过程中,由于 $\vert s_i\vert \le 20$,故对于 $t$ 上的每一位只用看前 $20$ 位,直接遍历即可。

当然,这一步也可以压缩。设 $dp$ 表示前 20 位的 $f$ 值,位运算操作即可。

时间复杂度为 $\mathcal O(\sum\vert t\vert\cdot\vert s\vert)$。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void insert (char* s) {
    int p = 0; int n = strlen (s + 1);
    for (int i = 1; i <= n; i ++) {
        int now = s[i] - 'a';
        if (!ch[p][now]) ch[p][now] = ++ cnt;
        p = ch[p][now]; 
    }
    msk[p] |= (1 << n);
}

int fail[N];

void build () {
    queue <int> q; 
    for (int i = 0; i < 26; i ++) if (ch[0][i]) q.push (ch[0][i]);
    while (!q.empty ()) {
        int u = q.front (); q.pop ();
        msk[u] |= msk[fail[u]];
        for (int i = 0; i < 26; i ++) {
            int& v = ch[u][i]; 
            if (v) fail[v] = ch[ fail[u] ][i], q.push (v);
            else   v = ch[ fail[u] ][i];
        }
    }
}

char ask[M];

int cacl (char* s) {
    int n = strlen (s + 1);
    int p = 0, ans = 0;
    f[0] = 1; int stat = 1;
    for (int i = 1; i <= n; i ++) {
        p = ch[p][s[i] - 'a'];
        int now = msk[p]; stat <<= 1;
        if (now & stat) ans = i, stat |= 1;
        stat &= (1 << 21) - 1;
    }
    return ans;
}

CF696D Legen…

给出 $n\le 200$ 个模式串 $\vert s_i\vert\le200$,每个模式串有价值。定义一个文本串的价值为每个模式串出现次数与对应价值的乘积。构造长度为 $l\le10^{14}$ 的串,使价值最大。

设 $f_{i,j}$ 表示长度为 $i$,在 Trie 上节点为 $j$ 的最大价值,设 $val_{i}$ 表示以节点 $i$ 位后缀的单词价值之和。显然有 $f_{i+1,ch(j,k)}\gets \max f_{i,j}+val_{ch_{j,k}}$。其实这个 $val_{ch{j,k}}$ 也能写成 $val_{j,ch_{j,k}}$,那么转移式就是 $f_{i+1,ch(j,k)}\gets \max f_{i,j}+val_{j,ch_{j,k}}$,第一维直接扔掉。啊发现这不就是矩阵乘法的形式吗!

直接做就完了。

Submission

P3715 [BJOI2017] 魔法咒语

给出 $n$ 基本串以及 $m$ 个非法串。求由基本串构成的长度 $L$ 且子串不含非法串的串个数。

基本串长度之和 $\le 100$,非法串长度之和 $\le 100$。

将非法串也插入 Trie 中,并对末尾进行非法标记。

设 $f(i,j)$ 表示填完前 $i$ 个元素后,状态在 Trie 的 $j$ 节点上的方案数。边界条件显然更为严格,当 $ch(i,k)$ 为基本串在 Trie 上节点且不是非法串末尾时,转移 $f(i+1,ch(j,k))\gets f(i,j)$。

这个形式显然是可以直接矩阵优化的。

P5319 [BJOI2019] 奥术神杖

给定 $m$ 个模式串 $s_i$,有权值 $w_i$。有一个存在一些空位的长度 $n\le1501$ 的文本串,补全,使出现的模式串权值几何平均值最大(重复算多次)。模式串长度之和不超过 $1501$。

类比算术平均数的分数规划思想,几何平均数的最大化也可以用二分解决:

$$(\prod w_i)^{\frac 1k}\ge mid\iff \prod w_i \ge mid^k\iff\prod \dfrac {w_i}{mid} \ge 1$$

$$(\prod w_i)^{\frac 1k}\ge \exp(mid)\iff \dfrac 1k\cdot \sum \ln w_i \ge mid\iff\sum (\ln w_i-mid) \ge 0$$

相比之下,第二个取对数的精度更高,速度更快。(但我写的时候按照第一个写的,浮点乘法导致速度奇慢无比。因此,下面按照第一个来讲。)

然后就能 dp,设 $f(i,j)$ 表示已经填了前 $i$ 位,在 Trie 的 $j$ 号节点的最大值,有转移式:$f(i+1,ch(j,k))\gets val_{k}\times\max f(i,j)$,这里的 $val_k$ 表示以 $k$ 节点为后缀的单词的价值之积。每一次二分都重新 build 一下。

这道题输出的是方案,于是在 dp 的过程中记录一下方案即可。

时间复杂度 $\mathcal O(n\sum\vert s_i\vert\log V)$ 。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
#include <queue>
#include <cstring>
#include <iostream>

using namespace std;

const int N = 1600;
const int p = 998244353;

int n, l;
int ch[N][10], nch[N][10], num[N], cnt;
double val[N], w[N];

void insert (char* s, int k) {

    int p = 0; int n = strlen (s + 1);
    for (int i = 1; i <= n; i ++) {
        int now = s[i] - '0';
        if (!ch[p][now]) nch[p][now] = ch[p][now] = ++ cnt;
        p = ch[p][now]; 
        num[p] = now;
    }
    w[p] = k;
}

int fail[N];

void build (double mid) {
    for (int i = 0; i <= cnt; i ++) fail[i] = 0;
    queue <int> q; while (!q.empty ()) q.pop ();
    for (int i = 0; i < 10; i ++) if (ch[0][i]) q.push (ch[0][i]);
    while (!q.empty ()) {
        int u = q.front (); q.pop ();
        for (int i = 0; i < 10; i ++) {
            int& v = ch[u][i]; 
            if (v) val[v] *= val[fail[v] = ch[ fail[u] ][i]], q.push (v);
            else   v = ch[ fail[u] ][i];
        }
    }
}

char T[N], s[N];
double f[N][N];
int where[N][N], vv[N][N];
vector <int> from, ans;

bool check (double mid) {
    for (int i = 0; i <= cnt; i ++) {
        if (w[i] > 0) val[i] = w[i] / mid; else val[i] = 1;
        for (int j = 0; j < 10; j ++) ch[i][j] = nch[i][j];
        f[0][i] = -1;
    }
    build (mid);
    from.assign (l + 1, -1);
    f[0][0] = 1;
    for (int i = 0; i < l; i ++) {
        for (int j = 0; j <= cnt; j ++) f[i + 1][j] = -1;
        for (int j = 0; j <= cnt; j ++) {
            if (T[1 + i] == '.') 
                for (int k = 0; k < 10; k ++) {
                    double lst = f[i][j] * val[ch[j][k]];
                    if (lst > f[i + 1][ch[j][k]])
                        f[i + 1][ch[j][k]] = lst, where[i + 1][ch[j][k]] = j, vv[i + 1][ch[j][k]] = k;
                }
            else {
                double from = f[i][j] * val[ch[j][T[i + 1] - '0']];
                if (f[i + 1][ch[j][T[i + 1] - '0']] < from)
                    f[i + 1][ch[j][T[i + 1] - '0']] = from, vv[i + 1][ch[j][T[i + 1] - '0']] = T[i + 1] - '0',
                    where[i + 1][ch[j][T[i + 1] - '0']] = j;
            }
        }
    }
    double ret = -1; int s = 0;
    for (int i = 0; i <= cnt; i ++) {
        if (f[l][i] > ret) {
            s = i;
            ret = f[l][i];
        }
    }
    for (int i = l; i >= 1; i --) {
        from[i] = vv[i][s];
        s = where[i][s];
    }
    return ret > 1;
}

int main (void) {

    scanf ("%d%d", &l, &n); scanf ("%s", T + 1);
    while (n --) { int v;
        scanf ("%s%d", s + 1, &v);
        insert (s, v);
    } 

    double ll = 1, r = 1e9; int ccc = 100;
    while (ccc --) {
        double mid = (ll + r) * 0.5;
        if (check (mid)) ll = mid, ans = from;
        else             r = mid;
    }

    for (int i = 1; i <= l; i ++) printf ("%d", T[i] != '.' ? T[i] - '0' : ans[i]);
    puts ("");

    return 0;
}

杂项

最小表示法

© 2025 - 2026 Toorean's Blog

🌱 Powered by Hugo with theme Dream.

About Me

来自安徽合肥。

lhxx(2015) -> hfsd48zx(2021) -> hf1z(2024) -> ?