数据结构(六)——KMP算法详解

前言

这周在学数据结构常见算法时遇到了很多的坑,真的是一步一个坑,先是背包问题,又是这个KMP算法,听的是尚硅谷韩老师的java数据结构与算法,他刚开始讲的算法思路还是听清晰的,但是一到代码上…
一言难尽。KMP最核心的一部分十分难理解,韩老师也是一笔带过,我搜了许多篇博客,专门为此算法做一个总结。

KMP算法所解决问题

KMP是为了解决字符串匹配问题,如判断一个字符串str1= “ABCDAB ABCDABCDABDE” 是否包含另一个字符串str2 = “ABCDABD”,并找到其位置。

关于字符串的查找算法又很多种实现方式,大家最容易想到的应该是暴力匹配算法。

暴力匹配思路

其思路就是,i 和 j 从左到右进行匹配。

  1. 我们定义两个变量指针 ,一个指针 i 指向 str1 的起始字符,另一个指针 j 指向 str2 的起始字符

在这里插入图片描述

  1. 如果相等 i 和 j 就往下一个字符移动进行对比
    在这里插入图片描述
  2. 如果不相等,让 j 回到 0 ,i 回到 i - j + 1的位置,就是把之前比较过的数都回退,i 从下一个数,j 从 0 ,继续比较。
    在这里插入图片描述
    不相等回退:
    在这里插入图片描述
    4.直到 j 的值与 str2 的长度相等,说明已经找到对应的字符串
    在这里插入图片描述

代码实现

package com.atguigu.Algorithm.KMP;

/**
 * @author lixiangxiang
 * @description KMP 暴力匹配
 * @date 2021/8/13 16:25
 */
public class ViolentMatch {
    public static void main(String[] args) {
        String str1 = "ABCDAB ABCDABCDABDE";
        String str2 = "BCDABC";
        int i = 0;
        int j = 0;
        while (i < str1.length() && j < str2.length()) {
            //如果两字符相等,继续往下比
            if (str1.charAt(i) == str2.charAt(j)) {
                i++;
                j++;
            } else {
                //如果不相等,i j 回退
                i = i -j + 1;
                j = 0;
            }
            if (j == str2.length()) {
                System.out.println("str2 在str1 中的起始位置为 "+ (i - j));
                return;
            }
        }
        System.out.println("未找到str2");
    }
}

KMP 算法思想

之前的想法虽然可以解决改类问题,但实在是太暴力了,算法时间复杂度很高。而KMP 是一个解决模式串在文本串是否出现过,如果出现过,最早出现的位置的经典算法。我们可以看下我们之前算法的问题。

发现规律

当我们的 j 第一次查找到D时,我们发现最后一位和 i 对应的字符不一样,没办法我们只能回溯,而我们直接将 i 回溯到了第二个元素B的位置,
在这里插入图片描述
但如果要我们自己考虑这个问题,我们绝不会把i回溯到 B。 因为我们知道,如果 i 从B、C、D开始绝对不可能跟str2的 j 相等的。
在这里插入图片描述
我们会选择从第五个元素A开始。
在这里插入图片描述
一个基本事实是,当空格与D不匹配时,你其实知道前面六个字符是"ABCDAB"。KMP算法的想法是,利用已经部分匹配这个有效信息,保持i指针不回溯,通过修改j指针,让模式串尽量地移动到有效的位置
所以,整个KMP的重点就在于当某一个字符与主串不匹配时,我们应该知道j指针要移动到哪?

如上述例子 我们 i 所在位置为空格,j 所在位置为D时
在这里插入图片描述
空格和D不匹配了,我们 j 应该移动到哪呢,正确答案是移动到第三位,我们可以观察 i 紧邻的 AB 与 i 紧邻的AB 相等 ,所以我们可以将 j 移动到第三位。

在这里插入图片描述

我们继续往下比较发现 C 和 空格还是不匹配,那么此时我们的 j 应该移动到哪呢。我们往下看发现j 之前已经没有字符能与 i 之前和AB之后的字符匹配了(其实现在就剩 i 一个了)。 所以我们只能将 j 移动到最开头的位置 。
在这里插入图片描述
再继续的话 j 已经没法再往前移动了,这时候只能是 i 往后移。
在这里插入图片描述
j 一直往下判断都匹配,得到我们想要的字符串位置开头就在 i 位置上。

总结 :我们可以得出这样一个结论,j要移动的下一个位置k。存在着这样的性质:最前面的k个字符和j 前面紧邻的k个字符是一样的。

比如说第一次 j 移动的是两格。如下图:
在这里插入图片描述

第二次我们的j没有移动,j 就在开头位置。是因为 我们并不能找到一个这样的一个位置k,我们只能让 j 在开头

image-20210814211647547

在这里插入图片描述

在这里我们用数组 T[] 来表示 str1 字符集合, 数组P[] 来表示 str2字符集合。

如果用数学公式来表示是这样的P[0 ~ k-1] == P[j-k ~ j-1]

