百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

别再暴力匹配字符串了,高效的KMP,才是真的香

wxin55 2024-11-17 16:47 7 浏览 0 评论

如果你想了解KMP算法,请静下心读完这篇文章,一定不会辜负你的时间

暴力匹配(BF)

字符串匹配是我们在编程中常见的问题,其中从一个字符串(主串)中检测出另一个字符串(模式串)是一个非常经典的问题,当提及到这个问题时我们首先想到的算法可能就是暴力匹配,下面的动图就展示了暴力匹配的流程。

上图中箭头指向的字符都为蓝色时代表二者匹配,都为黑色时代表二者不匹配,红色则代表在主串中找到模式串。

这种算法大致思路就是每当模式串和主串中有字符不匹配,模式串与主串对应的位置整体向后移动一位,再次从模式串第一位开始比较,重复上述做法直至在主串中匹配到模式串或者匹配到主串最后一位结束。

如果主串与模式串都比较短时,用暴力匹配还是不错的选择,编码也相对容易;但是如果主串与模式串过长时,我们只是简单想想就知道这个过程是非常耗时的,那么会不会有对应的优化算法呢?

下面就介绍本文的主角——KMP算法,不扯没用的概念,直接讲算法的应用过程及利用Python实现该算法的代码,最后会通过二者时间复杂度的分析,总结出为何KMP算法会优于暴力匹配算法。

KMP算法

构建前缀表

我们首先要确定一下引例的主串和模式串:

  • 主 串 S = "abacaababc"
  • 模式串P="ababc"

在模式串与主串匹配时,我们暂时只看第4步,明显主串S中的c和模式串P中的b是不匹配的:

如果用暴力匹配算法,那么就是后移模式串P,在从P的第一个字符开始比较。但是现在通过匹配我们可以知道的是当第4位不匹配时,前三个字符为"aba"是确定的,这个已知信息是十分有用的。

而KMP算法的核心就是利用匹配失败后获取的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的,比如对于这个不匹配现象我们是不是可以直接这样移动模式串呢?

那么信息从何而来呢?在KMP算法中,对于一个模式串都可以先计算出其内部的匹配信息,这样在匹配失败时可以最有效的移动模式串,从而减少匹配次数。在此之前,需要先理解一下前缀和后缀。

  • 前缀:abcde的前缀可以是a、ab、abc、abcd
  • 后缀:abcde的后缀可以是e、de、cde、bcde

这里需要引出一个新的概念——前缀表,可以用profix表示、且下标从0开始,profix[i]存储的信息就是前i+1个字符的最长公共前后缀,并且这个最长公共前后缀长度一定是小于字符串长度的。

可以看到"ababc"不是前后缀,但也被列到了表中。如果你曾经了解过KMP算法,那你可能听过next数组,当前缀表转化为next数组时,最后一位的值会被覆盖掉,对过程是没有什么影响的。由于本文仅是靠着前缀表profix完成KMP算法,所以不再过多讲述next数组,不同的方法只是表示形式不一样,但归根结底原理还是相同的。

上面的前缀表是我们通过肉眼比对得出的,程序毕竟不是人嘛,所以需要通过一种程序能够识别的方法构建前缀表,依据下图进行讲述流程。

通过这个动图可以将构建前缀表规划成下面五步:

  1. 首先创建两个指针,指针j指向模式串第一位(下标为0)、指针i指向模式串第二位(下标为1)。
  2. 由于模式串最开始是单一字符,没有前缀和后缀,所以对应前缀表第一位总为0。
  3. 当j=0时,比较j和i指向的字符,如果字符不匹配,i对应的前缀表位置填入0,且将i向后移动移位,j原地不变。
  4. 当j和i指向的字符匹配时,i对应的前缀表位置填入(j+1),且将j和i都后移一位。
  5. 如果j和i指向的字符不匹配,并且此时,j需要回溯到profix[j-1]的位置,再次与i指向的字符比较,重复此步骤直至j和i指向的字符匹配或者j=0。

当结合动图读完这五个步骤时,我猜你会不理解第五步,如果你都理解了,我也只能感叹一句NiuBi,利用下面这个例子更能凸显出步骤五的回溯机制。

依据上面步骤我写出了前缀表的前五位,而此时j和i指向的字符不匹配且j≠0,这里j的下标是3,所以需要在前缀表中找到下标为j-1的值,即profix[2],然后将j回溯到对应的位置。

