相信光吧,这是你无法忘记的 KMP

173 阅读22分钟

苦肝三日,我终于带了这份超易理解的 KMP 攻略,就连我没上过幼儿园的奶奶都差点看懂了。看完本文,你将清楚 kmp 的每一个细节,将收获对 kmp 思想的理解,将能够轻松写出 kmp 代码,最主要的是不会再忘记。

本文从字符串匹配的一般方法讲起,引出暴力匹配的缺点——主串和模式串都要回退。围绕解决这个问题,我们先想到的是仅模式串回退,那模式串怎么回退,回退到什么地方合适呢?假设模式串走到下标 j 时匹配失败,第一个方案就是从 0 开始到 j-1,每个位置都回退过去试一下,然后在能匹配的里面选个大的回退。但这样很耗时,我们期望着有没有人能够告诉我,我该回退到哪里,一步到位,这就引出了 next 数组。

上面模式串回退的两种方案,就跟游戏里撤退过程中找一个安全的草丛走一样。最笨的探草方法,就是每个草都探一下,然后选一个安全的,但这比较浪费时间啊。我们前进的时候是不是经过了一些草丛,我们知道那些安全的草,这就可以用来规划我们的撤退路线,失败的时候直接按照记忆撤退就好。

本文上下文联系较为紧密,环环相扣,建议从上往下开始阅读。

1、字符串匹配的概念

字符串匹配,有主串和模式串两个角色参与,通常来说,主串是长的,模式串是短的。

要做什么呢?就是在长的里面寻找短的,主串里面找模式串。

主串

模式串

2、字符串匹配的一般过程

我们要从暴力匹配开始,这很有必要,在这一过程中我们会引出很多关键信息。

下文中,我们使用 i 来表示主串的下标,j 来表示模式串的下标。

暴力匹配的第一轮

在开始匹配的初始状态,主串的下标 i = 0,模式串的下标 j = 0,从头开始,目前没有一个字符进行过匹配。

第一轮开始匹配前的初始状态

现在匹配第 i = 0,j = 0 的字符,匹配成功了,所以 i,j 都向后移动一位,i = 1,j = 1。

第一位匹配成功后的情况

现在匹配第 i = 1,j = 1 的字符,又匹配成功,此时已经有两个字符匹配成功了,i,j 都继续向后移动一位,i = 2,j = 2。

第二位匹配成功后的情况

继续这个过程,i = 2,j = 2 时也能匹配成功,双方都后移 i = 3,j = 3,当前总共匹配成功了三个字符。

第三位匹配成功后的情况

现在要匹配 i = 3,j = 3 上的字符了,很显然 a != b,匹配失败。

这说明从 i = 0 开始的主串没有办法和模式串匹配,所以我们这次让主串下标从 1 开始,模式串仍然要从头开始,下标的变化是

主串 i: 3——>1

模式串 j:3——>0

我们一起看看第二轮匹配的情况吧。

暴力匹配的第二轮

主串 i = 0 的位置被置为了灰色,表示该字符已经不参与匹配了,因为我们刚刚试过了,从它开始的字符串无法和模式串匹配成功。

第二轮开始匹配前的状态

第二轮从 i = 1,j = 0 开始,b != a,此时就宣告这一轮失败了,头都对不上,尾就没必要看了。

所以进入第三轮匹配,主串从下标 2 开始,模式串依然从 0 开始,第三轮匹配的过程就不继续演示了,总之它失败了。

我们下面直接看第四轮。

暴力匹配的第四轮

第四轮主串的初始位置为 i = 3,模式串的初始位置还是 j = 0。

第四轮开始匹配前的状态

逐位匹配,每次匹配成功后,j 和 j 都后移,一路成功,直到 i = 8,j = 5 时,发现匹配失败了,此前已经匹配成功了五个字符。

第六个字符匹配失败了

按照之前的规律,我们应该让 i = 4, j = 0,然后重新开始第五轮匹配。

但是主字符串可不是个任劳任怨的家伙,它不干了:“大爷我不想动了,每次都让我往回跑,我现在已经到下标 8 了,你却让我回退到 4,累死了!模式串你自己动吧,真男人从不回头!”

3、仅模式串回退

各位朋友,目前我们有什么已知信息呢? 1、主串不想回退了,它当前保持 i = 8,下次变化就是 i = 9 了; 2、主串从 i = 0, 1, 2, 3 这几个下标开始的匹配都尝试过了,无法匹配成功;但是 i = 4, 5, 6, 7 这几个位置开始的字符串没有进行过尝试,必须得想办法知道这几个位置能不能匹配成功; 3、仅模式串回退,看模式串有什么办法试探出 i = 4, 5, 6, 7 这几个位置开始的主串能不能成功了。

“好好好!主串你是大爷,我是打工仔,这个牛马我当了!”

怎么当?

不妨每个位置都回退试一下吧。

模式串回退到 j = 0

此时 i = 8, j = 0,b !=a,通过比较,我们能确定从 i = 8 开始的主串无法和模式串匹配成功。

模式串回退到 j = 0 开始匹配前

模式串回退到 j = 1

现在模式串回退到 j = 1 了,主串不动。

模式串回退到 j = 1 开始匹配前

那我们是直接从 j = 1 开始比较吗?不行的,一个模式串要完全匹配,它的头部必然也是要匹配才行。那先考虑 j = 0 的位置是否匹配吧。

但因为 i = 8 不能往前移动,那 j 也不能往前移动,因为移动后,i 和 j 的相对位置关系就变了。所以匹配前面的字符的时候,我们用模式串[j - x] 和 主串[i - x],x = 1,2,3,... 这样的形式比较,边界条件是 j-x >= 0。通过这样一个操作,虽然 i 没有回退,但也比较到了 i = 7 这个位置,这次匹配相当于是从 i = 7 开始进行的了,比较下来就能知道从 i = 7 开始的主串是否能和模式串成功匹配。

如图,j < 1 部分的模式串头部匹配成功了,用蓝色表示,从而和 j >=1 的主要流程匹配成功时的绿色背景做区分。

后续的匹配就跟我们最开始的动作一样的,如果匹配成功,i, j 都向后移动。不继续演示了,后面的流程都是重复的。

模式串回退到 j = 1,j < 1 部分的的模式串头部能匹配成功

模式串回到 j = 2

按照刚刚的思路,先比较模式串头部是否能匹配成功,但我们发现不能正常匹配,j = 2 及之后的比较就没必要进行了,这波说明了从 i = 6 开始的主串也不能和模式串匹配成功。

模式串回退到 j = 2,j < 2 部分的模式串头部不能匹配成功

模式串回到 j = 3

模式串回退到 j = 3 开始匹配前

还是先比较 j < 3 之前的模式串头部,我们发现这次头部匹配成功了。

模式串回退到 j = 3,j < 3 部分的模式串头部能匹配成功

后续接着进行主流程的匹配就行了,而且操作下去,会发现从 i = 5 开的主串能和模式串完全匹配,至此也就找到了答案。

但我们继续往下看看,看看回退到 j = 4 的情况。

模式串回到 j = 4

我们发现模式串回退到 j = 4,模式串头部匹配失败了,说明从 i = 4 开的主串不能和模式串完全匹配。

模式串回退到 j = 4,j < 4 部分的模式串头部不能匹配成功

现在在看一下我们的已知信息。

i = 8 虽然固定不动,但通过将模式串回退到不同的位置处,我们也能确定从 i = 4, 5, 6, 7 这几个位置开始的模式串能否匹配成功,不过要挨个试。 在回退后,我们的匹配过程可以拆分成两步: 1、比较 j 之前的模式串头部是否能成功匹配。这一步成功后才继续进行第二步。 2、比较 j 以及之后的模式串。

模式串位置 j 的含义

刚刚我们作了尝试,发现将模式串回退到 j = 0,1,2,3,4 后,有时候模式串 j 之前的头部能匹配成功,有时候模式串的头部又不能匹配成功。这种连头部都不能匹配成功的回退有什么意义吗?

浪费时间罢了,我们能不能避开这样的回退呢?不妨先搞清楚模式串位置 j 的含义吧,在这基础上在弄清回退的含义。

让我们找到最开始进行匹配的图。

此时 j = 0,还未开始匹配,也就是没有字符串匹配成功。

添加图片注释,不超过 140 字(可选)

当第一个字符匹配成功后,j 后移一位。此时 j = 1,已经匹配成功了一个字符。

添加图片注释,不超过 140 字(可选)

当 j = 2 时,已经有 2 两个字符成功匹配;

当 j = 3 时,已经有 3 个字符成功匹配;

......

当 j = 6 时,已经有 6 个字符成功匹配了。

添加图片注释,不超过 140 字(可选)

有什么想法吗?

模式串的数字 j 其实代表一种匹配程度的状态,代表位置 j 之前已经有 j 个字符匹配成功了。

模式串的数字 j 其实代表一种匹配程度的状态,代表位置 j 之前已经有 j 个字符匹配成功了!

那回退意味着什么?

回到位置 j,意味着回到状态 j,意味着要让模式串有 j 个字符能成功匹配。

哪 j 个字符呢?那不就是 j 之前的 j 个字符嘛(别忘了下标从 0 开始的)。

还是晕,不如把刚刚 j = 5 时匹配失败那张图拿过来吧,此时这轮匹配走不下去了,需要回退 j。

添加图片注释,不超过 140 字(可选)

再强调一次,回退到位置 j,代表着回到有 j 个字符匹配成功的状态。

这时候模式串中有个聪明的小弟 a 说话了,“大哥,你看最前面那个家伙和我长得一模一样,我在这能匹配成功,它肯定也行啊,我的长度是 1,代表着有一个字符能匹配成功,所以回退到 j = 1 吧”。

咦?回退到位置 1,有 1 个字符匹配成功,好像符合要求。

模式串一尝试,确实能保证 j 之前有一个字符匹配成功,这样一来,模式串再和主串比较时,只需要比较 j>=1 部分的字符了,前面的 j 个已经确定能匹配成功了。

回退到 j = 1,有一个字符匹配成功

这时候,另一个小弟 ab 也说话了,“大哥,我也发现了,最前面有个家伙 ab 和我一模一样,我现在能匹配成功,那它肯定也可以,我的长度是 2,所以我们能保证 2 个字符匹配成功,回退到 j = 2吧”。

模式串一尝试,靠,j = 2 前面的两个字符居然没匹配成功,小老弟,怎么回事?

添加图片注释,不超过 140 字(可选)

其实小弟不是有意的,问题出在这个小弟 ab 自身上,小弟 ab 和匹配失败的位置 j = 5 没有紧邻啊,这是必须的,不然位置对不齐。从图里应该很直观的看到吧,回退到 j = 2 后,“前面的家伙 ab”和“小弟 ab”错位了。

这就是为什么教材上说,需要用 j 之前子串的前缀和后缀来比较,“小弟 ab”并不是 j<5 部分子串的后缀,所以它出问题了。

顺便说一下前缀和后缀。对于字符串 ababa,它的前缀不能包含最后一个字符,有 a, ab, aba, abab;它的后缀不能包含第一个字符,有 a, ba, aba, baba。

针对我们上图 j = 5 时匹配失败的场景,必须要求“小弟”是绿色部分的后缀,而“前面的家伙”是绿色部分的前缀。

你可能有疑问,为什么是绿色的部分呢?

因为绿色部分的字符都是已经完全和主串匹配上了的,这里的每一个小弟都能保证和主串匹配,那当发现“前面的家伙”和某个小弟长一样,前面的自然也能和主串匹配。

“我悟了,大哥,只有贴着红色块的小弟才是好小弟。回退到 j = 3 吧,我的长度是 3, 而且我紧挨着红色块是后缀!最前面有个家伙 aba 和我一样,当它移到我的位置肯定能匹配上,能保证有 3 个字符被匹配”,小弟 aba 惊喜的说道。

好小弟 aba,紧挨红色块是后缀,长度是 3,回退到 j = 3,没毛病。能满足回退到位置 j,代表着回到有 j 字符匹配成功的状态。

回退到 j = 3,有 3 个字符匹配成功

那现在回到 j =1 和 j = 3 都能满足有 j 个字符匹配成功,我们要回退到哪里呢?

