KMP算法详解
2015-03-08 00:00
561 查看
摘要: KMP算法是著名的字符串匹配算法,能够以O(n)的时间复杂度完成对字符串的匹配,本文主要学习教材是《数据结构(C++语言)第三版》(邓俊辉,清华大学出版社)。
串匹配在实际生活中有着十分广泛的运用,比如分词中的词库匹配、DNA序列的匹配以及垃圾邮件的过滤都需要用到串匹配算法。这么多年来已经发展出了不下30种的算法。【来源:《数据结构(C++语言)第三版》(邓俊辉,清华大学出版社)。另:本文配备图片如无特殊说明该均来自此书或配套课件。】
为了方便讨论,我们将需要从中匹配信息的字符串称为文本串,长度为n;用于做匹配模板的字符串称为模式串,长度为m;
一、 暴力匹配算法
暴力算法是我们最容易想到的算法:先将两个字符串对齐一位一位的匹配,匹配失败后再将文本串的起始字符后移一位即可。
这样一个一个向右移直到移动到结束。
算法实现:
显然,暴力算法在最坏的情况下需要迭代n-m+1轮,每一轮匹配又需要m个比较,所以总的来说需要进行m*(n-m+1)次比较。渐进情况下时间复杂度就是O(nm)。如果m比较小的时候这种算法的劣势还不算明显,但是当m增长得比较大的时候就难以接受了,尤其是使用串匹配的环境通常m和n都相当大。
二、我想偷懒
偷懒是技术进步的动力。我们也来偷偷懒,看一下是不是每一次匹配失败后都有必要从头开始匹配。先允许我假设一种情况。令 T = "SSSSSSSJTU", P = "SSJTU"。我们先通过暴力匹配的流程进行匹配。
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
…………
SSSSSSSJTU
SSJTU
可以发现,每次匹配错误后都要从模式串第一位开始重新匹配,但是这是没有必要的,因为我们很容易就可以观察到当J字母匹配失败时,前面的SS总是匹配成功的,根本没有再次匹配的必要。那么具体怎么来操作这一过程使得跳过已经确认不需要匹配的部分呢。还是用那两个字符串。
SSSSSSSJTU
SSJTU
匹配到这里的时候,我们知道T[2]=S, P[2] = J,但是P[0]=P[1]=S。本着对T“不走回头路”的原则,我们只要稍微地移动P,让P在保证P[j]前匹配全中和 i 不变的情况下右移,同时也希望j尽可能少的后退,这样就能尽可能多地减少匹配次数。例如可以直接到这一步,跳过了2次比较。
SSSSSSJTU
SSJTU
那么问题来了,我们怎么知道P要右移多少,j要减少多少呢?
三、跳到哪里去
假设我们有一张表,这张表能告诉匹配到哪里失败时j减少多少,p右移多少最佳,那么问题就迎刃而解。幸运的是,我们确实可以构造出这样一张表,称之为next表。不过这张表长什么样子呢?
一般地,我们假设前一轮的比较在i,j时失败,即T[i] != P[j]。由于i不变(“不走回头路”),j要后退,假设变为t,那么就是从T[i]和P[t]对齐开始下一轮的比对。已知P[0,j)=T[i-j,i),此时右移p。如图所示,想要在p有以后能够与T的某一含T[i]字串匹配,其中一项必要条件就是P[0,t) = T[i-t,i) = P[j-t,j)。忽视中间T的那一项,就表示P[0,j)中长度为t的真前缀应该与长度为t的真后缀相匹配。更为重要的是t仅仅取决于P[j]而与T无关!当然,t的取值可能有多种,显然,我们应该去尽可能大的t,并且,如果P[t] = P[j],那么可以预料到跳转后的比较仍会失败,所以可以得到如下关系next[j] = max{0<=t<j | P[0,t)=P[j-t,j), P[j]!=P[t] }。也就是说,当T[i]与P[j]匹配失败时,只需另T[i]对其P[next[j]]继续匹配就好。
然而,在我们知道这个关系后如何构造出next表呢?
首先定义一个边界或者说是哨兵。显然如果P[0]和T[i]就匹配失败,P肯定“退无可退”。那么不妨定义“退无可退”的j值对应的next[j] = -1。 那么对于next[j],j>0的情况呢?联想动态规划的方法,考虑状态转移方程。已知next[i] | 0<=i<=j,求next[j+1]。还是先从实例入手.。
为了便于分析,我们假设一个长一点的字符串P = "Chinese china", 我们先手动来标注这个字符串的next表。
0 1 2 3 4 5 6 7 8 9 10 11 12
C h i n e s e c h i n a
-1 0 0 0 0 0 0 0 0 1 2 3 0 //找的方式就是在第j位的时候看前面是否出现过,是的话就指向那一位(t);否则就等于0;
-1 0 0 0 0 0 0 0 -1 0 0 0 0 //考虑到P[t]!=P[j]的定义,从意义上看,直接判断P[j]P[t]的关系,如果相等,就令next[j] = next[t],这是与上一步不同的地方。
由此我们可以得出构造next表的算法:
三、KMP大法好
将生成的表放到最开始我们暴力匹配的算法中加以改进就可以得到KMP算法
我们来分析一下这个算法,乍一看,在匹配失败的情况下j会后退,那么是不是意味着这会导致最差情况时间复杂度为O(mn)?我们令k = 2i-j。当匹配成功,k+=1;当匹配失败,j后退,所以k也会增加。所以无论失败还是成功,k都是严格单调递增而且k<2n,这就意味着完成整个算法所需要的时间复杂度是小于O(n)的。再算上构造next表所需要的O(m)的时间,整个KMP算法所需时间复杂度渐进上为O(m+n)。
相比O(mn)进步是不是很大?
串匹配在实际生活中有着十分广泛的运用,比如分词中的词库匹配、DNA序列的匹配以及垃圾邮件的过滤都需要用到串匹配算法。这么多年来已经发展出了不下30种的算法。【来源:《数据结构(C++语言)第三版》(邓俊辉,清华大学出版社)。另:本文配备图片如无特殊说明该均来自此书或配套课件。】
为了方便讨论,我们将需要从中匹配信息的字符串称为文本串,长度为n;用于做匹配模板的字符串称为模式串,长度为m;
一、 暴力匹配算法
暴力算法是我们最容易想到的算法:先将两个字符串对齐一位一位的匹配,匹配失败后再将文本串的起始字符后移一位即可。
这样一个一个向右移直到移动到结束。
算法实现:
int match(char* p, char* t){//暴力算法 int n = strlen(t), i = 0; int m = strlen(p), j = 0; while(j<m&&i<n){ if(T[i]==p[j])++i,++j else i-=j-1;j=0; } return i-j//如果搜索成功返回的就是第一次匹配到的位置。如果失败则返回-1 }
显然,暴力算法在最坏的情况下需要迭代n-m+1轮,每一轮匹配又需要m个比较,所以总的来说需要进行m*(n-m+1)次比较。渐进情况下时间复杂度就是O(nm)。如果m比较小的时候这种算法的劣势还不算明显,但是当m增长得比较大的时候就难以接受了,尤其是使用串匹配的环境通常m和n都相当大。
二、我想偷懒
偷懒是技术进步的动力。我们也来偷偷懒,看一下是不是每一次匹配失败后都有必要从头开始匹配。先允许我假设一种情况。令 T = "SSSSSSSJTU", P = "SSJTU"。我们先通过暴力匹配的流程进行匹配。
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
SSSSSSSJTU
SSJTU
…………
SSSSSSSJTU
SSJTU
可以发现,每次匹配错误后都要从模式串第一位开始重新匹配,但是这是没有必要的,因为我们很容易就可以观察到当J字母匹配失败时,前面的SS总是匹配成功的,根本没有再次匹配的必要。那么具体怎么来操作这一过程使得跳过已经确认不需要匹配的部分呢。还是用那两个字符串。
SSSSSSSJTU
SSJTU
匹配到这里的时候,我们知道T[2]=S, P[2] = J,但是P[0]=P[1]=S。本着对T“不走回头路”的原则,我们只要稍微地移动P,让P在保证P[j]前匹配全中和 i 不变的情况下右移,同时也希望j尽可能少的后退,这样就能尽可能多地减少匹配次数。例如可以直接到这一步,跳过了2次比较。
SSSSSSJTU
SSJTU
那么问题来了,我们怎么知道P要右移多少,j要减少多少呢?
三、跳到哪里去
假设我们有一张表,这张表能告诉匹配到哪里失败时j减少多少,p右移多少最佳,那么问题就迎刃而解。幸运的是,我们确实可以构造出这样一张表,称之为next表。不过这张表长什么样子呢?
一般地,我们假设前一轮的比较在i,j时失败,即T[i] != P[j]。由于i不变(“不走回头路”),j要后退,假设变为t,那么就是从T[i]和P[t]对齐开始下一轮的比对。已知P[0,j)=T[i-j,i),此时右移p。如图所示,想要在p有以后能够与T的某一含T[i]字串匹配,其中一项必要条件就是P[0,t) = T[i-t,i) = P[j-t,j)。忽视中间T的那一项,就表示P[0,j)中长度为t的真前缀应该与长度为t的真后缀相匹配。更为重要的是t仅仅取决于P[j]而与T无关!当然,t的取值可能有多种,显然,我们应该去尽可能大的t,并且,如果P[t] = P[j],那么可以预料到跳转后的比较仍会失败,所以可以得到如下关系next[j] = max{0<=t<j | P[0,t)=P[j-t,j), P[j]!=P[t] }。也就是说,当T[i]与P[j]匹配失败时,只需另T[i]对其P[next[j]]继续匹配就好。
然而,在我们知道这个关系后如何构造出next表呢?
首先定义一个边界或者说是哨兵。显然如果P[0]和T[i]就匹配失败,P肯定“退无可退”。那么不妨定义“退无可退”的j值对应的next[j] = -1。 那么对于next[j],j>0的情况呢?联想动态规划的方法,考虑状态转移方程。已知next[i] | 0<=i<=j,求next[j+1]。还是先从实例入手.。
为了便于分析,我们假设一个长一点的字符串P = "Chinese china", 我们先手动来标注这个字符串的next表。
0 1 2 3 4 5 6 7 8 9 10 11 12
C h i n e s e c h i n a
-1 0 0 0 0 0 0 0 0 1 2 3 0 //找的方式就是在第j位的时候看前面是否出现过,是的话就指向那一位(t);否则就等于0;
-1 0 0 0 0 0 0 0 -1 0 0 0 0 //考虑到P[t]!=P[j]的定义,从意义上看,直接判断P[j]P[t]的关系,如果相等,就令next[j] = next[t],这是与上一步不同的地方。
由此我们可以得出构造next表的算法:
int* buildNext(char* p){ int m = strlen(p), j = 0; int* N = new int[m]; int t = N[0] = -1; while(j<m-1){ if(t<0||p[j]==p[t]){ ++j; ++t; N[j] = (p[j]!=p[t]?t:N[t]); }else t = N[t]; } return N; }
三、KMP大法好
将生成的表放到最开始我们暴力匹配的算法中加以改进就可以得到KMP算法
int KMP(char* p, char* t){ int* next = buildNext(p); int n = (int)strlen(t), i = 0; int m = (int)strlen(p), j = 0; while(j<m&&i<n){ if(0>j||t[i] == p[j]){ i++; j++; }else j = next[j]; } delete[] next; return i-j; }
我们来分析一下这个算法,乍一看,在匹配失败的情况下j会后退,那么是不是意味着这会导致最差情况时间复杂度为O(mn)?我们令k = 2i-j。当匹配成功,k+=1;当匹配失败,j后退,所以k也会增加。所以无论失败还是成功,k都是严格单调递增而且k<2n,这就意味着完成整个算法所需要的时间复杂度是小于O(n)的。再算上构造next表所需要的O(m)的时间,整个KMP算法所需时间复杂度渐进上为O(m+n)。
相比O(mn)进步是不是很大?