KMP算法(通过例题深入图解KMP算法,有自己独到的理解)

"The only way to do great work is to love what you do." 

​- Steve Jobs

首先我们先来回顾一下题目:

1. 先正儿八经的拿比较官方的说法来解释一下KMP算法

KMP(Knuth-Morris-Pratt)算法是一种用于在一个文本串中查找一个模式串的高效字符串匹配算法。该算法的关键在于避免在搜索过程中对已经比较过的字符进行重复的匹配。它通过构建一个部分匹配表(Partial Match Table)来实现这一目标。以下是KMP算法的详细解释(别急着划走,先看看正规解释我在引入浅显易懂的解释,绝对能让你对该算法有一个非常清晰的认识)

  • 部分匹配表(Partial Match Table)的构建: 

  • 定义: 部分匹配表记录了模式串中前缀和后缀的最长匹配长度

  • 构建过程: 对于模式串中的每个位置,计算其前缀的最长匹配后缀长度。这可以通过迭代比较模式串中的字符来完成。

    例如,对于模式串 "ABABC", 部分匹配表PMT为:

    ‍ABABC
    00120

    这表示在每个位置上,它的前缀和后缀的最长匹配长度。

  • 搜索过程:

  • 匹配过程: 从文本串的开头开始,逐字符比较文本串和模式串。当字符不匹配时,根据部分匹配表决定模式串的滑动位置。

  • 滑动位置的决定: 部分匹配表告诉我们模式串在当前位置发生不匹配时,可以滑动的最大距离。这样,不必从头重新比较已经匹配的部分。

KMP算法通过构建部分匹配表,使得在搜索过程中可以有效地避免不必要的比较,从而提高了字符串匹配的效率。

2. 知识引入

2.1 为什么需要KMP算法

有时候看太多的文字,可能会比较难以理解算法的精髓,所以说我将结合例子来进行讲解。假设我们需要在一个字符串中找到下面所示的目标字符串:mumumubm(记住是目标字符串)

现在假设我有一个测试用例:mumumumubm,我想要找到目标字符串,按照原始的方法,那就是从前往后,一次次从当前点往后尝试匹配目标字符串,如果发现匹配不成功就从下一个位置开始,如下图:

但是我们很容易发现,这种方法并没有充分利用已经扫描过的内容的信息,比如看下图:

当我们从0扫描到haystack也就是测试用例的6号下标发现与需要找的目标needle不匹配,就需要继续回头从1号位继续去匹配,就是说扫描过的信息也就是0-5的信息如果失败我就不要了,这相当于是对信息的浪费,如果不想让信息被浪费,那就可以用空间换时间。如何空间换时间呢?用什么换呢?这就是KMP算法优化该问题的核心。

在引入之前首先提一个概念前缀与后缀,同时为了符合标准的术语,后面我们就将需要找的目标命名为模式串

2.2 前缀与后缀

假设我们有一个模式串为 "ABABC",在模式串 "ABABC" 中,我们可以找到如下的前缀和后缀:

  • 前缀:

    • "A"

    • "AB"

    • "ABA"

    • "ABAB"

  • 后缀:

    • "C"

    • "BC"

    • "ABC"

    • "BABC"

  • 前缀后缀集合

    • {"A", "AB", "ABA", "ABAB"}

    • {"C", "BC", "ABC", "BABC"}

我想大家也都看出来了啥是前缀,啥是后缀了。正规一点的解释为:

  • 如果字符串A和B,存在A=BS,其中S是任意的非空字符串,那就称B为A的前缀。

  • 如果字符串A和B,存在A=SB,其中S是任意的非空字符串,那就称B为A的后缀。

  • 要注意的是,字符串本身并不是自己的后缀,还有注意一下后缀的顺序,是从前向后读。

3. 如何空间换时间呢?——部分匹配表(Partial Match Table)

部分匹配表PMT是什么?PMT中的值是字符串的前缀集合与后缀集合的交集中最长元素的长度

示例1:对于”aba”,

它的前缀集合为{”a”, ”ab”}

后缀集合为{”ba”, ”a”}

两个集合的交集为{”a”}

那么长度最长的元素就是字符串”a”了,长度为1,所以对于”aba”而言,它在PMT表中对应的值就是1。

示例2:对于字符串“ababa”,

它的前缀集合为{”a”, ”ab”, ”aba”, ”abab”}

它的后缀集合为{”baba”, ”aba”, ”ba”, ”a”},

两个集合的交集为{”a”, ”aba”}

其中最长的元素为”aba”,长度为3,所以对于”ababa”而言,它在PMT表中对应的值就是3。

这时候在回头看看前面正规解释的示例对于模式串 "ABABC", 部分匹配表PMT为:

字符串:ABABC
PMT数组00120

这表示在每个位置上,它的前缀和后缀的最长匹配长度。我们详细看一下每个数字怎么得出来的。从前向后看:

  • 首先是A,前缀集合{},后缀集合{},集合交集为空,所以PMA[0] = 0

  • 再看“AB”,前缀集合{A},后缀集合{B},集合交集为空,所以PMA[1] = 0

  • 再看“ABA”,前缀集合{A, AB},后缀集合{A, BA},集合交集为{A},A长度为1,所以PMA[2] = 1

  • 再看“ABAB”,前缀集合{A, AB, ABA},后缀集合{BAB, AB, B},集合交集为{AB},AB长度为2,所以PMA[3] = 2

  • 再看“ABABC”,前缀集合{A, AB, ABA, ABAB},后缀集合{BABC, ABC, BC, C},集合交集为{},所以PMA[4] = 0

那这个PMT数组求出来有什么用呢?

先别急,先把PMT数组先求出来,就是我们最开始的测试用例中模式串为mumumubm,求出PMT数组,注意PMT数组是针对要查找的needle字符串(模式串)mumumubm的PMT数组,不是haystack字符串。求出来结果如下:

现在来思考这个PMT数组究竟有什么用?现在我将用不同测试用例让你深刻理解PMT数组的作用,在下面的例子中,我将模式串needle假设始终为mumumubm,测试用例haystack不同,这样可以更好的看出PMT的作用。(也就是针对同一个PMT数组看它在不同情况下能发挥什么作用)

  • 假设测试用例haystack=“Aumumumubm

那么显然在第一个字母也就是haystack[0]就不匹配。此时PMT数组对应的下标为0。要我们人来看的话下一次还是得从haystack[1]进行匹配。

  • 假设测试用例haystack=“mAmumumubm

那么显然在第二个字母也就是haystack[1]不匹配。此时PMT数组对应的下标仍然为0。(此时注意:我们已经遍历了haystack的第一个字母发现m与needle的第一个字母m是匹配的,我们才会去看haystack的第二个字母。) 此时按照人的逻辑——也就是最高效的逻辑,也可以把它理解为KMP的逻辑,我们还是要从haystack[2]开始匹配,也就是对照haystack[2]和needle[0],并向后匹配。

  • 假设测试用例haystack=“muAumumubm

那么显然在第三个字母也就是haystack[2]与needle[2]不匹配,此时PMT数组对应的下标为1。那按照最省事的逻辑(KMP逻辑),我们还是需要从haystack[3]开始和needle[0]尝试匹配,似乎PMT数组为1没什么用?再往后看。

  • 假设测试用例haystack=“mumAmumubm

那么显然在第四个字母也就是haystack[3]与needle[3]不匹配,此时PMT数组对应的下标为2重点:

    • 因为如果按照常规的方法,我们就是从haystack[1]继续开始尝试和needle[0]匹配

    • 但是此时我们可以利用mum的前缀和后缀的最长公共元素“m”的长度为1的性质,就可以直接用当前haystack[3]的“A”字符去匹配needle[1]的“u”字符看是否能成功。

    • 相当于省去了haystack回退到haystack[1]和needle回退到needle[0]的时间浪费。

    • 那按照最省事的逻辑(KMP逻辑),我们可以发现在haystack已经扫描过的mumA中,因为mum的PMT对应的值为1,也就是前缀集合{m, mu}和后缀集合{um, m}的最长公共元素m的长度为1——也就是说我把mum从前向后遍历长度为1和从倒数第1个位置遍历到结尾是相同的。那我们是不是就可以利用这个性质,省去对needle[0]的检查——因为我们已经匹配过了字符串mumA,其中mum都是相同的,只是{haystack[3]=A}≠{needle[3]=u}。

  • 在看一个例子:假设测试用例haystack=“mumuAumubm

