字符串
hash
数字工具
小质数:101, 103, 1009, 10007, 10009, 100003, 1000003, 1145141
大质数:998244353, 1000000007, 1000000009
kmp 和 border
性质
fail 树子树里的位置为该 border 的出现位置。
性质1. border 的 border 还是 border,并且每次取最大的 border 可以遍历原串所有 border。
性质2. (弱周期引理) 对于周期 $p, q$,如果 $p + q \le n$,则 $gcd(p, q)$ 也是周期。
性质3. 对于字符串 $s, t$,$s$ 为 $t$ 的前缀,$t$ 有周期 $a$,$s$ 有周期 $b$,满足 $a \le |s|$ 且 $b \mid a$,则 $b$ 也为 $t$ 的周期。
性质4. 对于字符串 $s, t$,若 $2|s| \ge |t|$,则 $s$ 在 $t$ 上的匹配位置形成等差数列。
性质5. 一个字符串长为 $n$,其长度 $\ge \lceil \frac{n}{2} \rceil$ 的 border 构成等差数列。具体的,如果最长 border 为 $n - p$,那么公差为 $p$。
性质6. 一个字符串其 border 形成 $O(log n)$ 个等差数列。
代码
// 均摊 O(n) 版
void kmp(const string &s) {
int n = s.size() - 1;
vector<int> fail(n + 1);
for (int i = 2, j = 0; i <= n; ++i) {
while (j && s[j + 1] != s[i]) j = fail[j];
if (s[j + 1] == s[i]) ++j;
fail[i] = j;
}
}
// 单次 O(log n) 版
void kmp(const string &s) {
int n = s.size() - 1;
vector<int> fail(n + 1);
for (int i = 2, j = 0; i <= n; ++i) {
while(j && s[j + 1] != s[i]){
if(j + 1 < i && fail[j + 1] * 2 > j + 1)
j = (j - 1) % (j - fail[j]) + 1;
else j = fail[j];
}
if (s[j + 1] == s[i]) ++j;
fail[i] = j;
}
}
z 函数(扩展kmp)
代码
void z_func(const string &s) {
int n = s.size() - 1;
vector<int> z(n + 1);
z[1] = n;
for (int i = 2, l = 0, r = -1; i <= n; ++i) {
int k = 0;
if (i <= r) k = min(z[i - l + 1], r - i + 1);
// s[i + k] 改成 t[i + k],可以求 t 的后缀与 s 的LCP
while (i + k <= n && s[i + k] == s[1 + k])
++k;
z[i] = k;
if (i + k - 1 > r) {
l = i;
r = i + k - 1;
}
}
}
manacher
代码
void manacher(const string &s) {
int n = s.size() - 1;
vector<int> d1(n + 1, 1);
// len = 2 * d1[i] - 1
for (int i = 1, l = 0, r = -1; i <= n; ++i) {
int k = 1;
if (i <= r) k = min(d1[l + r - i], r - i + 1);
while (i + k <= n && i - k >= 1 && s[i + k] == s[i - k])
++k;
d1[i] = k--;
if (i + k > r) {
l = i - k;
r = i + k;
}
}
vector<int> d2(n + 1, 0);
// len = 2 * d2[i]
for (int i = 1, l = 0, r = -1; i <= n; ++i) {
int k = 0;
if (i <= r) k = min(d2[l + r - i + 1], r - i + 1);
while (i + k <= n && i - k - 1 >= 1 && s[i + k] == s[i - k - 1])
++k;
d2[i] = k--;
if (i + k > r) {
l = i - k - 1;
r = i + k;
}
}
}
// 统一处理版
void manacher(const string &t) {
int n = t.size() - 1;
string s = " ";
for (int i = 1; i <= n; ++i) {
s += '#';
s += t[i];
}
s += '#';
n = 2 * n + 1;
vector<int> d(n + 1, 1);
// len = d[i]
for (int i = 1, l = 0, r = -1; i <= n; ++i) {
int k = 1;
if (i <= r) k = min(d[l + r - i], r - i + 1);
while (i + k <= n && i - k >= 1 && s[i + k] == s[i - k])
++k;
d[i] = --k;
if (i + k > r) {
l = i - k;
r = i + k;
}
}
}
AC自动机
性质
fail 指向 border,up 指向 fail 链中上一个真实具有字符串的节点。
性质1. AC自动机为 DAG,fail 形成树,有时可以采用拓扑排序或者子树 dfs 优化。
性质2. 字符串有总长限制时,不同长度的真实串只有 $O(\sqrt S)$ 个,遍历 up 链时可利用。
代码
struct ACAM {
#define fail(x) t[x].fail
#define trans(x) t[x].trans
struct node {
bool end_pos = 0;
int fail = 0;
int up = 0;
int trans[26] = {};
int len = 0;
};
vector<node> t;
ACAM(int n = 0) {
t.reserve(n + 1);
newnode();
}
int newnode() {
t.push_back(node());
return t.size() - 1;
}
int ins(const string &s) {
int p = 0;
for (char cha : s) {
int c = cha - 'a';
if (!t[p].trans[c]) {
t[p].trans[c] = newnode();
t[t[p].trans[c]].len = t[p].len + 1;
}
p = t[p].trans[c];
}
t[p].end_pos = 1;
return p;
}
void build() {
queue<int> q;
for (int i = 0; i < 26; ++i) {
if (t[0].trans[i]) {
q.push(t[0].trans[i]);
}
}
while (!q.empty()) {
int u = q.front(); q.pop();
for (int i = 0; i < 26; ++i) {
int &v = t[u].trans[i];
if (v) {
t[v].fail = t[t[u].fail].trans[i];
if (t[t[v].fail].end_pos) {
t[v].up = t[v].fail;
} else {
t[v].up = t[t[v].fail].up;
}
q.push(v);
} else {
v = t[t[u].fail].trans[i];
}
}
}
}
};
后缀数组
性质
$high_i$ 为 $sa_i$ 和 $sa_{i - 1}$ 的 LCP。
性质1. 按 high 最小值分治得到的本质是简化后缀树。
性质2. $high_{rk_i} \ge high_{rk_{i-1}} - 1$
性质3. 两个后缀的 LCP 为对应 high 的区间 min。
应用
求子串出现次数
对于子串 $s[l,r]$ ,求出前缀为该子串的字典序最小最大的后缀的排名 $x,y$,答案 $y-x+1$。
等价求有多少个后缀与 $s[l,n]$ 的 LCP $\ge r-l+1$。
比较子串大小
对于 $A=[a,b],B=[c,d]$,若 $LCP(a,c)\ge \min{|A|,|B|}$ ,那么比较 $|A|,|B|$ 大小,否则比较 $rk[a],rk[c]$。
本质不同子串个数
$\frac{n(n+1)}{2}-\sum_{i=2}^nhigh[i]$
计算所有新增的 LCP 即可。
最小表示法
求一个串循环同构中字典序最小的那个。
倍长原串,其同构转为后缀后字典序相对大小不改变,于是可得。
为什么相对大小不变?因为发现多出来的部分是个前缀,你原来比它字典序小,那之后仍然是小的。特殊情况是两个相同的循环同构,但是由于两者相同求出来的结果不变。
最长公共子串
等价于两(或多个)个串拼起来求最大的后缀 LCP 。两个串间添加不同的不在字符集中的元素作为分隔符。
我们考虑二分答案后,检验是否可达。具体就是找到一段连续段 $[l,r]$ 满足 $x\in[l,r],high[x]\ge ans$ ,且对于 $x\in[l-1,r]$ ,使得每个串都有至少一个元素在里面。
代码
struct SA{
int n;
vector<int> rk, id, cnt, sz, lark, sa;
vector<int> high, lg2;
vector<vector<pair<int, int>>> mn;
string s;
SA(const string &c, int _n) : n(_n), s(c),
rk(_n + 1), id(_n + 1), cnt(max(128, _n) + 1), sz(_n + 1),
lark(_n + 1), sa(_n + 1), high(_n + 1), lg2(_n + 1),
mn(make_vector(_n + 1, __lg(max(1, _n)) + 2, pair<int, int>()))
{
int m = max(128, n);
for (int i = 0; i <= m; ++i) cnt[i] = 0;
for (int i = 1; i <= n; ++i) ++cnt[rk[i] = c[i] ], s[i] = c[i];
for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; --i) sa[cnt[rk[i] ]--] = i;
for (int w = 1; w < n; w <<= 1){
int p = 0;
for (int i = n; i > n - w; --i) id[++p] = i;
for (int i = 1; i <= n; ++i)
if (sa[i] > w) id[++p] = sa[i] - w;
for (int i = 0; i <= m; ++i) cnt[i] = 0;
for (int i = 1; i <= n; ++i) ++cnt[rk[id[i] ] ];
for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; --i) sa[cnt[rk[id[i] ] ]--] = id[i];
for (int i = 1; i <= n; ++i) lark[i] = rk[i];
m = 0;
for (int i = 1; i <= n; ++i){
int x = sa[i] + w <= n ? lark[sa[i] + w] : 0;
int y = sa[i - 1] + w <= n ? lark[sa[i - 1] + w] : 0;
if (
i == 1 ||
lark[sa[i]] != lark[sa[i - 1]] ||
x != y
) ++m;
rk[sa[i] ] = m;
}
if (m == n) break;
}
get_high();
init_st();
}
void get_high(){
int t = 0;
for(int i = 1; i <= n; ++i){
if (rk[i] == 1) continue;
if (t) --t;
while (
i + t <= n &&
sa[rk[i] - 1] + t <= n &&
s[i + t] == s[sa[rk[i] - 1] + t]
) ++t;
high[rk[i] ] = t;
}
}
void init_st(){
for (int i = 1; i <= n; ++i) mn[i][0] = {high[i], i};
lg2[1] = 0;
for (int i = 2; i <= n; ++i){
lg2[i] = lg2[i >> 1] + 1;
}
for (int j = 1; (1 << j) <= n; ++j){
for (int i = 1; i + (1 << j) - 1 <= n; ++i){
mn[i][j] = min(mn[i][j - 1], mn[i + (1 << j - 1)][j - 1]);
}
}
}
pair<int, int> query(int l, int r){
if (l > r) return {-1, -1};
int s = lg2[r - l + 1];
return min(mn[l][s], mn[r - (1 << s) + 1][s]);
}
};
后缀自动机
性质
endpos 集合相同的点,我们认为他们等价。后缀自动机上一个点表示一个上述等价关系的等价类。定义后缀链接 link 指向 endpos 包含当前点的长度最长的等价类。
性质1. 等价类内部长度连续。
性质2. 两个子串的 endpos 要么没有交集,要么满足包含关系。
性质3. $len_{min}(x) = len_{max}(link(x)) + 1$
性质4. 点数最多 $2n - 1$,边数最多 $3n - 4$。
性质5. 每一个非克隆状态给出一个独有的 endpos,也就是每个前缀新建的点提供一个对应的 endpos,而其他的点不给出新的 endpos。
性质6. link树内,一个点的 len 等于子树的所有 endpos 最长公共后缀长度。
性质7. 反串的 SAM 为其正串的简化后缀树。
倍长之后建 SAM,一直走最小的字符,走 $|s|$ 个可以得到 s 的最小表示法。
对于广义后缀自动机,在我们每次设置的 lst 的位置维护信息即可。
构造
考虑怎么求出 $link(cur)$。
令 $p = lst$, 在 $p$ 的 link 链上找到第一个 $p’$ 满足 $q = \delta(p’, c) \neq \emptyset$。尝试使用 $q$ 拆出新节点。
如果没有 $q$ 直接连源点了。如果存在 $q$,查看是否满足性质3,满足的话直接 $link(cur) = q$。否则考虑把 $q$ 拆了,把出现位置增加的那部分后缀拆出来成 $q_2$,也就是 p 那边过来的部分,原本部分为 $q_1$,然后让原本的 $q_1$ 和 $cur$ 的 link 都连向 $q_2$ 即可。
代码
struct SAM {
struct node {
int len = 0;
int link = 0;
int trans[26] = {};
};
int lst = 1;
vector<node> t;
SAM(int n) {
t.reserve(2 * n + 2);
newnode();
newnode();
}
int newnode() {
t.push_back(node());
return t.size() - 1;
}
void ins(int c) {
int cur = newnode(), p = lst;
t[cur].len = t[lst].len + 1;
lst = cur;
while (p && !t[p].trans[c]) {
t[p].trans[c] = cur;
p = t[p].link;
}
if (!p) t[cur].link = 1;
else {
int q1 = t[p].trans[c];
if (t[p].len + 1 == t[q1].len) t[cur].link = q1;
else {
int q2 = newnode();
t[q2] = t[q1];
t[q2].len = t[p].len + 1;
t[q1].link = t[cur].link = q2;
while (p && t[p].trans[c] == q1) {
t[p].trans[c] = q2;
p = t[p].link;
}
}
}
}
};
struct GSAM {
struct node {
int len = 0;
int link = 0;
int trans[26] = {};
};
int lst = 1;
vector<node> t;
GSAM(int n) {
t.reserve(2 * n + 2);
newnode();
newnode();
}
int newnode() {
t.push_back(node());
return t.size() - 1;
}
void next_str() {
lst = 1;
}
void ins(int c) {
int p = lst;
if (t[p].trans[c]) {
int q1 = t[p].trans[c];
if(t[q1].len == t[p].len + 1) {
lst = q1;
return;
}
int q2 = newnode();
t[q2] = t[q1];
t[q2].len = t[p].len + 1;
t[q1].link = q2;
while (p && t[p].trans[c] == q1) {
t[p].trans[c] = q2;
p = t[p].link;
}
lst = q2;
return;
}
int cur = newnode();
t[cur].len = t[lst].len + 1;
lst = cur;
while (p && !t[p].trans[c]) {
t[p].trans[c] = cur;
p = t[p].link;
}
if (!p) t[cur].link = 1;
else {
int q1 = t[p].trans[c];
if (t[p].len + 1 == t[q1].len) t[cur].link = q1;
else {
int q2 = newnode();
t[q2] = t[q1];
t[q2].len = t[p].len + 1;
t[q1].link = t[cur].link = q2;
while (p && t[p].trans[c] == q1) {
t[p].trans[c] = q2;
p = t[p].link;
}
}
}
}
};
回文自动机
性质
fail 是最长回文后缀。
可以注意到回文自动机左插和右插其实是一样的,因为回文串的最长回文后缀也是前缀,只需要额外维护 last_l 即可。
子串出现次数是 fail 链上访问次数和,可以拓扑排序一下。
性质1. 回文串的 border 是回文串。
性质2. 一个长为 $n$ 的字符串具有长度 $\ge \lceil \frac{n}{2} \rceil$ 的 border,则其为回文串。
性质3. 回文串 $x$ ,$y$ 为 $x$ 的最长回文真后缀,$z$ 为 $y$ 的最长回文真后缀, $u,v$ 满足 $x=uy,y=vz$,则:
1.$|u|\ge |v|$。
2.若 $|u|\gt|v|$ ,则 $|u|\gt|z|$ 。
3.若 $|u|=|v|$,则 $u=v$。
性质4. 字符串 $s$ 的所有回文后缀按长度排序后可划分为 $O(\log |s|)$ 组等差数列。
性质5. 一个长为 $n$ 的字符串具有长度 $\ge \lceil \frac{n}{2} \rceil$ 的 border,则该 border 的只会作为前缀和后缀出现这两次。
优化回文划分的 $O(n ^ 2)$ 的 dp 时可以采用,只需要添加每个等差数列的新的值即可。
代码
struct PAM {
int lst = 0;
string s;
struct node {
int len = 0;
int fail = 0;
int trans[26] = {};
int dif = 0;
int slink = 0;
};
vector<node> t;
PAM(int n = 0) {
t.reserve(n + 2);
newnode(0);
newnode(-1);
t[1].fail = t[0].fail = 1;
}
int newnode(int L = 0) {
t.push_back(node());
t.back().len = L;
return t.size() - 1;
}
void ins(char ch) {
s += ch;
int c = ch - 'a';
int tip = s.size() - 1;
auto extend = [&](int x) {
while (tip - t[x].len - 1 < 0 || s[tip] != s[tip - t[x].len - 1])
x = t[x].fail;
return x;
};
int x = extend(lst);
if (!t[x].trans[c]) {
int y = newnode(t[x].len + 2);
t[y].fail = t[extend(t[x].fail)].trans[c];
t[x].trans[c] = y;
t[y].dif = t[y].len - t[t[y].fail].len;
if (t[y].dif == t[t[y].fail].dif) {
t[y].slink = t[t[y].fail].slink;
} else {
t[y].slink = t[y].fail;
}
}
lst = t[x].trans[c];
}
};
最小表示法
int min_cyclic_string() {
int k = 0, i = 0, j = 1;
while (k < n && i < n && j < n) {
if (s[(i + k) % n] == s[(j + k) % n]) {
k++;
} else {
s[(i + k) % n] > s[(j + k) % n] ? i = i + k + 1 : j = j + k + 1;
if (i == j) i++;
k = 0;
}
}
return min(i, j);
}
Lyndon 分解
性质
Lyndon 串指自己比所有后缀都小的串。
Lyndon 分解指将串分为多个 Lyndon 串,且字典序非严格单调降。不嫌麻烦可以 SA 每次取最小后缀分解得到。
动态维护的话,可以求出每个前缀的最小后缀,最小后缀为 Lyndon 分解中最后一个 Lyndon 串。
性质1. Lyndon 分解必定存在且唯一。
性质2. Lyndon 串没有 border。也就是说,Lyndon 串不应该具有周期。
性质3. 对于 Lyndon 串 $s = ab$,有 $a < b$。
性质4. 对于 Lyndon 串 $b$ 和另一个串 $a$, 有 $a < b \Leftrightarrow ab < b$。
性质5. Lyndon 串可以被分为小 Lyndon 串按字典序严格单调增拼接。
性质6. 若字符串 $s$ 和字符 $\overline x$ 满足 $s\overline x$ 是某个 Lyndon 串的前缀,则对于 $\overline y > \overline x$,$s\overline y$ 是 Ly。
性质7. 一个 Lyndon 串无法被分为两个 $< s$ 的子序列。
如果一个串 $t=w^k\overline{w}$,其中 $w$ 为 Lyndon 串,$\overline{w}$ 是 $w$ 的严格前缀,那么称 $t$ 为近似 Lyndon 串。
Duval 算法
duval 算法维护这个近似 Lyndon 串:
当新字符相匹配上了 $w$ 的下一位,那么周期不变;
当新字符比 $w$ 的下一位大,那么根据 性质6 形成 Lyndon 串,该 Lyndon 相较于 $w$ 字典序更大,不满足要求,需要把前面的 Lyndon 全部根据 性质4 合并;
当新字符比 $w$ 的下一位小,根据定义的字典序非严格单调降,前面的 $w$ 全部变成 Lyndon 分解里固定的 Lyndon 串。
代码
vector<string> duval(string const &s) {
int n = s.size(), i = 0;
vector<string> factor;
vector<int> min_suf(n);
while (i < n) {
min_suf[i] = i;
int j = i + 1, k = i;
while (j < n && s[k] <= s[j]) {
if (s[k] < s[j])
min_suf[j] = k = i;
else
min_suf[j] = min_suf[k] + j - k, ++k;
++j;
}
while (i <= k) {
factor.push_back(s.substr(i, j - k));
i += j - k;
}
}
return factor;
}