这样回溯是因为可以在模式串头部找到和j和i之间的字符串相匹配的前缀,也就是这个例子中的a,如果此时j和i指向的字符相匹配,那么最长公共前后缀的长度就是已匹配的前缀的长度(a)再加1。由此可见如果j和i之间字符串很长时,这个操作可以节省很多时间。

而此时j和i指向的字符仍然不匹配,那么需要继续回溯j,方法和上述一致,回溯的位置就是profix[0]。

此时j和i指向的字符还是不匹配,但这里需要做的就不是回溯了,因为j=0已经满足回溯结束条件,只需将i对应前缀表的位置(profix[5])中填入0即可,用肉眼匹配也会发现此时的确没有公共前后缀。

在理解上述步骤之后,可以将其当成伪代码,依据伪代码很容易编写出构建前缀表函数。

def PrefixTable(Pattern):
    i = 1;j = 0
    prefix = [0]*len(Pattern)
    while(i<len(Pattern)):
        if Pattern[j]==Pattern[i]:
            prefix[i] = j+1
            j+=1;i+=1
        else:
            if j == 0:
                prefix[i] = 0
                i+=1
            else:
                j = prefix[j-1]
    return prefix
复制代码

可以输入一个模式串,测试一下该代码是否能够得出对应前缀表。

优化前缀表

经过上文解释你可能会发现一个基本事实,即前缀表最后一位没有任何作用,这么说的理由是什么呢?因为当j和i指向的字符不匹配时,这里的解决办法是回溯j,而回溯依据一直都是prefix[j-1],j是永远不可能超越i的,所以前缀表最后一位永远也不会用到。

那么最后一位就可以去掉,将所有元素整体后移一位,并向前缀表第一位填入-1,如下图:

填入-1这个操作的原理等下结合图片一起讲述会更易懂,目前我们只需知道这个操作并且了解其对应代码即可。

-def MoveTable(prefix):
    for i in range(len(prefix)-1,0,-1):
        prefix[i] = prefix[i-1]
    prefix[0] = -1
    return prefix
复制代码

KMP匹配机制

主串和模式串还是利用上文所举例子,这里省略了一些简单的匹配过程,直接看关键点。

可以看到主串和模式串的第4位是不匹配的,现在需要做的是将Pattern[prefix[4]]对应主串中需要匹配的元素,也就是模式串下标为1的元素后移至与主串第4位对应的位置,看图可懂。

对应位置仍然不匹配,需要继续后移模式串,该位置对应前缀表的值为0,所以将Pattern[prefix[0]]对应主串中需要匹配的元素,即模式串下标为0的元素与主串该位置对应。

此时两串对应位置还是不匹配,但是a已经是模式串的第一位元素了,如果按照上面方法需要继续后移模式串,让主串那个位置与模式串下标为-1的元素匹配,可是前缀表中并不存在下标为-1的元素。

所以比较时如果模式串和主串对应位置不匹配,且模式串的元素对应前缀表的值为-1,那么直接将模式串整体后移一位,并且将指向主串的指针后移一位即可,这也是为什么在前缀表第一位插入-1的原因。

下面动图是利用KMP算法在主串中查找模式串的全过程。

KMP算法的代码如下:

def KMP(TheString,Pattern):
    m = len(TheString);n = len(Pattern)
    prefix = PrefixTable(Pattern)
    prefix = MoveTable(prefix)
    i = 0;j = 0#i为主串指针,j为模式串指针
    while(i<m):
        if j==n-1 and TheString[i]==Pattern[j]:
            print("已在主串下标%d处找到模式串" % (i-j))
            j = prefix[j]
        if TheString[i]==Pattern[j]:
            i+=1;j+=1
        else:
            j = prefix[j]
            if j==-1:
                i+=1;j+=1

这里只讲一下第一个if语句,当j指向了模式串最后一位,并且此时如果主串和模式串对应位置匹配,则代表在主串中找到了模式串,并打印出第一个字符出现的位置。而j= prefix[j]这个语句的作用是在找到模式串后继续匹配剩余的主串,因为可能会有主串中含有若干个模式串的现象出现。

最后整个程序运行截图如下:

BF与KMP比较

为什么KMP会优于BF,这里通过对比二者的时间复杂度给出原因,假设有这么两个比较极端的主串和模式串:

  • 主 串 S = "aaaaaaab"
  • 模式串P="aaab"

首先看一下BF算法解决该匹配问题的流程:

然后再看一下KMP算法解决该匹配问题的流程:

