宝塔服务器面板,一键全能部署及管理,送你10850元礼包,点我领取

文章目录

  • 字符串匹配
    • 一、基本概念
      • 字符串匹配问题
      • 符号和术语
      • 后缀重叠引理
    • 二、朴素字符串匹配算法
    • 三、Rabin-Karp算法(字符串Hash算法)
      • 进制Hash
      • 质数Hash
    • 四、利用有限自动机进行字符串匹配
      • 有限自动机
      • 字符串匹配自动机
      • 计算状态转移函数
      • 代码
    • 五、Knuth-Morris-Pratt算法
      • 模式的前缀函数
      • 代码
    • 六、Z-函数(扩展KMP)

字符串匹配

一、基本概念

字符串匹配问题

假设文本是一个长度为nnn的数组T[1…n]T[1 \ldots n]T[1n],而模式是一个长度为mmm的数组P[1…m]P[1 \ldots m]P[1m],其中m≤nm \leq nmn,进一步假设PPPTTT的元素都是来自一个有限的字母集∑\sum的字符。数组TTTPPP通常被称为字符串

如果0≤s≤n−m0 \leq s \leq n-m0snm,并且T[s+1…s+m]=P[1…m]T[s+1 \ldots s+m]=P[1 \ldots m]T[s+1s+m]=P[1m],那么称模式PPP在文本TTT中出现过,且偏移为sss。如果PPPTTT中以偏移sss出现,那么称sss有效偏移,否则为无效偏移

字符串匹配问题就是在TTT中找到PPP的所有有效偏移sss

符号和术语

  1. 字符串集合:我们用∑∗\sum^{*}来表示包含所有有限长度的字符串的集合,该字符串是由字母表∑\sum中的字符串组成。在本文中,我们只考虑有限长度的字符串。长度为000的字符串用ϵ\epsilonϵ表示,也属于∑∗\sum^{*}
  2. 字符串的长度:一个字符串TTT的长度用∣T∣|T|T表示。特别的,∣ϵ∣=0|\epsilon| = 0ϵ=0
  3. 连结:两个字符串xxxyyy的连结表示为xyxyxy,意义为xxx后接yyy,长度为∣x∣+∣y∣|x| + |y|x+y
  4. 前缀:如果对于三个个字符串x=wyx=wyx=wy,那么称字符串wwwxxx的前缀,记作w⊏xw \sqsubset xwx,且∣w∣≤∣x∣|w| \leq |x|wx
  5. 后缀:如果对于三个个字符串x=ywx=ywx=yw,那么称字符串wwwxxx的后缀,记作w⊐xw \sqsupset xwx,且∣w∣≤∣x∣|w| \leq |x|wx。特别的,空字符串ϵ\epsilonϵ是任意一个字符串的前缀和后缀。

后缀重叠引理

假设xxxyyyzzz满足x⊐zx \sqsupset zxzy⊐zy \sqsupset zyz。如果∣x∣≤∣y∣|x| \leq |y|xy,那么x⊐yx \sqsupset yxy,反过来如果∣y∣≤∣x∣|y| \leq |x|yx,那么y⊐xy \sqsupset xyx。如果∣x∣=∣y∣|x| = |y|x=y,那么x=yx = yx=y

为了符号简洁,我们把模式P[1…m]P[1 \ldots m]P[1m]的由kkk个字符串组成的的前缀P[1…k]P[1 \ldots k]P[1k],记作PkP_{k}Pk。因此P0=ϵ,Pm=P=P[1…m]P_{0} = \epsilon, P_{m}=P=P[1 \ldots m]P0=ϵ,Pm=P=P[1m]。与此类似,我们把文本TTT中由kkk个字符组成的前缀记作TkT_{k}Tk

字符串匹配问题转化为:找到所有的偏移s(0≤s≤n−m)s(0 \leq s \leq n-m)s(0snm),使得P⊐Ts+mP \sqsupset T_{s+m}PTs+m


二、朴素字符串匹配算法

