KMP算法及相关问题的总结

来源:互联网 发布:淘宝卖家版聊天工具 编辑:程序博客网 时间:2024/05/22 07:45

        原文链接:KMP算法及相关问题的总结

        KMP算法是字符串匹配技术中一种比较高效的算法,对于给定的一个字符串,使用该算法可以快速的判断该字符串是否在另一个字符串中存在,若存在,那么它在什么位置?KMP算法能够在O(m+n)的时间内给我们答案,其中m表示模式串的长度(pattern string),n表示文本串(text string)的长度。其实所谓的“模式串”和“文本串”只不过是对两个待比较的字符串的称呼,具体哪个串叫文本串,哪个串叫模式串并没有具体的限制,不过习惯上我们把较短的串称之为模式串,另一个串就是文本串,至于原因以后再说。          在朴素的串匹配算法中,模式串和文本串在失配时均需要回溯,最坏的时间复杂度为O(m * n)。这是因为,从文本串的第i个字符与模式串的第0个字符开始逐个比较,如果在模式串的最后一个位置失配,那么最坏的时间为O(m),而i的取值范围为【0~n】,所以时间复杂度为O(m * n)。但是是不是每次比较都是必要的呢?其实文本串和模式串在某个位置失配时,有一些隐藏的信息告诉了我们下次我们应当从哪个位置开始匹配!这些信息我们用一个叫做next的数组存起来,数组中的每一个元素next[i]表示下次我们应当将模式串中下标为next[i]的字符与文本串失配位置的字符对齐,然后继续进行匹配。   

         next数组的确定与文本串无关。为什么?现在我们研究一般问题,文本串、模式串分别为 :S0  S1  S2  …… Sk  ……  Sn-1和P0  P1  P2  ……  Pk  ……  Pm-1,其中n >= m 。假设当模式串的下标为j文本串的下标为i时失配,即:

S0  S1  S2  …… Si-j  Si-j+1  Si-j+2 …… Si-3  Si-2  Si-1  Si  ……  Sn-1

                                                                                             P0    P1     P2   …… Pj-3  Pj-2  Pj-1   Pj ……  

那么,S[i - j , i - 1] == P[0 , j - 1]。注:"[" 或 "]"表示闭区间,"(" 或 ")"表示开区间。

现在假设我们应当移动P串使下标为k的字符与文本串中下标为i的字符对齐,即:

S0  S1  S2  …… Si-j  Si-j+1  Si-j+2 …… Si-3  Si-2  Si-1  Si  ……  Sn-1

                                                                                                        P0  P1  P2  ……   Pk-2  Pk-1  Pk ……

可以得到下列等式:

P0  P1  P2  …… Pk-2  Pk-1  == Si-k  Si-k+1  Si-k+2 …… Si-2  Si-1

Si-k  Si-k+1  Si-k+2 …… Si-2  Si-1  == Pj-k  Pj-k+1  Pj-k+2 …… Pj-2  Pj-1

所以:P0  P1  P2  …… Pk-2  Pk-1  ==  Pj-k  Pj-k+1  Pj-k+2 …… Pj-2  Pj-1

我们只需要找到一个最大的k值满足上式就可以移动最长的距离,然后将Pk与Si比较。仔细观察可以得知上式中等式的左边为P[0 , j)的前缀,等式的右边为P(0 , j - 1)的后缀,我们要的next[j]的值就是k , 即next[j] = k ,并且next[j]只由模式串决定。那么如何求这个最大的k值呢?我们待会再讨论这个问题,现在我们假设我们已经对模式串中的每一个位置j求得了next[j],我们应当如何进行匹配?

        将模式串和文本串的左端对齐,从左到右逐个开始比较,如果文本串和模式串中对应的字符匹配,那么就继续向前推进;否则,我们可以根据已经计算出来的next数组决定下一跳的位置,即将以模式串的失配位置j为下标的值next[j]作为模式串中新的待匹配的位置与文本串失配位置的字符比较,表示P[0 , next[j])的字符已经与文本串匹配了,现在要确定P[next[j]]是否与对应位置的文本串字符匹配。特别的,如果模式串的首位字符与文本串对应的字符不匹配,那该怎么办?显然,我们只需将首位字符与文本串失配位置的下一个字符位置对齐即可。重复上述步骤,直到模式串中的字符全部被匹配,假设此时文本串中下一个待比较的字符的下标为i,那么模式串与文本串匹配的开始位置为i - P.length 。写程序时输出i - P.length + 1是因为下标为i - P.length的字符实际上是文本串中第i - P.length + 1个字符。下面是C++的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
