KMP算法:字符串匹配问题

1,暴力匹配算法

1.1,基本介绍

  • 暴力匹配算法是对字符串及匹配子串的字符进行一一匹配,完全匹配后则说明字符串匹配
  • 假设存在字符串str和子串childStr,需要进行字符串的暴力匹配,才从头开始匹配
  • 此时取字符串str的索引位置i = 0,子串childStr的索引位置j = 0,用str[0]childStr[0]进行比较,如果匹配,则进行i++, j++,并依次进行下一个字符匹配
  • 此时如果不匹配,说明i = 0处起始匹配是匹配不到的,则进行回溯,重新从i = 1处开始重复以上逻辑进行匹配
  • 但是此时会存在一个问题,i已经经过了多次的递增,不过i的递增和j的递增是同步的,所以需要回溯到i后的一个索引位置,需要进行计算i = i - j + 1
  • 在过程中可以进行优化,比如字符串str的后续长度不足子串childStr的长度,则直接可推出
  • java.lang.String.indexOf(..)就是使用的暴力匹配算法
  • 暴力匹配算法有一个天然劣势,就是在每一次回溯时是回溯到初始匹配的下一个位置进行下一轮匹配,这样会导致这个回溯时间很长,所以有了KMP算法改进

1.2,代码实现

package com.self.datastructure.algorithm.kmp;

/**
 * 暴力匹配算法
 * 算法解析:
 * * 对一个字符串A, 匹配目标子串a
 * * 定义两个变量, 字符串A的起始标记i, 从0开始, 字符串a的起始比较offset, 从0开始
 * * 用A[i]匹配a[offset], 如果匹配则++
 * * 如果不匹配, i移位到原始i的后一位, 因为过程中, i可能存在++操作
 * * 但是i具体移位了多少是通过offset体现出来的, 所以i的后一位就是(i-j+1)
 * @author pj_zhang
 * @create 2020-07-05 12:33
 **/
public class ViolenceMatch {

    public static void main(String[] args) {
        String source = "硅硅谷 尚硅谷你尚硅 尚硅谷你尚硅谷你尚硅你好";
        String target = "尚硅谷你尚硅你";
        System.out.println(violenceMatch(source, target));
    }

    private static int violenceMatch(String source, String target) {
        // 先分别转换为char数组
        char[] sourceArr = source.toCharArray();
        char[] targetArr = target.toCharArray();

        // 定义source字符串的偏移量i
        // 定义target字符串的偏移量offset
        int i = 0;
        int offset = 0;
        // 计算i的最大范围, 因为target的长度不定, 所以source剩余位置不够即可匹配完成
        int max = sourceArr.length - targetArr.length;
        // 开始匹配, 只要i <= max, 即可满足
        for (;i <= max;) {
            // 先查找到第一个位置, 第一个位置不对, 遍历向后查找
            if (sourceArr[i] != targetArr[offset]) {
                for (;sourceArr[i] != targetArr[offset] && i <= max; i++);
            }
            // if条件符合, 说明已经查找到第一个位置, 进行后续位置查找
            if (i <= max) {
                // 进行后续字符串匹配, 如果全部匹配成功, 则j == targetArr.length - 1
                for (;i < sourceArr.length && offset < targetArr.length && sourceArr[i] == targetArr[offset]; i++, offset++);
                // 如果j不等于该值, 说明中间存在位置没有匹配上
                // 此时对j置0
                // i设置为i的原始值+1
                if (offset != targetArr.length) {
                    i = i - offset + 1;
                    offset = 0;
                } else {
                    // 匹配到, 直接返回结果
                    return i - offset;
                }
            }
        }
        // 没有匹配到直接返回-1
        return -1;
    }

}

2,KMP算法

2.1,算法介绍

  • Knuth-Morries-Pratt字符串查找算法,简称KMP算法,常用于在一个文本串中查找一个模式串的出现位置,这个算法有Donald Knuth,Vaughan Pratt,James H. Morries三人于1977年联合发标,故取三个人姓氏命名,是最早出现的位置匹配的经典算法
  • 暴力匹配算法在经过一段匹配后,如果遇到匹配不成功的字符,则完全回溯,从开始匹配的下一个位置再进行字符匹配,过程中会有很多的无用功且耗时;
  • KMP算法就是利用之前判断过的信息,通过一个next数组,即部分匹配表,保存模式串中前后最长公共子序列的长度,每次回溯时,通过已经匹配到的位置,从部分匹配表中查找,确定已经匹配过的重合部分,直接移动主串的索引偏移到该位置,减少回溯,节省大量时间

