数据结构与算法(九)-- 字符串匹配问题(KMP算法)

240 阅读13分钟

KMP 是由 D.E.Knuth,J.H.Morrs 和 VR.Pratt。发表的一个模式匹配算法,可以大大避免重复遍历的情况。

KMP模式匹配算法原理

我们拿上一篇中的 BF 算法,与 KMP 算法实现过程分析做对比说明。

假如有题目一:

假如现在有一个主串 S = "abcdefgabed",模式串 T = "abcdex",找出T 在 S 中完全匹配时的首地址。

BF 算法执行流程

上图便是BF算法的执行过程,并由此可以得出,当在模串"abcdex"中 字符'a' 是唯一存在的情况下,经过第一轮的比较得到了主串和模串前 5 个字符都是相等的,这时我们其实是可以直接跳到第六步的,前面的2、3、4、5 其实都没有意义的,因为模串的第一个字符 ‘a’ 是不可能与2、3、4、5 的字符相等的。

此时便引出 KMP 算法,刚好补助了这一缺点,在回移比较时,回到有意义的位置再次比较

KMP 算法执行流程

使用 KMP 算法,在经过第一轮比较后,我们知道了,i = 6 并且 j = 6,那么也就是说,前 5 个字符都是相等的,i和 j 才能到 6 这个位置,然后模串中 'a' 字符又是唯一的,那么,'a' 必定也不等于主串中的位置为 2、3、4、5 的字符,所以就可以省略BF 算法中2、3、4、5步,保留 BF 算法中的第六步,因为无法判断 S[6] != T[1],因此这个两个位置字符还需比较一下。

假如有题目二:

假如现在有一个主串 S = "abcababca",模式串 T = "abcabx",找出T 在 S 中完全匹配时的首地址。

这时在模串中字符 'a' 存在不是唯一的这时要怎么处理呢?

BF 算法执行流程

主串与模串跟题目一的字符不同,所以执行流程有所变化,但是缺陷跟题目一中的BF算法一样,于是按照 KMP 算法分析一下

KMP 算法执行流程

在模串中我们可以看出字符 'a' 在前 3 个字符中是唯一的,并且与主串中的前 3 个字符也是完全相等的,所以根据一题目中 KMP 的经验可知,模串中的字符 'a' 与主串中位置为 2、3 的字符是一定不不等的,所以我们可以说 BF 算法中的 2、3 步是无效的,

然后继续看BF执行步骤

在 BF 算法的第一步就把主串和模串中的 第 1 、第 2 和 第 3 、第 4 的对应的 'a'、'b' 进行比较,得出同地址的字符是相等的,那么也就是说,在第 4 、第 5 步中,又对主串和模串的 'a'、'b' 字符进行比较就是没有意义的,可以对其省略,省略了之后 KMP 算法流程如下

KMP 算法总结

  • 当我们在 BF 匹配算法中,当检测到两个字符串不字符不匹配时,i ,j 值就回溯,也是说通过不断回溯主串的 i和模串的 j 值来完成字符匹配的。
  • 然后又通过分析又发现 BF 算法中很多回溯是没必要的
  • 而 KMP 算法就是为了避免让不必要的回溯发送

对比以上两个题目的分析,我们会发现,模串 T 中首字符 'a' 在整个T串中出现的次数不一样,回溯时,j 的值也会发生变化,也就是说 j 值得变化不知与主串对比有关系,还与模串 T 中字符是否有重复有关。

主串S = "abcdefgabed",模式串 T = "abcabx"

如上图,由于模串 T 中没有重复的字符,进行第二轮匹配是,模串就要从第一个字符开始匹配,故, j = 1 。

主串 S = "abcababcaed",模式串 T = "abcabx"

上图,由于主串和模串前5个字符都是相同的,所以i = 6,然后主串中出现了两次“ab”,恰好模串 T 中 “ab” 也出现两次,所以主串 i = 6 时,前面的第 4 、第 5 的字符'a'、'b'就与模串 j = 3时,前面的第 1 、第 2 的字符'a'、'b' 之前是比较过并且是相等的,所以模串就从 第 3 个字符开始匹配,故, j = 3 。

所以,回溯时 j 的位置不是固定的,为了更好的推导回溯时,j 的位置,便将模串各个位置 j 的变化定义为一个next 数组, next 的数组长度应与模串数组一样。

