引例
题目描述
给定一个字符串 AAA 和一个字符串 BBB,求 BBB 在 AAA 中的出现次数。AAA 和 BBB 中的字符均为英语大写字母或小写字母。
AAA 中不同位置出现的 BBB 可重叠。
输入格式
输入共两行,分别是字符串 AAA 和字符串 BBB。
输出格式
输出一个整数,表示 BBB 在 AAA 中的出现次数。
样例输入
zyzyzyz
zyz
样例输出
3
数据范围与提示
1≤A,B1 \leq A, B1≤A,B 的长度 ≤106\leq 10 ^ 6≤106,AAA、BBB 仅包含大小写字母。
暴力求解思路
逐一枚举 AAA 中的位置 iii 作为 BBB 的起点,检查是否可以匹配,时间复杂度为 O(n2),显然会超时。
一、进制
通过对各种进制的观察,我们不难发现:
- 任意一个 RRR 进制的数,都可以看成是一个满足如下条件的字符串:
- 每个位上都是 [0,R−1][0, R-1][0,R−1] 之间的一个数字;
- 两个字符串相等,当且仅当这两个字符串代表的 RRR 进制数相等。
- 判断两个字符串相等,需要一层循环,是 O(n) 的,而判断两个数相等,是 O(1) 的。
- 所有英文字母的取值范围都在 128 以内,因此,每个英文字母均可以看成是一个 R(R>=128)R(R>=128)R(R>=128) 进制数的基数值,任意一个字符串均可看作有一个或多个位的 RRR 进制数。
H("abcd")=97⋅R3+98⋅R2+99⋅R+100H("ab")=97⋅R+98H("cd")=99⋅R+100=H(abcd)−H(ab)⋅R2 \begin{aligned} H("abcd") &= 97\cdot R^3+98\cdot R^2+99\cdot R+100 \\ H("ab") &= 97\cdot R+98 \\ H("cd") &= 99\cdot R+100=H(abcd)-H(ab)\cdot R^2 \end{aligned} H("abcd")H("ab")H("cd")=97⋅R3+98⋅R2+99⋅R+100=97⋅R+98=99⋅R+100=H(abcd)−H(ab)⋅R2
不难看出,在已知某个字符串的所有前缀的 RRR 进制数值的前提下,计算任意一个子串的 RRR 进制数值只需 O(1) 的时间(当然还需要预处理出 RiR^iRi 的值)。
至此,对于上面的题目,我们可以:
- 把 BBB 转为一个 RRR 进制数 hbhbhb,时间复杂度为 O(n)。
- 逐一枚举 AAA 中的位置 iii,预处理出 AAA 的前 iii 位构成的 RRR 进制数的数值 h[i]h[i]h[i],时间复杂度为 O(n)。
- 逐一枚举 AAA 中的位置 iii,用 O(1) 的时间 AAA 中从第 iii 个位开始的与 BBB 相同的一个字符串对应的 RRR 进制数 hahaha,检查是否满足 hb==hahb==hahb==ha。
按照这个思路,整个算法的时间复杂度就降到了 O(n),可以通过了。
但是等等,这里好像有一个问题:由于 RRR 是大于等于 128 的数,RiR^iRi 很容易就会超出 intintint 甚至 long longlong\ longlong long 的取值范围,我们根本无法存储。而如果采用大整数来运算及存储,就得不偿失了。
那该怎么办呢?
我们遇到了一个取值范围远大于表示范围的对应问题,就如同关键字与位置下标的对应问题,要将取值范围非常大的一组数(字符串的 RRR 进制数值),尽量没有冲突地均匀存入一个空间有限的数组(基础变量类型的取值范围)中,这是标准的散列问题。
二、散列
设计这种散列函数一定要简单且快,通常采用经典的“除留余数法”,为了减少冲突,我们需要做 2 件事情:
- 要让余数的取值范围尽量大(采用最大的数据类型
unsigned long long
,相当于模 264)。 - RRR 选取一个大于 128 的素数,例如:131,13331 等等。
H("abcd")=97×1313+98×1312+99×131+100=218064827+1681778+12969+100=219746605
\begin{aligned}
H("abcd") &= 97\times 131^3+98\times 131^2+99\times 131+100\\
&=218064827+1681778+12969+100\\
&=219746605
\end{aligned}
H("abcd")=97×1313+98×1312+99×131+100=218064827+1681778+12969+100=219746605
那么,上面为什么没有去模 264 呢?因为 unsigned long long
本身恰好就是 64 位,它计算出来的结果本来就是只保留小于 264 的部分,这称作自然溢出。
好啦!到此为止,我们就完成了真个算法设计,看看代码吧!
#include <iostream>
#include <cstring>
using namespace std;
using ULL = unsigned long long;
const int N = 1e6 + 7, P = 131;
ULL sum[N], sa, pw[N];
char s[N];
int main() {
scanf("%s", s + 1);
pw[0] = 1;
int len = strlen(s + 1);
for (int i = 1; s[i]; ++i) {
sum[i] = sum[i-1] * P + s[i];
pw[i] = pw[i-1] * P;
}
scanf("%s", s + 1);
int lena = strlen(s + 1), ans = 0;
for (int i = 1; s[i]; ++i)
sa = sa * P + s[i];
for (int i = 1; i+lena-1 <= len; ++i) {
ULL d = sum[i+lena-1] - sum[i-1]*pw[lena];
if (d == sa)
++ans;
}
printf("%d", ans);
return 0;
}
三、遗留问题
我们都知道散列一定会出现冲突的,理论上一定存在两个不同字符串的散列值相同,对策有两条:
- 仅用散列判断两个字符串不同,即若两个字符串的散列值不同,那它们一定是两个不同的字符串。
- 当两个字符串的散列值相同时,可以采用以下两种策略之一:
- 双哈希,即再用另一个素数计算以下散列,看看是否相同。
- 直接用循环判断以下字符串是否相同。
四、拓展问题
在字符串匹配问题中,经常需要用判断一个串在另一个串中的匹配次数,例如下面的题目:
题目描述
给定若干个长度为 ≤106\le 10^6≤106 的由可见字符构成的字符串,询问每个字符串最多是由多少个相同的子字符串重复连接而成的。如:ababab
则最多有 333 个 ab
连接而成。
输入格式
输入若干行,每行有一个字符串。特别的,字符串可能为 .
即一个半角句号,此时输入结束。
样例输入
abcd
aaaa
ababab
.
样例输出
1
4
3
数据范围与提示
字符串长度 ≤106\le 10^6≤106。
枚举思路
直接枚举前缀子串的长度(长度显然是总长度的一个因子),检查是否能够重复覆盖整个字符串,时间复杂度为 O(n2)O(n^2)O(n2)。
有没有一种办法,可以直接判断出一个字符串 aaa 是否可以通过重复连接构成另一个字符串 bbb 呢?
仔细观察下图,长为 10 的字符串,abcdefgABCDEFG
都是 char
类型变量,各自代表对应位置的一个字符。
如果其前 777 个字符构成的前缀与最后 777 个字符构成的后缀能够匹配,即 abcdefg
=ABCDEFG
,是否就说明整个字符串可以用前 333 个字符重复连接构成?
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|
* | * | * | a | b | c | d | e | f | g |
A | B | C | D | E | F | G |
证明:
首先,对应位置的字符分别相等,即 abcd
=DEFG
又由 abcdefg
=ABCDEFG
,可知 abc
=ABC
,
由此可得 abc
=ABC
=DEF
=def
,EFG
=efg
=bcd
=bca
,即该字符串为 abcabcabca
,得证。