Java数据结构之KMP算法详解以及代码实现

目录
  • 暴力匹配算法(Brute-Force,BF)
  • 概念和原理
  • next数组
  • KMP匹配
  • KMP全匹配
  • 总结

我们此前学了前缀树Trie的实现原理以及Java代码的实现。Trie树很好,但是它只能基于前缀匹配实现功能。但是如果我们的需求是:一个已知字符串中查找子串,并且子串并不一定符合前缀匹配,那么此时Trie树就无能为力了。

实际上这种字符串匹配的需求,在开发中非常常见,例如判断一个字符串是否包括某些子串,然后进行分别的处理。

暴力匹配算法(Brute-Force,BF)

这是最常见的算法字符串匹配算法,暴力匹配也叫朴素匹配。

思路很简单,从主串的第i个字符开始遍历,依次与子串的每个字符进行匹配,如果某个字符匹配失败,则主串回溯第i+1个字符,子串回溯到第1个字符,重新开始匹配,直到遍历完主串匹配失败或者遍历完子串匹配成功。

很明显这种算法需要在一个双重for循环中实现,时间复杂度为O(m*n),m为主串长度,n为子串长度。随着字符串长度的增长,时间复杂度快速上升。

Java中字符串的contains方法实际上就是采用的BF算法。

public static int bf(String word, String k) {
    char[] wordChars = word.toCharArray();
    char[] keyChars = k.toCharArray();
    for (int i = 0; i < wordChars.length; i++) {
        int j = 0, x = i;
        //依次匹配
        while (x < wordChars.length && j < keyChars.length && wordChars[x] == keyChars[j]) {
            x++;
            j++;
        }
        if (j == keyChars.length) {
            return i;
        }
    }
    return -1;
}

概念和原理

KMP 算法是 D.E.Knuth、J,H,Morris 和 V.R.Pratt 于1977年共同提出的,称之为 Knuth-Morria-Pratt 算法,简称 KMP 算法。

KMP算法是一种改进的字符串匹配算法,核心是利用之前的匹配失败时留下的信息,选择最长匹配长度直接滑动,从而减少匹配次数。KMP 算法时间复杂度为O(m+n),m为主串长度,n为子串长度。

BF匹配失败之后,主串和子串都会最大回溯,但是很多时候都是没有必要的。例如对于主串abababcd,子串ababc,第一次匹配之后,很明显主串和子串会匹配失败,但是我们能够知道他们的能够匹配的前缀串,即abab:

如果在第二次匹配的时候,主串不回溯,子串滑动两个字符长度,那么我们就能在第二次的时候实现匹配成功。

到这里,这种加速的方法已经呼之欲出了,但是我们先介绍两个重要概念:

1.匹配前缀:在某一次主串和子串的匹配失败之后,前面匹配成功的那部分子串就被称为匹配前缀。这就是一次匹配失败时留下的信息。

例如主串abcde,子串abcc,那么在第一次匹配的时候,匹配前缀为abc。

2.最长匹配长度:对于每次匹配失败后的匹配前缀串,其前缀子串(连续,且一定包括第一个字符,不包括最后一个字符)和后缀子串(连续,且一定包括最后一个字符,不包括第一个字符)中,相同的前后缀子串的最长子串长度,此时的前缀、后缀字串也被称为最长真前缀、后缀子串。

  • 例如匹配前缀abc,没有匹配的前缀和后缀,那么其最长匹配长度为0。
  • 例如匹配前缀cbcbc,最长匹配的前缀和后缀子串为cbc,那么其最长匹配长度为3。
  • 例如匹配前缀abbcbab,最长匹配的前缀和后缀子串为ab,那么其最长匹配长度为2。

有了这两个概念,那么我们才能进行跳跃式滑动,对于主串,在匹配失败的位置不进行回溯,对于子串,则是回溯(滑动)到其匹配前缀的最长匹配长度的位置上继续匹配,这样就跳过了之前的部字符串的匹配,且只需要匹配剩下的部分字符串即可。

我们再详细解释下,这里子串跳过的到底什么?实际上它跳过的就是匹配前缀串的最长匹配长度串。
设主串abababcd,子串ababc,第一次匹配失败之后,主串匹配索引i=4,子串匹配索引j=4,此时匹配的相同前缀串为abab,它的最长匹配长度为2,即最长前缀串ab和最长后缀串ab。