intmyKmp(conststring &pattern , conststring &text)
{
    intpatternLength = pattern.length() , textLength = text.length() ;
    vector<int> next(patternLength+1) ;
    getNext(pattern , next) ;//传的是引用
    inti = 0 , j = 0 ;
    while(i < textLength)
    {
        if(j == -1 || text[i] == pattern[j])
        {
            ++i ; ++j ;
        }
        elsej = next[j] ;
        if(j == patternLength)
            returni - patternLength + 1 ;
    }
return0 ;
}

        下面解释一下代码,因为有时候知道算法是怎么回事,但是就是看不懂代码,代码的实现是很具有个性的,不同的人对同一思想的实现方式很有可能不同。首先,使用这个函数之前必须做如下准备:包含string及vector的头文件,声明函数getNext的函数原型并且你已经实现了该函数。函数的输入输出参数的含义:输入参数为模式串、文本串 ; 输出参数为一个整型数,假设模式串中的每一个字符都与文本串中对应的字符匹配(模式串的结束标志符'\0'除外)我们可以得到匹配的起始position,输出为position + 1,表示模式串与文本串匹配是从文本串中的第position + 1个字符开始的;如果模式串不在文本串中那该输出什么?我们只需要输出一个特殊一点的不引起歧义的数就可以了,比如说输出为0 ,表示模式串与文本串是从文本串中第0个字符开始的,可是第0个字符在哪?不存在!因为我们输出时文本串的首位字符被认为是第1个字符。不要把下标和序号搞混了!在程序中我们使用下标表示位置,下标大于等于0 ,而在表示是从哪个位置开始匹配时,我们用序号,并且序号大于等于1 。下面进入正文,获得模式串、文本串长度,这无需多说。然后定义一个长度为patternLength + 1的next数组,为什么长度要是patternLength + 1而不是patternLength?我们待会再说。调用函数getNext,其中pattern和next参数都是用的传引用。下面的部分是匹配函数的精髓部分,首先,变量 i , j 分别表示文本串和模式串的下标,表示当前应当是比较字符text[i]与pattern[j] 是否相等,i , j 均初始化为0 。当text[i] == pattern[j]时 i 、 j 同时加1表示模式串与文本串当前待比较的字符相同应当继续比较他们的下一个字符是否相同,j = next[j] 表示模式串与文本串当前待比较的字符不同应当移动模式串使得模式串中以next[j]为下标的字符与text[i]对齐,当模式串中的字符全部被匹配了之后此时j指向的位置是patternLength(里面存的是'\0')输出结果。这里都比较容易理解,但是为什么会有“j == -1”这样的语句呢?这就是因为前面提到过的,当模式串的首位字符与对应的文本串字符不匹配该怎么办?移动模式串使模式串的首位字符对应文本串失配位置的下一个字符!可是这个怎么写代码呢?我们可以把这种情况单独挑出来,比如说实现如下:

1
if(0 == j && pattern[j] != text[i]) ++i ;

当模式串的待匹配字符的下标为0,并且pattern[0] != text[i] 将pattern[0]与文本串的下一个字符比较就可以啦!但是第1种写法代码是不是更加简洁一些,同样是失配为什么是两种不同的待遇?所以崇尚简单美的程序员们发明了第一种写法,这对于初学者来说晦涩难懂,但习惯了其实也就那么回事。我们看一下这个“j == -1”到底是怎么来的。当j == 0 并且pattern[0] != text[i] 时我们令next[0]的值为-1 ,当j == 0 并且pattern[j] == text[i]不成立,那么就j = next[j] , j就等于-1了,然后又进入循环判断:

1
if(j == -1 || text[i] == pattern[j])

j == -1成立,然后 i 和 j 都加1,使 i 变成 i + 1 、j 变成0,这样不就实现j = 0 不变但i = i + 1了!如果将if的判断条件调换顺序会怎么样?糟糕!pattern[-1] 是多少呀?下标越界了,所以不能调换顺序。

 

        好了,说了这么多终于将这一部分讲完了,可是总感觉少了点什么,少了点什么呢?对了!我们的getNext函数在哪呀?这个还是要自己写的!getNext函数的C++实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
