一文搞懂KMP算法

什么是KMP算法

KMP算法是由 D.E.Knuth、J.H.Morris 和 V.R.Pratt 提出的模式匹配算法,简称 KMP 算法。KMP 算法对 BF算法进行了很大改进,提高了匹配效率。

KMP算法和BF算法的具体区别这里就不多做解释,主要讲解如何实现KMP算法

KMP算法分析

有一个子串 ababc,我们需要在主串 xxxxxxxxxxxx 中搜索这个子串中搜索这模式串。用 i 和 j 分别表示当前匹配到主串和子串的哪个位置

字符匹配成功:

当前字符匹配成功时,i 和 j 都 +1,匹配下一个字符

字符匹配失败:

当最后一个字符匹配失败时,那么说明前面的字符都匹配成功,可以确定主串中前四个字符与子串的前四个字符相等。

如果按照BF算法,那么 i 需要指向 主串的第二个字符,j 指向子串的第一个字符,然后重新匹配

每次发生匹配失败时 i 都需要往回退

而 KMP 算法 的 i 则不需要变动

为了方便观察,我们可以在当前字符的左边画一条分界线,分界线左边的字符都是已知的

我们可以让子串每次往左移动一个字符,看看子串的第一个字符与主串的第二个字符是否相等

显然它们不相等,那么就没有必要在匹配一次这两个字符。再把子串往右移动一个字符

主串的第三个字符是 a,子串的第一个字符也是 a,它们两个相等,那么对比子串的下一个字符和主串的下一个字符是否相等,显然也相等。主串下一个字符是未知的,所以不能再移动子串。此时 i 指向的是这个未知的字符,而 j 指向的就是要与这个未知字符匹配的子串字符,即第三个字符(j 之前的字符已经匹配成功了,不需要在进行匹配)。

所以当 第五个字符匹配失败时,我们不需要让 i 往回退,让 i 保持不变,j = 3 即可。

当第四个字符匹配失败时,说明前面三个字符匹配成功,主串与子串的前三个字符都相等

在第四个字符前画一条分界线,分界线左边的字符已知

子串依次往右移动一个字符,对比子串的第一个字符与主串的第二个字符是否相等,不相等时继续移动子串,对比主串的下一个字符。对比到主串的第三个字符时,与子串的第一个字符相等,且下一个字符是未知的字符,停止移动子串。

此时 i = 4,j = 2。所以当第三个字符匹配失败时,直接让 j = 2 即可

当第三个字符匹配失败时,说明前面两个字符匹配成功,主串与子串的前两个字符都相等

同样的在第三个字符前画一条分界线,分界线左边的字符已知

子串往右移动进行对比,a 与 b 不相等,继续移动一个字符

此时子串来到了 i 这个位置,因为 i 所指向的主串的字符未知,所以要和子串的第一个字符进行匹配.此时 i = 3,j = 1

最后剩下第一和第二个字符,这两个位置有点特殊

无论什么情况下,当第一个字符匹配失败时,让 j = 0;当第二个字符匹配失败时,让 j = 1;

当第二个字符匹配失败时,主串的第二个字符是未知的,因为我们只知道它不等于 b,可能等于其他任何值。所以子串需要往右移动一位,和这个未知的字符进行匹配

可以思考一下,不管是什么情况下,只要第二个字符没有匹配上,就可以让 j = 1

当第一个字符匹配失败时,直接从主串的下一位字符开始匹配。 根据前面的规律,i 需要保持不变,j 需要等于某个值。为了保持跟之前的处理逻辑一样,可以先让 j = 0,然后 i 和 j 都 +1

整合以上各个字符匹配错误时对应的 j 的值,可以得到一个 next数组

为了和子串字符的位序保持一致,不使用 0 这个位置,直接从 1 开始表示

next[0] next[1] next[2] next[3] next[4] next[5]
0 1 1 2 3

在主串中搜索子串的位置时,当某一个字符匹配错误,可以让 j 等于 next 数组中这个字符所对应的值。这样可以跳过一些不必要的匹配

用 next 数组来模拟搜索一下子串

主串:a c a b a d a b a b c

子串:a b a b c

第一位匹配成功,i 和 j 都 +1

第二位匹配失败,根据 next 数组,让 j = 1

第一位匹配失败,j 置为 0,i 和 j 都 +1

若匹配成功 i 和 j 都 +1,匹配下一个字符,直到遇到匹配失败的字符

第四个字符配对失败,i 保持不变,j = 2

第二个字符配对失败,i 不变,j = 1

第二个字符配对失败,i 不变,j = 1

后面的字符都能配对成功,如果 j 大于了子串的长度,说明主串中能够找到子串

代码实现

定义一个结构体,包含一个存放字符串的字符数组和一个变量记录字符串的长度

C 复制代码
typedef struct {
	char ch[255];
	int length;
} SString;

准备主串、子串和一个空的 next 数组

字符串从 1 开始,所以 0 的位置可以用一个特殊字符表示这个位置为空

next 数组也是从 1 开始存储,所以长度为 S2.length+1

C 复制代码
int main() {
    SString S1 = { "#acabadababc", 11 };
    SString S2 = { "#ababc", 5 };
    int next[S2.length+1];
}

实现 next 数组