朴素字符串算法是通过一个循环找到所有的有效偏移,该循环对n−m+1n-m+1nm+1个可能的sss值进行检测,看是否满足条件P[1…m]=T[s+1…s+m]P[1 \ldots m]=T[s+1 \ldots s+m]P[1m]=T[s+1s+m]

NAIVE-STRING-MATCHER(T,P):n = T.lengthm = P.lengthfor s = 0 to n-m:if P[1...m] = T[s+1 ... s+m]:print "Pattern occurs whit shift",s

伪代码中的循环枚举sss的位置,for中的判断隐含了一个循环,这个循环检测偏移sss是否为有效偏移。

在最坏情况下,朴素算法的运行时间为O((n−m+1)m)O((n-m+1)m)O((nm+1)m)。如果m=n/2m = n/2m=n/2,则运行时间为Θ(n2)\Theta(n^{2})Θ(n2)

我们看到,朴素字符串匹配算法不是解决该问题的最佳过程。是因为当其他无效的sss值存在时,他只关心一个有效的sss的值,而完全忽略了检测无效的sss值的时候所带来的有用信息。例如对于P=aaabP=aaabP=aaab并且我们发现s=0s=0s=0是一个有效的偏移值,由于T[4]=bT[4] = bT[4]=b,所以s=1,2,3s=1,2,3s=1,2,3都不是有效的。

三、Rabin-Karp算法(字符串Hash算法)

进制Hash

Rabin-Karp算法运用了初等数论的知识,其实就是对字符串进行HashHashHash运算,比较每一位上的HashHashHash值是否相等即可。

为了便于说明,我们假设∑={0,1,2,3,4,5,6,7,8,9}\sum = \{0,1,2,3,4,5,6,7,8,9\}={0,1,2,3,4,5,6,7,8,9},这样每个字符都是十进制数字。(在通常情况下,可以假定每个字符都是以ddd为基数表示的数字,其中∣∑∣=d|\sum| = d=d),每一个字符串就是相当与一个十进制数,其长度为kkk

给定一个模式P[1…m]P[1 \ldots m]P[1m],令ppp为表示的十进制数。类似的,有字符串T[1…n]T[1 \ldots n]T[1n],假设tst_{s}ts表示子串T[s+1…s+m]T[s+1 \ldots s+m]T[s+1s+m]对应的十进制数。当然,只有当sss是一个有效偏移的时候,p=tsp = t_{s}p=ts,如果能在时间Θ(m)\Theta(m)Θ(m)内计算出ppp的值,并且在总时间Θ(n−m+1)\Theta(n-m+1)Θ(nm+1)内计算出所有的tst_{s}ts值,那么通过比较tst_{s}tsppp的值就可以在时间Θ(m)+Θ(n−m+1)=Θ(n)\Theta(m) + \Theta(n-m+1) = \Theta(n)Θ(m)+Θ(nm+1)=Θ(n)时间内找到所有的有效偏移。

其实这是一种HashHashHash运算,根据霍纳法则,我们可以在规定的时间内计算出ppp

p=P[m]+10(P[m−1]+10(P[m−2]+…+10(P[2]+10P[1])…))p = P[m] + 10(P[m-1] + 10(P[m-2] + \ldots + 10(P[2] + 10P[1]) \ldots )) p=P[m]+10(P[m1]+10(P[m2]++10(P[2]+10P[1])))

类似的,我们可以通过规定的时间计算出t0t_{0}t0的值,并且能在O(1)O(1)O(1)的时间内利用t0t_{0}t0的值计算出t1t_{1}t1的值,依次类推,总结出递推公式:

ts+1=10(ts−10m−1T[s+1])+T[s+m+1]t_{s+1} = 10(t_{s}-10^{m-1}T[s+1])+T[s+m+1] ts+1=10(ts10m1T[s+1])+T[s+m+1]

减去10m−1T[s+1]10^{m-1}T[s+1]10m1T[s+1],表示减去最高位的数字,乘以十表示将这个数向左移动一位,加上T[s+m+1]T[s+m+1]T[s+m+1],表示加上最低位。

预先计算出常数10m−110^{m-1}10m1,可以使用快速幂计算。