intgetNext(conststring &pattern , vector<int> &next)
{
    intpatternLength = pattern.length() ;
    next[0] = -1 ;
    inti = 0 , j = -1 ;
    while(i < patternLength)
    {
    if(j == -1 || pattern[i] == pattern[j])
    {
        next[++i] = ++j ;
    }
    elsej = next[j] ;
    }
return0 ;
}

        求 i 的next值就是求P[0 , i)中能与后缀匹配的最大前缀长度k。下面解释代码。首先输入输出参数:输入参数模式串,输出参数next数组 , 整型变量的返回值没什么作用,写成这样纯粹是个人习惯。获得模式串长度,然后给next[0]赋值为 -1 (上面已经讲过)。定义变量 i , j 并初始化为0和-1 ,为什么 j 要初始化为  -1 ?如果将 j 初始化为 0会怎样?我们会得到next[1] = 1 , 但是这是对的吗?显然不对,next[1]表示pattern[1]与文本串中对应的字符不匹配时,应当将模式串中下标为next[1]的字符与文本串的失配字符对齐,可是你还是拿着字符pattern[1]与文本串对齐,比较时不又会失配,从而陷入死循环中吗?next[1] 不等于1它只能等于0 ,表示pattern[1]与对应的文本串字符失配时应当把pattern[0]与文本串失配字符对齐,所以我们将 j 初始化为 -1,这样next[1] = 0 。现在假设next[i] = k ,那么我们可以得到:

P0  P1  P2  …… Pi-k……  Pi-2  Pi-1  Pi

                           P0 ……   Pk-2 Pk-1  Pk

我们可以把第一个串看作是文本串,第二个串看作是模式串,如果Pi == Pk ,此时P[0 , k]是P[0 , i + 1)的最大前缀,所以当Pi+1与文本串的对应字符失配时,应当把下标为k + 1的字符与失配位置对齐,所以next[i+1] = k + 1;如果Pi != Pk,那又当如何呢?将P[0 , k)串看成新的文本串,将模式串中下标为next[k] 的字符与Pi对齐。这样我们就可以根据前面字符的next值算出后面字符的next值。我们还有一个问题没有解决,为什么要让next数组的长度为patternLength + 1?观察源码,当 i = patternLength - 1 时循环尚未结束,我们可以找到P[0 , patternLength)的最大前缀与它的后缀匹配,得到next[patternLength]的next值,pattern[patternLength]中存放的是字符串结束标志。如果不将next数组的大小声明成patternLength + 1 ,数组访问将越界。其实,如果你只想获得模式串在文本串中的一个位置,此时next[patternLength]不会被用到,但是,如果你想获得多个位置呢?我是不是可以继续移动模式串使模式串中以next[patternLength]为下标的字符与当前的文本串的待比较字符对齐,然后再比较?这样做,我们确实可以得到多个匹配位置(如果文本串中有的话)。

           模式串的next数组还有一个特殊的性质,非常重要。仔细想想P[0 , i - next[i])这一段字符在P[0 , i)中是不是有些特殊?举个例子:假设模式串为aabaabaab ,

         0 1 2 3 4 5 6 7 8 9

串1   a a b a a b a a b

             串2     a a b a a b a a b

                       0 1 2 3 4 5 6 7 8

显然next[9] = 6 ,9 - next[9] == 3 , P[0 , 3) == "a a b",当 i % (i - next[i]) == 0 时,我们可以将 i / (i - next[i]) 个P[0 , 3)首尾连接起来构成P[0 , 9),所以称P[0 , 3)为P[0 , 9)最小覆盖。为什么有这种性质?通过观察我们可以发现:

串1的P[0 , 3) == 串2的P[0 , 3)

串2的P[0 , 3) == 串1的P[3 , 6)

串1的P[3 , 6) == 串2的P[3 , 6)

串2的P[3 , 6) == 串1的P[6 , 9)

所以:P[0 , 3) == P[3 , 6) ==P[6 , 9),其他的模式串也具有这种性质,利用这种性质可以求出给定字符串的最小覆盖或者最小覆盖的个数。

           假若我们遇到的模式串是这种极端形式的:P[0 , n) = aaaaaaaaaaa……ab我们的求next值的算法是不是会有什么问题呢?首先让我们来算一下:

       0 1 2 3 4 5 …… n - 2   n - 1 n

a a a a a a ……  a       b

        -1 0 1 2 3 4 ……  n - 3  n - 2  0

