【寒假复健Day4】字符串匹配(上)—— 字符串哈希

字符串匹配 - 字符串哈希

导航:

  • 字符串哈希
  • 有限状态自动机
  • 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 mn 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 nm+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×kn1+S2×kn2++Sn×k0=i=1nSi×kni
其中 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 nm个有效偏移时,我们没必要每次都用 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(tskm1Ts)+Ts+m
k m − 1 T s k^{m-1}T_s km1Ts即最左侧字符,去掉后 × 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]),1xn 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..s1]),即将其不需要的前缀 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(2641)=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 RabinKarp算法。个人认为其原因是字符串哈希其实是个特殊地哈希方法,其作用于字符串,函数 f f f满足霍纳法则,或者说一种“前缀性质”,使得能够产生递推与前缀和。而一般情况下的哈希函数,则只会满足对一个关键字的映射,往往不会考虑关键字之间的关系,也不会如字符串这般,在串上递推过去。

  • 8
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值