到目前为止,我们就始终在回避一个问题,如果mmm的值过大,则tit_{i}ti的值就可能很大,以至于超过了运算的范围,因此我们就需要运用数论的知识,将其进行取模运算。

选定一个素数qqq,使得dqdqdq在一个机器字长内,我们就可以进行取余运算:

ts+1=(d(ts−hT[s+1])+T[s+m+1])modqt_{s+1} = (d(t_{s}-hT[s+1])+T[s+m+1]) \mod q ts+1=(d(tshT[s+1])+T[s+m+1])modq

其中dddddd进制,hhhh≡dm−1modqh \equiv d^{m-1} \mod qhdm1modq

但是这种结果并不是很完美,因为ts≡pmodqt_{s} \equiv p \mod qtspmodq,并不能说明ts=pt_{s} = pts=p,因此,我们还必须仿照朴素算法那样,验证一下这个答案。

如果qqq是一个素数,并且这个qqq足够大,这种伪命中点的概率发生的几率很小。

Rabin-Karp算法的期望时间复杂度为O(n)+O(m(v+n/q))O(n) + O(m(v+n/q))O(n)+O(m(v+n/q))。预计较好的情况(有效偏移比较少,素数大,mmm远远小于nnn)。这个算法的时间复杂度为O(n)O(n)O(n)

还可以使用链接法或开放寻址法来进行无错Hash。

同时,我们可以定义h[0]=s[0]h[0] = s[0]h[0]=s[0]h[k]=(h[k−1]A+s[k])modBh[k] = (h[k-1]A + s[k]) \mod Bh[k]=(h[k1]A+s[k])modB,位权p[0]=1p[0] = 1p[0]=1p[k]=(p[k−1]A)modBp[k] = (p[k-1]A) \mod Bp[k]=(p[k1]A)modB

这样我们就能在O(1)O(1)O(1)的时间内计算出某个子串S[a…b]S[a \ldots b]S[ab]的hash值(h[b]−h[a−1]p[b−a+1])modB(h[b]-h[a-1]p[b-a+1]) \mod B(h[b]h[a1]p[ba+1])modB

质数Hash

如果我们想判断两个单词是不是同体词(即两个字符串每个字母出现的次数都相同,例如eat和ate)。此时我们将每一个字母替换成一个唯一的质数,那么根据算数基本定理,我们就可以把一个字符串看成是一个由多个质数相乘得到的唯一的整数,那么如果两个单词是同体词,那么这个两个Hash值一定相等。

四、利用有限自动机进行字符串匹配

有限自动机

一个有限自动机MMM是一个五元组(Q,q0,A,∑,δ)(Q,q_{0},A,\sum,\delta)(Q,q0,A,,δ),其中:

  • QQQ是状态的有限集合
  • q0∈Qq_{0} \in Qq0Q是初始状态
  • A⊆QA \subseteq QAQ是一个特殊接受状态的集合
  • ∑\sum是有限输入字母表
  • δ\deltaδ是一个从Q×∑Q \times \sumQ×QQQ的函数,称为MMM的转移函数

有限自动机状态开始于q0q_{0}q0,读入一个字符aaa,则他从状态qqq变成了状态δ(q,a)\delta(q,a)δ(q,a)。当自动机的状态属于QQQ的时候,我们说自动机接受了迄今为止所有的字符。

有限自动机MMM引入一个终态函数ϕ(w)\phi(w)ϕ(w),他是一个从∑∗\sum^{*}QQQ的一个函数,函数的值为自动机MMM处理完字符串www之后所处的最终状态。因此当且仅当ϕ(w)∈A\phi(w) \in Aϕ(w)A的时候,MMM接受字符串www。存在递归定义:

ϕ(ϵ)=q0ϕ(wa)=δ(ϕ(w),a)\phi(\epsilon)=q_{0} \\ \phi(wa) = \delta(\phi(w),a) ϕ(ϵ)=q0ϕ(wa)=δ(ϕ(w),a)

字符串匹配自动机

对于给定一个模式PPP,我们可以进行预处理构造一个有限状态自动机,之后,对于字符串TTT,就可以在O(n)O(n)O(n)的时间内求出所有的有效偏移。

为了构造自动机,我们要先定义后缀函数σ(x)\sigma(x)σ(x),函数是一个从∑∗\sum^{*}{0,1,2,3,…,m}的映射函数\{0,1,2,3,\ldots,m\}的映射函数{0,1,2,3,,m}

给定模式PPP,模式PPP的后缀函数为

σ(x)=max⁡(k:Pk⊐x)\sigma(x)=\max(k:P_{k} \sqsupset x) σ(x)=max(k:Pkx)

也就是说存在一个最大长度的子串,既是PPP的前缀又是xxx的后缀,子串的长度就是后缀函数的值。

给定模式P[1…m]P[1 \ldots m]P[1m]对应的字符串匹配自动机为:

  • 状态集合QQQ{0,1,2,3,…,m}\{0,1,2,3,\ldots,m\}{0,1,2,3,,m}。开始状态q0q_{0}q0000,并且只有状态mmm是唯一的接受状态。
  • 对于任意的状态qqq和字符aaa,转移状态函数δ(q,a)\delta(q,a)δ(q,a)的定义为δ(q,a)=σ(Pqa)\delta(q,a)=\sigma(P_{q}a)δ(q,a)=σ(Pqa)

也就是说,存在有效偏移sss,那么PPP一定是Ts+mT_{s+m}Ts+m的后缀,状态机的状态就是后缀函数的值。并且存在有效偏移sss,当且仅当状态q=mq=mq=m

状态机维护一个不变式:

ϕ(Ti)=σ(Ti)\phi(T_{i}) = \sigma(T_{i}) ϕ(Ti)=σ(Ti)

TiT_{i}Ti表示T[1…i]T[1 \ldots i]T[1i]

字符串——字符串匹配-编程部落
图片中描述了模式P=ababacaP=ababacaP=ababaca的自动机的DAGDAGDAG模型,除了这个模型,还有二维表模型。

考虑一下两种情况:

  • a=P[q+1]a=P[q+1]a=P[q+1],此时我们可以放心的把状态转移到σ(Pqa)=q+1\sigma(P_{q}a)=q+1σ(Pqa)=q+1,这个状态沿着主轴的方向进行。
  • a≠P[q+1]a \neq P[q+1]a=P[q+1],这时我们必须找到一个更小的子串,确保这个子串满足σ(Pqa)\sigma(P_{q}a)σ(Pqa)

此时,构造完自动机,我们就可以根据自动机处理文本TTT,找到状态q=mq=mq=m的时候,偏移i−mi-mim就是有效偏移。

为了证明有限自动机的正确性,我们首先要说明两个引理。

后缀不等式:对于任意字符串xxx和字符aaaσ(xa)≤σ(x)+1\sigma(xa) \leq \sigma(x) + 1σ(xa)σ(x)+1

也就是说如果字符aaa正好是下一个字符,等号成立,如果不是,那么后缀不可能再长了。

后缀函数递归定理:对任意字符串xxx和字符aaa,若q=σ(x)q=\sigma(x)q=σ(x),则σ(xa)=σ(Pqa)\sigma(xa)=\sigma(P_{q}a)σ(xa)=σ(Pqa)

也就是说,PqP_{q}Pq这个后缀已经到了左边界,即从左边界开始求即可。

定理:如果ϕ\phiϕ是有限自动机的终态函数,那么ϕ(Ti)=σ(Ti)\phi(T_{i}) = \sigma(T_{i})ϕ(Ti)=σ(Ti)

证明对iii进行归纳即可。

计算状态转移函数

先确定一个kkk的上界,然后在缩小范围即可,这种方式效率很低,接下来的KMPKMPKMP算法可以优化这个思路。

代码

int sp[100][10];int eval(const string & p)
{for(int q = 0; q<=p.size(); q++){for(char a = '0'; a<='9'; a++){int k = q + 1;string x = p.substr(0,q) + a;while(1){bool ok = true;for(int i = x.size() - 1,j=k-1; j>=0; i--,j--){if(p[j] != x[i]){ok = false;break;}}if(ok) break;else k--;}sp[q][a] = k;}}
}void match(const string & t,int end)
{int q = 0;for(int i = 0; i<t.size(); i++){q = sp[q][t[i]];if(q == end) cout << i - end + 1 << endl;}
}int main()
{string p = "01011";string t = "0101111010111010101011111";int end = p.size();eval(p);match(t,end);return 0;
}