在第五个字母也就是haystack[4]与needle[4]不匹配,此时PMT数组对应的值为3。此时按照上面的解释,发现匹配失败该如何做呢?

    • 因为已经匹配过了mumu都是相等的,而且“mumu“的PMT数组对应的值为2,所以我们只需要用当前字符也就是haystack[4]=”A“尝试和needle[2]=”m“进行匹配,因为”mumu“的前缀和后缀集合分别为{mum, mu, m}和{umu, um, u},最长公共元素”mu“长度为2

    • 有没有发现点什么?没发现规律再来一次。

  • 假设测试用例haystack=“mumumAmubm

在第六个字母也就是haystack[5]与needle[5]不匹配,此时PMT数组对应的值为4。继续来看已经匹配过的字符串”mumum“,它的前缀与后缀集合为{mumu, mum, mu, m}和{umum, mum, um, m}最长交集为 mum长度为3。

  • (有没有发现我当前位置如果匹配不成功,我看的是前面的已经匹配的字符串的公共前后缀的长度,也就是当前位置 - 1处的PMT的值) 因为PMT[5 - 1] = 3,那么说明我就不需要去匹配needle的前3个字符”mum“了,只需要从needle[3],也就是第四个字符和当前的haystack[5]进行比较。

  • (可以看看上图,当前haystack匹配不成功的haystack[5]=A和needle[5]=u不匹配,也就是我尝试发现和needle的第六个字母不匹配。而haystack[5]的前面PMT[5 - 1] = 3个字符是”mum“,也是needle的前缀”mum“,那就是说我可以直接跳过对needle前三个字母mum的审查,直接用当前的haystack[5]=A和needle[3]=u匹配看是否能成功)

总结一下

所以这里我们可以发现,如果当前位置haystack[x]与needle[y]匹配失败,那么needle[0]—到—needle[y - 1]肯定是和haystack[x - y]—到—haystack[x - 1]是相等的(y为已经匹配过的needle的长度)。而根据needle[y - 1]处的PMT表对应的值,就可以知道我有哪些部分不需要重新比较了,也就是直接从needle【PMT[y - 1]】处的值和和当前的haystack[x]比较。

  • 如果成功继续向后比较,直到needle到结尾都相等那当时的haystack[x - y]就是我们要找的位置。

  • 如果失败那么继续按照刚才的说法,令P = PMT[y - 1] - 1,那么needle[0]—到—needle[P]肯定是和haystack[x - P]—到—haystack[x - 1]是相等的。而根据needle[P - 1]处的PMT表对应的值,就可以知道我有哪些部分不需要重新比较了,也就是直接从needle【PMT[P - 1]】处的值和和当前的haystack[x]比较。

现在我想大家对于KMP算法的基本思想应该有所了解了。核心就分为两个步骤

  • 求出needle的PMT数组

  • 遍历haystack数组,进行匹配,匹配成功就往后走,匹配失败,就看对应的PMT数组的前一个位置的值,也就是表明了我不需要重新匹配的部分的长度,然后用当前位置和不需要重新匹配部分的下一个字符进行比较。

而该算法的精髓精髓精髓:利用了构建PMT时,每个位置的PMT值为从开始到当前位置的字符串的前缀和后缀的最大匹配长度。而这个最大匹配长度实际上就是靠前缀集合和后缀集合的比较,其实本质也就相当于我们最开始上一篇讲的解法中每次haystack匹配不成功向后移动一位的解法,只不过通过一个PMT表把它记录了下来。