那么第二次匹配之前,字串匹配索引j直接跳到第一匹配的相同前缀串的最长匹配长度的索引位置上即j=2。我们可以这么理解,主串的第一次匹配的相同前缀串的最长匹配后缀,与子串第一次匹配的相同前缀串的最长匹配前缀相等(或者说重合)。这是我们在底层一次失败匹配之后得到的有效信息,在第二次匹配时自然可以利用起来,利用最长的前后缀匹配信息,跳过这些多余的匹配,实现加速。(后续学习的AC自动机也是采用了前后缀匹配的思想)

这就是KMP算法加速的核心原理,每次匹配失败之后,利用匹配失败的信息,找到最长匹配长度,然后主串不回溯,子串尽可能少的回溯,相比于BF算法,减少了没必要的匹配次数。

next数组

基于上面的原理,我们知道可能会不止一次查找最长匹配长度,而且我们会发现,最长匹配长度的范围只能在子串长度范围之内,而且其计算结果只和子串有关。那么我们就可以先初始化一个数组,用来保存不同长度的前缀的最长匹配长度。

这就是所谓的next数组,也被称为部分匹配表(Partial Match Table),也是KMP算法的核心。next数组的大小就是子串的长度,每个的索引位置i表示长度为i+1的子串的匹配前缀子串,值v表示对应匹配前缀子串的最长匹配长度。

假设子串为ababc,那么next数组值为:

假设子串为abcabdabcabc,那么对应的next数组如下:

其实很好理解:

子串匹配前缀串 最长匹配长度
a 0
ab 0
abc 0
abca 1
abcab 2
abcabd 0
abcabda 1
abcabdab 2
abcabdabc 3
abcabdabca 4
abcabdabcab 5
abcabdabcabc 3

现在,我们的首要问题变成了求next数组。

首先,切next数组的问题实际上就是求最大的前、后缀长度的问题,那么我们可以使用最朴素的方式求解:

public static int[] getNext(String word) {
    int[] next = new int[word.length()];
    //从两个字符的子串开始遍历
    for (int i = 1; i < word.length(); i++) {
        int k = i;
        //从最大的最长匹配值开始缩短
        while (k > 0) {
            //如果前缀等于后缀,那么表示获取到了最长匹配,直接返回
            if (word.substring(0, k).equals(word.substring(i - (k - 1), i + 1))) {
                next[i] = k;
                break;
            }
            k--;
        }
    }
    return next;
}

不难发现,求解next数组的时间复杂度为O(n^2),是否有更快速的方法呢?当然有,可以发现,在求next[i]的最长匹配长度的时候,next[0], next[1], … next[i-1]的结果已经求出来了。因此我们尝试利用此前的结果直接推导出后面的结果。下面是分情况讨论。

设子串为str=ababc,i=3,那么next[i-1]=1,即子串aba的的最长匹配长度为1,那么str[next[i-1]]实际上就是最长匹配子串前缀后一个字符,即str[1]=b。

如果str[i]=str[next[i-1]],就相当于在前一个子串的最长匹配长度的基础上增加了一位,即next[i]=next[i-1]+1。如下图:

如果str[i]!=str[next[i-1]],此时就会复杂一些。此时我们需要缩短最长匹配子串的长度,具体怎么缩短呢?

设str = abcabdabcabc,设i = 11,即最后一个字符c,那么next[i-1] = 5,但是由于str[i] != str[next[i-1]],即d != c,那么此时我们需要求i-1的最长匹配长度子串abcab的最长匹配长度子串,即next[next[i-1]-1] = 2,然后判断str[i]是否等于str[next[next[i-1]-1]],如果相等则同第一种情况,否则继续缩减直到next[next[i-1]-1]为0为止,此时表示当前子串的最长匹配长度也为0。如下图:

基于上面的规律,我们的改进算法如下:

public static int[] getNext2(String k) {
    int[] next = new int[k.length()];
    char[] chars = k.toCharArray();
    //i表示匹配的字符索引,pre表示前一个子串的最长匹配长度,即next[i-1]
    int i = 1, pre = next[i - 1];
    while (i < k.length()) {
        //如果新增的字符与前一个子串的最长匹配子串前缀的后一个字符相等
        if (chars[i] == chars[pre]) {
            //next[i]=next[i-1]+1
            pre++;
            next[i] = pre;
            //继续后移
            i++;
        }
        //如果不相等,且前一个子串的最长匹配长度不为0
        //那么求i-1的最长匹配长度子串的最长匹配长度子串,即pre=next[next[i-1]-1]
        //然后在下一轮循环中继续比较chars[i] == chars[pre],此时i并没有自增
        else if (pre != 0) {
            //next[next[i-1]-1]
            pre = next[pre - 1];
        }
        //如果不相等,且前一个子串的最长匹配长度为0,那么说明当前子串的最长匹配长度也为0
        else {
            //当前子串的最长匹配长度为0
            next[i] = 0;
            //继续后移
            i++;
        }
    }
    return next;

这种算法的时间复杂度为O(n),大大缩短了求next数组的时间。

KMP匹配

有了next数组,那么KMP算法就很容易实现了。

使用i和j分别表示主串和子串的匹配进度,i永远不会回退,依次匹配主串和子串的字符:

1.如果字符相等则推进i、j,并且判断如果匹配到了一个完整的子串,那么返回起始索引。

2.如果不相等:

  • 如果当前子串进度为0,那么子串不需要回退,主串向后推进i,重新开始匹配;
  • 如果当前子串进度不为0,那么子串进度需要回退到next[j-1]的位置,此前的位置不再需要匹配,主串不需要向后推进i,随后重新开始匹配。

如果i进度匹配完毕,那么退出循环,表示没有匹配到任何完整的子串,返回-1。

public static int kmp(String word, String k) {
    int[] next = getNext(k);
    //i,j分别表示主串和子串的匹配进度
    int m = word.length(), n = k.length(), i = 0, j = 0;
    //如果i匹配完毕,那么退出循环
    while (i < m) {
        //如果字符相等,那么向后推进i、j
        if (word.charAt(i) == k.charAt(j)) {
            i++;
            j++;
            //如果匹配到了一个完整的子串
            if (j == n) {
                //返回起始索引
                return i - n;
            }
        }
        //如果当前子串进度为0,那么子串不需要回退,主串向后推进i
        else if (j == 0) {
            i++;
        }
        //如果当前子串进度不为0,那么子串需要回退,主串不需要向后推进i
        else {
            //子串进度j回退
            j = next[j - 1];
        }
    }
    return -1;
}

KMP全匹配

上面我们的实现是返回第一个匹配到的模式串的起始索引,那么如果我们需要返回所有匹配到的模式串的起始索引呢?
其实也很简单。在每次匹配某个字符成功之后判断,如果匹配到了一个完整的子串,那么我们求起始索引并且加入结果集,然后子串点位j需要回退,继续循环。

public static List<Integer> kmpAll(String word, String k) {
    List<Integer> res = new ArrayList<>();
    int[] next = getNext(k);
    //i,j分别表示主串和子串的匹配进度
    int m = word.length(), n = k.length(), i = 0, j = 0;
    //如果i匹配完毕,或者j匹配完毕,那么退出循环
    while (i < m) {
        //如果字符相等,那么向后推进i、j
        if (word.charAt(i) == k.charAt(j)) {
            i++;
            j++;
            //如果匹配到了一个完整的子串
            if (j == n) {
                //将起始索引加入结果集
                res.add(i - n);
                //子串进度j回退
                j = next[j - 1];
            }
        }
        //如果当前子串进度为0,那么子串不需要回退,主串向后推进i
        else if (j == 0) {
            i++;
        }
        //如果当前子串进度不为0,那么子串需要回退,主串不需要向后推进i
        else {
            //子串进度j回退
            j = next[j - 1];
        }
    }
    return res;
}

总结

KMP算法是一种优化的字符串匹配算法,m为主串长度,n为子串长度。由于构建了 next 数组,空间复杂度为 O(m)。匹配时主串不会回退,子串回退不会超过n,总体算法时间复杂度为O(m+n)。

next数组是实现算法加速的关键,它的核心是查找最长前后缀匹配长度,这也是理解KMP算法的核心。

到此这篇关于Java数据结构之KMP算法详解以及代码实现的文章就介绍到这了,更多相关Java KMP算法内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 如何通过Java代码实现KMP算法

    这篇文章主要介绍了如何通过Java代码实现KMP算法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt同时发现,因此人们称它为克努特--莫里斯--普拉特操作(简称KMP算法).KMP算法的关键是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的.具体实现就是实现一个next()函数, 函数本身包含了模式串的局部匹配信息.时间复

  • Java数据结构彻底理解关于KMP算法

    大家好,前面的有一篇文章讲了子序列和全排列问题,今天我们再来看一个比较有难度的问题.那就是大名鼎鼎的KMP算法. 本期文章源码:GitHub源码 简介 KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特-莫里斯-普拉特操作(简称KMP算法).KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的.具体实现就是通过一个next()函数实现,函数本身包含了模式串的局部匹配信息.KMP算法

  • java 中模式匹配算法-KMP算法实例详解

    java 中模式匹配算法-KMP算法实例详解 朴素模式匹配算法的最大问题就是太低效了.于是三位前辈发表了一种KMP算法,其中三个字母分别是这三个人名的首字母大写. 简单的说,KMP算法的对于主串的当前位置不回溯.也就是说,如果主串某次比较时,当前下标为i,i之前的字符和子串对应的字符匹配,那么不要再像朴素算法那样将主串的下标回溯,比如主串为"abcababcabcabcabcabc",子串为"abcabx".第一次匹配的时候,主串1,2,3,4,5字符都和子串相应的

  • 详解Java中KMP算法的图解与实现

    目录 图解 代码实现 图解 kmp算法跟之前讲的bm算法思想有一定的相似性.之前提到过,bm算法中有个好后缀的概念,而在kmp中有个好前缀的概念,什么是好前缀,我们先来看下面这个例子. 观察上面这个例子,已经匹配的abcde称为好前缀,a与之后的bcde都不匹配,所以没有必要再比一次,直接滑动到e之后即可. 那如果好前缀中有互相匹配的字符呢? 观察上面这个例子,这个时候如果我们直接滑到好前缀之后,则会过度滑动,错失匹配子串.那我们如何根据好前缀来进行合理滑动? 其实就是看当前的好前缀的前缀和后缀

  • Java数据结构之KMP算法的实现

    目录 问题介绍 暴力求解 知识补充 Next示例 Next代码 匹配示例 匹配代码 完整代码 本次我们介绍数据结构中的KMP算法,我们会从下面几个角度来介绍: 问题介绍 首先我们先介绍适用于KMP算法的问题: 给定一个字符串S,以及一个模式串P,所有字符串中只包含大小写英文字母以及阿拉伯数字. 模式串P在字符串S中多次作为子串出现. 求出模式串P在字符串S中所有出现的位置的起始下标. 我们给出一个问题的简单示例: // 输入 p长度 p s长度 s 3 aba 5 ababa // 输出结果 0

  • JAVA实现KMP算法理论和示例代码

    一.理论准备KMP算法为什么比传统的字符串匹配算法快?KMP算法是通过分析模式串,预先计算每个位置发生不匹配的时候,可以省去重新匹配的的字符个数.整理出来发到一个next数组, 然后进行比较,这样可以避免字串的回溯,模式串中部分结果还可以复用,减少了循环次数,提高匹配效率.通俗的说就是KMP算法主要利用模式串某些字符与模式串开头位置的字符一样避免这些位置的重复比较的.例如 主串: abcabcabcabed ,模式串:abcabed.当比较到模式串'e'字符时不同的时候完全没有必要从模式串开始位

  • java 实现KMP算法

    KMP算法是一种神奇的字符串匹配算法,在对 超长字符串 进行模板匹配的时候比暴力匹配法的效率会高不少.接下来我们从思路入手理解KMP算法. 在对字符串进行匹配的时候我们最容易想到的就是一个个匹配,类似下面这种: 换成Java代码就是: public static boolean bfSearch(String pattern,String txt){ if (txt.length() < pattern.length()) return false; for (int i = 0; i < t

  • Java数据结构之KMP算法详解以及代码实现

    目录 暴力匹配算法(Brute-Force,BF) 概念和原理 next数组 KMP匹配 KMP全匹配 总结 我们此前学了前缀树Trie的实现原理以及Java代码的实现.Trie树很好,但是它只能基于前缀匹配实现功能.但是如果我们的需求是:一个已知字符串中查找子串,并且子串并不一定符合前缀匹配,那么此时Trie树就无能为力了. 实际上这种字符串匹配的需求,在开发中非常常见,例如判断一个字符串是否包括某些子串,然后进行分别的处理. 暴力匹配算法(Brute-Force,BF) 这是最常见的算法字符

  • Java C++题解leetcode字符串轮转KMP算法详解

    目录 题目要求 思路一:双指针(模拟) Java C++ 思路二:子串 手写KMP Java dp C++ dp 调API Java C++ 总结 题目要求 思路一:双指针(模拟) Java class Solution { public boolean isFlipedString(String s1, String s2) { if (s1.length() != s2.length()) return false; int n = s1.length(); if (n == 0) retu

  • Java数据结构顺序表用法详解

    目录 1.什么是顺序表 2.顺序表的基本功能和结构 3.顺序表基本功能的实现和解析 1.判断线性表是否为空 2.获取指定位置的元素 3.向线性表表添加元素 4.在位置i处插入元素 5.删除指定位置的元素,并返回该元素 6.查找t第一次出现的位置 7.手动扩容方法 1.什么是顺序表 在程序中,经常需要将一组(通常是同为某个类型的)数据元素作为整体管理和使用,需要创建这种元素组,用变量记录它们,传进传出函数等.一组数据中包含的元素个数可能发生变化(可以增加或删除元素). 对于这种需求,最简单的解决方

  • Java数据结构之散列表详解

    目录 介绍 1 散列表概述 1.1 散列表概述 1.2 散列冲突(hash collision) 2 散列函数的选择 2.1 散列函数的要求 2.2 散列函数构造方法 3 散列冲突的解决 3.1 分离链接法 3.2 开放定址法 3.3 再散列法 4 散列表的简单实现 4.1 测试 介绍 本文详细介绍了散列表的概念.散列函数的选择.散列冲突的解决办法,并且最后提供了一种散列表的Java代码实现. 数组的特点是寻址容易,插入和删除困难:而链表的特点是寻址困难,插入和删除容易.而对于tree结构,它们

  • Java数据结构之堆(优先队列)详解

    目录 堆的性质 堆的分类 堆的向下调整 堆的建立 堆得向上调整 堆的常用操作 入队列 出队列 获取队首元素 TopK 问题 例子 数组排序 堆的性质 堆逻辑上是一棵完全二叉树,堆物理上是保存在数组中 . 总结:一颗完全二叉树以层序遍历方式放入数组中存储,这种方式的主要用法就是堆的表示. 并且 如果已知父亲(parent) 的下标, 则: 左孩子(left) 下标 = 2 * parent + 1; 右孩子(right) 下标 = 2 * parent + 2; 已知孩子(不区分左右)(child

  • Java数据结构之对象比较详解

    目录 1. PriorityQueue中插入对象 2. 元素的比较 2.1 基本类型的比较 2.2 对象比较的问题 3. 对象的比较 3.1 覆写基类的equals 3.2 基于Comparble接口类的比较 3.3 基于比较器比较 3.4 三种方式的对比 4.集合框架中PriorityQueue的比较方式 本篇博客主要内容: Java中对象的比较 集合框架中PriorityQueue的比较方式 模拟实现PriorityQueue 1. PriorityQueue中插入对象 优先级队列在插入元素

  • Java垃圾回收机制算法详解

    概述 Java GC(Garbage Collection,垃圾回收)机制,是Java与C++/C的主要区别之一,作为Java开发者,一般不需要专门编写内存回收和垃圾清理代码,对内存泄露和溢出的问题,也不需要像C程序员那样战战兢兢.这是因为在Java虚拟机中,存在自动内存管理和垃圾清扫机制.概括地说,该机制对JVM中的内存进行标记,并确定哪些内存需要回收,根据一定的回收策略,自动的回收内存,永不停息的保证JVM中的内存空间,防止出现内存泄露和溢出问题. 在真实工作中的项目中,时不时的会发生内存溢

  • Java数据结构之单链表详解

    一.图示 二.链表的概念及结构 链表是一种物理存储结构上非连续存储结构,数据元素的逻辑顺序是通过链表中的引用链接次序实现的 . 实际中链表的结构非常多样,以下情况组合起来就有8种链表结构: 单向.双向 带头.不带头 循环.非循环 今天,我们实现的是一个 单向 无头 非循环的链表. 下面是此链表的结构组成. 三.单链表的实现 (1)定义一个节点类型 我们创建一个 ListNode 的类作为节点类型,那么我们如何定义成员属性呢? 通过上面的结构分析,我们需要定义两个成员变量 val --作为该节点的

  • Java实现权重随机算法详解

    目录 应用场景 本文目标 算法详解 权重比例 Java 实现 参考 应用场景 客户端负载均衡,例如 Nacos 提供的客户端负载均衡就是使用了该算法 游戏抽奖(普通道具的权重很高,稀有道具的权重很低) 本文目标 Java 实现权重随机算法 算法详解 比如我们现在有三台 Server,权重分别为1,3,2.现在想对三台 Server 做负载均衡 Server1 Server2 Server3 weight weight weight 1 3 2 权重比例 我们算出每台 Server 的权重比例,权

  • Java数据结构之线段树详解

    目录 介绍 代码实现 线段树构建 区间查询 更新 总结 介绍 线段树(又名区间树)也是一种二叉树,每个节点的值等于左右孩子节点值的和,线段树示例图如下 以求和为例,根节点表示区间0-5的和,左孩子表示区间0-2的和,右孩子表示区间3-5的和,依次类推. 代码实现 /** * 使用数组实现线段树 */ public class SegmentTree<E> { private Node[] data; private int size; private Merger<E> merge

随机推荐