如果我们使用上面的算法来计算这个模式串的next值我们是不是会感到很揪心?很明显next[n] = 0 ,可是我们反复的拿 'a' 与 'b' 比较即使前面的失配已经表明'a' 与 'b'不匹配,在这个上面我们浪费了大量的时间。所以应当将算法进行改进。改进的原始思想是:判断P[i] == P[next[i]]是否成立,如果成立,那么我们可以断定新一轮的比较必定失配,因为我们寻找模式串的下一个待比较字符的原因就是此次待比较的字符与文本串中对应的字符失配了,现在下一个待比较的字符与上次比较的字符相同,那么失配是必然的。具体的改进代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
intgetNextval(conststring &pattern , vector<int> &nextval)
{
    intpatternLength = pattern.length() ;
    nextval[0] = -1 ;
    inti = 0 , j = -1 ;
    while(i < patternLength)
    {
        if(j == -1 || pattern[i] == pattern[j])
        {
            ++i ; ++j ;
            if(pattern[i] != pattern[j]) nextval[i] = j ;
            elsenextval[i] = nextval[j] ;
        }
        elsej = nextval[j] ;
    }
return0 ;
}

   注意:用getNextval函数计算出来的nextval值不具备最小覆盖的性质。采用getNextval函数的目的只是尽快的找到模式串在文本串中的位置。下面解释代码:getNextval函数与getNext函数代码的不同点在于:getNext函数中i , j 的值均增加之后,next[i]的值就等于j ,但是这样会导致上面特例中的问题。getNextval函数中i , j 的值增加后,先判断P[i] != P[j]是否成立,若成立则表明当模式串中字符P[i]与文本串中对应字符比较失配时,以值next[i]为下标的字符(P[j])与文本串对应字符是有可能匹配的;否则就将nextval[j] 的值赋给nextval[i]这其中有并查集的味道。使用nextval函数计算上述特例:

                  0 1  2  3  4  5 …… n - 2   n - 1      n

        a a  a  a  a  a   ……  a       b

                 -1 -1 -1 -1 -1 -1 ……  -1        n -2    0

这样速度就明显提升上去了。getNextval函数的调用和getNext函数的使用方法是相同的。将以上文章看完,有关于KMP算法的疑问应该基本解决了吧。其实KMP算法的应用不只是进行字符串的匹配上。只要是具有位置特性的某些量,量与量之间可以比较是否相同就可以使用KMP算法,比如说串排名(这个我们在具体问题中再讨论)。

       好了,理论讲完了,是该实践一下检验学习成果了,到底使用KMP算法可以解决哪些问题呢?

问题一:找出一个字符串在另一个字符串中出现的次数

例子:POJ3461,原题链接:Oulipo

解题思路:当模式串中所有的字符都与文本串中对应的字符匹配时,此时j == patternLength , i 为文本串中下一个待匹配的位置,将模式串在文本串中出现的次数加1,然后j = next[j] 。这里也可以使用nexval数组。代码的C++实现如下:其中std::ios::sync_with_stdio(false)是用来提高数据的读入速率的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include<iostream>
//#include<fstream>
#include<string>
#include<vector>
usingnamespace std ;
intmyKmp(conststring &pattern, conststring &text) ;
intmain()
{
    std::ios::sync_with_stdio(false) ;
//    ifstream cin("test.txt") ;
    intn ;
    cin >> n ;
    inti ;
    for(i = 0 ; i < n ; i++)
    {
        string pattern , text ;
        cin >> pattern >> text ;
        cout << myKmp(pattern , text) << endl;
    }
    return0 ;
}
intmyKmp(conststring &pattern , conststring &text)
{
    intpatternLength = pattern.length() , textLength = text.length() ;
    vector<int> nextval(patternLength + 1) ;
    inti = 0 , j = -1 ;
    nextval[0] = -1 ;
    while(i < patternLength)
    {
        if(j == -1 || pattern[i] == pattern[j])
        {
            ++i ; ++j ;
            if(pattern[i] != pattern[j]) nextval[i] = j ;
            elsenextval[i] = nextval[j] ;
        }
        elsej = nextval[j] ;
    }
    intcount = 0 ;
    i = 0 ; j = 0 ;
    while(i < textLength)
    {
        if(j == -1 || pattern[j] == text[i])
        {
            ++j ; ++i ;
        }
        else j = nextval[j] ;
        if(j == patternLength)
        {
            ++count ;
            j = nextval[j] ;
        }
    }
    returncount ;
}

