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

大白话讲解KMP算法、AC自动机算法的原理及Python实现

wxin55 2024-11-17 16:48 8 浏览 0 评论


KMP算法

要弄懂AC自动机算法,首先弄清楚KMP算法。

这篇文章讲得很好:

http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html

结合这篇文章,我说下我的理解

比如如下查找,上面是原始串,下面是搜索串

要从原始串中查找是否出现搜索串,现在已经查到ABCDAB都匹配了,查找到D的时候,发现不匹配,那么下一步正常的想法可能就是从原始串的下一个字符开始重新匹配,也就是第5个B。

KMP的思想就是,要充分利用前面已经比较过的信息,所以就是要决定下一步原始串究竟是往后移动几个位置。

核心就是要先针对搜索串建立一张部分匹配表: ABCDABD

表的结构是搜索到每个位置时,对应的部分匹配值,那么对应的部分匹配值是怎么算的呢?

比如搜索到ABCDABD即第7个位置D的时候,部分匹配值就是2,这个部分匹配值就是从起始A到最后一个匹配位置B这个子串的最长的前缀和后缀的公共长度

因为前缀AB=后缀AB,所以部分匹配值就是2

再比如ABCDXYABCD,那么部分匹配值就是4

再比如ABCD,那么部分匹配值就是0

那么最后匹配到搜索串每个位置(第一个不匹配位置)向后移动的位数的计算公式是:

移动位数=已匹配的字符数-对应的部分匹配值

其实可以这么理解:

基于搜索串搜索到D位置时,部分匹配值是2,即前缀AB=后缀AB, 又因为,ABCDAB和原始串中的ABCDAB匹配, 所以:

2AB=1AB, 2AB=4AB, 所以1AB=4AB, 所以下一步可以直接移动到如下图: 即向后移动4位(6-2),这之前的都不可能和搜索串匹配,所以不用再比较

并且此时,1AB=4AB了,所以可以直接从两者的下一个字符开始继续匹配了。

所以总结下KMP算法的思路:

a、针对搜索串建立一个部分匹配表,其实可以直接建立一个移动位数表

b、开始搜索,一旦搜索到某个位置时不匹配时,查表得出搜索串的下一个匹配位置

AC自动机算法

AC自动机其实就是在Trie字典树的基础上,加了类似于KMP算法的next数组

它包含两个操作:

a、将所有的模式串构建成Trie树
b、在Trie树上构建失败指针,就相当于KMP算法中失效函数next数组

假设现在有这一个Trie树: 模式串有[abcd, bcd, c] 三个

现在要查找的搜索串是efabcgh, 目标是从这个搜索串中查找所有含有的模式串,很显然头部ef都不匹配, 所以从a开始匹配,b也匹配,c也匹配,但是继续往下查找到g时,发现d != g, 那么接下来,可能就是正常的做法就是从搜索串中a的下一个字符b开始继续从Trie的根节点开始查找。

AC自动机引入了失败指针,即此时也可以利用之前已经比较过的串,通过失败指针,当匹配失败的时候,指向下一步跳到的比较位置。

肉眼看失败指针查找方式:

找搜索串从起始到当前失败的位置这个子串的后缀与所有模式串前缀匹配的最长的那个。比如efabcgh搜索串,当前搜索到g的时候,发现失败,即在状态3的时候失败了,abc是匹配的,那么就找abc的后缀与所有的模式串[abcd, bcd, c]前缀匹配的最长的那个的, abc的后缀有[c,bc], 很显然bc后缀与模式串bcd的前缀bc一致,而且是最长的那个,所以下一步就从Trie树的bcd中的c的位置开始继续找,那么节点3的失败指针就是节点6。


失败指针的递归思路:

上面是我们肉眼直接看出来的失败指针,实际在代码中找失败指针的时候其实是一个递归的过程,是基于父节点的失败指针来查找子节点的失败指针,比如当前我们要找3的失败指针,首先3的父节点2的失败指针是, 用上面同样的方式,我们知道是5, 那么这就是递归中已知的第n-1步的结果。 那么如果父节点的失败指针5在输入c字符(注意这里的c跟2到3之间的字符是一致的)后能找到一个子节点,那么这个子节点就是3的失败指针。 如果字符不匹配,则继续找父节点2的失败指针5的失败指针。通过这种方式其实就是找到的搜索串的后缀和所有模式串的前缀的公共长度最长的节点。

所以总结下AC自动机的思路: 具体的逻辑可参考下面的代码