假设主串长度为m,模式串长度为n。对于BF算法,每当遇到不匹配字符时,都要从模式串开头再次匹配,所以对应时间复杂度O(m*n);对于KMP算法,每当遇到不匹配字符时,根据获得的信息它不会重复匹配的已知前缀,所以对应时间复杂度为O(m+n)。当字符串较长时,就时间复杂度而言KMP算法是完全优于BF算法的。

总结

个人认为KMP算法难度不低,讲这个算法的博客与视频很多,但都各有差异,虽然原理都是大致相同的,但不要同时看前缀表和next数组,由于这两个很像所以会容易混淆,可以先弄透前缀表然后再看next数组相关知识点,这样对于KMP的理解才算透彻。

作者:奶糖猫

链接:https://juejin.im/post/5eb8ac9d5188256d9147983e

来源:掘金

相关推荐

ES6中 Promise的使用场景?(es6promise用法例子)

一、介绍Promise,译为承诺,是异步编程的一种解决方案,比传统的解决方案(回调函数)更加合理和更加强大在以往我们如果处理多层异步操作,我们往往会像下面那样编写我们的代码doSomething(f...

JavaScript 对 Promise 并发的处理方法

Promise对象代表一个未来的值,它有三种状态:pending待定,这是Promise的初始状态,它可能成功,也可能失败,前途未卜fulfilled已完成,这是一种成功的状态,此时可以获取...

Promise的九大方法(promise的实例方法)

1、promise.resolv静态方法Promise.resolve(value)可以认为是newPromise方法的语法糖,比如Promise.resolve(42)可以认为是以下代码的语...

360前端一面~面试题解析(360前端开发面试题)

1.组件库按需加载怎么做的,具体打包配了什么-按需加载实现:借助打包工具(如Webpack的require.context或ES模块动态导入),在使用组件时才引入对应的代码。例如在V...

前端面试-Promise 的 finally 怎么实现的?如何在工作中使用?

Promise的finally方法是一个非常有用的工具,它无论Promise是成功(fulfilled)还是失败(rejected)都会执行,且不改变Promise的最终结果。它的实现原...

最简单手写Promise,30行代码理解Promise核心原理和发布订阅模式

看了全网手写Promise的,大部分对于新手还是比较难理解的,其中几个比较难的点:状态还未改变时通过发布订阅模式去收集事件实例化的时候通过调用构造函数里传出来的方法去修改类里面的状态,这个叫Re...

前端分享-Promise可以中途取消啦(promise可以取消吗)

传统Promise就像一台需要手动组装的设备,每次使用都要重新接线。而Promise.withResolvers的出现,相当于给开发者发了一个智能遥控器,可以随时随地控制异步操作。它解决了三大...

手写 Promise(手写输入法 中文)

前言都2020年了,Promise大家肯定都在用了,但是估计很多人对其原理还是一知半解,今天就让我们一起实现一个符合PromiseA+规范的Promise。附PromiseA+规范地址...

什么是 Promise.allSettled()!新手老手都要会?

Promise.allSettled()方法返回一个在所有给定的promise都已经fulfilled或rejected后的promise,并带有一个对象数组,每个对象表示对应的pr...

前端面试-关于Promise解析与高频面试题示范

Promise是啥,直接上图:Promise就是处理异步函数的API,它可以包裹一个异步函数,在异步函数完成时抛出完成状态,让代码结束远古时无限回掉的窘境。配合async/await语法糖,可...

宇宙厂:为什么前端离不开 Promise.withResolvers() ?

大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发。1.为什么需要Promise.with...

Promise 新增了一个超实用的 API!

在JavaScript的世界里,Promise一直是处理异步操作的神器。而现在,随着ES2025的发布,Promise又迎来了一个超实用的新成员——Promise.try()!这个新方法简...

一次搞懂 Promise 异步处理(promise 异步顺序执行)

PromisePromise就像这个词的表面意识一样,表示一种承诺、许诺,会在后面给出一个结果,成功或者失败。现在已经成为了主流的异步编程的操作方式,写进了标准里面。状态Promise有且仅有...

Promise 核心机制详解(promise机制的实现原理)

一、Promise的核心状态机Promise本质上是一个状态机,其行为由内部状态严格管控。每个Promise实例在创建时处于Pending(等待)状态,此时异步操作尚未完成。当异步操作成功...

javascript——Promise(js实现promise)

1.PromiseES6开始支持,Promise对象用于一个异步操作的最终完成(包括成功和失败)及结果值的表示。简单说就是处理异步请求的。之所以叫Promise,就是我承诺,如果成功则怎么处理,失败怎...

取消回复欢迎 发表评论: