字符串算法 | AC自动机算法

978 阅读8分钟

1、简介

  • 一种多模式串匹配算法, 可以快速从主串中同时找出所有包含的所有模式串.
  • 对比KMP是单模式匹配, 虽然可以使用单模式串匹配算法逐个进行查找模式串, 但是实际场景中,若模式串的数量可能很大,并且要匹配的文本内容很多,导致匹配的时长过长.
  • 时间复杂度:
    • 字典树的生成(只需要处理一次即可): O(模式串数量 x 模式串长度)
    • 构建失败指针(只需要处理一次即可): O(节点个数 x 树高)
    • 模式匹配过程(进行多次): O( 主串长度 x 树高) , 如果模式串长度不是很长, 则字典树很扁,则AC自动机的搜索过程复杂度趋近于O(n)
  • 应用场景: 对发表的文章或者评论可以过滤出内容中包含的所有敏感词条。

2、预备知识

2.1 字典树

  • 字典树又叫前缀树
  • 它本质是一颗多叉树, 除了根节点, 每个节点存放一个字符, 从根节点到某一节点,路径上经过的字符连接起来,就是该节点对应的字符串。
  • 在实现上一般为了知道一个节点是否是一个合法的单词结尾, 会在该节点上打上标记.
  • 将每个字符串插入字典树后, 每个字符串的公共前缀都将作为一个字符节点保存。
  • 常应用于词频统计 和 前缀匹配.

字典树动画演示网站地址

  • 可以尝试插入 he、she、hers、his、shy 等字符串

在这里插入图片描述

2.2 树的广度遍历

  • 就是按层级遍历, 在字典树构造失败指针的过程中, 需要使用BFS遍历顺序依次对每个节点构建失败指针

树的BFS遍历伪代码:

queue = Queue()
queue.put(self.root) # 先把根节点入队

# 只要队列不为空, 就每次取出队头节点, 然后将该节点的所有子节点依次入队, 依次循环完成BFS遍历
while not queue.empty():
	# 取出队头消费
	parentNode = queue.get()
	
	.....
	
	# 获取所有将该节点所有子节点依次入队
	for childrenNode in parentNode.getAllChildrenNode():
		queue.put(childrenNode)

3、AC自动机代码实现

3.1 构建字典树

  • 其实字典树并没有太多的约束规则, 所以生成字典树过程也是非常简单的.

1、AC字典树节点设计:

class Node:
    def __init__(self) -> None:
        # 失败指针
        self.fail: Node = None
        # 哈希表存放所有子节点, key是字符, value是对应的Node节点
        self.next: Dict[str, Node] = {}
        # 标记单词结尾:  如果该节点是单词结尾, 用set集合存放其单词长度
        self.wordLenSet = set()

2、将一个单词插入字典树:

    def insert(self, pattern):
    	# 从根节点开始, 从cur指针遍历字典树
        cur = self.root
		# 遍历每一个字符
        for c in pattern:
        	# 如果该节点的孩子节点中不存在该字符, 则新建并插入
            if not cur.next.get(c):
                cur.next[c] = Node()
            # 指向下一个字符节点
            cur = cur.next.get(c)
		# 最后cur指向最后一个字符节点, 将其标记为单词结尾
        cur.wordLenSet.add(len(pattern))

比如将模式串 he、she、hers、his、shy 依次插入字典树后, 并构建成AC字典树如下

在这里插入图片描述

3.2 构建失败fail指针

什么失败指针

  • 比如如果节点A的fail指针指向节点B, 那么节点B的代表的单词是节点A代表的单词的最长后缀
  • 它表示的是在主串发生匹配失败时, 下一步应该跳转的节点, 然后继续去匹配.

假设下图是构建好的失败指针的字典树:

  • 比如节点9代表的单词就是she, 那么节点9的失败指针指向节点4, 因为节点4代表的单词是节点9代表的单词的最长后缀 在这里插入图片描述

失败指针有什么用?

  • 因为通过预处理得到了每个节点的失败指针指向, 这样在主串某个节点进行匹配失败的时候, 不用再重新从根节点出发进行匹配, 减少了匹配的过程, 提高了搜索效率

AC自动机构建失败指针过程动画演示:

在这里插入图片描述

代码实现

  • 肉眼对每个节点的构建失败指针很容易, 因为一眼就能看出在树中最长后缀是否存在
  • 而代码实现需要基于BFS广度遍历 和 父节点的失败指针去得到每个节点的失败指针指向
  • 核心在于pFail指针的回退, 每一次只需要判断当前的pFail指针的孩子节点是否存在该字符, 如果存在则让节点的失败指针指向它, 因为父节点的失败指针pFail就代表了它此时指向的最长后缀, 只需在判断最新的一位就知道此时是不是该节点此时的最长后缀. 但是如果不存在, 需要继续回退pFail=pFail.fail, 从pFail的失败指针开始继续寻找, 以此循环回退.
    def buildFial(self):
        queue = Queue()
        queue.put(self.root)
        
        # 对字典树进行BFS遍历对每个节点的所有孩子节点构建失败指针
        while not queue.empty():
            parentNode: Node = queue.get()
			
			# 对每个孩子节点构建失败指针
            for c, node in parentNode.next.items():
                # 孩子节点node的父节点的失败指针
                pFail = parentNode.fail

                # 循环判断其父节点的失败指针下是否存在该字符,如果有指向它并退出, 否则最终回退到指向根节点
                while pFail is not None and not pFail.next.get(c):
                    pFail = pFail.fail
				
				# 如果pFail为null说明回退到了根节点说明在树上不存在最长后缀,直接指向root
                if pFail is None:
                    node.fail = self.root
                else:
                    node.fail = pFail.next.get(c)
                    
                    # 把失败指针存放的单词长度也添加进来这个失配的节点,方便后面快速获取到所有匹配的模式串
                    if len(node.fail.wordLenSet) > 0:
                        node.wordLenSet.update(node.fail.wordLenSet)

                #
                queue.put(node)

