基础字符串算法

1 哈希

1.1 概念

哈希就是构造一个数字使之唯一的代表一个字符串。

我们来考虑一下二进制数的转化:

\((1001)_2=1\times 2^3+0\times2^2+0\times2^1+1=(9)_{10}\)

现在,我们令 \('a'=1,'b'=2,'c'=3\cdots,'z'=26\)。然后将进制 \(p\) 设为 \(131\)。就能得到:

\((abc)_p=1\times p^2+2\times p+3=(2248356)_{10}\)

这个过程就叫做字符串哈希。

在实际应用中,哈希常常用来判断字符串是否相等。然而,在有些情况下,难免会有两个不同字符串哈希值相等,我们的程序就会爆掉。因此,在正常情况下,通常取 \(p=131,13331\) 等素数,而且还需要取模,模数一般取 \(2^{64}-1\)

1.2 实现

1.2.1 单哈希

我们设 hash[i] 为字符串前 \(i\) 位的哈希值。则有递推公式:

hash[i] = hash[i - 1] * p + s[i] - 47

由于模数取 \(2^{64}-1\),因此可以用 unsigned long long 类型的自然溢出来达到取模的效果。

代码:

string s;
ull h[Maxn];//注意 c++14 中 "hash" 是关键字
const ull p = 131;

void Hash() {
	for(int i = 1; i <= s.size(); i++) {
		h[i] = h[i - 1] * p + (s[i] - 'a' + 1);
	}
}

1.2.2 双哈希

由于一个哈希仍仍可能被毒瘤出题人卡掉,我们可以对一个字符串用两个 \(q\) 计算,只有两个哈希都相等时才满足。这被称之为双哈希,几乎不可能再有错误概率。

如下:

string s;
ll h1[Maxn], h2[Maxn];
const ll p = 131;
const ll q1 = 998244353;
const ll q2 = 1000000007;

void Hash() {
	for(int i = 1; i <= s.size(); i++) {
		h1[i] = (h1[i - 1] * p % q1 + (s[i] - 'a' + 1) % q1) % q1;
		h2[i] = (h2[i - 1] * p % q2 + (s[i] - 'a' + 1) % q2) % q2;
	}
}

1.3 基本应用

1.3.1 获取字串哈希

我们举一个浅显的例子:

设一个字符串为 \(s_1s_2s_3s_4\)

则哈希值分别如下:

\(hash[1]=s_1\)

\(hash[2]=s_1\times p + s_2\)

\(hash[3] = s_1\times p^2 + s_2 \times p + s_3\)

\(hash[4] = s1 \times p^3 + s_2 \times p^2 + s_3\times p + s_4\)

假如我们要知道 \(s_3s_4\) 的哈希值,也就是 \(s_3\times p + s_4\),应该怎么算呢?

很明显,用 \(hash[4]\)\(hash[2]\) 得到,但 \(hash[2]\) 差了一个 \(p^2\),我们就给他乘上,所以结果为:

\(hash[4]-hash[2]\times p^2\)

这启示我们一个重要的公式:

一个字符串 \(|S|\) ,其字串 \(s_l\cdots s_r\) 的哈希值的为:

\(hash[r] - hash[l-1]\times p^{r-l+1}\)

注意实际应用中的取模。

1.3.2 一些应用

首先,有一句不知道谁说的话:

哈希可以当半个 KMP、Manacher 等其他字符串算法使用。

1.3.2.1 字符串匹配

这个其实很好做了,求出文本串中每个与模式串长度相等的串的哈希,比较即可。

1.3.2.2 允许至多 \(k\) 次失配的字符串匹配

这道题无法使用 KMP 解决,但是可以通过哈希 + 二分来解决。