问题二:求给定字符串的最小覆盖

例子:POJ2406,原题链接:Power Strings

解题思路:将此题转化成求最小覆盖(如果存在),然后将字符串长度除以最小覆盖的长度。使用period = patternLength - next[patternLength] ,如果patternLength % period == 0那么输出patternLength / period ;如果不存在输出1 。C++实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include<iostream>
//#include<fstream>
#include<string>
#include<vector>
usingnamespace std ;
intmain()
{
    std::ios::sync_with_stdio(false) ;
//    ifstream cin("test.txt") ;
    string pattern ;
    while(cin >> pattern && pattern != ".")
    {
        intpatternLength = pattern.length() ;
        vector<int> next(patternLength + 1) ;
        inti = 0 , j = -1 ;
        next[0] = -1 ;
        while(i < patternLength)
        {
            if(j == -1 || pattern[i] == pattern[j])
                next[++i] = ++j ;
            elsej = next[j] ;
        }
        intperiod = patternLength - next[patternLength] ;
        if(patternLength % period == 0) cout << patternLength / period << endl ;
        elsecout << 1 << endl ;
        pattern.clear();
    }
    return0 ;
}

例子:POJ1961,原题链接:Period

解题思路:仍然是求最小覆盖,不过它是对模式串P[0 , length)中所有前缀子串P[0 , i)求最小覆盖,输出最小覆盖的长度大于1的所有i和最小覆盖的长度。在上题的基础上加一次遍历就可以了,C++实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include<iostream>
//#include<fstream>
#include<vector>
#include<string>
usingnamespace std ;
intmain()
{
//    ifstream cin("test.txt") ;
    intn , m = 0 ;
    while(cin >> n && n != 0)
    {
        string pattern ;
        cin >> pattern ;
        intpatternLength = pattern.length() ;
        vector<int> next(patternLength+1) ;
        next[0] = -1 ;
        inti = 0 , j = -1 ;
        while(i < patternLength)
        {
            if(j == -1 || pattern[i] == pattern[j])
            {
                next[++i] = ++j ;
            }
            elsej = next[j] ;
        }
        cout << "Test case #" << ++m << endl ;
        for(i = 1 ; i < next.size() + 1; i++)
        {
            intperiod = i - next[i] ;
            if(i % period == 0 && i / period > 1) cout << i << " " << i / period << endl ;
        }
        cout << endl ;
    }
    return0 ;
}

问题三:给定一个字符串,求字符串中与后缀匹配的前缀

例子:POJ2752,原题链接:Seek the Name, Seek the Fame

解题思路:位置i的next值就是P[0 , i)的最大前缀。首先将以patternLength为长度的字符串P[0,patternLength)作为前缀,那么它肯定与后缀匹配,将patternLength压入栈中,然后将以next[patternLength]为长度前缀字符串使用同样的方式处理,一直往下找,直到前缀长度为2为止,C++代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include<iostream>
//#include<fstream>
#include<string>
#include<vector>
usingnamespace std ;
intmain()
{
//    ifstream cin("test.txt") ;
    string pattern ;
    while(cin >> pattern)
    {
        intpatternLength = pattern.length() ;
        inti = 0 , j = -1 ;
        vector<int> nextval(patternLength + 1) ;
        nextval[0] = -1 ;
        while(i < patternLength)
        {
            if(j == -1 || pattern[i] == pattern[j])
            {
                ++i ; ++j ;
                nextval[i] = j ;
            }
            elsej = nextval[j] ;
        }
        vector<int> result ;
        while(true)
        {
            if(i == 0 || i == -1) break;
            result.push_back(i);
            i = nextval[i] ;
        }
        intlength = result.size() ;
        for(i = 0 ; i < length ; i++) cout << result.at(length - i - 1) << " " ;
        cout << endl ;
        pattern.clear();
    }
    return0 ;
}

关于KMP算法的还有两个好题,POJ2185和POJ3167但是我还没有AC,我对POJ2185的题意理解不清楚,POJ3167的匹配部分清楚了但是如何快速找到某个数在区间中的排名不清楚,我使用的是重新扫描一遍,但是超时了,网上说要用树状数组做,我还没有学,等我学了在重新写一下吧。附上两题的链接便于查看:

各位朋友如果发现上面的理解有错或者是讲解不清楚,欢迎给我留言,谢谢!


原创粉丝点击