证明为什么移动K位

然后咱们证明下为什么不用去比较k之前的数了。。(考验数学的时候到了)

  1. 当 T[i] != P[i] 时,
  2. 有 T[i-j ~ i-1] == P[0 ~ j-1] (表示之前已经比较过的数,他们是相等的)
  3. 如果 P[0 ~ k-1] == P[j-k ~ j-1] (如果 P 数组 前k个数和 j 之前的 k个数 相等)
  4. 得到T[i-k ~ i-1] == P[0 ~ k-1] (这点很难理解,如果转换为白话文就是 可以得出 T 数组 i 前面 k 个数 必然和 P 数组的前 K 个数相等,这是根据第一个条件我们p [j - k ~ j - 1] 已经在之前 跟 T数组确认过是相等的了,)

总结下这个推导过程表达的意思,

  1. 首先我们要在 T[i] 和 P[i] 不相等的情况下(废话,要是相等还用移位吗… 直接往下走就得了)

  2. 我们要充分利用我们已经比较过的字符。(T数组 i 前面 j 个和 P 数组前面 j 个数,我们已经比过了,都匹配。)

  3. 如果 我们的 P 数组 有这样一个规律: 数组前 k 个数 和 j 前面 k 个数 相等

  4. 我们可以确定 i 前面 k 个数 跟 p 前面k 个数绝对相等,不用比较了。

这就是为什么我们只要将 j 移动到 k 位置就行了。

如何求K

我们推出上面的规律后,重点就变成如何 求这个 (这些)K了?

因为在P的每一个位置都可能发生不匹配,也就是说我们要计算每一个位置 j 对应的k,所以用一个数组next来保存,next[j] = k,表示当T[i] != P[j]时,j指针的下一个位置。

而整个算法最关键的部分也在于此。下面先给出求 next[] 的代码

public static int[] getNext(String ps) {

    char[] p = ps.toCharArray();

    int[] next = new int[p.length];

    next[0] = -1;

    int j = 0;

    int k = -1;

    while (j < p.length - 1) {

       if (k == -1 || p[j] == p[k]) {
           j++;
           k++;
           next[j] = k;
       } else {
           k = next[k];
       }

    }

    return next;

}

这个代码是目前流传最广的求next数组的方法,诈一看很难理解,许多人并不知道为什么这样求,只是死记硬背。下面我们来推导一下:

推导前我们要记住一点 next[j] 的值 (也就是K) 表示:当P[j] != T[i] 时,j指针的下一步移动位置 我们在这里称为 匹配值

为什么叫匹配值呢,其实我们这个next[]数组早就有人给出了定义,叫《部分匹配表》/…

img

匹配值就是j在每一个位置所对应的k值,这个匹配表是如何产生的呢?

匹配表的产生
  1. 首先,要了解两个概念:“前缀"和"后缀”。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。

  2. "部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。以"ABCDABD"为例,

  - "A"的前缀和后缀都为空集,共有元素的长度为0;

  - "AB"的前缀为[A],后缀为[B],共有元素的长度为0;

  - "ABC"的前缀为[A, AB],后缀为[BC, C],共有元素的长度0;

  - "ABCD"的前缀为[A, AB, ABC],后缀为[BCD, CD, D],共有元素的长度为0;

  - "ABCDA"的前缀为[A, AB, ABC, ABCD],后缀为[BCDA, CDA, DA, A],共有元素为"A",长度为1;

  - "ABCDAB"的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为"AB",长度为2;

  - "ABCDABD"的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0

下面我们根据这个匹配表来推论上述代码的为什么这样写。

代码推导

1. 为什么next[0] = -1;

当j 为 0时,如果i 和 j 不匹配,匹配值应该是多少?

image-20210815201052402

j 是 0 字符串就是空,不可能有前缀和后缀所以 我们将next[0]标识为-1,方便后期判断。

**2. j++;k++; next[j] = k;这段代码从何而来 **

  1. 请看下图

image-20210814212943610

我们发现此规律:

当P[k] == P[j]时,

有next[j+1] == next[j] + 1

这个规律的原因是什么呢,很简单

如上述例子,我们需要计算 j 的匹配值,就是求 “ABCAB”的共有元素 ,可以看出其 为“AB”,所以匹配值为2

当我们求j+1的匹配值时,如果我们知道了,P[k] = P[j] = “C” 那么共有元素肯定是“ABC”了,所以匹配值为2 + 1;

所以j+1的匹配值 p[j+1] 就等于p[j]的匹配值+1

用公式我们也可推出

  1. 因为 p[0 ~ k -1] == p[j-k ~ j-1];
  2. 又因为 p[k] == p[j]
  3. 所以 p[0 ~ k - 1] + p[k] == p[j - k ~ j - 1] + p[j]
  4. 所以 p[0~k] == p[j-k ~ j]
  5. 根据结论 p[0 ~ k-1] == p[j-k ~ j-1]时, next[j] = k 那么, p[0~k] == p[j-k ~ j] 时 next[j + 1] = k +1;
  6. 所以 next[j+1] = next[j] +1 = k + 1;