枚举所有可能匹配的子串,假设现在枚举的子串为 \(s'\) ,通过哈希 + 二分可以快速找到 \(s'\)\(p\) 第一个不同的位置。之后将 \(s'\)\(p\) 在这个失配位置及之前的部分删除掉,继续查找下一个失配位置。这样的过程最多发生 \(k\) 次。

总的时间复杂度为 \(O(m+kb\log_2m)\)

1.3.2.3 最长回文子串

这就体现出半个 Manacher 的好处了。

二分答案,判断可行时枚举回文中心(对称轴),哈希来判断来两侧是否相等。需要正着和倒着都处理一遍哈希,复杂度 \(O(n\log n)\)

(其实哈希就可以当 Manacher 使用,有一种 \(O(n)\) 的哈希回文,具体看 OI Wiki

2 KMP

2.1 问题概述

这个算法是一种在文本串 \(s\) 中查找模式串 \(p\) 的算法。

我们先想一下暴力算法会怎么做:

int i = 0, j = 0;
while (i < s.size())
{
    if (s[i] == p[j])
        ++i, ++j;
    else
        i = i - j + 1, j = 0;
    if (j == p.length())  // 匹配成功
    {
        cout << i - j << endl;
        i = i - j + 1;
        j = 0;
    }
}

在暴力匹配中,我们定义了两个指针 \(i\)\(j\),分别在 \(s\)\(p\) 上。每次 \(i\)\(j\) 失配时,\(i,j\) 都要回溯至起始点重新匹配。复杂度 \(O(nm)\)

由于每次失配时,\(i,j\) 都要回溯至起始点重新匹配,这样大大浪费了时间。我们考虑将 \(i\) 定住,不让 \(i\) 回溯。现在的问题是:\(j\) 如何转移?

2.2 KMP 算法

2.2.1 PMT 表

\(j\) 如何转移,至于模式串 \(p\) 有关。每个模式串都有一个 PMT。我们定义 pmt[i] 表示前 \(i\) 位中公共真前后缀的长度。通俗来讲就是从 \(p_0\) 往后,\(p_i\) 往前数,保证相同的情况下最多能数多少位。

例如:

下标 0 1 2 3 4 5 6 7 8
p[i] a b a b c a b a a
pmt[i] 0 0 1 2 0 1 2 3 1

接下来,我们先不管 pmt[i] 如何求,我们先来考虑一下 PMT 对于 \(j\) 转移的作用。

我们来看下面两个字符串:

a b a b a b c a b
a b a b c

我们来看第 \(5\) 格,此时失配,我们保持 \(i\) 不变。由于 abab 已经匹配成功,同时有公共前后缀 ab,我们把它利用起来,变成这样:

a b a b a b c a b
a b a b c

我们就充分利用到了之前的结果。这里实际上就是进行 j=pmt[j-1] 操作。

当然,如果一直不匹配,我们要一直让 \(j\) 等于 pmt[j-1]pmt[pmt[j-1]-1]……直到等于 \(0\)

代码如下:

void KMP(string s, string p) {
	for(int i = 0, j = 0; i < s.size(); i++) {
		while(j && s[i] != p[j]) j = pmt[j - 1];
		if(s[i] == p[j]) j++;
		if(j == p.size()) {
			//do something...
			j = pmt[j - 1];
		}
	}
}

2.2.2 求 PMT

上面已经讲述了如何求解 KMP,但有一个很小的大问题:

如何求出 PMT?

有一个相当精妙的方法:将 \(p\) 错开一位后,自己去匹配自己。这其实相当于用前缀来匹配一个公共的后缀。

先令 pmt[0]=0。然后如下:

a b a b c a b a a
a b a b c a b a a

第一位匹配失败,pmt[1]=0\(i\) 后移。

a b a b c a b a a
a b a b c a b a a

匹配成功,则 \(j\) 指针右移,于是 pmt[2]=1,两个指针均右移。

而后不断重复。其实这段代码与 KMP 过程相当相似:

void PMT(string p) {
	for(int i = 1, j = 0; i < p.size(); i++) {
		while(j && p[i] != p[j]) j = pmt[j - 1];
		if(p[i] == p[j]) j++;
		pmt[i] = j;
	}
}

然后就得到了完整的 KMP 代码。

2.3 完整代码

POJ3461 乌力波 为例:

#include <bits/stdc++.h>

using namespace std;

typedef long long ll;
typedef unsigned long long ull;
const int Maxn = 1e6 + 5;

string s, p;
int pmt[Maxn], cnt;

void PMT(string p) {
	for(int i = 1, j = 0; i < p.size(); i++) {
		while(j && p[i] != p[j]) j = pmt[j - 1];
		if(p[i] == p[j]) j++;
		pmt[i] = j;
	}
}

void KMP(string s, string p) {
	for(int i = 0, j = 0; i < s.size(); i++) {
		while(j && s[i] != p[j]) j = pmt[j - 1];
		if(s[i] == p[j]) j++;
		if(j == p.size()) {
			cnt++;
			j = pmt[j - 1];
		}
	}
}

int T;

int main() {
	ios::sync_with_stdio(0);
	cin >> T;
	while(T--) {
		cin >> p >> s;
		cnt = 0;
		PMT(p);
		KMP(s, p);
		cout << cnt << '\n';
	}
	return 0;
}

2.3.1 补充

大多数文章和教材会使用 next[i] 数组,其实就是将 pmt 整体右移一位(令 next[0]=-1

而上面的算法也只能叫 MP 算法,因为我们还缺少一个由 Knuth 提出的常数优化。但时间复杂度永远是 \(O(n+m)\)。具体可以看 这个

3 Trie

3.1 概念

Trie(字典树)是一个比较好理解的数据结构,用来存储和查询字符串。

如下图,就是一颗由 waterwishwintietired 组成的字典树。

树上的一个节点就是一个字符,树上的一条链就是一个字符串。相同前缀的字符串共用一部分节点。

这样,我们牺牲了一部分空间,实现了 \(O(n)\) 的查询。

3.2 实现

3.2.1 建树

考虑建树。用 nxt[i][j] 表示 \(i\) 号点的儿子中,存储字符映射值为 \(c\) 的节点编号。注意 \(1\) 号点一般不放字符,代码如下:

int nxt[Maxn][27], cnt = 1;

void insert(string s) {
	int u = 1;
	for(int i = 0; i < s.size(); i++) {
		int c = s[i] - 'a' + 1;
		if(!nxt[u][c]) {//尽可能重复使用前缀,否则建立新节点
			nxt[u][c] = ++cnt;
		}
		u = nxt[u][c];
	}
}

3.2.2 查询

3.2.2.1 查询前缀

我们可以用字典树简单的查询到一个前缀是否存在。与建树代码比较相似:

bool find(string s) {
	int u = 1;
	for(int i = 0; i < s.size(); i++) {
		int c = s[i] - 'a' + 1;
		if(!nxt[u][c]) {
			return 0;
		}
		u = nxt[u][c];
	}
	return 1;
} 

3.2.2.2 查询字符串

如果要查询某个字符串是否出现,就不能够直接判断,因为可能出现一个字符串是另一个的前缀。

我们用一个 vis 数组。在插入操作完成时,把当前叶子结点的 vis 设为 \(1\)。查询完成后判断 vis 是否存在即可。

这是一个常见的套路,在 Trie 中用叶子结点代表整个字符串,来保存信息已完成要求。

3.2.3 模板

P8306 【模板】字典树 - 洛谷 为例。

#include <bits/stdc++.h>

using namespace std;

typedef long long ll;
const int Maxn = 4e6 + 5;

int nxt[Maxn][65], cnt = 1, vis[Maxn];

void insert(string s) {
	int u = 1;
	for(int i = 0; i < s.size(); i++) {
		int c;
		if(s[i] >= '0' && s[i] <= '9') c = s[i] - '0' + 1;
		if(s[i] >= 'A' && s[i] <= 'Z') c = s[i] - 'A' + 11;
		if(s[i] >= 'a' && s[i] <= 'z') c = s[i] - 'a' + 38;
		if(!nxt[u][c]) {
			nxt[u][c] = ++cnt;
		}
		u = nxt[u][c];
		vis[u] ++;
	}
}

int find(string s) {
	int u = 1;
	for(int i = 0; i < s.size(); i++) {
		int c;
		if(s[i] >= '0' && s[i] <= '9') c = s[i] - '0' + 1;
		if(s[i] >= 'A' && s[i] <= 'Z') c = s[i] - 'A' + 11;
		if(s[i] >= 'a' && s[i] <= 'z') c = s[i] - 'a' + 38;
		if(!nxt[u][c]) {
			return 0;
		}
		u = nxt[u][c];
	}
	return vis[u];
} 

int T;

int main() {
	ios::sync_with_stdio(0);
	cin.tie(0), cout.tie(0);
	cin >> T;
	while(T--){
		int n, q;
		cin >> n >> q;
		for(int i = 1; i <= cnt; i++) {//卡 memset 了 
			memset(nxt[i], 0, sizeof(nxt[i]));
			vis[i] = 0;
		}
		cnt = 1;
		for(int i = 1; i <= n; i++) {
			string s;
			cin >> s;
			insert(s);
		}
		for(int i = 1; i <= q; i++) {
			string t;
			cin >> t;
			cout << find(t) << '\n';
		}
	}
	return 0;
}

3.3 0-1 Trie

3.3.1 概念

01-Trie,是一种用 Trie 来存储数字二进制的数据结构,常常被用来求解数字异或问题。

我们将原本 Trie 的字符集缩减为只有 \(\{0,1\}\),将原先字符串变为一个个二进制数,以此来建树。这样我们得到的 Trie 树就叫做 01-Trie。

3.3.2 建树

建树与普通 Trie 没有什么区别,到位了做题方便,一般还用 num 来记录当前叶子结点所对应的数字(又是上面的小技巧)。

int nxt[Maxn][2], cnt = 1, num[Maxn];

void insert(int x) {
    int u = 1;
	for(int i = 30; i >= 0; i--) {//枚举可能的位数 
		int c = x >> i & 1;//取出 x 的第 i 位
		if(!nxt[u][c]) {
			nxt[u][c] = ++ cnt;
		} 
		u = nxt[u][c];
	}
	num[u] = x;
} 

3.3.3 具体应用及代码

3.3.3.1 最大异或对

The XOR Largest Pair 为例。

题目大意:在 \(n\) 个数中选出两个数,使他们的异或和最大。

我们先建立 01-Trie,然后使用贪心求解。枚举当前的数 \(a_i\),在树上走一条路径。由于要求异或和最大,所以要尽可能让当前位上的数与 \(a_i\) 当前位上的数不一样,否则就只能走相同的了。

同时,依次为模板题,给出 01-Trie 代码:

#include <bits/stdc++.h>

using namespace std;

typedef long long ll;
const int Maxn = 2e5 + 5;

int nxt[Maxn][2], cnt = 1, num[Maxn];

void insert(int x) {
	int u = 1;
	for(int i = 30; i >= 0; i--) {//枚举可能的位数 
		int c = x >> i & 1;//取出 x 的第 i 位
		if(!nxt[u][c]) {
			nxt[u][c] = ++ cnt;
		} 
		u = nxt[u][c];
	}
	num[u] = x;
} 

int find(int x) {
	int u = 1;
	for(int i = 30; i >= 0; i--) {
		int c = x >> i & 1;
		if(nxt[u][c ^ 1]) {//尽量不同 
			u = nxt[u][c ^ 1];
		}
		else {
			u = nxt[u][c];
		}
	}
	return x ^ num[u];//返回异或值 
} 

int n, a[Maxn], ans;

int main() {
	ios::sync_with_stdio(0);
	cin >> n;
	for(int i = 1; i <= n; i++) {
		cin >> a[i];
		insert(a[i]);
	}
	for(int i = 1; i <= n; i++) {
		ans = max(ans, find(a[i]));//枚举取最大 
	}
	cout << ans;
	return 0;
}

3.3.3.2 最大异或路径

The XOR-longest Path 为例。

题目大意:给定一棵 \(n\) 个点的带权树,求树上最长的异或和路径。

由于异或具有一个特点:\(a\operatorname{xor}a=0\),所以,设 \(t_i\) 为从根节点到点 \(i\) 的异或和,那么对于树上两点 \(A,B\),他们的路径异或和就是 \(t_A\operatorname{xor}t_B\),因为 \(t_{LCA}\) 部分被算了两次,抵消了。

而此时我们再看,在 \(n\) 个结点的 \(t_i\) 中,选出两个数的最大异或和。这非常熟悉,实际上就是 3.3.3.1 的情况。

代码:

#include <bits/stdc++.h>

using namespace std;

typedef long long ll;
const int Maxn = 2e6 + 5;

int n;
int head[Maxn], edgenum;
struct node{
	int nxt, to, w;
}edge[Maxn];

void add_edge(int from, int to, int w) {
	edge[++edgenum].nxt = head[from];
	edge[edgenum].to = to;
	edge[edgenum].w = w;
	head[from] = edgenum;
}

int t[Maxn];
void dfs(int x, int fa, int val) {
	t[x] = t[fa] ^ val;
	for(int i = head[x]; i; i = edge[i].nxt) {
		int to = edge[i].to;
		if(to == fa) continue;
		dfs(to, x, edge[i].w); 
	}
}

int nxt[Maxn][2], cnt = 1, num[Maxn];

void insert(int x) {
	int u = 1;
	for(int i = 30; i >= 0; i--) {
		int c = x >> i & 1;
		if(!nxt[u][c]) {
			nxt[u][c] = ++cnt;
		}
		u = nxt[u][c];
	}
	num[u] = x;
}

int find(int x) {
	int u = 1; 
	for(int i = 30; i >= 0; i--) {
		int c = x >> i & 1;
		if(nxt[u][c ^ 1]) {
			u = nxt[u][c ^ 1];
		}
		else {
			u = nxt[u][c];
		}
	}
	return x ^ num[u];
}

int ans = 0;

int main() {
	ios::sync_with_stdio(0);
	cin >> n;
	for(int i = 1; i < n; i++) {
		int u, v, w;
		cin >> u >> v >> w;
		add_edge(u, v, w);
		add_edge(v, u, w);
	}
	dfs(1, 0, 0);
	for(int i = 1; i <= n; i++) {
		insert(t[i]);
	}
	for(int i = 1; i <= n; i++) {
		ans = max(ans, find(t[i]));
	}
	cout << ans;
	return 0;
}
posted @ 2024-02-27 17:53  UKE_Automation  阅读(8)  评论(0编辑  收藏  举报