3.3 进行模式匹配

  • 模式匹配规程中,先按常规的字典树的查找过程进行匹配,如果在某个节点匹配失败,则回退到失败指针继续匹配。回退结束后, 如果匹配成功, 并且该节点存储了模式串的信息(比如模式串的长度集合), 则可以根据当前遍历索引i - wordLen + 1计算得到每个模式串在主串索引的起始位置index, 所以[index, index + wordLen] 就是其在主串的索引范围

比如对主串 ishery 进行搜索匹配找出的所有的模式串过程

在这里插入图片描述

代码实现

    # 搜索主串, 找出其中所有包含的模式串
    def search(self, text):
        cur = self.root

        for i in range(len(text)):   
            # 如果该节点不存在该字符, 则从失败指针继续判断, 不断回退直到找到或者回退到根节点
            while cur.fail and cur.next.get(text[i]) is None:
                cur = cur.fail

            if cur.next.get(text[i]):
                cur = cur.next.get(text[i])
                
                # 如果匹配成功并且该节点是单词结尾, 用 i - 单词长度即可获得模式串在主串的位置
                if len(cur.wordLenSet) > 0:
                    for wordLen in cur.wordLenSet:
                        index = i - wordLen + 1
                        match = text[index:i+1]
                        print(f"匹配到了模式串: {match}, 其实索引为: {index}")

4、完整代码实现(Python)

from queue import Queue
from typing import Dict


class Node:

    def __init__(self) -> None:
        # 失败指针
        self.fail: Node = None
        # 哈希表存放所有子节点, key是字符, value是对应的Node节点
        self.next: Dict[str, Node] = {}
        # 如果该节点是单词结尾, 用set结合存放其单词长度
        self.wordLenSet = set()


class AcTree:

    def __init__(self, patternList) -> None:
        self.root = Node()
        
        # 将所有单词插入字典树
        for e in patternList:
            self.insert(e)
        
        # 对字典树构建失败指针
        self.buildFial()

    def insert(self, pattern):
    	# 从根节点开始, 从cur指针遍历字典树
        cur = self.root
		# 遍历每一个字符
        for c in pattern:
        	# 如果该节点的孩子节点中不存在该字符, 则新建并插入
            if not cur.next.get(c):
                cur.next[c] = Node()
            # 指向下一个字符节点
            cur = cur.next.get(c)
		# 最后cur指向最后一个字符节点, 将其标记为单词结尾
        cur.wordLenSet.add(len(pattern))

    def buildFial(self):
        queue = Queue()
        queue.put(self.root)
        
        # 对字典树进行BFS遍历对每个节点的所有孩子节点构建失败指针
        while not queue.empty():
            parentNode: Node = queue.get()
			
			# 对每个孩子节点构建失败指针
            for c, node in parentNode.next.items():
                # 孩子节点node的父节点的失败指针
                pFail = parentNode.fail

                # 循环判断其父节点的失败指针下是否存在该字符,如果有指向它并退出, 否则最终回退到指向根节点
                while pFail is not None and not pFail.next.get(c):
                    pFail = pFail.fail
				
				# 如果pFail为null说明回退到了根节点说明在树上不存在最长后缀,直接指向root
                if pFail is None:
                    node.fail = self.root
                else:
                    node.fail = pFail.next.get(c)
                    
                    # 把失败指针存放的单词长度也添加进来这个失配的节点,方便后面快速获取到所有匹配的模式串
                    if len(node.fail.wordLenSet) > 0:
                        node.wordLenSet.update(node.fail.wordLenSet)

                #
                queue.put(node)
    
    # 搜索主串, 找出其中所有包含的模式串
    def search(self, text):
        cur = self.root

        for i in range(len(text)):   
            # 如果该节点不存在该字符, 则从失败指针继续判断, 不断回退直到找到或者回退到根节点
            while cur.fail and cur.next.get(text[i]) is None:
                cur = cur.fail

            if cur.next.get(text[i]):
                cur = cur.next.get(text[i])
                
                # 如果匹配成功并且该节点是单词结尾, 用 i - 单词长度即可获得模式串在主串的位置
                if len(cur.wordLenSet) > 0:
                    for wordLen in cur.wordLenSet:
                        index = i - wordLen + 1
                        match = text[index:i+1]
                        print(f"匹配到了模式串: {match}, 其实索引为: {index}")



if __name__ == '__main__':
    # 测试
    ac = AcTree(["he", "she", "hers", "his", "shy"])
    ac.search("ishery")