C#算法系列(11)——KMP模式匹配算法

来源:互联网 发布:怎么查看淘宝客pid 编辑:程序博客网 时间:2024/06/01 19:11

       今天要实现的这个算法是在复习串操作时出现的,之前看过一遍,但时隔久远,毫无印象,捡起来还有点儿困难,发现当时理解不是那么透彻,自己主要理解难点是这个算法如何求解next数组。明白之后,发现它也并不难理解,就是有些资料的术语起了误导的作用,下面会按照小白的思路进行一系列说明,力求道明它的本质。

一、KMP算法背景

       主要是要解决求解子串在主串中第一次出现位置的问题,KMP的提出能使得这个求解过程效率得到提升。下面看一下使用串的基本操作来实现这个功能。代码如下:

//若主串S中第pos个字符之后存在与T相等的子串//则返回第一个与子串T相同的在主串S中的位置,否则返回0public int IndexOfStr(string S, string T,int pos){    int n = S.Length, m = T.Length, i = pos;    string sub;    if (pos >= 0)    {        while (i <= n - m + 1)        {            //取出主串第i个位置长度与T相等子串给sub            sub = S.Substring(i,m);            if (sub != T)                ++i;            else                return i;        }    }    return -1;}

       上述代码使用了,求字符串长度、字符串取子串以及字符串比较等基本操作。下面将单纯用数组来实现同样的功能,这种被称为朴素的模式匹配,主要思路如下:将子串的字符挨个与主串的字符比较,若相同,则进行下一个字符比较;若不相同,则主串的迭代变量回退到上次能够匹配的下一位置,而子串的迭代变量回到首位。代码如下:

/// <summary>/// 返回子串在主串中的索引/// </summary>/// <param name="s">主串</param>/// <param name="t">子串</param>/// <returns></returns>/// 最坏的时间复杂度为O((n-m+1)*m)public int IndexString(string s, string t){    int i=0,j=0;//i,j,分别指向S,T串中,当前下标位置    //j<t.Length的作用在于只检测一次子串,即返回第一个与主串发生匹配    while (i < s.Length && j < t.Length)    {        //挨个字符匹配        if (s[i] == t[j])        {            ++i;            ++j;        }        //不匹配,则i,j回退        else        {            i = i - j + 1;//i退回到上次匹配首位的下一位            j = 0;//j退回到子串T的首位        }    }    if (j >= t.Length)        return i - t.Length;//返回子串在主串中的第一个起始位置    else        return -1;}

       但是,i = i-j+1这样回退,会导致出现重复匹配的过程,导致效率降低。比如:主串S为”abcdefgab…”, 子串T为“abcdex”,当主串i=2、3、4、5、6(下标从1开始)时,主串的首字符与子串的首字符均不等。仔细观察发现,对于要匹配的子串T来说,”abcdex”首字母“a”与后面的串“bcdex”中任意一个字符都不相等。若子串的前五位与主串的前五位相等,如步骤1,意味着子串T的首字符“a”不可能与S串的第2位到第5位的字符相等,因此上图2,3,4,5步判断就是多余的,因此主串的迭代变量是可以保持不变。

这里写图片描述

       上面的重复性是主串的迭代变量重复,下面在来看另外一个例子,主串S=”abcababca”,子串T = “abcabx”。对于开始的判断,前5个字符完全相等,第6个字符不等。此时,根据刚才的经验,T的首字符“a”与T的第二位字符“b”、第三位字符“c”均不等,所以不需要做判断,因此当主串i=2,3时,判断就是多余的。关键的地方来了,T的首位“a”与T的第四位“a”相等,第二位“b”与第五位“b”相等。而在步骤1时,T串的第四位“a”与第五位“b”已经与主串S中相应位置比较过了是相等的,因此可以断定,T首字符“a”、第二位字符“b”与S的第四位字符和第五位字符也不需要比较,肯定也相等了,所以步骤4,5这两步也可以省略。
这里写图片描述

       KMP模式匹配就是为了减少这种没必要的重复性比对操作。因此在保持主串的迭代变量i值不回溯,也就是不可以变小,则需要变化的就是子串的迭代变量j值。j值的变化也是有规律的。j值的多少取决于当前字符的串的真前后缀的相似度。于是,j值的变化定义了一个数组next来进行描述了,那么next的长度的长度就是T串的长度。next数组函数定义如下:
这里写图片描述

二、next数组值推导

       以子串“ababaaaba”为例,有如下表:

这里写图片描述

       当j=0时,next[0] = 0(此时下标从0开始);当j=1,2时,next[1]=next[2]=1(属于其它情况);
       当j=3时,j有0到2的串是,“aba”,前缀字符“a”,与后缀字符“a”相等,next[3]=1+1=2;
       当j=4时,j有0到3的串是,“abab”,前缀字符“ab”,与后缀字符“ab”相等,next[4]=2+1=3;
       当j=5时,j有0到4的串是,“ababa”,前缀字符“aba”,与后缀字符“aba”相等,next[5]=3+1=4;
       当j=6时,j有0到5的串是,“ababaa”,前缀字符“ab”,与后缀字符“aa”不相等,next[6]=1+1=2;
       当j=7时,j有0到6的串是,“ababaaa”,只有前缀字符“a”,与后缀字符“a”相等,next[7]=1+1=2;
       当j=8时,j有0到7的串是,“ababaaab”,只有前缀字符“ab”,与后缀字符“ab”相等,next[8]=2+1=3;
       因此,我们可以根据经验得到,如果前后缀有n个相等的k值,就是n+1。这里的前后缀为真前后缀,即不包括自身的子串