C 复制代码
void nextArr(SString S1, SString S2, int next[]) {
    //  第 o 位用 -1 表示这个位置不使用
    next[0] = -1;
    //  无论什么情况第一位都为 0
    next[1] = 0;
    //  无论什么情况第二位都为 1
    next[2] = 1;
    //  i 表示第几个字符匹配失败,循环的次数为子串的长度-3
    for (int i = 3; i <= S2.length; i++) {
        //  x 表示每次发生匹配错误,对主串前面已知的字符与子子串对比时主串开始的位置
        //  y 表示表示子串当前对比到的位置
        //  子串和主串的字符配对成功后,x 和 y 都需要 +1,然后对比下一个字符,若主串下一个字符与子串不相等,则需要让主串开始对比的位置往右移一位,所以需要用 z 来记录主串最初开始对比的位置
        int x = 2, y = 1, z = 2;
        //  循环对比主串已知的字符和子串是否一样。x 超过匹配失败的那个字符时停止循环
        while (x < i) {
            //  如果一样,x 好 y 同时 +1
            if (S1.ch[x] == S2.ch[y]) {
                x++;
                y++;
            //  如果不一样,主串开始对比的位置 +1,让 x 回到这个位置,y 回到子串的第一位
            } else {
                z++;
                x = z;
                y = 1;
            }
        }
        //  对比结束时 y 的值就是子串下一个要匹配的字符的位置
        next[i] = y;
    }
}

把 next 传入搜索函数,根据数组来判断匹配失败时 j 的值应该为多少

C 复制代码
int main() {
    SString S1 = { "#acabadababc", 11 };
    SString S2 = { "#ababc", 5 };
    int next[S2.length+1];
    //  把主串、子串和 next 数组传入 nextArr 函数
    nextArr(S1, S2, next);
    //  把主串、子串和 next 数组传入 search 函数
    int result = search(S1, S2, next);
    printf("result: %d\n", result);
}

实现 search 函数

C 复制代码
int search(SString S1, SString S2, int next[]) {
    //  i 和 j 表示主串和子串匹配到了哪个字符
    int i = 1, j = 1;
    //  当 i 大于子串的长度或 j 大于子串的长度结束循环
    while (i <= S1.length && j <= S2.length) {
        //  如果这两个字符相等,则匹配下一个字符。因为当第一个字符匹配失败时,会让 j = 0,然后 i 和 j 都 +1,所以这里要处理 j = 0 的情况
        if (j == 0 || S1.ch[i] == S2.ch[j]) {
            i++;
            j++;
        } else {
            //  不相等则直接根据 next 设置 j 的值
            j = next[j];
        }
    }
    //  j 大于子串的长度说明子串匹配成功,返回子串的位置,也就是子串在主串中第一个字符的位置
    if (j > S2.length) {
        return i - S2.length;
    } else {
        //  匹配失败则返回 0
        return 0;
    }
}

最后运行代码看一下结果是否正确

测试另一个主串和子串

S1 = { "#eabcbcaeeab", 11 }; S2 = { "#bcaeea", 6 };

优化 next 数组

大家有没有发现在上面的字符串中,第三位字符如果匹配失败,根据 next 数组,j 的值应该为 1,也就是说 j 应该指向子串的第一个字符。但是子串的第一个字符和第三个字符相等,都是 a,那么第三个字符匹配失败了,说明主串中这个未知的字符肯定不是 a,第一个字符也肯定会匹配失败。所以我们可以优化一下 next 数组,如果 S2.ch[j] == S2.ch[next[j]] 的话,可以直接让 j 的值等于第一个字符 j 的值,这样可以跳过这次肯定会失败的对比。

基于 next 优化成 nextval 数组

C 复制代码
int main() {
    SString S1 = { "#acabadababc", 11 };
    SString S2 = { "#ababc", 5 };
    int next[S2.length+1];
    nextArr(S1, S2, next);
	
    int nextval[S2.length+1];
    nextvalArr(S2, next, nextval);
	
    int result = search(S1, S2, nextval);
    printf("result: %d\n", result);
}

实现 nextval 数组

C 复制代码
void nextvalArr(SString S2, int next[], int nextval[]) {
    nextval[0] = -1;
    nextval[1] = 0;
    for (int j = 2; j <= S2.length; j++) {
        if (S2.ch[j] == S2.ch[next[j]]) {
            nextval[j] = nextval[next[j]];
        } else {
            nextval[j] = next[j];
        }
    } 
}

运行优化后的代码,结果也没有问题

相关推荐
我是哈哈hh44 分钟前
专题十_穷举vs暴搜vs深搜vs回溯vs剪枝_二叉树的深度优先搜索_算法专题详细总结
服务器·数据结构·c++·算法·机器学习·深度优先·剪枝
Tisfy1 小时前
LeetCode 2187.完成旅途的最少时间:二分查找
算法·leetcode·二分查找·题解·二分
Mephisto.java1 小时前
【力扣 | SQL题 | 每日四题】力扣2082, 2084, 2072, 2112, 180
sql·算法·leetcode
robin_suli1 小时前
滑动窗口->dd爱框框
算法
丶Darling.1 小时前
LeetCode Hot100 | Day1 | 二叉树:二叉树的直径
数据结构·c++·学习·算法·leetcode·二叉树
labuladuo5202 小时前
Codeforces Round 977 (Div. 2) C2 Adjust The Presentation (Hard Version)(思维,set)
数据结构·c++·算法
jiyisuifeng19912 小时前
代码随想录训练营第54天|单调栈+双指针
数据结构·算法
꧁༺❀氯ྀൢ躅ྀൢ❀༻꧂2 小时前
实验4 循环结构
c语言·算法·基础题
新晓·故知2 小时前
<基于递归实现线索二叉树的构造及遍历算法探讨>
数据结构·经验分享·笔记·算法·链表
总裁余(余登武)2 小时前
算法竞赛(Python)-万变中的不变“随机算法”
开发语言·python·算法