这个就结论就对应代码 next[++j] = ++k 即: j++ ; k++ ;next[j] = k ;

3. 当 P[k] != P[j] 时 k = next[k];

情况如下图

img

我们找 j - 1 的匹配值时,[A B A C D A B A B]的 共同元素为[A B A]

当我们继续往下找 j 的匹配值 时,[A B A C D A B A B C] 发现 p[k] != P[j] 即[A B A C] 与[A B A B] 不匹配 ,此时我们要找 j 的匹配值 肯定没法通过之前的方式 next[j+1] = next[j] + 1 来找了。

那咋办呢,没办法只能进行回退,那要对谁进行回退呢?肯定是k了,前面的 j 都已经找到了匹配值了。

重要的是我们要将k回退到哪呢?

我们在寻找next[j+1] 时,刚开始寻找的是与前缀串[A B A C]相同的后缀串,但我们发现后缀串为[A B A B] 不匹配。这时我们是不是应该从新寻找共同元素。而这个过程是不是相当于寻找k的匹配值 next[k] 呢?就如下图所示:

img

其实还有一种思路可以理解为什么 k = next[k] ,因为k是j的下一步移动位置,如果 p[k] != p[j]的话,我们需要继续往下走,那么就相当于j往下下一步走,k 是 j 的下一步位置,那么next[k] 就是 j 的下下一步位置。所以k = next[k]

ok,这就是匹配表的求解思路。这点困扰了我好久,硬是看了一天才理解。以上这些都是我对这个算法的理解,如果有更好的看法请在评论留言大家一起讨论讨论。

在此特别感谢“孤~影” 博主的博客 https://www.cnblogs.com/yjiyjige/p/3263858.html

以及阮一峰大佬的博客http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html

我的思路都来源这两位大佬的启发。感谢!!!

next数组弄明白之后我们就可以写KMP算法了。

package com.atguigu.Algorithm.KMP;

/**
 * @author lixiangxiang
 * @description KMP 
 * @date 2021/8/13 16:25
 */
public class KMPSearch {
 	public static int KMP(String str1, String str2) {
        int i = 0;
        int j = 0;
        int[] next = getNext(str2);
        while (i < str1.length() && j < str2.length()) {
            //如果两字符相等,继续往下比
            if (j == -1 || str1.charAt(i) == str2.charAt(j)) {
                i++;
                j++;
            } else {
                //如果不相等,j 回退到 next[j]
                //i = i -j + 1;
                j = next[j];
            }
            // 如果 j == str2.length 说明匹配成功
            if (j == str2.length()) {
                return i - j;
            }
        }
        return -1;
    }
    
    /**
     * description:  获取匹配表
     *
     * @author: lixiangxiang 
     * @param str 字符串
     * @return int[] 
     * @date 2021/8/16 11:06 
     */
    public static int[] getNext(String str) {

        char[] p = str.toCharArray();

        int[] next = new int[p.length];

        next[0] = -1;

        int j = 0;

        int k = -1;

        while (j < p.length - 1) {

            if (k == -1 || p[j] == p[k]) {
                j++;
                k++;
                next[j] = k;
            } else {
                k = next[k];
            }

        }
        return next;
    }
}

网上很多文章有提到 这个获取匹配表的算法有缺陷,就是当 p[j+1] 和 p[k+1] 相等时应让k回退到 next[k] 这里我来解释一波。

如下例:

img

这个获得匹配表应该是 [ -1,0,0,1 ]

按算法执行 应该将 j 移动到 第二个元素B的位置

img

而很显然,其不相等,这步操作是无意义的,这类操作有个共同特点就是 p[j] = p[k] 因为k就是我们下一步要移动的位置,如果p[j] 都和T[i]不相等了,自然p[k] 也和T[i] 不相等,那我们就果断跳过。

image-20210816114713616

那往下跳应该跳到哪?自然就是往j的下下一步跳,他的下一步是k 下下一步就是 next[k] 。

所以我们可以在得知 p[j] = p[k] 时,让其 下一步next[j] 跳到下下一步 next[k]

改进后的匹配表算法。

 /**
     * description:  匹配表改进
     *
     * @author: lixiangxiang 
     * @param str 字符串
     * @return int[] 
     * @date 2021/8/16 11:06 
     */
    public static int[] getNext(String str) {

        char[] p = str.toCharArray();

        int[] next = new int[p.length];

        next[0] = -1;

        int j = 0;

        int k = -1;

        while (j < p.length - 1) {

            if (k == -1 || p[j] == p[k]) {
                
                j++;
                k++;
                if (p[j] == p[k]) {
                    next[j] = next[k];
                }else {
                    next[j] = k;
                }
            } else {
                k = next[k];
            }

        }
        return next;
    }
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值