可能这段话并不是特别容易理解,接下来请看下面的图示:

假设我在haystack[x+5]和needle[5]处匹配失败了,因为needle[5]的对应的前一个PMT数组值为PMT[4] = 3,这表示字符串 mumum的前缀集合{mumu, mum, mu, m}与后缀集合{umum, mum, um, m}的交集为{mum, m},其中最长的为mum长度为3,这我们前面都讲过。我们现在主要来思考一下这个前缀与后缀集合求交集的最大长度对应到我们的搜索过程的实际意义到底是什么。

按照上一篇文章讲的解法,我们需要从haystack[x+1]开始重新进行匹配。

可以看到在第一个位置就会失败,而这不就是前缀集合中的{mumu}和后缀集合的{umum}进行比较吗?在这种情况下,如果按照我们上一篇讲的解法我们继续从haystack[x+2]开始进行匹配。

]这时候不就是前缀集合中的{mum}和后缀集合中的{mum}进行匹配吗,发现这二者匹配成功了,然后看haystack[x+5]和needle[3]的字符。但是这时候还是不行,那按照上一篇文章讲的解法(也就是朴素解法一个一个比)还是继续往后走。

发现不能匹配成功,这不就是前缀集合中的{mu}和后缀集合中的{um}进行匹配的过程吗?再按照朴素解法再往后走:

这时不也是前缀集合中的{m}和后缀集合中的{m}进行匹配的过程吗?也是能匹配成功看到这我想大家应该会更加理解为什么PTM能帮助我们减少时间复杂度,就是因为通过这个表,我们可以减少一些无效的起始位置。

至于说为什么要在前缀集合和后缀集合中找出最长的部分的长度作为当前位置的PMT值,我们再来探究一下,我们把PMT数组也加上看一看:

匹配失败,按照KMP解法,找到当前位置前一个位置的PMT值,可以看到等于3,实际上就是已经匹配的needle部分:mumum的前缀与后缀集合的交集={mum, m}中的最长交集 mum的长度,而这个交集的意思其实就像我上面解释的两个匹配成功的部分。按照KMP算法,我只需要匹配haystack[x+5]和needle[3]:

如果我的最长交集mum在后续的匹配还是不成功,那么就会继续找当前位置,也就是needle[3]的前一个位置的PMT值,也就是PMT[2]=1,那就是相当于用当前haystack[x+5]匹配needle[1]看是否能成功:

而这不就是相当于对于 mumum的前缀和后缀的交集{mum, m}的两次尝试吗?

到这里应该对于PMT的作用了解更深入了。其实PMT表的本质就是对于匹配过程的巧妙省略,消除了无效的起始点的开销,同时简化了有效起始点也就是交集的部分的匹配过程(不需要对已经走过的部分再去回头匹配)。通过找到最长前后缀交集,不断去匹配,如果最长前后缀交集的匹配失败,就从第二长前后缀交集去匹配,再失败就从第三长前后缀交集去匹配……一直到尝试过了所有的前后缀交集,如果还是不成功就从haystack的下一个位置去尝试。

所以上述过程的代码可以按照如下方式:

public static int strStr3(String haystack, String needle) {
    if (needle.isEmpty()) return 0;
    // 分别读取原串和匹配串的长度
    int haystackLen = haystack.length();
    int needleLen = needle.length();
    // 有的解释中也会把它叫next数组,其实没什么区别只不过为了编程方便把PMT数组右移了一		 位, 然后把PMT[0] = 0
    // 我这还是按照我文章中讲的来,不对PMT数组进行移位,但是在实际使用的时候记着用的是当		前位置-1的PMT的值
    // 并且最后一个PMT值是用不到的,因为最后一个PMT值是在他后面的位置的元素匹配失败才会		 用的,但已经是最后一个元素了,所以用不到
    int[] PMT = new int[needleLen];
    // ===============先空着===============
    // 1. 构造PMT数组
    // ===============先空着===============
    // 2. 匹配过程
    for (int i = 0, j = 0; i < haystackLen; i++) {
        // while相当于当前位置匹配失败了,而对于已经扫描过的和目标字符串匹配的字符串
        // 的前缀和后缀的交集{xxxx, xxx, x}的不断尝试
        // 也就是对有效起始点也就是交集的部分的匹配过程
        while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
            j = PMT[j - 1];
        }
        // 如果匹配成功,那么j向后移动一位,尝试匹配下一个位置
        if (haystack.charAt(i) == needle.charAt(j)) {
            j++;
        }
        // 当j等于needleLen的时候,说明needleLen个字符匹配成功,那也就是整个字符串完			全匹配
        // 就可以返回匹配的起始位置
        if (j == needleLen) {
            return i - needleLen + 1;
        }
    }
    //没有找到匹配的字符串,返回-1
    return -1;
}