a、构建Trie树
b、构建失败指针
c、开始查找:
     循环开始
     如果从Trie树中找到了匹配,那么Trie树去向子节点,并且搜索串后移
     如果没有找到匹配,并且此时在根节点,那么搜索串后移,此时会重新从根节点开始查找
     如果没有找到匹配,并且此时不在根节点,那么就去该节点的失败指针 
     回到循环开始

AC自动机算法小实验:

#-*- coding:utf8 -*-
 
import time
import pdb
import sys
from collections import defaultdict
 
class Node:
    def __init__(self):
        self.patterns = []
        self.children = {}  #key是一个word,value是下一个Node
        self.fail_node = None  
 
class AC_Automation:
    def __init__(self, patterns):
        self.root = Node()  #Trie树的根节点
        self.fail_node = {}  #存每个节点的失败指针
        for pattern in patterns:
            self.add_pattern(pattern)
        self.build_fail()
 
    def add_pattern(self, pattern):
        curr_node = self.root
        for word in pattern:
            if word not in curr_node.children:
                child_node = Node()
                curr_node.children[word] = child_node
 
            curr_node = curr_node.children[word]
        curr_node.patterns.append(pattern)  
 
    #BFS遍历求每个节点的失败指针
    def build_fail(self):
        self.root.fail_node = self.root
        node_queue = []
        #深度为1的节点的失败指针都是root
        for word in self.root.children:
            child = self.root.children[word]
            child.fail_node = self.root
            node_queue.append(child)  
        index = 0
        while node_queue:
            print index
            index += 1
            node = node_queue.pop(0)
            for word in node.children:
                child_node = node.children[word]
                node_queue.append(child_node)  #子孩子入队
 
                # 递归式:fail(child) = goto(fail(father), ch)
                # 基于父节点的失败指针,递推子节点的失败指针。父节点到子节点之间的字符是ch,找到父节点的失败指针后,需要看该指针有没有子节点ch, 如果有,那子节点ch就是child的失败指针,否则,继续找father的失败指针
                v = node.fail_node  #先取父节点的失败指针
                while v != self.root and word not in v.children:  
                    v = v.fail_node
                fail_node = v.children.get(word, self.root)       
                child_node.fail_node = fail_node
        
    def search_pattern(self, text):
        curr_node = self.root
        patterns = []  
 
        index = 0
        curr_node = self.root
        while index < len(text):
            print index
            word = text[index]
            if word not in curr_node.children:
                if curr_node == self.root:
                    index += 1
                else:  
                    curr_node = curr_node.fail_node
            else:
                index += 1
                curr_node = curr_node.children[word]     
            patterns.extend(curr_node.patterns)
 
        #可能字符串刚匹配完的时候,恰好找到了最后,比如一个分支是def,另外有一个分支ef, 那么匹配字符串def会匹配成功,此时应该继续跳失败节点才能把ef匹配成功
        while curr_node != self.root:
            curr_node = curr_node.fail_node
            patterns.extend(curr_node.patterns)
 
        return patterns
 
if __name__ == '__main__':
    patterns = ['abc', 'ab', 'def', 'acg', 'cd', 'bc', 'bcd', 'ef', 'de', 'efg', 'fg', 'ghk', 'gk', 'hk', 'a']
    ac = AC_Automation(patterns)
    print ac.search_pattern('abcdefghk')  #输出:['a', 'ab', 'abc', 'bc', 'bcd', 'cd', 'de', 'def', 'ef', 'efg', 'fg', 'ghk', 'hk']

我们知道,AC自动机有一个很大的问题是,消耗的内存特别大,当我们要做的中文字符串的查找时,如何选择最小的字符单位,会直接影响查询速度和占用内存,比如:

建造Trie树的时间、构建失败指针的时间,占用的内存等

下面是我做的测试:

我的模式串总共10w个,每个串都不长,平均差不多4个汉字

搜索串的长度为489个汉字

a、以utf8为最小单位,即树的每个节点是utf8的,所以一个汉字会被拆分成两个节点,得到的结果


b、以unicode为最小单位,即树的每个节点都是unicode的,所以一个汉字占一个节点,得到的结果


c、以分词为单位,即树的每个节点都是一个分词的结果,所以是一个词占一个节点,得到的结果

总结:

可以看到,选择的单节点的字节数越多,消耗的内存越少,并且建树、检索的时间的时间也越短。

正常情况下可能会选择用分词的方式,这种方式的确查询效率很高,但是分词本身是需要消耗时间的,所以如果为了降低内存消耗,可以牺牲掉查询速度。如果为了提高查询速度,那么选择unicode作为单节点是比较合理的方式。


原文链接:https://blog.csdn.net/u011734144/article/details/90061561

相关推荐

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,就是我承诺,如果成功则怎么处理,失败怎...

取消回复欢迎 发表评论: