字符串匹配算法学习笔记

本文所有内容整理自《算法导论》第32章。

绪论

字符串匹配问题的形式化定义为:假设文本(text)是一个长度为 n n n 的数组 T [ 1.. n ] T[1..n] T[1..n] ,而模式(pattern)是一个长度为 m m m 的数组 P [ 1.. m ] P[1..m] P[1..m] ,其中 m ⩽ n m \leqslant n mn ,进一步假设 P P P T T T 的元素都是来自一个有限字母集 ∑ \sum 的字符。例如, ∑ = { 0 , 1 } \sum = \{0,1\} ={0,1} 或者 ∑ = { a , b , ⋯   , z } \sum = \{ a,b,\cdots,z \} ={a,b,,z} 。字符数组 P P P T T T 通常称为字符串

如果 0 ⩽ s ⩽ n − m 0\leqslant s \leqslant n-m 0snm ,并且 T [ s + 1.. s + m ] = P [ 1.. m ] T[s+1..s+m]=P[1..m] T[s+1..s+m]=P[1..m] ,那么称模式 P P P 在文本 T T T 中出现,且偏移 s s s 。如果 P P P T T T 中以偏移 s s s 出现,那么称 s s s有效偏移;否则,称它为无效偏移。字符串匹配问题就是找到所有的有效偏移,使得在该有偏移下,所给的模式 P P P 出现在给定的文本 T T T 中。

本文将阐述四种算法,其对比如下:

算法预处理时间匹配时间
朴素算法 0 0 0 O ( ( n − m + 1 ) m ) O((n-m+1)m) O((nm+1)m)
Rabin-Karp Θ ( m ) \Theta(m) Θ(m) O ( ( n − m + 1 ) m ) O((n-m+1)m) O((nm+1)m)
有限自动机算法 O ( m ∣ ∑ ∣ ) O(m\vert \sum \vert) O(m) Θ ( n ) \Theta(n) Θ(n)
Knuth-Morris-Pratt Θ ( m ) \Theta(m) Θ(m) Θ ( n ) \Theta(n) Θ(n)

除了表格中直接指出以外,本文不会提及时间复杂度的问题,也不做时间复杂度分析,因为我不会。

朴素字符串匹配算法

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

NAIVE-STRING-MATCHER(T,P)
n = T.length
m = P.length
for s = 0 to n-m
	if P[1..m] == T[s+1..s+m]
		print "Pattern occurs with shift"s

这种算法效率很低,因为当发现 s s s 无效时,它完全忽略了检测无效 s s s 值时获得的文本的信息。

Rabin-Karp 算法

不失一般性,我们假设 ∑ = { 0 , 1 , 2 , ⋯   , 9 } \sum = \{ 0,1,2,\cdots,9 \} ={0,1,2,,9} ,即每个字符都是十进制数字(在一般情况下,可以假定每个字符都是以 d d d 为基数表示的数字,其中 d = ∣ Σ ∣ d=\vert\Sigma\vert d=Σ)。那么,我们便可以用长度为 k k k 的十进制数来表示由 k k k 个连续的字符组成的字符串。比如,字符串'31415'对应着十进制数31415

给定一个模式 P [ 1.. m ] P[1..m] P[1..m] ,假设 p p p 表示其相应的十进制值。类似地,给定文本 T [ 1.. n ] T[1..n] T[1..n] ,假设 t s t_s ts 表示长度为 m m m 的子字符串 T [ s + 1.. s + m ] T[s+1..s+m] T[s+1..s+m] 所对应的十进制值,其中, s = 0 , 1 , ⋯   , n − m s=0,1,\cdots,n-m s=0,1,,nm 。当且仅当 T [ s + 1.. s + m ] = P [ 1.. m ] T[s+1..s+m]=P[1..m] T[s+1..s+m]=P[1..m] 时, t s = p t_s=p ts=p 。那么,通过比较 p p p 和每一个 t s t_s ts 值,就能找到所有的有效偏移 s s s

计算 p p p 时可以运用霍纳法则
p = P [ m ] + 10 ( P [ m − 1 ] + 10 ( P [ m − 2 ] + ⋯ + 10 ( P [ 2 ] + 10 P [ 1 ] ) ⋯   ) ) p=P[m]+10(P[m-1]+10(P[m-2]+\cdots+10(P[2]+10P[1])\cdots)) p=P[m]+10(P[m1]+10(P[m2]++10(P[2]+10P[1])))
计算 t s t_s ts 时可以使用递归:
t s + 1 = 10 ( t s − 1 0 m − 1 T [ 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]

在实际操作过程中, p p p t s t_s ts 的值可能太大,影响对其进行的操作,因而,我们需要在原有式子上添加模数 q q q ,则公式变为这样:
p = ( P [ m ] + d ( P [ m − 1 ] + d ( P [ m − 2 ] + ⋯ + d ( P [ 2 ] + d P [ 1 ] ) ⋯   ) ) )   m o d   q p=(P[m]+d(P[m-1]+d(P[m-2]+\cdots+d(P[2]+dP[1])\cdots))) \bmod q p=(P[m]+d(P[m1]+d(P[m2]++d(P[2]+dP[1]))))modq
t s + 1 = ( d ( t s − h T [ s + 1 ] ) + T [ s + m + 1 ] )   m o d   q t_{s+1}=(d(t_s-hT[s+1])+T[s+m+1]) \bmod q ts+1=(d(tshT[s+1])+T[s+m+1])modq
其中 h ≡ d m − 1 ( m o d q ) h \equiv d^{m-1} \pmod q hdm1(modq)

当然基于模 q q q 得到的结果并不完美,模 q q q 同余不代表就相等,但可以作为一种快速的启发式测试方法用于检测无效偏移,排除掉这些无效偏移后,剩下的再用朴素算法去一一验证即可。

RABIN-KARP-MATCHER(T,P,d,q)
n = T.length
m = P.length
h = d^{m-1} mod q
p = 0
t_0 = 0
// preprocessing
for i = 1 to m
	p = (dp + P[i]) mod q
	t_0 = (dt_0 + T[i]) mod q
// matching
for s = 0 to n-m
	if p == t_s
		if P[1..m] == T[s+1..s+m]
			print "Pattern occurs with shift"s
	if s < n-m
		t_{s+1} = (d(t-T[s+1]h) + T[s+m+1]) mod q

在最坏情况下,Rabin-Karp和朴素算法是一样的,因为它们都会对每个有效偏移进行显示验证。

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

一个有限自动机 M M M 是一个五元组 ( Q , q 0 , A , Σ , δ ) (Q,q_0,A,\Sigma,\delta) (Q,q0,A,Σ,δ) ,其中:

  • Q Q Q状态的有限集合。
  • q 0 ∈ Q q_0\in Q q0Q初始状态
  • A ⊆ Q A\subseteq Q AQ是一个特殊的接受状态集合。
  • Σ \Sigma Σ 是有限输入字母表
  • δ \delta δ 是一个从 Q × Σ Q\times \Sigma Q×Σ Q Q Q 的函数,称为 M M M转移函数

有限自动机开始于状态 q 0 q_0 q0 ,每次读入输入字符串的一个字符。如果有限自动机在状态 q q q 时读入了字符 a a a ,则它从状态 q q q 变为状态 δ ( q , a ) \delta(q,a) δ(q,a) (进行了一次转移)。每当其当前状态 q q q 属于 A A A 时,就说自动机 M M M 接受了迄今为止所读入的字符串。没有被接受的输入称为被拒绝的输入。

举例来说,假如有一个有限自动机 M = ( Q = { 0 , 1 } , q 0 = 0 , A = { 1 } , Σ = { a , b } , δ ) M=(Q=\{0,1\},q_0=0,A=\{1\},\Sigma=\{a,b\},\delta) M=(Q={0,1},q0=0,A={1},Σ={a,b},δ) ,其中, δ ( 1 , a ) = δ ( 1 , b ) = δ ( 0 , b ) = 0 , δ ( 0 , a ) = 1 \delta(1,a)=\delta(1,b)=\delta(0,b)=0,\delta(0,a)=1 δ(1,a)=δ(1,b)=δ(0,b)=0,δ(0,a)=1 。则不难看出,这个自动机只接受以奇数个 a a a 结尾的字符串。比如,对于输入 a b a a a abaaa abaaa ,则状态序列为 0 , 1 , 0 , 1 , 0 , 1 0,1,0,1,0,1 0,1,0,1,0,1 ,因而它接受这个输入。如果输入是 a b b a a abbaa abbaa ,则状态序列为 0 , 1 , 0 , 0 , 1 , 0 0,1,0,0,1,0 0,1,0,0,1,0 ,因而它拒绝这个输入。

Alt

对于一个给定的模式 P P P ,我们可以在预处理阶段构造出一个字符串匹配自动机,然后再利用它来逐个读取文本 T T T 中的字符,并利用自动机的状态进行匹配,接受的状态表示匹配成功,否则表示匹配失败,比如下图所示的字符串匹配自动机,它接受所有以 a b a b a c a ababaca ababaca 结尾的字符串。

Alt
那么问题来了,我们如何通过模式 P P P 来确定这个状态转移函数 δ \delta δ 呢?

通过观察上图,我们不难发现,状态 q q q 其实质上就代表着当前已读入的字符串含有后缀 P [ 1.. q ] P[1..q] P[1..q] ,且 q q q 是满足这一条件的最大值。为了方便书写,我们记 P [ 1.. k ] P[1..k] P[1..k] P k P_k Pk ,记 a a a b b b 的后缀为 a ⊐ b a \sqsupset b ab 。则状态转移函数 δ \delta δ 满足 P δ ( q , a ) ⊐ P q a P_{\delta(q,a)} \sqsupset P_qa Pδ(q,a)Pqa 其中 a a a 是任意字符, P q a P_qa Pqa P q P_q Pq a a a 拼接而成的字符串。

进一步地,为了将 δ \delta δ 显式地表示出来,我们定义一个辅助函数 σ \sigma σ ,称为对应 P P P后缀函数,满足 σ ( x ) = max ⁡ { k ∣ P k ⊐ x } \sigma(x)=\max\{k \vert P_k\sqsupset x\} σ(x)=max{kPkx}那么,我们就有 σ ( T s ) = q ⇒ σ ( T s + 1 ) = δ ( q , T [ s + 1 ] ) \sigma(T_s)=q \Rightarrow \sigma(T_{s+1})=\delta(q,T[s+1]) σ(Ts)=qσ(Ts+1)=δ(q,T[s+1])其中 T s T_s Ts 表示当前已读入的字符串。

因此,对于给定模式 P [ 1.. m ] P[1..m] P[1..m] ,其相应的字符串匹配自动机定义如下:

  • 状态集合 Q Q Q { 0 , 1 , ⋯ &ThinSpace; , m } \{0,1,\cdots,m\} {0,1,,m} 。开始状态 q 0 = 0 q_0=0 q0=0 ,接受状态集合为 A = { m } A=\{m\} A={m}

  • 对任意的状态 q q q 和字符 a a a ,转移函数 δ \delta δ 满足: δ ( q , a ) = σ ( P q a ) \delta(q,a)=\sigma(P_qa) δ(q,a)=σ(Pqa)

下面给出算法:

FINITE-AUTOMATON-MATCHER(T,δ,m)
n = T.length
q = 0
for i = 1 to n
	q = δ(q,T[i])
	if q == m
		print "Pattern occurs with shift"i-m

其中的 δ \delta δ 可以放在预处理中做如下定义:

COMPUTE-TRANSITION-FUNCTION(P,Σ)
m = P.length
for q = 0 to m
	for each charater a∈Σ
		k = min(m+1,q+2)
		repeat
			k = k-1
		until P_k ⊐ P_q a
		δ(q,a) = k
return δ

这个算法的缺点也很明显:对于 δ \delta δ 函数的预处理效率比较低。

Knuth-Morris-Pratt 算法

由Knuth、Morris、Pratt三人设计并冠名的KMP算法是一种线性时间字符串匹配算法,这个算法无需计算转移函数 δ \delta δ ,只用到了辅助函数 π \pi π ,它会在线性时间内根据模式预先计算出来,并且存储在数组 π [ 1.. m ] \pi[1..m] π[1..m] 中。

考察朴素字符串匹配算法的操作过程。如下图所示,匹配文本 T = b a c b a b a b a a b c b a b T=bacbababaabcbab T=bacbababaabcbab ,模式 P = a b a b a c a P=ababaca P=ababaca ,当前正在进行匹配的有效偏移 s = 4 s=4 s=4 。此时, q = 5 q=5 q=5 个字符已经匹配成功,但第 6 6 6 个字符不能匹配。

Alt

现在,我们要根据这 q q q 个已匹配成功的字符来确定哪一些偏移一定是无效的。
比如, s + 1 s+1 s+1 的偏移就一定是无效的,因为 P [ 1 ] = a P[1]=a P[1]=a ,而 T [ ( s + 1 ) + 1 ] = b T[(s+1)+1]=b T[(s+1)+1]=b 已经和 P [ 2 ] = b P[2]=b P[2]=b 匹配成功了;而 s + 2 s+2 s+2 的偏移,根据我们目前的信息,是可能有效的,因为 P P P 的前三个字符恰好是 P 5 P_5 P5 的后缀,而且, P 3 P_3 P3 是构成 P 5 P_5 P5 后缀的最长前缀。

这里就有点像刚才 δ \delta δ 函数的味道了。但是类似于这样的信息,我们现在不把它们放入函数中,而是事先计算好存进 π \pi π 数组。比如刚才的那句话,就是 π [ 5 ] = 3 \pi[5]=3 π[5]=3

下面是预计算过程的形式化说明:
已知一个模式 P [ 1.. m ] P[1..m] P[1..m] ,则模式 P P P前缀函数 π \pi π 满足 π [ q ] = max ⁡ { k ∣ k &lt; q 且 P k ⊐ P q } \pi[q]=\max\{k\vert k&lt;q且P_k \sqsupset P_q\} π[q]=max{kk<qPkPq} π [ q ] \pi[q] π[q] P q P_q Pq 的后缀 P P P 的最长前缀长度。

下面给出伪代码,你会发现它和刚才的自动机算法很像:

KMP-MATCHER(T,P)
n = T.length
m = P.length
π = COMPUTER-PREFIX-FUNCTION(P)
q = 0
for i = 1 to n
	while q > 0 and P[q+1] ≠ T[i]
		q = π[q]
	if P[q+1] == T[i]
		q = q+1
	if q == m
		print "Pattern occurs with shift"i-m
		q = π[q]
COMPUTER-PREFIX-FUNCTION(P)
m = P.length
let π[1..m] be a new array
π[1] = 0
k = 0
for q = 2 to m
	while k > 0 and P[k+1] ≠ P[q]
		k = π[k]
	if P[k+1] == P[q]
		k = k+1
	π[q] = k
return π

而且,这两个程序之间也十分相像,因为它们都是一个字符串针对模式 P P P 的匹配,前者是 T T T 针对 P P P 的匹配,后者是模式 P P P 针对自己的匹配。

最后,附上用C语言实现的KMP算法模板,其中int kmp(char text[],char pattern[])函数返回pattern[]text[]中首次出现的下标,int kmp_count(char text[],char pattern[])函数返回pattern[]text[]中出现的次数。

#include <stdio.h>
#include <string.h>
const int maxn=1e3+10;
struct KMP{
    int lent,lenp;
    int next[maxn];
    void preprocess(char text[],char pattern[]){
        lent=strlen(text);
        lenp=strlen(pattern);
        int k=-1;
        next[0]=-1;
        for (int q=1;q<lenp;){
            if (k==-1||pattern[q]==pattern[k])
                next[++q]=++k;
            else k=next[k];
        }
    }
    int kmp(char text[],char pattern[]){
        preprocess(text,pattern);
        int i,q;
        for (i=0,q=0;i<lent&&q<lenp;){
            if (q==-1||text[i]==pattern[q])
                ++i,++q;
            else q=next[q];
        }
        return q==lenp?i-lenp:-1;
    }
    int kmp_count(char text[],char pattern[]){
        preprocess(text,pattern);
        if (lent==1&&lenp==1) return text[0]==pattern[0];
        int res=0;
        for (int i=0,q=0;i<lent;++i){
            while (q>0&&text[i]!=pattern[q])
                q=next[q];
            if (text[i]==pattern[q])
                ++q;
            if (q==lenp){
                ++res;
                q=next[q];
            }
        }
        return res;
    }
}K;

int main(){
    printf("%d\n",K.kmp("abcbcaeebcae","bcae"));//3
    printf("%d\n",K.kmp_count("abcbcaeebcae","bcae"));//2
}

写在最后

以前也曾经听别人讲过KMP算法,但都没有提及自动机的概念,这导致我一直对这个算法理解得不是很深刻,学了很容易忘。今天终于通过《算法导论》厘清了几个字符串匹配算法的逻辑结构,这才大致了解到了KMP的由来。以前一直是认为,KMP仅仅是减少匹配的次数,脑子里是小滑片在大滑片上移来移去,现在用状态的思想来看这个算法,虽然略抽象了一些,但却十分自然。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
KMP算法是一种字符串匹配算法,用于在一个文本串S内查找一个模式串P的出现位置。它的时间复杂度为O(n+m),其中n为文本串的长度,m为模式串的长度。 KMP算法的核心思想是利用已知信息来避免不必要的字符比较。具体来说,它维护一个next数组,其中next[i]表示当第i个字符匹配失败时,下一次匹配应该从模式串的第next[i]个字符开始。 我们可以通过一个简单的例子来理解KMP算法的思想。假设文本串为S="ababababca",模式串为P="abababca",我们想要在S中查找P的出现位置。 首先,我们可以将P的每个前缀和后缀进行比较,得到next数组: | i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | | --- | - | - | - | - | - | - | - | - | | P | a | b | a | b | a | b | c | a | | next| 0 | 0 | 1 | 2 | 3 | 4 | 0 | 1 | 接下来,我们从S的第一个字符开始匹配P。当S的第七个字符和P的第七个字符匹配失败时,我们可以利用next[6]=4,将P向右移动4个字符,使得P的第五个字符与S的第七个字符对齐。此时,我们可以发现P的前五个字符和S的前五个字符已经匹配成功了。因此,我们可以继续从S的第六个字符开始匹配P。 当S的第十个字符和P的第八个字符匹配失败时,我们可以利用next[7]=1,将P向右移动一个字符,使得P的第一个字符和S的第十个字符对齐。此时,我们可以发现P的前一个字符和S的第十个字符已经匹配成功了。因此,我们可以继续从S的第十一个字符开始匹配P。 最终,我们可以发现P出现在S的第二个位置。 下面是KMP算法C++代码实现:

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值