五、Knuth-Morris-Pratt算法

现在来介绍一种线性的字符串匹配算法KMPKMPKMP算法。和自动机算法相比,他只需要用到一个辅助函数π\piπ,而且这个函数和字符集无关,不需要遍历字符集。并且在O(m)O(m)O(m)的时间计算出所有的函数值以留备用,在O(n)O(n)O(n)的时间内完成匹配。

模式的前缀函数

字符串——字符串匹配-编程部落

面对一个偏移sss,匹配长度为qqq,字符q+1q+1q+1匹配失败,按照朴素算法,我们应该测试下一个偏移s+1s+1s+1,但是我们知道,偏移s+1s+1s+1必然失败,因为P[1]P[1]P[1]必然不可能匹配T[s+2]T[s+2]T[s+2],可以匹配的最短偏移为s+2s+2s+2。这个222我们可以通过计算前缀函数计算出来。

也就是说,假设模式字符串P[1…q]P[1 \ldots q]P[1q]与文本字符串T[s+1…s+q]T[s+1 \ldots s+q]T[s+1s+q]匹配,s′s's是最小的偏移量,s′>ss' \gt ss>s,那么对某些k<qk \lt qk<q满足:

P[1…k]=T[s′+1…s′+k]P[1 \ldots k] = T[s'+1 \ldots s'+k] P[1k]=T[s+1s+k]

的最小偏移s′s's是多少,其中s′+k=s+qs' + k=s+qs+k=s+q

这时,我们就需要借助前缀函数π(q)\pi(q)π(q),其值为存在最长的子串,既是PPP的前缀,又是PqP_{q}Pq真后缀

已知一个模式P[1…m]P[1 \ldots m]P[1m],模式PPP的前缀函数是函数π:{1,2,3,4,…,m}→{0,1,…,m−1}\pi: \{1,2,3,4,\ldots,m\} \to \{0,1,\ldots,m-1\}π:{1,2,3,4,,m}{0,1,,m1}的一个函数,满足:

π[q]=max(k:k<q∣Pk⊐Pq)\pi[q]=max(k: k < q | P_{k} \sqsupset P_{q}) π[q]=max(k:k<qPkPq)

之后我们就可以利用辅助函数匹配文本。

代码

#include <bits/stdc++.h>using namespace std;#define FR freopen("in.txt", "r", stdin)typedef long long ll;int nxt[1000005];void buildNext(string &p)
{nxt[0] = 0;for (int i = 1; i < p.size(); i++){nxt[i] = nxt[i - 1];while (p[nxt[i]] != p[i] && nxt[i] > 0){nxt[i] = nxt[nxt[i] - 1];}nxt[i] = p[nxt[i]] == p[i] ? nxt[i] + 1 : 0;}
}void kmp(string &t, string &p)
{int ti = 0;int pi = 0;while (ti < t.size()){if (t[ti] == p[pi]){if (pi == p.size() - 1){cout << ti + 2 - p.size() << endl;pi = nxt[pi];}else{pi++;}ti++;}else if (pi == 0){ti++;}else{pi = nxt[pi - 1];}}
}int main()
{string s1, s2;cin >> s1 >> s2;buildNext(s2);kmp(s1, s2);for (int i = 0; i < s2.size(); i++){cout << nxt[i] << " ";}return 0;
}

六、Z-函数(扩展KMP)

定义ZZZ数组Z[i]Z[i]Z[i]为,字符串s[0…i−1]s[0 \ldots i – 1]s[0i1]和字符串s[i…n−1]s[i \ldots n-1]s[in1]的最长公共前缀长度。特别的Z[0]=0Z[0] = 0Z[0]=0

我们有线性时间O(n)O(n)O(n)的算法来计算Z-函数,叫做Z-算法。