字符串基础知识总结

再不总结就要退役了.

本文的字符串下标均从 \(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;
	} 
}
posted @ 2025-05-21 15:55  Ydoc770  阅读(11)  评论(0)    收藏  举报