2.2,部分匹配表

  • 在部分匹配表之前,先看看字符串的前缀字符串和后缀字符串:入bread
    • 前缀字符串是必须存在第一个字符的所有可能连续字符,且不包含字符串本身:如b, br, bre, brea
    • 后缀字符串是必须存在最后一个字符的所有可能连续字符,且不包含字符串本身:如d, ad, ead, read
  • 部分匹配表是部分匹配值的数组,部分匹配值是前缀和后缀的最长共有元素的长度,下以ABCDABD举例
    • 对应索引0位置来讲,字符串是A,前后缀都为空,则共有元素长度为0,第一位默认为0
    • 对于索引1位置,字符串是AB,前缀是[A],后缀是[B],共有元素长度0
    • 对于索引2位置,字符串是ABC,前缀是[A, AB],后缀是[C, BC],共有元素长度为0
    • 对于索引3位置,字符串是ABCD,前缀是[A, AB, ABC],后缀是[D, CD, BCD],共有元素长度为0
    • 对于索引4位置,字符串是ABCDA,前缀是[A, AB, ABC, ABCD],后缀是[A, DA, CDA, BCDA],共有元素为[A],最大长度为1
    • 对于索引5位置,字符串是ABCDAB,前缀是[A, AB, ABC, ABCD, ABCDA],后缀是[B, AB, DAB, CDAB, BCDAB],共有元素是[AB],最大长度为2
    • 对于索引6位置,字符串是ABCDABD,前缀是[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀是[D, BD, ABD, DABD, CDABD, BCDABD],最大长度为0
    • 所以综上,模式串ABCDABD对应的部分匹配值分别为[0, 0, 0, 0, 1, 2, 0],也是该字符串对应的部分匹配表,
  • 至于部分匹配表的真正价值何在,下一步继续分析

2.3,KMP算法匹配演示

  1. 在字符串String str = "BBC ABCDAB ABCDABCDABD" 中匹配模式串String childStr = "ABCDABD"

  2. 首先进行第一个字符比对,str.charAt(0) = B比对childStr.charAt(0) = A,匹配不成功,str索引后移,继续比对,直到str.charAt(4) = A比对childStr.charAt(0) = A成功,开始后续字符匹配
    在这里插入图片描述

  3. 后续字符顺序匹配,在ABCDAB段是匹配正常,可以继续匹配到,直到str.charAt(10) = 空格比对childStr.charAt(6) = D失败
    在这里插入图片描述

  4. 此时,按照暴力匹配算法的原则,会回溯到初始匹配位置的下一位,即str.charAt(5) = B,与模式串的第一个字符串,即childStr.charAt(0) = A进行比对,如图;此处问题在于之前的ABCDAB是已经匹配过的,已知的字符串,并且ABCDAB的后缀两位与前缀两位是一致的,也就是部分匹配值为2,是不需要再次进行匹配,可直接用AB的下一位匹配模式串的第三的;如果进行后缀两位的识别及初始匹配索引的推进,就是KMP算法接下来要处理的问题
    在这里插入图片描述

  5. 部分匹配表部分,我们已经算出字符串ABCDABD的部分匹配表为int[] arr = [0, 0, 0, 0, 1, 2, 0],其中索引对应的是字符串中各个字符的索引位置,值代表以该字符及之前的字符组成一个完整串时,对应的部分匹配值,如在索引5处,对应的字符串是ABCDAB,其前后缀最长匹配串为AB,长度为2
    在这里插入图片描述

  6. 继续回到第三步,已知D不匹配,则前面ABCDAB六个字符是匹配的,从部分匹配表的对应索引5处寻找该字符的部分匹配值2位,说明前两个字符是与模式串的前两个字符相匹配的,即AB,那此时初始匹配位置需要移动到这个A的位置,即str[8];又因为AB已经匹配过,则直接从第三位开始匹配,即str.charAt(10) = 空格比对childStr.charAt(2) = C

    移动位数 = 当前已匹配位数 - 部分匹配值 = 6 - 2 = 4位
    在这里插入图片描述

  7. 因为空格与C不匹配,继续部分匹配表中找C所在索引2对应的部分匹配值arr[2] = 0,则移动位数 = 2 - 0 = 2,继续后续两位
    在这里插入图片描述

  8. 后移两位后,空格与A不匹配,直接后移一位,注意此处不存在匹配,不考虑部分匹配规则;后移一位后A与A匹配,并继续后续匹配到str.charAt(17) = C比对childStr.charAt(6) = D失败
    在这里插入图片描述

  9. 比对失败后,因为已匹配字符是6个,对应的部分匹配值是arr[5] = 2,则后移4位,继续从第三位开始进行匹配,直至匹配结束,全部7位匹配完成后,算匹配成功,返回当前匹配阶段的主串初始索引index = 主串当前索引 - 匹配个数 + 1 = 21 - 7 + 1 = 15,至此全部匹配完成
    在这里插入图片描述

2.4,代码实现

package com.self.datastructure.algorithm.kmp;

/**
 * KMP算法
 * * 生成部分匹配表, 此处注意生成规则
 * * 按照匹配字符依次向后匹配, 如果匹配后则继续匹配,
 * * 如果没有匹配到, 按照已经匹配到的字符长度从部分匹配表中找到对应的部分匹配值, 即已匹配部分的前缀
 * * 匹配的首字符后移位数 = 当前已经匹配位数 - 部分匹配表中以匹配部分对应的值
 * * 后移完成后, 继续从上次匹配的断点与子串需要新对应位置匹配, 匹配到继续, 未匹配到重复步骤
 * * 匹配完成后, 如果要进行所有子串匹配, 则后移子串位数, 继续匹配即可
 * @author PJ_ZHANG
 * @create 2020-07-07 17:35
 **/
public class KMP {

    public static void main(String[] args) {
        //String str = "ABCDABCABCDABCCABCDABCABCDABCD";
        //String childStr = "ABCDABCABCDABCD";
        String str = "BBC ABCDAB ABCDABCDABDE";
        String childStr = "ABCDABD";
        System.out.println(kmp(str, childStr));
    }

    /**
     * 通过KMP算法进行匹配
     * @param str 字符串
     * @param childStr 子串
     * @return 返回索引
     */
    private static int kmp(String str, String childStr) {
        // 获取串的部分匹配值
        int[] arr = partMachingValue(childStr);
        // 进行KMP计算
        // i: 主串的匹配索引位置
        // j: 模式串的匹配索引位置
        for (int i = 0, j = 0; i < str.length(); i++) {
            // 如果存在匹配过的位数, 并且当前字符没有匹配到
            // 则将模式串后移, 通过j减的方式实现
            // 比如j已经匹配到了6位, 第7位没有匹配到
            // 则取前6位的部分匹配值, 即arr[5]
            // 如果前六位有三位是前后缀匹配的, 则继续从第四位开始匹配剩余的部分, 以此类推
            // 如果不存在匹配过的位数, 则直接通过i++进行第一位的匹配
            if (j > 0 && str.charAt(i) != childStr.charAt(j)) {
                j = arr[j - 1];
            }
            // 如果匹配, 则j++ 继续向后比较
            if (str.charAt(i) == childStr.charAt(j)) {
                j++;
            }
            // 如果j的值等于childStr的长度, 则匹配完成
            if (j == childStr.length()) {
                return i - j + 1;
            }
        }
        return -1;
    }

    /**
     * 部分匹配值数组生成
     * @param modelStr 字符串
     * @return 部分匹配值数组
     */
    private static int[] partMachingValue(String modelStr) {
        int[] arr = new int[modelStr.length()];
        // 第一个空初始化为0, 此处不多余处理
        // 将长字符串先按两个字符进行处理, 并依次添加字符, 知道匹配完成
        for (int i = 1, j = 0; i < modelStr.length(); i++) {
            // 如果对应的两个字符不相等
            // 个人感觉此处基于的思想是:
            // 首先, j > 0, 也就是说已经在前后缀有较长字符串匹配了, 再继续匹配该值
            // 然后, 添加上该值后, 因为该值影响, 前后缀现有的长字符串匹配断开,
            // 再然后, 虽然添加该值影响长匹配断开, 但不排除依旧有较短的前后缀字符串可以匹配, 如: ABCDABCABCDABCD
            // 虽然 ABCDABC 的匹配因为前缀的 A 和后缀的 D匹配不到端口, 但是不影响 ABC 的后一个字符 D 的匹配
            // 所以此处需要往前找, 找到的合适的位置与后缀的新匹配字符进行匹配, 如果匹配到则可关联上部分匹配
            // 此时找的标准是, 先找前缀字符串(ABCDABC)最末索引(j - 1 = 7 - 1 = 6)对应的部分匹配值(arr[j - 1] = 3),
            // 并以该部分匹配值作为索引获取到对应的字符(modelStr.charAt(j) = D)
            //     此处注意, 一定是前缀再前缀, 如果前缀的后一个字符与当前字符能匹配到, 那就不可能走到这一步
            //     继续注意: 前缀与后缀匹配, 前缀再前缀就相当于后缀再后缀, 所以前缀再前缀等于即将与当前字符关联的子串
            // 所以, 判断前缀的后一个字符, 即索引出的字符与当前拼接字符是否相等, 如果相等, 则部分匹配值+1
            // 如果不相等, 继续这部分循环, 直到匹配到或者遍历完前缀确定为0
            while (j > 0 && modelStr.charAt(i) != modelStr.charAt(j)) {
                j = arr[j - 1];
            }
            // 如果两个字符相等, 说明已经匹配到了
            if (modelStr.charAt(i) == modelStr.charAt(j)) {
                // j自增是将j一直向推
                // 如果只有两个字符, 则i表示第一个, j表示第二个
                // 此时如果对应的字符相等, 则匹配到一个
                // 如果有三个字符, 在两个字符的基础上继续推进, i, j都有自增
                // 此时如果对应的字符依旧相等, 则j=2, 表示匹配两个, 以此类推
                j++;
            }
            arr[i] = j;
        }

        return arr;
    }

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值