Imrazor's Blog

Stay Hungry, Stay Foolish

KMP模式匹配算法

在写程序时,我们常常会用到从一个字符串找出子串的位置。

比如,在某个句子快速找到某个你感兴趣的词:“csscsdn”中找到csdn

以下是查找算法,注意:下标为1代表第一个位置,以此类推,跟数组不同

一、朴素模式匹配 朴素模式匹配是从第一个字符开始,依次向后匹配,这里我们假设游标起始点为1

c s s c s d n
| | ?
c s d n

当i(主串的游标) = 3,j(子串的游标) = 3时,字符出现了不等,于是i回到2,j回到1

c s s c s d n
  ?
  c s d n

直到

c s s c s d n
      | | | |
      c s d n

朴素模式匹配最大的问题就是i值回溯,如果碰到当子串的前面都与主串匹配,只有最后一个字符不同,而这个子串又在主串的末尾时,j值会每次遍历整个字串,i值会每次回溯 000…共n个0…0001 0…共m个0…01 当出现上面的情况时,会发生最坏的情况,此时时间复杂度为O((n-m+1)*m)

二、KMP算法 后来有三位前辈(Knuth、Pratt和Morris)发布了一个大大避免重复遍历的算法,简称KMP算法 1、

c s d c s d n
      ?
c s d n

2、

c s d c s d n
  ?
  c s d n

3、

c s d c s d n
    ?
    c s d n

可以看到,其实2和3步骤完全是没有必要的,因为在第一次比较时csd都是一样的,所以主串中的第一个s必然不等于字串的c,主串中的第一个d也必然不等于子串中的c

再看另一种情况 1、

c s c s c s b n
        ?
c s c s d n

2、

c s c s c s b n
  ?
  c s c s d n

3、

c s c s c s b n
    |
    c s c s d n

4、

c s c s c s b n
    | | 
    c s c s d n

5、

c s c s c s b n
    | | |  
    c s c s d n

6、

c s c s c s b n
    | | | | |
    c s c s d n

7、

c s c s c s b n
    | | | | | ?
    c s c s d n

可以看出,2、3、4步是没有必要的,2步没必要跟上面的情况一致,而3、4没必要,是因为子串在1中已经知道前4个字符都与主串是一样的了,既然即主串中第三和第四个位置上的字符与子串第三和第四个位置上的字符是一样的,而子串第三和第四个位置上的字符又与子串第一和第二个位置上的字符一样,那子串中第一和第二个位置上的字符必然与主串中第三和第四个位置上的字符一样,所以是不需要3、4步骤的

综上,我们可以总结出两点:1、主串中的i是不需要回溯的,子串中的j需要回溯;2、子串中的j回溯的位置,跟子串字符的重复有关系

接下来,我们需要找出子串字符的重复关系,通过上面c s c s c s b n与 c s c s d n的查找,我们可以看到,如果省略步骤3和4,当i = 5时,是与j = 3的字符来比较的,

我们把子串j值的变化用一个next数组来表示: j: 1 2 3 4 5 6 T: c s c s d n next: 0 1 1 2 3 1 下面来说说为什么这么表示,首先,next[1] = 0,是为了简化判断,这个我们稍后再说;然后看next[5] = 3,之所以等于3,是因为在j = 5不匹配时,我们无需让j回溯到1,只需要回溯到3即可,因为T[1] = T[3],T[2] = T[4],可以推出next[j] = max{k|1<k<j, 且’p->1…p->k-1 = ‘p->j-k+1…p->j-1’}(当此集合不为空的时候,这里->代表下标),实际上,k就是子串开头字符重复的个数加1

next数组的生成代码,注意:T[0]表示子串的长度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void get_next(char *T, int *next) {
    int i,j;
    i = 1;
    j = 0;
    next[i] = 0;

    while (i < T[0]) {
        if (j == 0 || T[i] == T[j]) {
            ++i;
            ++j;
            next[i] = j;
        } else {
            j = next[j];
        }
    }
}

有了next数组,KMP实现代码就出来了,注意:T[0]表示子串的长度,S[0]表示主串的长度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int index_KMP(char *S, char *T, int pos) {
    int i = pos;
    int j = 1;
    int next[255];

    get_next(T, next);

    while (i <= S[0] && j <= T[0]) {
        if (j == 0 || S[i] == T[j]) {
            ++i;
            ++j;
        } else {
            j = next[j];
        }
    }

    if (j > T[0])
        return i - T[0];
    else
        return 0;
}

现在我们看到next[1]为0可以简化判断j是否回溯到开头的代码,并且方便的实现从j=1开始向后匹配,这个算法还是有点问题,比如当子串为aaaab情况时,会有重复向前匹配的情况,因为此时next数组为01234,如果主串S[i] != T[4]时,j值依次回溯,可是前四个都是完全一样的字符,所以j值的回溯是没有必要的

也就是说,在满足上一篇next数组的条件下,当子串有字符重复时,它所对应的next数组中的值只需要与它第一次出现时的在next数组中记录的值一样即可,比如,对于一个子串’a’, ‘b’, ‘a’, ‘b’, ‘a’, ‘c’, ‘c’, ‘a’, ‘c’,改进后的next数组应该为010104102

新的next数组生成代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void get_nextval(char *T, int *next) {
    int i,j;
    i = 1;
    j = 0;
    next[i] = 0;

    while (i < T[0]) {
        if (j == 0 || T[i] == T[j]) {
            ++i;
            ++j;
            if (T[i] != T[j]) {
                next[i] = j;
            } else {
                next[i] = next[j];
            }
        } else {
            j = next[j];
        }
    }
}

KMP代码只需把get_next(T, next);替换成get_nextval(T, next);

Comments