三、KMP算法实现过程

        KMP算法的提出就是解决这种重复匹配的过程。也是在上述朴素模式匹配算法的基础上修改得到的,下来面看下next的实现。
(1)实现得到next数组,代码如下:

//计算next数组//next数组含义,子串的最长前缀和最长后缀相同的长度+1(+1的原因,在于相同的字符串的下一个位置)//next数组的每个值,即是对应位置下次往前移动的距离private void GetNext(string T,int[] next){    int i = 0, j = 0; //i为串T的迭代时的下标,j为子串的相同部分的数量+1    next[0] = 0;    while ((i+1) < T.Length)    {        //T[i]表示后缀的单个字符,T[j]表示前缀的单个字符        if (j == 0 || T[i] == T[j-1])        {            ++i;            ++j;            next[i] = j;        }        //当前字符比较不同,j根据next表值进行回溯        //回溯的目的是为了找到上一次相同的字符位置,来确定当前i位置对应的n        else            //此时的j值,在上一轮判断中自动后移了,而next数组对应的位置从0开始,因此下标需要减1;若next数组从1开始,则此处不需要减1            j = next[j-1];    }}

       再来看下代码中是如何实现回溯的,过程如下图:

这里写图片描述

       看到这里的时候,不由得感慨写出这段代码的人编程内功深厚,佩服佩服!!!以上纯属个人感概,不喜勿喷,谢谢^_^.下面我们在再看看下子串在主串位置的主逻辑,代码如下,与朴素模式匹配基本一致。

public int Index_KMP(string S,string T){   int i = 0, j = 0; //i为主串的迭代变量,j为子串迭代变量   int[] next = new int[T.Length];   //计算next数组   GetNext(T,next);   while (i < S.Length && j < T.Length)   {       //两字母相等则继续       if (j == 0 || S[i] == T[j])       {           ++i;           ++j;       }       //指针后退重新开始匹配       else       {           //j根据next数组回退到合适的位置,i值不变           //此时的j值,在上一轮判断中自动后移了,而next数组对应的位置从0开始,因此下标需要减1;若next数组从1开始,则此处不需要减1           j = next[j-1];       }   }   if (j >= T.Length)       return i - T.Length;   else       return -1;}

       此时,上述代码就是去掉了i值回溯的部分,其余与朴素算法一样。对于get_next函数来说,若T的长度为m,因此只设计到简单的单循环,时间复杂度为O(m),Index_KMP的i值不回溯之后,while的时间复杂度为O(n)。因此,整个算法时间复杂度为O(n+m)。相比较朴素模式匹配算法O((n-m+1)*m)来说,是要好些。这里需要强调的是,KMP算法仅当模式与主串之间存在许多“部分匹配”的情况下才会体现出它的优势,否则二者差异不明显

四、KMP算法改进

       改进其实也是针对于next数组改进。目前还存在如下问题:

这里写图片描述

       这当中的第2,3,4,5步骤,其实都是多余的。由于T串的第2,3,4,5位置的字符都与首位的“a”相等,那么可以用首位next[0]的值去取代与它相等的字符后续next[j]的值。于是GetNext函数代码修改后如下:

//对上述计算next移动数组的优化private void GetNextVal(string T, int[] next){    int i = 0, j = 0;    next[0] = 0;    while ((i + 1) < T.Length)    {        if (j == 0 || T[i] == T[j - 1])        {            ++i;            ++j;            //若当前字符与前缀字符不同            if (T[i] != T[j - 1])                //当前j为next在i位置上的值                next[i] = j;            else                next[i] = next[j - 1];        }        else            j = next[j - 1];    }}

五、实验结果

       主函数测试代码如下:

using System;using System.Collections.Generic;using System.Linq;using System.Text;using System.Threading.Tasks;namespace KMP_模式匹配{    class Program    {        static void Main(string[] args)        {            string s = "googlegood";            string t = "google";            Console.WriteLine("字符串t:"+t+"在字符串s:"+s+"中的位置在第几个?");            //传统方法            int pos1 = KMP_Test.Instance.IndexOfStr(s,t,0);            Console.WriteLine("传统方法求解:" + (pos1 + 1));            //朴素的模式匹配(即未优化匹配项)            int pos2 = SimpleKMP.Instance.IndexString(s,t);            Console.WriteLine("朴素模式匹配求解:" + (pos2+1));            //KMP算法            int pos3 = KMP_Test.Instance.Index_KMP(s,t);            Console.WriteLine("KMP算法求解:" + (pos3 + 1));            Console.ReadKey();        }    }}

       实验截图:

这里写图片描述

       以上,就是本次KMP算法的讲解,如有疑问,欢迎留言!谢谢浏览!!!