KMP 匹配算法中_next 数组值推导

我们把模串 T 各个位置的 j 值对应需回溯的位置变化定义为一个 next 数组,那么 next 的长度就是模串 T 的长度,于是便有一下函数定义

此图的意思是,当j = 1 时,next[j] = 0;

字符串中无重复字符时,next[j] = 1;

当有重复字符或字符串出现时,就看串的前后缀相等字符的个数 n,一个字符相等 n = 1 时,k = n + 1 = 2, 两个字符相等 n = 2 时, k = n + 1 = 3。

情景一:模式串中无重复字符

分析:T = "abcdex"

根据上图进行以下推到

  • j = 1 时,1 前面没任何字符----------------------------------故,next[1] = 0;
  • j = 2 时,1 - 2 的范围内有字符 'a',并没有重复字符-----------故,next[2] = 1;
  • j = 3 时,1 - 3 的范围内有字符 "ab",并没有重复字符----------故,next[3] = 1;
  • j = 4 时,1 - 4 的范围内有字符 "abc",并没有重复字符---------故,next[4] = 1;
  • j = 5 时,1 - 5 的范围内有字符 "abcd",并没有重复字符--------故,next[5] = 1;
  • j = 6 时,1 - 6 的范围内有字符 "abcde",并没有重复字符-------故,next[6] = 1;

可得出,模式串 T = "abcdex", next = {0,1,1,1,1,1};

情景二:模式串中有重复字符

分析:T = "abcabx"

根据上图进行一下推到

  • j = 1 时,1 前面没任何字符----------------------------------故,next[1] = 0;
  • j = 2 时,1 - 2 的范围内有字符 'a',并没有重复字符-----------故,next[2] = 1;
  • j = 3 时,1 - 3 的范围内有字符 "ab",并没有重复字符----------故,next[3] = 1;
  • j = 4 时,1 - 4 的范围内有字符 "abc",并没有重复字符---------故,next[4] = 1;
  • j = 5 时,1 - 5 的范围内有字符 "abca",前后缀重复字符"a"-----故,next[5] = 2;
  • j = 6 时,1 - 6 的范围内有字符 "abcab",前后缀重复字符"ab"---故,next[6] = 3;

可得出,模式串 T = "abcabx", next = {0,1,1,1,2,3};

情景三:模式串类似于 "ababaaaba"

分析:T = "ababaaaba"

  • j = 1 时,1 前面没任何字符----------------------------------故,next[1] = 0;
  • j = 2 时,1 - 2 的范围内有字符 'a',并没有重复字符-----------故,next[2] = 1;
  • j = 3 时,1 - 3 的范围内有字符 "ab",并没有重复字符----------故,next[3] = 1;
  • j = 4 时,1 - 4 的范围内有字符 "aba",前后缀重复字符"a"------故,next[4] = 2;
  • j = 5 时,1 - 5 的范围内有字符 "abab",前后缀重复字符"ab"----故,next[5] = 3;
  • j = 6 时,1 - 6 的范围内有字符 "ababa",前后缀重复字符"aba"--故,next[6] = 4;
  • j = 7 时,1 - 7 的范围内有字符 "ababaa",前后缀重复字符"a"---故,next[7] = 2;
  • j = 8 时,1 - 8 的范围内有字符 "ababaaa",前后缀重复字符"a"--故,next[8] = 2;
  • j = 9 时,1 - 9 的范围内有字符 "ababaaab",前后缀重复字符"ab",故,next[9] = 3;

可得出,模式串 T = "ababaaaba", next = {0,1,1,2,3,4,2,2,3};

注意:

  1. next 数组是比较连续的前缀字符和后缀字符,例如j = 6 时,字符串 "ababa", 此时 "aba",既是前缀也是后缀。
  2. 当 i = 7 时,字符串 "ababaa", 此时前后缀相等的字符是 'a',因为 next[j] 对应的值为相同字符个数 n 加 1, 所以 next[7] = 1 + 1 = 2。

情景四:模式串类似于 "aaaaaaaab"

