字符串基础知识总结
再不总结就要退役了.
本文的字符串下标均从 \(1\) 开始.
哈希
字符串直接比较是否相等是 \(O(n)\) 的.
考虑把字符串映射为一个特定的值,可以 \(O(1)\) 比较两个字符串是否相等. 进一步的,求出字符串所有前缀映射的值可以 \(O(1)\) 比较子串是否相等.
为了尽量保证字符串映射值的独特性,常用比字符集大的质数 \(Base\) 作为进制,将其转为 \(Base\) 进制的数. 但是字符串长度太大什么都存不下,所以需要取模. 取模就意味着可能有完全不同的字符串映射为相同的值,所以实际使用哈希时要小心,很有可能被刻意卡.
哈希的优势在于比较泛用,加上二分之类的可以替代很多下面的一些算法,但是复杂度要多一个 \(\log n\).
Trie 树
字典树是可以接受所有串前缀的数据结构,其上把每个串公共前缀压缩在了一起. 时空复杂度都是 \(O(k\sum |s|)\),其中 \(k\) 是字符集大小, \(\sum |s|\) 是串长之和. 值得一提的是满的 Trie 是 \(O(k^n)\) 的,但是这意味着串长之和至少是 \(O(k^n)\).
实现
实现是用类似动态开点的方法,新加入的字符如果没有一个在 Trie 中的位置就新建节点. 查询同理,遇到不存在的节点直接返回 \(0\). 实现非常的简单易懂:
void insert(char s[]) {
int u = 1, len = strlen(s + 1);
for(int i = 1, c; i <= len; i++) {
c = get(s[i]);
if(!ch[u][c]) ch[u][c] = ++tot;
u = ch[u][c];
cnt[u]++;//记前缀出现次数
}
return;
}
int ask(char s[]) {
int u = 1, len = strlen(s + 1);
for(int i = 1, c; i <= len; i++) {
c = get(s[i]);
if(!ch[u][c]) return 0;
u = ch[u][c];
}
return cnt[u];
}
KMP
可以 \(O(n)\) 求字符串每个前缀的最长前后缀.
border
字符串一对相等的真前缀和真后缀叫做 border.
KMP 求的其实就是每个前缀的最长 border.
实现
记位置 \(i\) 的最长 border 为 \(fail_i\).
考虑双指针维护,\(j\) 记录前缀的匹配,\(i\) 记录后缀的匹配. 我们在之前 \(j\) 匹配 \(i-1\) 的基础上,要找到一个 \(j\) 匹配 \(i\). 对于每个 \(i\),如果 \(s_i\) 与 \(s_{j+1}\) 不等,那么 \(j\) 就要跳到一个 \(s_{j'}=s_{i-1}\) 的最大位置,这样一定优. 然后我们会发现这个 \(j'\) 就是 \(fail_j\) 的定义,所以直接不断地令 \(j=fail_j\) 重复上面的判断即可.
其实既可以求字符串自己与自己的最长 border 也可以求两个串的最长 border,区别只是后者额外再匹配一遍另一个串.
for(int i = 2, j = 0; i <= m; i++) {// fail[1] 无价值
while(j && s2[j + 1] != s2[i]) j = fail[j];
if(s2[j + 1] == s2[i]) j++;
fail[i] = j;
}
for(int i = 1, j = 0; i <= n; i++) {
while(j && s2[j + 1] != s1[i]) j = fail[j];
if(s1[i] == s2[j + 1]) j++;
if(j == m) cout << i - j + 1 << endl, j = fail[j];
}
注意 \(fail_1=1\) 是无价值的,而且这样会让程序陷入死循环.
时间复杂度
挪动 \(i\) 显然是线性的,但为什么 \(j\) 跳的次数是 \(O(n)\) 的?
其实因为 \(j\) 跳一次 \(fail_j\) 至少减少 \(1\),但是一次匹配上最多增加 \(1\),这就保证了 \(j\) 一定不会跳超过 \(O(n)\) 次. 所以总复杂度就是 \(O(n)\) 的.
失配树
第一次听说这个东西,新手就是爱记录.
失配树就是 KMP 的延伸应用,把 KMP 求得的结果连成一棵树,可以描述 border 的从属关系.
引理
字符串 border 的 border 仍是原串的 border.
证明很简单,因为 border 的 border 出现在原串的 border 中,而原串的 border 出现在前缀和后缀中,所以原串的前缀和后缀中都有 border 的 border,那 border 的 border 就是原串的 border.
实现
引理启示我们,所有 border 的包含关系是树形结构的,其中父亲节点包含于子节点的 border. 而 KMP 中求的 \(fail\) 数组就是每个位置的最长 border,把所有 \(i\) 连向 \(fail_i\) 就得到了 失配树.
由于失配树是描述 border 从属关系的,所以最多的应用就是求最长公共 border,也就是失配树上求 lca,所以比较板.
但是定义原串不能作为 border,所以特判一下 lca 是自己的情况就好了.
点击查看代码
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e6 + 10;
int n, m; char s[maxn];
int fail[maxn];
vector<int> e[maxn];
struct hld{
int dep[maxn], sz[maxn], son[maxn], tp[maxn];
void dfs1(int u, int fa) {
dep[u] = dep[fa] + 1, sz[u] = 1, son[u] = -1;
for(int v : e[u]) {
if(v == fa) continue;
dfs1(v, u); sz[u] += sz[v];
if(son[u] == -1 || sz[v] > sz[son[u]]) son[u] = v;
}
return;
}
void dfs2(int u, int t) {
tp[u] = t; if(son[u] != -1) dfs2(son[u], t);
for(int v : e[u]) {
if(v == fail[u] || v == son[u]) continue;
dfs2(v, v);
}
return;
}
int lca(int u, int v) {
while(tp[u] != tp[v]) {
if(dep[tp[u]] < dep[tp[v]]) swap(u, v);
u = fail[tp[u]];
}
return dep[u] < dep[v] ? u : v;
}
}t;
void pre_calc() {
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; e[fail[i]].push_back(i);
} e[0].push_back(1);
t.dfs1(0, 0), t.dfs2(0, 0);
return;
}
int main() {
ios :: sync_with_stdio(false); cin.tie(0); cout.tie(0);
cin >> (s + 1); n = strlen(s + 1);pre_calc();
cin >> m;
for(int i = 1; i <= m; i++) {
int p, q, l; cin >> p >> q; l = t.lca(p, q);
if(l == p || l == q) l = fail[l];
cout << l << endl;
}
return 0;
}
Z 函数
很多地方也把 Z 函数叫做扩展 KMP,实际上二者关系不大,只是它们可以做某些类似的事情,比如字符串匹配.
可以 \(O(n)\) 求字符串每个后缀和原串的最长公共前缀(lcp).
主要原理是用之前的区间信息来更新后面的 Z 函数值,而右区间是单调的.
实现
记位置 \(i\) 的 Z 函数值为 \(z_i\),之前最远匹配到的右端点 \(r\) 和对应的左端点 \(l\). 根据定义这一段的 \(s_{l\cdots r}=s_{1\cdots r-l+1}\),所以我们可以首先拿之前更新过的 \([l,r]\) 来考虑 \(z_{i-l+1}\) 能否直接作为 \(z_i\) 的值. 这要求:
- \(i \in [l,r]\)
- \(z_{i-l+1}\le r-i+1\)
第一条限制说明了 \(i\) 的范围,实际上只用判 \(i\le r\). 第二条限制说明了 \(i-l+1\) 位置的 lcp 没有超出当前已经匹配的范围.
不同时满足这两条性质的说明当前 \(r\) 过时或者,或者 \(z_{i-l+1}\) 太大,我们需要向右拓展 \(z_i\). 虽然如此但是 \(z_i\) 仍然存在一个下界 \(\max(0,r-i+1)\),因为 \([i,r]\) 已经匹配. 正是这一下界保证了复杂度,因为 \(r\) 向右更新最多只会进行 \(O(n)\) 步. 匹配到最远位置后如果匹配到的位置 \(i+z_i-1>r\) 则更新 \(l=i,r=i+z_i-1\) 即可.
以上的分讨本质上是说:后面位置的 lcp 虽然可以从前面的 lcp 推过来,但是既可能大于,小于也可能等于前面的 lcp.
for(int i = 2, l = 0, r = 0; i <= m; i++) {
if(i <= r && z[i - l + 1] < r - i + 1) z[i] = z[i - l + 1];
else {
z[i] = max(0, r - i + 1);
while(i + z[i] <= m && b[1 + z[i]] == b[i + z[i]]) z[i]++;
if(i + z[i] - 1 > r) l = i, r = l + z[i] - 1;
}
}
for(int i = 1, l = 0, r = 0, zx; i <= n; i++) {
if(i <= r && z[i - l + 1] < r - i + 1) zx = z[i - l + 1];
else {
zx = max(0, r - i + 1);
while(i + zx <= n && zx <= m && b[1 + zx] == a[i + zx]) zx++;
if(i + zx - 1 > r) l = i, r = l + zx - 1;
}
}
Manacher
可以 \(O(n)\) 求所有回文子串的不同位置.
思路和 Z 函数几乎一样,是拿之前的回文信息来更新后面的回文信息.
特殊处理
回文串有奇回文和偶回文两种,也就是对称点可能是字符也可能是字符之间的空隙. 如果分类讨论的话非常地不优美,所以我们有一个聪明的解决办法:
- 在每个字符之间包括头尾插入一个特殊字符如
# - 最开头插入另一个特殊字符如
~
这样既可以避免越界也可以让所有最长回文串都只能是奇回文. 而且原串最长回文子串长度就是新串最长回文半径长度减去 \(1\).
s[0] = '~', s[1] = '#';
for(int i = 1; i <= n; i++) s[++tot] = c[i], s[++tot] = '#';
实现
考虑维护每个节点可以拓展的最大半径 \(d_i\),维护最远的 \((mid,r)\) 使得令 \(l=2\times mid-r\),有 \(s_{l\cdots r}\) 是一个回文中心为 \(mid\) 的最长回文串. 回文串对称的性质告诉我们,你要求一个 \(i\in[mid,r]\) 的 \(d_i\),可以在这个极长回文串的对称位置的 \(d_{l}\) 基础上更新来. 具体的,与 Z 函数类似有:
- \(i \in [mid,r]\)
- \(d_{l}\le r-i+1\)
符合上述要求就可以直接 \(d_i=d_l\) 了. 如果不行就在 \(d_i=\max(1,r-i+1)\) 基础上暴力往两边拓展,并更新 \((mid,r)\) 就好了.
for(int i = 1, mid = 0, r = 0; i <= tot; i++) {
if(i <= r && d[2 * mid - i] < r - i + 1) d[i] = d[2 * mid - i];
else {
d[i] = max(1, r - i + 1);
while(s[i + d[i]] == s[i - d[i]]) d[i]++;
if(i + d[i] > r) r = i + d[i] - 1, mid = i;
}
}

浙公网安备 33010602011771号