4. 求PMT数组

下面讲一下编程实现查找一个字符串的PMT数组。因为PMT数组每一项的定义就是从开始到当前的位置这一串字符串最长的前缀和后缀交集的元素长度。那么我们就可以把给定的字符串从第二位开始(因为第一个字符本身并不能得出其前缀与后缀,所以肯定为0)和它的头部进行匹配,也就相当于前缀和后缀的匹配了。如下图:

匹配不成功就记为0,以下一个位置为起点,向后移动:

匹配成功,记为1,起点不动,继续向后匹配:

成功,继续向后匹配:

成功,继续向后匹配:

还是成功,继续向后匹配:

这时我们发现前缀和后缀不一样了,如果现在我们把需要找的目标needle当作haystack,把与原needle匹配的当作新的needle,那是不是又相当于我们的初始问题,就像本来的题目一样?如上图左侧红字部分。

针对上图而言,其实就是当前位置haystack[6]和needle的[4]匹配不成功,我们就需要找对应needle的PMT数组的[4 - 1]处的值作为下一次比较的值,也就是2,也就是比较haystack[6]=b尝试和needle[2]=m进行比较

我们发现还是不相等,就继续看needle当前位置前一个位置的PMT数组的值,也就是PMT[1]=0,这时就相当于用haystack[6]=b和needle[0]=m进行匹配如下的匹配:

发现还是不成功,那此时就可以确定当前位置的PMT值为0了。然后再从下一个位置开始继续计算。

也就是说我在求PMT数组的当前值的时候,我还可能会用到PMT数组的前面的值,来帮助我更快速的求出,本质上还是一个字符串的匹配,还是很有意思的。我求PMT是为了更快的进行字符串的匹配,但是我要得到PMT还得用之前PMT的值帮助我更快的得出当前的值。不过仅仅是相当于起加速作用,就如我前面说的:消除了无效的起始点的开销,同时简化了有效起始点也就是交集的部分的匹配过程(不需要对已经走过的部分再去回头匹配)

现在可以根据上述步骤写出代码:

    // 根据needle获取PTM数组
    public static int[] getPTM(String needle) {
        int[] PMT = new int[needle.length()];
        // 第一个位置置为0
        PMT[0] = 0;
        int i = 1, j = 0;
        // 从第i=1开始向后计算PMT
        while (i < needle.length()) {
            // 如果匹配,就让PMT[i]=当前能够匹配到第几个
            if (needle.charAt(i) == needle.charAt(j)) {
                ++j;
                PMT[i] = j;
                ++i;
            } else {
                // 防止当第二个字符位置不相等时,j-1<0 数组越界
                if (j < 1) {
                    PMT[i] = 0;
                    i++;
                    j = 0;
                } else {
                // 还是相当于遍历前缀和后缀集合的交集,看不懂的可以自己设置一个needle					  值手算一下再debuge一下
                    j = PMT[j - 1];
                }
            }
        }
        return PMT;
    }

最终运行结果:

5. KMP算法复杂度分析

时间复杂度:n 为原串的长度,m 为匹配串的长度。复杂度为 O(m+n)。空间复杂度:构建了 PMT 数组。复杂度为 O(m)。

以上内容均来自本人公众号,专注于最详细易懂的算法解析

  • 44
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值