分析:T = "aaaaaaaab"

  • j = 1 时,1 前面没任何字符----------------------------------故,next[1] = 0;
  • j = 2 时,1 - 2 的范围内有字符 'a',并没有重复字符-----------故,next[2] = 1;
  • j = 3 时,1 - 3 的范围内有字符 "aa",前后缀重复字符"a"-------故,next[3] = 2;
  • j = 4 时,1 - 4 的范围内有字符 "aaa",前后缀重复字符"aa"-----故,next[4] = 3;
  • j = 5 时,1 - 5 的范围内有字符 "aaaa",前后缀重复字符"aaa"---故,next[5] = 4;
  • j = 6 时,1 - 6 的范围内有字符 "aaaaa",前后缀重复字符"aaaa"-故,next[6] = 5;
  • j = 7 时,1 - 7 的范围内有字符 "aaaaaa",前后缀重复字符"aaaaa",故,next[7] = 6;
  • j = 8 时,1 - 8 的范围内有字符 "aaaaaaa",前后缀重复字符"aaaaaa",故,next[8] = 7;
  • j = 9 时,1 - 9 的范围内有字符 "aaaaaaaa",前后缀重复字符"aaaaaaa",故,next[9] = 8;

可得出,模式串 T = "aaaaaaaab", next = {0,1,2,3,4,5,6,7,8};

注意:

注意完全相同的情况下,前后缀字符串的标识就可以了

经过以上的分析可以得出:

  • 字符串匹配在位置 j = 1 时,回溯位置从下标 0 处开始重新匹配;

  • 在其他位置进行回溯时,都要回溯到模串的下标 j 在next数组中对应的值位置进行重新匹配;

  • next 对应的值,就是字符串匹配过程中不相等时的模串的回溯位置。

KMP 模式匹配算法 next 数组回溯位置求解

过程模拟

代码实现

//通过计算返回子串 T 的 next 数组
//注意:字符串T[0]中是存储的字符串长度,真正的字符串内容从 T[1] 开始
void get_next(String T, int *next) {
    int i = 0, j = 1;
    next[j] = 0;
    
    //遍历 T 模式串,此时T[0]为字符串T长度
    while (j <= T[0]) {
        if (i == 0 || T[i] == T[j]) {
            //T[i] 表示后缀的单个字符
            //T[j] 表示前缀的单个字符
            i++;
            j++;
            next[j] = i;
//            printf("next[%d]=%d\n",j,next[j]);
        } else {
            //如果字符不相同,则i值回溯
            i = next[i];
        }
    }
}

KMP 模式匹配算法代码实现

#define MAXSIZE 20

typedef char String[MAXSIZE + 1];

void insertString(String T, char *s) {
    if (s == NULL) {
        printf("字符串为空!");
    }
    T[0] = strlen(s);
    for (int i = 1; i <= T[0]; i++) {
        T[i] = s[i-1];
    }
}

void printfString(String T) {
    if (T[0] <= 0) {
        printf("字符串为空!");
    }
    for (int i = 1; i <= T[0]; i++) {
        printf("%c ", T[i]);
    }
    printf("\n");
}

int get_StrLength(String T) {
   return T[0];
}

void pirntfNext(int next[], int length) {
    printf("next 数组元素为:");
    for (int i = 1; i <= length; i++) {
        printf("%d ", next[i]);
    }
    printf("\n");
}

//通过计算返回子串 T 的 next 数组
//注意:字符串T[0]中是存储的字符串长度,真正的字符串内容从 T[1] 开始
void get_next(String T, int *next) {
    int i = 0, j = 1;
    next[j] = 0;
    
    //遍历 T 模式串,此时T[0]为字符串T长度
    while (j <= T[0]) {
        if (i == 0 || T[i] == T[j]) {
            //T[i] 表示后缀的单个字符
            //T[j] 表示前缀的单个字符
            i++;
            j++;
            next[j] = i;
//            printf("next[%d]=%d\n",j,next[j]);
        } else {
            //如果字符不相同,则i值回溯
            i = next[i];
        }
    }
}

//返回子串T在主串S中第pos个字符之后的位置, 如不存在则返回0;
int index_KMP(String S, String T, int pos) {
    //i 是主串当前位置的下标准,j是模式串当前位置的下标准
    int i = pos, j = 1;
    //定义一个空的next数组;
    int next[T[0]];
    //对T串进行分析,得到next数组;
    get_next(T, next);
    //打印next数组
    pirntfNext(next, get_StrLength(T));
    
    //注意: T[0] 和 S[0] 存储的是字符串T与字符串S的长度;
    //若i小于S长度并且j小于T的长度是循环继续;
    while (i <= S[0] && j <= T[0]) {
        //如果两字母相等则继续,并且j++,i++
        if (S[i] == T[j] || j == 0) {
            i++;
            j++;
        } else {
            //如果不匹配时,j回退到合适的位置,i值不变;
            j = next[j];
        }
    }
    if (j > T[0]) {
        return i - T[0];
    }
    return -1;
}


int main(int argc, const char * argv[]) {
    // insert code here...
    printf("字符串匹配算法RK算法!\n");
    String T , S;
    
    char *s = "abcababca";
    char *t = "abcdex";
    //将 s copy 到 s 中
    insertString(S, s);
    printf("主串为:");
    printfString(S);
    //将 t copy 到 T 中
    insertString(T, t);
    printf("模串为:");
    printfString(T);
    int ret = index_KMP(S, T, 1);
    printf("模串在主串中第一次出现的位置为: %d\n",ret);
    printf("\n");
    
    char *s1 = "abcababca";
    char *t1 = "babc";
    //将 s copy 到 s 中
    insertString(S, s1);
    printf("主串为:");
    printfString(S);
    //将 t copy 到 T 中
    insertString(T, t1);
    printf("模串为:");
    printfString(T);
    ret = index_KMP(S, T, 1);
    printf("模串在主串中第一次出现的位置为: %d\n",ret);

    return 0;
}

打印结果:

KMP 模式匹配算法优化

存在的缺陷,用图说明

例子 S = "aaaabc" T = "aaaaax"

注意 : 之前数组元素都是从 1 开始存储的,所以读取也是从 1 开始

由上图可得:

  1. 在第一次比较的时候,前五个字符都是一样的,所以, i = 5,j = 5;
  2. 但是在后面3,4,5,6,7 通过 next 获取回溯位置,反倒还往前移了,达不到正确匹配的效果了
  3. 模式串 T 字符前 5 个字符都是 'a',还一次往前已,每次都是字符 'b',与字符 'a'比较。
  4. 到第六步执行完后,此时 j = next[1] = 0,根据KMP 算法再次循环一次,因为 j = 0,所以 i++, j++,,即 i = 6,j = 1;再循序又回到原点,直到 i > S[0],即, i > 6,结束循环,所以 3,4,5,6 这几个步骤就是多余的了。

既然结果就是最后会回到 j = 1, 并且next[1] = 0,所以,优化目标就是要是省略中间的 3,4,5,6 这个几个步骤,直接到达第七步

那么就应该对获取 next 数组的函数进行优化

目前 next = {0,1,2,3,4,5};

如果要节省刚刚 3,4,5,6 的无效不交,需要 next[] = [0,0,0,0,0,0,5];

也就是如果是一样的字符,那么 next 当前位置对应的值就等于,它前面一个相同字符的位置 next 对应的值。

KMP 模式匹配算法 next 数组优化思路图

当 T = "ababaaaba" 时

最后一步 j = 9,T[0] = 9, 因不满足 j < T[0],即,跳出循环。

最后next = {0,,1,,0, 1, 0, 4, 2, 1, 0}

文字有点多,但每一步对照着文字,看是能看懂的。

注意想表达的意思就是在满足 j == 0 || T[i] == T[j] 条件时,j++,i++ 后,在判断一下 T[i] != T[j],如果不等于,next[j] = i,如果等于 next[j] = next[i];

KMP 模式匹配算法 next 数组优化代码实现

//通过计算返回子串 T 的 next 数组
//注意:字符串T[0]中是存储的字符串长度,真正的字符串内容从 T[1] 开始
void get_next(String T, int *next) {
    int i = 0, j = 1;
    next[j] = 0;
    
    //遍历 T 模式串,此时T[0]为字符串T长度
    while (j <= T[0]) {
        if (i == 0 || T[i] == T[j]) {
            //T[i] 表示后缀的单个字符
            //T[j] 表示前缀的单个字符
            i++;
            j++;
            if (T[i] != T[j]) {
                next[j] = i;
            } else {
                next[j] = next[i];
            }
        } else {
            //如果字符不相同,则i值回溯
            i = next[i];
        }
    }
}

打印结果: