字符串
字符串,就是由字符连接而成的序列。
常见的字符串问题包括字符串匹配问题、子串相关问题、前缀/后缀相关问题、回文串相关问题、子序列相关问题等。
字符串哈希
Hash
的核心思想在于,将输入映射到一个值域较小、可以方便比较的范围。
双值哈希 / 前缀哈希序列
令 f i (s ) = f (s [1..i ])
为字符串 s 的前缀哈希序列,预处理字符串 s 的前缀哈希序列。
1 2 3 4 5 6 constexpr u64 M = 1e9 + 7 , M2 = (u64) 1e16 + 61 , B = 133 ;vector<u64> hs1 (n + 2 ) , hs2 (n + 2 ) ;for (int i = 1 ; i <= n; i++) { hs1[i] = (hs1[i - 1 ] * B + s[i - 1 ]) % M; hs2[i] = (hs2[i - 1 ] * B + s[i - 1 ]) % M2; }
获取子串哈希值
注意到 f (s [l + 1..r ]) = f r (s ) − f l (s ) × b r − l
。
其中 b r − l
可以 O (n )
的预处理出来然后 O (1)
的回答每次询问。
对于回文子串问题,可以预处理字符串 s 与其反转字符串的前缀哈希序列,并
O (1) 的判断回文子串。
注意下列实现中查询区间左开右闭 ,即查询 f (s [l + 1..r ]) 。
1 2 3 4 5 6 7 8 vector<u64> b1 (n + 2 , 1 ) , b2 (n + 2 , 1 ) for (int i = 1 ; i <= n; i++) { b1[i] = b1[i - 1 ] * B % M; b2[i] = b2[i - 1 ] * B % M2; } u64 h1 = (hs1[r] - hs1[l] * b1[r - l] % M + M) % M; u64 h2 = (hs2[r] - (u128) hs2[l] * b2[r - l] % M2 + M2) % M2;
前缀函数
定义 border 为字符串一对相同的真前缀与真后缀,定义字符串
p a t
的前缀函数值 π [i ]
为前缀 i 的最长
border 长度。特别地,π [0] = 0 。
如果前缀 i 的有长度为 l e n 的
border ,则该前缀长度仅次于 l e n 的第二长
border 长度为 π [l e n − 1]
,通过不断地令 l e n = π [l e n − 1]
可遍历所有 border 长度。
利用前缀函数的性质可以解决一下问题:
字符串的所有周期 :由 s 有长度为 r 的 border 可推出
|s |−r 为 s 的一个周期,通过遍历所有
border 长度,可以确定所有周期。
字符串的最小周期 :最长 border
确定最小周期,即 n − π [n − 1] 为
s 的最小周期。
字符串压缩 : 令最短周期 k = n − π [n − 1] ,且
n 能被 k 整除,则字符串可由长度为 k
的子串重复拼接表示,否则不存在一个有效的压缩。
统计每个前缀的出现次数 :通过每个后缀的 π [i ] 为其最长
border 设置初始值,然后逆向递推
ans[pre[i - 1]] += ans[i] ,再加上每个前缀本身即可。
一个字符串中本质不同子串的数目 :利用前缀函数增量计算,每次添加新字符时,新增的不同子串数目为
(i + 1) − π [i ] 。
以下代码可以在 O (n )
的时间复杂度内处理出字符串 p a t 的前缀函数
p i 。
1 2 3 4 5 6 7 8 9 10 11 vector<int > pi; void init (const string &pat) { int n = pat.size (); pi.resize (n); for (int i = 1 , j = 0 ; i < n; i++) { while (j > 0 && pat[i] != pat[j]) j = pi[j - 1 ]; if (pat[i] == pat[j]) j++; pi[i] = j; } }
KMP 算法
在预处理出模式串 p a t 的前缀函数
p i
后,查询模式串在文本中的所有匹配位置(即匹配子串的末位置),时间复杂度
O (n + m )
。
1 2 3 4 5 6 7 8 9 10 vector<int > kmp (const string &txt, const string &pat) { vector<int > res; int n = pat.size (); for (int i = 0 , j = 0 ; i < txt.size (); i++) { while (j > 0 && txt[i] != pat[j]) j = pi[j - 1 ]; if (txt[i] == pat[j]) j++; if (j == n) res.push_back (i), j = pi[j - 1 ]; } return res; }
KMP 自动机
通过 KMP 的转移,可以构建一个自动机,其状态为当前的前缀函数值 j ,而从一个状态到另一个状态的转移则由下一个字符确定,匹配全串后的状态(即接受状态)为
|s | 。
通过构建该自动机及合理保存不仅可以解决动态的匹配问题(如树上匹配),还可以解决在通过一些规则构造的巨型字符串上的匹配问题,即定义
G [i ][s ]
为状态 i 接受特殊字符串 s
后的状态,然后通过字符串构建规则转移该自动机与统计答案。
1 2 3 4 5 6 7 8 9 10 11 12 13 vector<vector<int >> aut; void compute (string s) { s += '#' ; int n = s.size (); 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]); } } }
Manacher 算法
Manacher 算法可以在 Θ (n )
的时间内处理出回文半径数组,原字符串下标 i 的字符对应新字符串下标 i * 2 + 1
,回文串判断函数左闭右闭实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 vector<int > p; auto manacher = [&](const string &str) { string t = "#" ; for (char ch: str) t += ch, t += "#" ; p.resize (t.size ()); int len = (int ) t.size (), c = 0 , r = 0 ; for (int i = 1 ; i < len - 1 ; ++i) { if (i < r) p[i] = min (r - i, p[2 * c - i]); while (i + p[i] + 1 < len && i - p[i] - 1 >= 0 && t[i + p[i] + 1 ] == t[i - p[i] - 1 ]) p[i]++; if (i + p[i] > r) c = i, r = i + p[i]; } }; manacher (s);auto palindromic = [&](int l, int r) { return p[l + r + 1 ] >= r - l + 1 ; }
字典树
一个像字典一样的树,用边来代表字符,从根结点到树上某一结点的路径就代表了一个字符串。将结点视为状态,则字典树可以看作一种自动机。
普通字典树
一个动态开点的普通字典树,维护了边数 s z 、结点对应字符串的数量
c n t
和结点对应字符串作为前缀的字符串 p r e 数量,其中
get 函数定义了字符到编号的转换。
支持新增字符串、删除字符串与查询字符串在 Trie
上对应结点(不存在则返回 0),单次操作时间复杂度均为 O (L ) ,插入操作空间复杂度
O (Σ L ) ,其中
L 为字符串长度,Σ 为字符集大小。
注意 array 的动态开点需求 c++17,若版本不足需要改为 vector
实现,或在构造函数提前开好足量 c h 数组大小,并修改
create 函数。
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 struct Trie { int sz; vector<array<int , 26> > ch; vector<int > pre, cnt; explicit Trie () : ch(1 ), pre(1 ), cnt(1 ) { sz = 0 ; } static int id (char c) { return c - 'a' ; } int create () { int x = ch.size (); ch.emplace_back (); pre.push_back (0 ); cnt.push_back (0 ); return x; } void insert (const string &s) { int p = 0 ; pre[p]++; for (char c: s) { int i = id (c); if (!ch[p][i]) ch[p][i] = create (); p = ch[p][i]; sz += pre[p]++ == 0 ; } cnt[p]++; } void remove (const string &s) { int p = 0 ; --pre[p]; for (char c: s) { int i = id (c); p = ch[p][i]; sz -= --pre[p] == 0 ; } --cnt[p]; } int find (const string &s) { int p = 0 ; for (char c: s) { int i = id (c); if (!ch[p][i]) return 0 ; p = ch[p][i]; } return p; } };
01 字典树
将数的二进制表示看做一个字符串,就可以建出字符集为 {0, 1} 的 trie 树。
insert(u, x, k) 表示向以 u 节点为根的字典树中插入 k 个值 x 。
query(u, x) 表示在 u
节点为根的字典树中贪心以查询字典树中的值异或上 x 后的最大值 。
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 struct Trie { static constexpr int mxb = 30 ; vector<array<int , 2> > ch; vector<int > pre; Trie () : ch (1 ), pre (1 ) { } int create () { int x = ch.size (); ch.emplace_back (); pre.push_back (0 ); return x; } void insert (int u, int x, int k) { pre[u] += k; for (int i = mxb; i >= 0 ; i--) { int b = x >> i & 1 ; if (!ch[u][b]) ch[u][b] = create (); pre[ch[u][b]] += k; u = ch[u][b]; } } int query (int u, int x) const { int res = 0 ; for (int i = mxb; i >= 0 ; i--) { int b = x >> i & 1 ; if (pre[ch[u][b ^ 1 ]]) { u = ch[u][b ^ 1 ], res |= 1 << i; } else { u = ch[u][b]; } } return res; } };
AC 自动机
AC(Aho–Corasick)自动机是以 Trie 的结构为基础,结合 KMP
的思想建立的自动机。沿文本串 s
的字符顺序在 AC 自动机转移到的状态对应 s 在 Trie 中的最长匹配后缀。
以下实现基于动态开点的普通字典树,包含插入所有模式串和查询字符串在
Trie 上对应结点,复杂度同普通字典树。
调用 build() 对 Trie 树上所有的结点 p 构造失配指针 f a [p ] (在 Trie
的结点中的最长真后缀),同时在 Trie 的基础上构建失配树的邻接表 a d j 和 AC
自动机的邻接表 c h
。
调用 dfs(0) 以对所有的结点 p 构建后缀链接 l k [p ] ,代表 p 所对应的字符串在所有模式串(即
c n t 非 0 的串)中的最长真后缀。
调用 match(s) 对文本串 s 执行匹配,采用暴力跳 l k 指针统计答案,总复杂度
$O(S\sqrt{T})$ ,其中 S
为文本串长度,但该复杂度通常不紧,常数较小。
设匹配串总长度为 T
,由于每个结点 p
对应的所有匹配串长度均不同,s
的任意位置匹配的模式串个数为 $O(\sqrt{T})$ ,即任意结点 p 跳后缀链接 l k 的次数为 $O(\sqrt{T})$ ,优于跳 f a 指针的 O (T ) 。
在一些特殊的问题上可以修改 match 统计答案的方式,再修改
dfs 函数以沿着失配树 DP
达到线性复杂度统计答案,如匹配多模式串时状态 p 对所有失配树上的所有祖先有匹配次数
+1 ,则可先记录 p 结点匹配次数,再在失配树上 DP
求每个结点的子树中匹配次数和。
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 struct ACAutomaton { vector<array<int , 26> > ch; vector<vector<int > > adj; vector<int > cnt, ans, fa, lk; ACAutomaton () : ch (1 ), adj (1 ), cnt (1 ), ans (1 ), fa (1 ), lk (1 ) { } static int id (char c) { return c - 'a' ; } int create () { int x = ch.size (); ch.emplace_back (); adj.emplace_back (); cnt.push_back (0 ); ans.push_back (0 ); fa.push_back (0 ); lk.push_back (0 ); return x; } void insert (const string &s) { int p = 0 ; for (char c: s) { int i = id (c); if (!ch[p][i]) ch[p][i] = create (); p = ch[p][i]; } cnt[p]++; } int find (const string &s) { int p = 0 ; for (char c: s) { int i = id (c); if (!ch[p][i]) return 0 ; p = ch[p][i]; } return p; } void build () { queue<int > q; for (int p: ch[0 ]) { if (!p) continue ; q.push (p); adj[0 ].push_back (p); } while (!q.empty ()) { int u = q.front (); q.pop (); for (int i = 0 ; i < 26 ; i++) { int &p = ch[u][i]; if (p) { adj[ch[fa[u]][i]].push_back (p); fa[p] = ch[fa[u]][i]; q.push (p); } else { p = ch[fa[u]][i]; } } } } void dfs (int u) { for (int v: adj[u]) { lk[v] = cnt[u] ? u : lk[u]; dfs (v); } } void match (const string &s) { int p = 0 ; for (char c: s) { p = ch[p][id (c)]; for (int i = p; i; i = lk[i]) ans[i]++; } } };
后缀数组
后缀数组是一种将字符串所有后缀按字典序排序的字符串数据结构,以下经典问题均可以使用后缀数组解决:
两子串最长公共前缀 :直接使用 SA 的 LCP 数组结合 RMQ
快速查询任意两后缀的 LCP 。
两子串的大小关系 :假设需要比较的是 A = S[a..b] 和 B
= S[c..d] 的大小关系,若 lcp(a , c ) ≥ min (|A |,|B |)
,A < B ⇔ |A | < |B |
,否则,A < B ⇔ rk[a ] < rk[c ]
。
不同子串的数目 :总子串数减去相邻后缀的 LCP 之和,即
$\frac{n(n+1)}{2} - \sum\text{ht}[i]$
。
两串最长公共子串 :拼接两字符串并加分隔符,在后缀数组上属于不同串的相邻后缀的
LCP 的最大值即为答案。
多串最长公共子串 :拼接所有串并加分隔符,在 SA
上滑动窗口求包含所有串的区间的多串 LCP 最大值。
出现至少 k 次的子串的最大长度 :出现至少 k 次意味着后缀排序后有至少连续 k 个后缀以这个子串作为公共前缀,求出每相邻
k − 1 个 ht 的最小值,再求这些最小值的最大值就是答案,可以使用滑动窗口解决。
字符串循环移位最小表示 :将字符串复制一份接在后面,构建
SA 后找首个长度 ≥n 的后缀起始位置。
在字符串中在线找子串 :先构造出 t x t
的后缀数组,我们可以通过在 s a
数组中二分并暴力比较后缀与模式串 p a t
,找到其出现位置,总时间复杂度为 O (|S |log |T |) ,出现次数可以通过再次二分找到。
以上只是后缀数组所能解决的问题中的一小部分,在使用后缀数组时,常常从以下角度思考:
对子串问题,任何子串都是某个后缀的前缀,将对子串操作转化为对后缀的操作。
多字符串问题,可拼接字符串并用唯一分隔符隔开(如 S1#S2$),转化为子串问题。
涉及最大化匹配与公共前缀问题,考虑 s a 上的相邻串。
涉及字典序的比较,考虑串在 s a 数组的先后顺序。
涉及 LCP 的比较,考虑 h t 数组求 RMQ
带来的单调性。
从 SA
数组上的性质出发,结合其它算法或数据结构,可以解决更复杂的字符串问题:
某些题目求解时要求你将后缀数组划分成若干个连续 LCP
长度大于等于某一值的段,我们只需将查询离线并要维护一个并查集,每次合并相邻的两个区间,并维护统计信息。
某些题目让你求满足条件的前若干个数,而这些数又在后缀排序中的一个区间内,可以用归并排序的性质来合并两个结点的信息,利用线段树维护和查询区间答案。
倍增 + 基数排序法求后缀数组
以下代码使用倍增算法和基数排序构建后缀数组,总构建复杂度 O (n log 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 struct SuffixArray { int n; vector<int > sa, rk; explicit SuffixArray (const string &s) : n(s.size()), sa(n), rk(n) { iota (sa.begin (), sa.end (), 0 ); sort (sa.begin (), sa.end (), [&](int a, int b) { return s[a] < s[b]; }); for (int i = 1 ; i < n; i++) rk[sa[i]] = rk[sa[i - 1 ]] + (s[sa[i]] != s[sa[i - 1 ]]); vector<int > tmp (n) , cnt (n) ; for (int k = 1 ; rk[sa[n - 1 ]] < n - 1 ; k <<= 1 ) { tmp.clear (); for (int i = 0 ; i < k; i++) tmp.push_back (n - k + i); for (auto i: sa) if (i >= k) tmp.push_back (i - k); fill (cnt.begin (), cnt.end (), 0 ); for (int i = 0 ; i < n; i++) cnt[rk[i]]++; for (int i = 1 ; i < n; i++) cnt[i] += cnt[i - 1 ]; for (int i = n - 1 ; i >= 0 ; i--) sa[--cnt[rk[tmp[i]]]] = tmp[i]; swap (rk, tmp); rk[sa[0 ]] = 0 ; for (int i = 1 ; i < n; i++) { int inc = tmp[sa[i - 1 ]] != tmp[sa[i]] || sa[i - 1 ] + k == n || tmp[sa[i - 1 ] + k] != tmp[sa[i] + k]; rk[sa[i]] = rk[sa[i - 1 ]] + inc; } } } };
LCP 数组
这段代码实现了一个基于后缀数组的 LCP 数组,支持快速查询任意两个后缀的
LCP 长度,其中 h t [i ] = l c p (s a [i ], s a [i + 1])
,注意 rmq 函数使用左闭右开实现,预处理时间复杂度为 O (n log n ) ,查询时间复杂度为
O (1) 。
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 struct LCPArray { int n; SuffixArray sa; vector<int > ht; vector<vector<int > > st; explicit LCPArray (const string &s) : n(s.size()), sa(s), ht(n - 1 ), st(__lg(n - 1 ) + 1 , vector<int>(n - 1 )) { if (n <= 1 ) return ; for (int i = 0 , j = 0 ; i < n; i++) { if (sa.rk[i] == 0 ) continue ; for (j -= j > 0 ; i + j < n && sa.sa[sa.rk[i] - 1 ] + j < n && s[i + j] == s[sa.sa[sa.rk[i] - 1 ] + j]; j++) { } ht[sa.rk[i] - 1 ] = j; } st[0 ] = ht; for (int j = 1 ; j <= __lg(n - 1 ); j++) { for (int i = 0 ; i + (1 << j) <= n - 1 ; i++) { st[j][i] = min (st[j - 1 ][i], st[j - 1 ][i + (1 << (j - 1 ))]); } } } int rmq (int l, int r) { if (l >= r) return 0 ; int k = __lg(r - l); return min (st[k][l], st[k][r - (1 << k)]); } int lcp (int i, int j) { if (i == j || i == n || j == n) return min (n - i, n - j); int a = sa.rk[i], b = sa.rk[j]; if (a > b) swap (a, b); return min ({n - i, n - j, rmq (a, b)}); } };
SA-IS 算法
SA-IS
算法是一种线性时间构造后缀数组的算法,核心思想是通过诱导排序 和递归缩减问题规模 实现高效构造。
算法首先将后缀分为两类:L
型后缀 (字典序大于后继后缀)和 S
型后缀 (字典序小于后继后缀)。特殊地,当 s [i ] 为 S 型且 s [i − 1] 为 L 型时,s [i ] 称为LMS
后缀 (Left-Most S-type)。这些 LMS 后缀将字符串分割为若干 LMS
子串。
算法流程分为三个阶段:首先扫描字符串(从右向左)标记所有后缀类型并记录
LMS 位置。接着通过三重诱导排序 初步确定 LMS
子串顺序:
将 LMS 后缀放入对应字符的 S 型桶尾部
从左向右扫描,将 L 型后缀插入对应字符的 L 型桶头部
从右向左扫描,将 S 型后缀插入对应字符的 S 型桶尾部
此时检查 LMS 子串是否全部唯一:若唯一则直接映射为有序序列;否则将 LMS
子串的排名组成新字符串递归处理。递归结果确定 LMS
子串顺序后,再次进行三重诱导排序即可得到完整后缀数组。时间复杂度为 O (n ) ,递归深度 log n ,每层处理 O (n ) 时间,满足 T (n ) = T (n /2) + 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 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 struct SuffixArray { int n; vector<int > sa, rk; explicit SuffixArray (const string &s) : n(s.size()), sa(n + 1 ), rk(n + 1 ) { vector<int > a (n + 1 ) ; for (int i = 0 ; i < n; i++) a[i] = s[i]; sais (a, sa, n + 1 , 256 ); sa.erase (sa.begin ()); for (int i = 0 ; i < n; i++) rk[sa[i]] = i; } static void sais (const vector<int > &a, vector<int > &sa, int n, int sig) { if (n == 1 ) return ; vector<int > tp (n) , lm ; for (int i = n - 2 ; i >= 0 ; i--) tp[i] = a[i] > a[i + 1 ] || a[i] == a[i + 1 ] && tp[i + 1 ]; auto ilm = [&](int i) { return i >= 1 && !tp[i] && tp[i - 1 ]; }; for (int i = 0 ; i < n; i++) if (ilm (i)) lm.push_back (i); vector<int > sum (sig + 1 ) ; for (int i = 0 ; i < n; i++) sum[a[i] + 1 ]++; for (int i = 1 ; i <= sig; i++) sum[i] += sum[i - 1 ]; auto induce = [&](const vector<int > &slm) { fill (sa.begin (), sa.end (), -1 ); vector<int > mbu = sum, lbu = sum, sbu = sum; for (int i = slm.size () - 1 ; i >= 0 ; i--) sa[--mbu[a[slm[i]] + 1 ]] = slm[i]; for (int i = 0 ; i < n; i++) if (sa[i] > 0 && tp[sa[i] - 1 ]) sa[lbu[a[sa[i] - 1 ]]++] = sa[i] - 1 ; for (int i = n - 1 ; i >= 0 ; i--) if (sa[i] > 0 && !tp[sa[i] - 1 ]) sa[--sbu[a[sa[i] - 1 ] + 1 ]] = sa[i] - 1 ; }; induce (lm); bool flag = false ; int cnt = 0 , lx = -1 ; vector<int > nm (n) ; auto eq = [&](int x, int y) { do { if (a[x] != a[y]) return false ; x++, y++; } while (!ilm (x) && !ilm (y)); return a[x] == a[y]; }; for (int i = 0 ; i < n; i++) { if (!ilm (sa[i])) continue ; if (~lx && !eq (lx, sa[i])) cnt++; if (~lx && cnt == nm[lx]) flag = true ; nm[sa[i]] = cnt, lx = sa[i]; } vector<int > slm (lm.size()) ; if (!flag) { for (int x: lm) slm[nm[x]] = x; } else { vector<int > a1 (lm.size ()), sa1 (lm.size ()); for (int i = 0 ; i < lm.size (); i++) a1[i] = nm[lm[i]]; sais (a1, sa1, a1. size (), cnt + 1 ); for (int i = 0 ; i < lm.size (); i++) slm[i] = lm[sa1[i]]; } induce (slm); } };
后缀自动机
后缀自动机(SAM)是一个接受 s 的所有后缀的最小 DFA (确定性有限状态自动机)。
以下代码实现了动态开点的后缀自动机,维护了等价类最长串长度 l e n
和后缀链接树的父亲 f a
。
insert 操作均摊时间复杂度为 O (log Σ ) ,总空间复杂度
O (n ) ,其中 Σ 为字符集大小,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 27 28 29 30 31 32 33 34 35 36 37 struct SuffixAutomaton { int las; vector<int > len, fa; vector<map<int , int > > ch; explicit SuffixAutomaton () : las(1 ), len(2 ), fa(2 ), ch(2 ) { } int create () { int x = ch.size (); len.push_back (0 ); fa.push_back (0 ); ch.emplace_back (); return x; } void insert (int i) { int p = las, np = las = create (); len[np] = len[p] + 1 ; for (; p && !ch[p].contains (i); p = fa[p]) ch[p][i] = np; if (!p) { fa[np] = 1 ; } else { int q = ch[p][i]; if (len[q] == len[p] + 1 ) { fa[np] = q; } else { int nq = create (); len[nq] = len[p] + 1 ; fa[nq] = fa[q]; ch[nq] = ch[q]; fa[q] = fa[np] = nq; for (; p && ch[p].contains (i) && ch[p][i] == q; p = fa[p]) ch[p][i] = nq; } } } };
子串出现次数
节点 u 表示的串的 e n d p o s
大小就是后缀连接树上 u
子树中的前缀点数量。
以下实现使用桶排序得到节点拓扑顺序,并按拓扑序 dp
得到每个等价类的子串出现次数,时间复杂度 O (n ) 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 SuffixAutomaton sam; vector<int > pren (s.size()) ; for (int i = 0 ; i < s.size (); i++) { sam.insert (s[i]); pren[i] = sam.las; } int maxl = sam.len[sam.las], tot = sam.ch.size () - 1 ; vector<int > bu (maxl + 1 ) , tp (tot + 1 ) ; for (int i = 1 ; i <= tot; i++) bu[sam.len[i]]++; for (int i = 1 ; i <= maxl; i++) bu[i] += bu[i - 1 ]; for (int i = 1 ; i <= tot; i++) tp[bu[sam.len[i]]--] = i; vector<i64> cnt (tot + 1 ) ; for (int i = 0 ; i < s.size (); i++) cnt[pren[i]] = 1 ; for (int i = tot; i >= 2 ; i--) cnt[sam.fa[tp[i]]] += cnt[tp[i]]; cnt[1 ] = 0 ;