字符串匹配 - 字符串哈希
导航:
- 字符串哈希
- 有限状态自动机
- KMP
匹配
给定一个长为 n n n的文本串 T [ 1.. n ] T[1..n] T[1..n]与一个长为 m m m的模式串 M [ 1.. m ] M[1..m] M[1..m],其中 m ≤ n m\leq n m≤n, T T T与 M M M的字符来自同一个字符集 Σ \Sigma Σ。寻找在 T T T中的所有有效偏移 s s s,使得 T [ s + 1.. s + m ] = P [ 1.. m ] T[s+1..s+m]=P[1..m] T[s+1..s+m]=P[1..m],则称字符串匹配。
显然,有效偏移 s s s的位置有 n − m + 1 n-m+1 n−m+1个,若 m > n m>n m>n则无有效偏移,此时讨论字符串匹配无意义,或者说必为 0 0 0。
当然,字符串匹配不仅仅局限于字符串,将字符抽象为任意元素,字符集抽象为任意元素对应的集合,只需元素之间能够判断是非等同,亦可进行匹配。
字符串哈希
求其值
我们将每个字符视为一个值,整个字符串视为一个
k
k
k进制表示。比如十六进制下,
B
16
=
1
1
10
B_{16}=11_{10}
B16=1110,而
A
B
16
=
171
10
AB_{16}=171_{10}
AB16=17110,我们将每个字符映射至一个值,整个字符串视为一个
k
k
k进制表示,则可以轻松计算出
T
T
T与
P
P
P的“值”,匹配问题就成了在
T
T
T中是否有连续的
m
m
m位数位与
P
P
P相同。我们将这个将字符串映射为一个唯一的值的函数,定义为
f
f
f。
f
(
S
)
=
S
1
×
k
n
−
1
+
S
2
×
k
n
−
2
+
⋯
+
S
n
×
k
0
=
∑
i
=
1
n
S
i
×
k
n
−
i
f(S)=S_1\times k^{n-1}+S_2\times k^{n-2}+\cdots+S_n\times k^0=\sum_{i=1}^nS_i\times k^{n-i}
f(S)=S1×kn−1+S2×kn−2+⋯+Sn×k0=i=1∑nSi×kn−i
其中
S
S
S表示某一字符串,
S
i
S_i
Si表示该字符串第
i
i
i个字符所对应的值。
显然,要避免冲突,使每个字符串产生唯一的值,每个字符串映射至的值应该唯一且小于进制数 k k k,故一般取 [ 0 , k ) [0,k) [0,k)。
在后续引入哈希时,此步并非必要,而是减小冲突的手段。
例如取字符集为全体小写字母,每个小写字母对应值为其字典中出现顺序 − 1 -1 −1,取进制 k = 26 k=26 k=26, Σ = { a , b , … , z } , a = 0 , b = 1 , … , z = 25 \Sigma=\lbrace a,b,\dots,z\rbrace,a=0,b=1,\dots,z=25 Σ={a,b,…,z},a=0,b=1,…,z=25。
则字符串 S = b e c S=bec S=bec,则 f ( S ) = 1 × 2 6 2 + 4 × 26 + 2 = 782 f(S)=1\times 26^2+4\times 26+2=782 f(S)=1×262+4×26+2=782
挪过去
在 O ( m ) O(m) O(m)的时间内,我们能求出 T [ 1.. m ] T[1..m] T[1..m]与 P [ 1.. m ] P[1..m] P[1..m](即 f ( P [ 1.. m ] ) f(P[1..m]) f(P[1..m])与 f ( T [ 1.. m ] ) f(T[1..m]) f(T[1..m])),能够比较有效偏移 s = 0 s=0 s=0是否匹配,在比较剩下 n − m n-m n−m个有效偏移时,我们没必要每次都用 f f f的定义去求其值,不然就完全退化成了朴素匹配的模样。
已知
t
s
=
f
(
T
[
s
.
.
s
+
m
]
−
1
)
t_s=f(T[s..s+m]-1)
ts=f(T[s..s+m]−1),欲求
t
s
+
1
=
f
(
T
[
s
+
1..
s
+
m
]
)
t_{s+1}=f(T[s+1..s+m])
ts+1=f(T[s+1..s+m])(说白了往右挪一个位置去匹配),我们只需计算
t
s
+
1
=
k
(
t
s
−
k
m
−
1
T
s
)
+
T
s
+
m
t_{s+1}=k(t_s-k^{m-1}T_s)+T_{s+m}
ts+1=k(ts−km−1Ts)+Ts+m
k
m
−
1
T
s
k^{m-1}T_s
km−1Ts即最左侧字符,去掉后
×
k
\times k
×k相当于整体左移再加入最低位
k
0
×
T
s
+
m
k^0\times T_{s+m}
k0×Ts+m即得。
故此我们只需 O ( n + m ) O(n+m) O(n+m)的时间复杂度与 O ( 1 ) O(1) O(1)的额外空间即可处理得到所有有效偏移。
//该代码仅做示例
const int K;//“进制数”
vector<int> find(const string& t, const string& p) {
vector<int> res;
int n = t.size(), m = p.size(), s = 0;
LL fp = 0, ft = 0, k = pow_(K, m - 1);
for (int i = 0; i < m; i++, k *= K)//处理T与P
fp = p[i] + fp * K, ft = t[i] + ft * K;
if (ft == fp) res.push_back(0);
for (int i = 0; i < n - m; i++)
if ((ft = K * (ft - k * t[i]) + t[i + m]) == fp) res.push_back(i + 1);//挪
return res;
}
前缀和
对模式串 P P P,我们需要快速得知任意一个偏移 s s s是否匹配,而非求出所有有效偏移,此时一个个挪过去就显得过于鸡肋。
根据上式,我们只需一个前缀和即可,前缀和通过上述递推式,获得所有 f ( T [ 1.. x ] ) , 1 ≤ x ≤ n f(T[1..x]),1\leq x\leq n f(T[1..x]),1≤x≤n即 T T T的任意前缀字符串的值。要获得 f ( T [ s + 1.. s + m ] ) f(T[s+1..s+m]) f(T[s+1..s+m]),只需使用 f ( T [ 1.. s + m ] ) − k m × f ( T [ 1.. s − 1 ] ) f(T[1..s+m])-k^{m}\times f(T[1..s-1]) f(T[1..s+m])−km×f(T[1..s−1]),即将其不需要的前缀 T [ 1.. s ] T[1..s] T[1..s]挪到对应位置(高位)上,再减去即可。
以十进制数显示地表示: T = 123456 T=123456 T=123456,欲求 f ( T [ 3..5 ] ) f(T[3..5]) f(T[3..5]),通过前缀和可知 f ( T [ 1..5 ] ) = 12345 , f ( T [ 1..2 ] ) = 12 f(T[1..5])=12345,f(T[1..2])=12 f(T[1..5])=12345,f(T[1..2])=12, f ( T [ 3..5 ] ) = f ( T [ 1..5 ] ) − 1 0 3 × f ( T [ 1..2 ] ) = 345 f(T[3..5])=f(T[1..5])-10^3\times f(T[1..2])=345 f(T[3..5])=f(T[1..5])−103×f(T[1..2])=345。
//该代码仅做示例
const int K;//“进制数”
const int N;
LL sum[N];//仅做示例,从sum[0]开始,一般情况下标从1开始方便处理边界
void init(const string& t) {
sum[0] = t[0];
LL k = 1;
for (int i = 1; i < t.size(); i++)
sum[i] = sum[i - 1] * (k *= K) + sum[i];
}
LL get(int pos, int m) {
return pos ? (sum[pos + m - 1] - sum[pos - 1] * pow(K, m)) : sum[m - 1];
}
有点大
我们假设值全部采用uint64_t
存储(一般情况为unsigned long long
),其取值范围为
[
0
,
2
64
)
=
18
,
446
,
744
,
073
,
709
,
551
,
616
[0,2^{64})=18,446,744,073,709,551,616
[0,264)=18,446,744,073,709,551,616,若字符集大小为
∣
Σ
∣
=
26
\vert\Sigma\vert=26
∣Σ∣=26,则我们只能存储
⌊
log
26
(
2
64
−
1
)
⌋
=
13
\lfloor\log_{26}{(2^{64}-1)}\rfloor=13
⌊log26(264−1)⌋=13。也就是说由于指数爆炸的缘故,受限于有限的存储位数,我们没法快速存储或计算一个较长字符串在
f
f
f下的的唯一值。如果采用高精度的方式,高精度计算效率会使匹配速度大打折扣,甚至退化至朴素算法的时间复杂度(甚至不如)。
此时我们采用的方法往往是——取模。此时引入函数 h ( s ) = f ( s ) m o d M h(s)=f(s)\mod{M} h(s)=f(s)modM, M M M为模数。但是取模后计算出的结果是“错误”的,有可能两字符串并不匹配但其取模后的值相同,也即 f ( S 1 ) ≠ f ( S 2 ) f(S_1)\neq f(S_2) f(S1)=f(S2),但是 f ( S 1 ) ≡ f ( S 2 ) m o d M f(S_1)\equiv f(S_2)\mod{M} f(S1)≡f(S2)modM,在字符串匹配中,对于这样的偏移 s s s,我们称之为伪命中点,从哈希视角看则是哈希碰撞。
此时一个看似完美的 O ( n + m ) O(n+m) O(n+m)的解决方案被现实击倒了,迫于存储与计算的限制,我们只能采用取模的方式规避,然而这就导致了伪命中点的出现,对于伪命中点,我们的处理方法有两种:
对所有命中情况,执行 O ( m ) O(m) O(m)的朴素匹配进行判断。
或者使出人类终极奥义——不管。该方法往往用于竞赛当中(答应我,打CF的时候别用),当然这个不管是有前提的,只要概率足够低,出题人不故意卡哈希,你总能找到合适的函数 h h h(注意这个 h h h既包括了函数 f f f也含有模数 M M M),“骗”过所有测试数据。接下来就可以通过哈希的视角去考虑如何构造出一个良性的函数 h h h。
哈希
具体内容请移步哈希。
回到第一小节,我们的 k k k应该足够大,同时每一位的字符对应的值应该互不冲突且小于 k k k(大于 k k k会导致“进位”),增大碰撞概率。
既然已经用上了哈希,我们的 k k k和 M M M的取值就可以奔放一点(反正你也在模 M M M下计算,就不用考虑值的具体大小了,没必要抠抠搜搜取个 ∣ Σ ∣ \vert\Sigma\vert ∣Σ∣),一般情况下 k k k和 M M M是一大一小的两个素数,例如 k k k取 1009 1009 1009, M M M一般取经典值 1 e 9 + 7 1e9+7 1e9+7。
为何常取素数,请移步数论。
1007 = 19 × 53 1007=19\times 53 1007=19×53, 1 e 9 + 9 1e9+9 1e9+9也是个常用质数,其与 1 e 9 + 7 1e9+7 1e9+7相加不爆
int
,相乘不爆long long
。
除了对哈希函数构造的艺术外,还可采用双哈希的方式减小碰撞。双哈希是指同时采用两种哈希函数 h 1 h_1 h1和 h 2 h_2 h2,唯有在 h 1 ( x ) = h 1 ( s ) , h 2 ( x ) = h 2 ( s ) h_1(x)=h_1(s),h_2(x)=h_2(s) h1(x)=h1(s),h2(x)=h2(s)时,才可能命中,两个哈希函数的相互之间独立性越强,二者同时碰撞的概率就越低,构造双哈希函数也是一种艺术。
双哈希并不是二哈希,对哈希后的值再哈希,这样处理没有意义,若 h 1 ( x ) = h 1 ( s ) h_1(x)=h_1(s) h1(x)=h1(s),则 h 2 ( h 2 ( x ) ) = h 2 ( h 1 ( x ) ) h_2(h_2(x))=h_2(h_1(x)) h2(h2(x))=h2(h1(x)),反之,若本来 h 1 ( x ) = h 1 ( s ) h_1(x)=h_1(s) h1(x)=h1(s),再经过 h 2 h_2 h2的计算,反而可能发生碰撞。
真 ⋅ \cdot ⋅代码
前置代码
const LL K = 1009;
const LL MOD = 1e9 + 7;
LL pow_(LL b, LL p) {
LL res = 1;
while (p) {
if (p & 1) (res *= b) %= MOD;
p >>= 1;
(b *= b) %= MOD;
}
return res;
}
求值
LL cul(const char* str, int n) {
LL res = 0;
for (int i = 0; i < n; i++) res = (res * K + str[i]) % MOD;
return res;
}
递推
vector<int> find(const char* t, int n, const char* p, int m) {
vector<int> res;
LL fp = cul(p, m), ft = cul(t, n), k = pow_(K, m - 1);
if (ft == fp) res.push_back(0);
for (int i = 0; i < n - m; i++)
if ((ft = ((K * ((ft - k * t[i]) % MOD) + t[i + m] % MOD + MOD) % MOD)) == fp)
res.push_back(i + 1);//挪,要注意取模后的负数情况
return res;
}
前缀和
const int N;//大于等于字符串最大长度
LL sum[N];
//[l,r]
LL get(int l, int r) {
return (sum[r] - (l ? sum[l - 1] * pow_(K, r - l + 1) : 0) % MOD + MOD) % MOD;
}
void init(const char* str, int n) {
sum[0] = str[0];// % MOD
for (int i = 1; i < n; i++) sum[i] = (K * sum[i - 1] + str[i]) % MOD;
}
- 用一对
const char*
与int
表示字符串通用性强,通过<string>.c_str()
与<string>.size()
可直接转换,并且可以调整二者值来表示任意子串。 - 一个老生常谈的问题,取模一定要注意可能出现负数的情况!
哈希?
然而在《算法导论》中的字符串匹配专题,其并未采用字符串哈希的说法,而是 R a b i n − K a r p Rabin-Karp Rabin−Karp算法。个人认为其原因是字符串哈希其实是个特殊地哈希方法,其作用于字符串,函数 f f f满足霍纳法则,或者说一种“前缀性质”,使得能够产生递推与前缀和。而一般情况下的哈希函数,则只会满足对一个关键字的映射,往往不会考虑关键字之间的关系,也不会如字符串这般,在串上递推过去。