你居然问我这个问题?肯定是要多的啊,本着少回退、少比较的原则,我们自然选择回退到 j = 3,那可是已经有 3 个字符匹配成功不用再比较了啊,谁 tm 喜欢多干活!

好勒,现在我们知道了,当模式串在位置 j 匹配失败时,要进行回退,要回退到最大的那个位置,回到最大匹配程度的状态。

那得先找到最大的那个位置吧,我们刚刚是在绿色部分找“最长的后缀小弟”,要求小弟在最前面有个一模一样的家伙(前缀),专业的说法就是寻找相等前、后缀的最大长度。

对于模式串,我们找出每个位置之前的子串的最大相等前后缀长度,把它们先记录到一个表里,等用的时候再查就能做到快速回退,这个表就是 next 数组。

我已经迫不及待的想求 next 数组了,但缓一缓,先梳理一下整个匹配的过程,假设我们现在已经有了 next 数组吧,我们该怎么做呢?

有点急,所以直接上代码吧。

/*
 * 主串是 haystack,模式串是 needle,leetcode 上直接复制过来的
 */
int strStr(string haystack, string needle) {
    /*
     * 下面两行计算 next 数组,现在不关注
     */
    int next[needle.size()];
    getNext(needle, next);

    int j = 0; // 模式串下标 j 初始为 0
    // 主串下标 i 初始也为 0
    for (int i = 0; i < haystack.size(); ++i) {
        /*
         * 当某个位置不匹配时,模式串需要回退
         * j == 0 时都匹配不成功,那么主串下标 i 就需要后移了,所以有 j>0
         */
        while (j > 0 && haystack[i] != needle[j]) {
            j = next[j];
        }
        /*
         * 匹配成功时,主串和模式串都后移,主串下标 i 在 for 循环头部后移
         */
        if (haystack[i] == needle[j]) {
            j++;
        }
        /*
         *  模式串下标为 needle.size(),意味着已经有 needle.size() 个字符匹配成功了
         *  那就是全部匹配成功了
         */
        if (j == needle.size()) {
            return (i - needle.size() + 1);
        }
    }
    return -1;
}

注释的应该还算详细,但还是强调一下:

  • j = next[j]; 回退操作是放在一个 while 循环里的。这是因为回退到位置 j,只是保证了 j 之前的部分是完全和主串匹配的,但第 j 个字符可没有保证,如果 needle[j] 还是不能和主串匹配,那就需要继续回退;
  • j == needle.size(),表示 needle.size() 个字符已经匹配成功了,也就是全部匹配成功,返回这次匹配时主串的起始位置。

如果整个主串都遍历结束了,j 还是没有等于 needle.size(),此时的最后的 j 是否有意义呢?

没有,但匹配过程中 j 值最大的一次有意义,它表示主串能包含的模式串最长前缀。比如下面的模式串在匹配某个主串的过程中,出现的 j 最大值是 3,那就表示该主串最多能包含该模式串的前 3 个字符。换句话说,如果用模式串的不同前缀来和主串做匹配,那能匹配成功的最长前缀是 3。

留意一下这个说法,我们计算 next 数组时会用到。

添加图片注释,不超过 140 字(可选)

4、next 数组的计算

终于要计算 next 数组了,现在我们再强调一下几个重要信息,这是我们的立足点。

  1. 模式串的位置 j 代表一种匹配程度的状态,代表模式串已经有 j 个字符匹配成功了;
  2. next[j] 的作用是模式串在位置 j 匹配失败时,j 可以回退到 next[j],也即 j = next[j],回退到 next[j] 代表回退到模式串有 next[j] 个字符匹配成功的状态。
  3. next[j] 的值表示模式串在下标 j 之前的部分,最长的相等前、后缀长度是 next[j],求 next[j] 也是通过求最长相等前、后缀来进行的。

根据第 3 点,现在我们要做的是求相等前后缀的最大长度。对于模式串 ababaa,假设我们现在要求 next[5],那我们需要考虑的是 [0, 4] 间的字符串。

j = [0, 4] 部分的字符串

前缀不能包含最后一个字母只能使用如下的字符。

j = [0, 4] 部分的最长前缀

后缀不能包含第一个字符,使用的字符如下。

j = [0, 4] 部分的最长后缀

我们的做法是用最大长度的前缀串来和最大长度的后缀串做匹配,如果匹配成功了,该最大长度就是我们的 next[j] 值;但如果匹配不成功,那过程中出现的最大匹配长度就是要寻找的 next[j] 的值了,这个最大匹配长度既是前缀的长度也是后缀的长度。

等等,前面这段话怎么有些眼熟?

添加图片注释,不超过 140 字(可选)

我们是不是把上图那段中的主串换成后缀串就能求 next 数组了!所以 next 的求法,就是把子串的最长前缀串当做模式串,最长后缀串当做主串来做匹配。

好,下面我们来操作一下,对于模式串 ababaa。

求 next[0]:

j = 0 之前没有子串,前后缀不存在,所以能匹配的前后缀长度显然是 0,next[0] = 0。

求 next[1]:

j = 1 之前就一个字符 a,前缀和后缀长度都是 0,所以 next[1] = 0。

添加图片注释,不超过 140 字(可选)

求 next[2]:

j = 2 之前子字符串的前缀只有 a,后缀为 b,匹配失败所以 next[2] = 0。(前缀串做模式串时的下标用 jj,为了与 j 区分。)

添加图片注释,不超过 140 字(可选)

求 next[3]:

j = 3 之前子字符串的最长前缀为 ab,最长后缀为 ba。我们直接用它们做匹配,最后发现只能匹配上一个字符,所以 next[3] = 1;

添加图片注释,不超过 140 字(可选)

求 next[4]:

j = 4 之前子字符串的最长前缀为 aba,最长后缀为 bab。直接用它们做匹配,最后发现能匹配上两个字符,所以 next[3] = 2;

添加图片注释,不超过 140 字(可选)

求 next[5]:

j = 5 之前子字符串的最长前缀为 abab,最长后缀为 baba。直接用它们做匹配,最后发现能匹配上三个字符,所以 next[5] = 3;

添加图片注释,不超过 140 字(可选)

至此 next 数组全部求解完毕。这个例子证明了求模式串的数组 next[j] 的值过程,就是用 j 之前的最长前缀子串做模式串,最长后缀子串做主串的 kmp 匹配过程。

好,做 kmp 过程的代码我们在上一节就写出来了,现在又有何难。

void getNext(string needle, int* next) {
    int size = needle.size();
    next[0] = 0; // 0 之前没有子串,自然没有长度相等的前、后缀
    if (size == 1) return; // 模式串就一个字符的话,没必要进行后续步骤
    next[1] = 0; // 下标 1 前面就一个字符,前缀和后缀长度都为 0
    /*
     * slow 用来表示前缀串的下标;也是内部 kmp 的模式串下标
     */
    int slow = 0;
    /*
     * fast 表示后置串的下标,为什么从 1 开始呢,因为后缀串不能包含第一个字符;也是内部 kmp 的主串下标
     */
    for (int fast = 1; fast < size - 1; ++fast) {
        /*
         * 当主串和模式串对应位置字符不相等时,需要进行回退
         * 模式串下标 slow 进行回退,回退到 next[slow]
         * 直到 slow == 0 为止
         */
        while (slow > 0 && needle[fast] != needle[slow]) {
            slow = next[slow];
        }
        /*
         * 如果主串和模式串对应位置相等,那么 fast 和 slow 都 +1
         * fast 的 +1 在 while 循环的头里
         */
        if (needle[fast] == needle[slow]) {
            slow++;
        }
        /*
         * 还记得 slow 是什么吗,slow 是前缀串(模式串)的下标,表示已经有 slow 个字符匹配成功了
         * 匹配成功了?那就意味着最长相等前后缀长度是 slow
         * 更具体就是说在下标 [0, fast] 的子串里,最长相等前后缀的长度是 slow
         * 这不就是 next[fast+1] 的含义吗
         */
        next[fast + 1] = slow;
    }
}

虽然代码里已经加了详细的注释,但这里还是再强调几个地方:

  • 求 next 数组,其实就是用模式串不同位置之前的最长前缀做模式串,最长后缀做主串的又一次字符匹配,我们的代码也是秉承这一思想。slow 代表的区间是 [0, slow], 表示前缀串的下标,同时也是模式串的下标,模式串的下标还表示已经匹配成功了 slow 个字符;fast 表示的区间是 [1, fast],是后缀串的下标,同时也是主串的下标。
  • slow = next[slow] 这一步回退,没有太多描述,因为我们在前面已经多次强调,next[slow] 表示在第 slow 个字符匹配失败时,模式串下标应该回退的位置,同时也意味着下标 slow 之前的子串里最长相等前、后缀的长度为 next[slow]。
  • next[fast+1]= slow。还记得我们前面的表述吗,next[j] 的值是下标 j 之前的字符里,最长相等前后缀的长度。那这里换一下说法就是“next[fast + 1] 的值是下标 fast+1 之前的字符里,最长相等前后缀的长度”,而此时前缀串(模式串)的下标是 slow,代表已经成功匹配了 slow 个字符,相应的前缀串长度是 slow,所以相等前、后缀的长度自然也是 slow。

确实有点绕,我们再回顾一下关于 next 数组和模式串的信息,这其实就是一切的依据。

  1. 模式串走到下标 j,表是已经有 j 个字符匹配成功了;
  2. next[j] 表示 j 之前的字符里,最长相等前后缀的长度是 next[j];
  3. 当模式串走到下标 j 匹配失败时,应该回退到 next[j]。

最后,给出完整的全部代码。

void getNext(string needle, int* next) {
    int size = needle.size();
    next[0] = 0; // 0 之前没有子串,自然没有长度相等的前、后缀
    if (size == 1) return; // 模式串就一个字符的话,没必要进行后续步骤
    next[1] = 0; // 下标 1 前面就一个字符,前缀和后缀长度都为 0
    /*
     * slow 用来表示前缀串的下标;也是内部匹配的模式串下标
     */
    int slow = 0;
    /*
     * fast 表示后置串的下标,为什么从 1 开始呢,因为后缀串不能包含第一个字符;也是内部匹配的主串下标
     */
    for (int fast = 1; fast < size - 1; ++fast) {
        /*
         * 当主串和模式串对应位置字符不相等时,需要进行回退
         * 模式串下标 slow 进行回退,回退到 next[slow]
         * 直到 slow == 0 为止
         */
        while (slow > 0 && needle[fast] != needle[slow]) {
            slow = next[slow];
        }
        /*
         * 如果主串和模式串对应位置相等,那么 fast 和 slow 都 +1
         * fast 的 +1 在 while 循环的头里
         */
        if (needle[fast] == needle[slow]) {
            slow++;
        }
        /*
         * 还记得 slow 是什么吗,slow 是前缀串(模式串)的下标,表示已经有 slow 个前缀字符匹配成功了
         * 匹配成功了?那就意味着最长相等前后缀长度是 slow
         * 更具体就是说在下标 [0, fast] 的子串里,最长相等前后缀的长度是 slow
         * 这不就是 next[fast+1] 的含义吗
         */
        next[fast + 1] = slow;
    }
}

/*
 * 主串是 haystack,模式串是 needle,leetcode 上直接复制过来的
 */
int strStr(string haystack, string needle) {
    /*
     * 下面两行计算 next 数组,现在不关注
     */
    int next[needle.size()];
    getNext(needle, next);
    int j = 0; // 模式串下标 j 初始为 0
    // 主串下标 i 初始也为 0
    for (int i = 0; i < haystack.size(); ++i) {
        /*
         * j == 0 时都匹配不成功,那么主串下标 i 就需要后移了;
         * 不匹配时,模式串回退
         */
        while (j > 0 && haystack[i] != needle[j]) {
            j = next[j];
        }
        /*
         * 匹配成功时,主串和模式串都后移,主串下标 i 在 for 循环头部后移
         */
        if (haystack[i] == needle[j]) {
            j++;
        }
        /*
         *  模式串下标为 needle.size(),意味着已经有 needle.size() 个字符匹配成功了
         *  那就是全部匹配成功了
         */
        if (j == needle.size()) {
            return (i - needle.size() + 1);
        }
    }
    return -1;
}

可 AC。

添加图片注释,不超过 140 字(可选)