字符串匹配算法
BF算法
BF算法中的BF是 Brute Force 的缩写,中文叫作暴力匹配算法,也叫朴素匹配算法。从名字可以看出,这种算法的字符串匹配方式很暴力 ,当然也就会比较简单、好懂,但相应的性能也不高。
在开始讲解这个算法之前,我先定义两个概念,方便我后面讲解。它们分别是主串和模式串。这俩概念很好理解,我举个例子你就懂了。 比方说,我们在字符串A中查找字符串B,那字符串A就是主串,字符串B就是模式串。我们把主串的长度记作n,模式串的长度记作m。因为我们是在主串中查找模式串,所以 n>m。
作为最简单、最暴力的字符串匹配算法,BF算法的思想可以用一句话来概括,那就是,我们在主串中,检查起始位置分别是0、1、2...n-m 且长度为 m 的 n-m+1 个子串,看有没有跟模式串匹配的。
在极端情况下,比如主串是 "aaaaa…aaaaaa" (省略号表示有很多重复的字符 ),模式串是 "aaaaaaab"。 我们每次都比对m个字符,要比对n-m+1次,所以,这种算法的最坏情况时间复杂度是O(n*m)。
尽管理论上,BF算法的时间复杂度很高,是O(n*m),但在实际的开发中,它却是一个比较常用的字符串匹配算法。为什么这么说呢?原因有两点。
第一,实际的软件开发中,大部分情况下,模式串和主串的长度都不会太长。而且每次模式串与主串中的子串匹配的时候,当中途遇到不能匹配的字符的时候,就可以就停止了,不需要把m个字符都比对一下。 所以,尽管理论上的最坏情况时间复杂度是O(n*m),但是,统计意义上,大部分情况下,算法执行效率要比这个高很多。
第二,朴素字符串匹配算法思想简单,代码实现也非常简单。 简单意味着不容易出错,如果有bug也容易暴露和修复。在工程中,在满足性能要求的前提下,简单是首选。这也是我们常说的 KISS(Keep it Simple and Stupid) 设计原则。
所以,在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了。
RK算法
RK算法的思路是这样的:我们通过哈希算法对主串中的n-m+1个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。 如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(这里先不考虑哈希冲突的问题,后面我们会讲到)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了。
不过,通过哈希算法计算子串的哈希值的时候,我们需要遍历子串中的每个字符。尽管模式串与子串比较的效率提高了,但是,算法整体的效率并没有提高。有没有方法可以提高哈希算法计算子串哈希值的效率呢?
特殊的哈希算法
这就需要哈希算法设计的非常有技巧了。我们假设要匹配的字符串的字符集中只包含K个字符,我们可以用一个K进制数来表示一个子串,这个K进制数转化成十进制数,作为子串的哈希值。表述起来有点抽象,我举了一个例子,看完你应该就能懂了。
比如要处理的字符串只包含 az 这26个小写字母,那我们就用二十六进制来表示一个字符串。我们把 az 这26个字符映射到0~25这26个数字,a就表示0,b就表示1,以此类推,z表示25。
在十进制的表示法中,一个数字的值是通过下面的方式计算出来的。对应到二十六进制,一个包含a到z这26个字符的字符串,计算哈希的时候,我们只需要把进位从10改成26就可以。
为了方便解释,在下面的讲解中,我假设字符串中只包含a~z这26个小写字符,我们用二十六进制来表示一个字符串,对应的哈希值就是二十六进制数转化成十进制的结果。
从这里例子中,我们很容易就能得出这样的规律:相邻两个子串 s[i-1] 和 s[i] ( i 表示子串在主串中的起始位置,子串的长度都为m) ,对应的哈希值计算公式有交集,也就是说,我们可以使用 s[i-1] 的哈希值很快的计算出 s[i] 的哈希值。如果用公式表示的话,就是下面这个样子:
不过,这里有一个小细节需要注意,那就是26^(m-1)这部分的计算,我们可以通过查表的方法来提高效率。我们事先计算好26^0、26^1、26^2......26^(m-1),并且存储在一个长度为m的数组中,公式中的“次方”就对应数组的下标。当我们需要计算26的x次方的时候,就可以从数组的下标为x的位置取值,直接使用,省去了计算的时间。
复杂度分析
整个RK算法包含两部分,计算子串哈希值和模式串哈希值与子串哈希值之间的比较。
第一部分,我们前面也分析了,可以通过设计特殊的哈希算法,只需要扫描一遍主串就能计算出所有子串的哈希值了,所以这部分的时间复杂度是O(n)。
模式串哈希值与每个子串哈希值之间的比较的时间复杂度是O(1),总共需要比较 n-m+1 个子串的哈希值,所以,这部分的时间复杂度也是O(n)。
所以,RK算法整体的时间复杂度就是O(n)。
哈希冲突的问题
之前我们只需要比较一下模式串和子串的哈希值,如果两个值相等,那这个子串就一定可以匹配模式串。但是,当存在哈希冲突的时候,有可能存在这样的情况,子串和模式串的哈希值虽然是相同的,但是两者本身并不匹配。
实际上,解决方法很简单。当我们发现一个子串的哈希值跟模式串的哈希值相等的时候,我们只需要再对比一下子串和模式串本身就好了。
所以,哈希算法的冲突概率要相对控制得低一些,如果存在大量冲突,就会导致RK算法的时间复杂度退化,效率下降。极端情况下,如果存在大量的冲突,每次都要再对比子串和模式串本身,那时间复杂度就会退化 O(n*m)。但也不要太悲观,一般情况下,冲突不会很多,RK算法的效率还是比BF算法高的。
BM算法
核心思想
在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。
原理分析
BM算法包含两部分,分别是坏字符规则(bad character rule)和好后缀规则(good suffix shift)。
坏字符规则
BM算法的匹配顺序比较特别,它是按照模式串下标从大到小的顺序,倒着匹配的。
我们从模式串的末尾往前倒着匹配,当我们发现某个字符没法匹配的时候。我们把这个没有匹配的字符叫作坏字符(主串中的字符)。
当发生不匹配的时候,我们把坏字符对应的模式串中的下标记作 si。如果坏字符在模式串中存在,我们把这个坏字符在模式串中的下标记作 xi。如果不存在,我们把 xi 记作 -1。那模式串往后移动的位数就等于si-xi。(注意,我这里说的下标,都是字符在模式串的下标)。
如果坏字符在模式串里多处出现,那我们在计算 xi 的时候,选择最靠右的那个,因为这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。
利用坏字符规则,BM算法在最好情况下的时间复杂度非常低,是 O(n/m) 。比如,主串是 aaabaaabaaabaaab,模式串是 aaaa。每次比对,模式串都可以直接后移四位,所以,匹配具有类似特点的模式串和主串的时候,BM算法非常高效。
不过,单纯使用坏字符规则还是不够的。因为根据 si-xi 计算出来的移动位数,有可能是负数,比如主串是 aaaaaaaaaaaaaaaa ,模式串是 baaa ,si 是 0, xi 是3, si-xi = -3 。不但不会向后滑动模式串,还有可能倒退。所以, BM算法还需要用到好后缀规则 。
这里我觉得完全可以加上一个条件限制,si 必须要大于 xi,这样就不会出现移动位数可能是负数的情况了。
示例图
好后缀规则
我们把已经匹配的 bc 叫作好后缀,记作 {u} 。我们拿它在模式串中查找,可能出现两种情况:
- 如果找到了另一个跟 {u} 相匹配的子串 {},那我们就将模式串滑动到子串 {} 与主串中 {u} 对齐的位置
- 如果没有找到可匹配的子串,我们需要检查好后缀的后缀子串,是否存在跟模式串的前缀子串相匹配的情况
- 不存在,直接将模式串移动到好后缀的后面
- 若存在,我们从好后缀的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设是 {v},然后将模式串滑动到如图所示的位置。
示例图
坏字符与好后缀如何配合使用?
我们可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。
代码实现
#!/usr/bin/python
# -*- coding: UTF-8 -*-
SIZE = 256
def bm(main, pattern):
"""
BM算法
匹配规则:
1. 坏字符规则
2. 好字符规则
"""
assert type(main) is str and type(pattern) is str
n, m = len(main), len(pattern)
if n <= m:
return 0 if main == pattern else -1
# bc
bc = [-1] * SIZE
generate_bc(pattern, m, bc)
# gs
suffix = [-1] * m
prefix = [False] * m
generate_gs(pattern, m, suffix, prefix)
i = 0
while i < n-m+1:
j = m - 1
while j >= 0:
if main[i+j] != pattern[j]:
break
else:
j -= 1
# pattern整个已被匹配,返回
if j == -1:
return i
# 1. bc规则计算后移位数
x = j - bc[ord(main[i+j])]
# 2. gs规则计算后移位数
y = 0
if j != m - 1: # 存在gs
y = move_by_gs(j, m, suffix, prefix)
i += max(x, y)
return -1
def generate_bc(pattern, m, bc):
"""
生成坏字符哈希表
"""
for i in range(m):
bc[ord(pattern[i])] = i
def generate_gs(pattern, m, suffix, prefix):
"""
好后缀预处理
"""
for i in range(m-1):
k = 0 # pattern[:i+1]和pattern的公共后缀长度
for j in range(i, -1, -1):
if pattern[j] == pattern[m-1-k]:
k += 1
suffix[k] = j
if j == 0:
prefix[k] = True
else:
break
def move_by_gs(j, m, suffix, prefix):
"""
通过好后缀计算移动值
需要处理三种情况:
1. 整个好后缀在pattern仍能找到
2. 好后缀里存在 *后缀子串* 能和pattern的 *前缀* 匹配
3. 其他
"""
k = m - 1 - j # j指向从后往前的第一个坏字符,k是此次匹配的好后缀的长度
if suffix[k] != -1: # 1. 整个好后缀在pattern剩余字符中仍有出现
return j - suffix[k] + 1
else:
for r in range(j+2, m): # 2. 后缀子串从长到短搜索
if prefix[m-r]:
return r
return m # 3. 其他情况
if __name__ == '__main__':
print('--- search ---')
m_str = 'dfasdeeeetewtweyyyhtruuueyytewtweyyhtrhrth'
p_str = 'eyytewtweyy'
print('[Built-in Functions] result:', m_str.find(p_str))
print('[bm] result:', bm(m_str, p_str))
性能分析
我们先来分析BM算法的内存消耗。整个算法用到了额外的3个数组,其中bc数组的大小跟字符集大小有关,suffix数组和prefix数组的大小跟模式串长度m有关。
如果我们处理字符集很大的字符串匹配问题,bc数组对内存的消耗就会比较多。因为好后缀和坏字符规则是独立的,如果我们运行的环境对内存要求苛刻,可以只使用好后缀规则,不使用坏字符规则,这样就可以避免bc数组过多的内存消耗。不过,单纯使用好后缀规则的BM算法效率就会下降一些了。
BM算法在最坏情况下的时间复杂度为O(mn),在最好情况下的时间复杂度为O(n/m)。
KMP算法
KMP算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位?
我们只需要在好前缀中,查找那个最长的可以跟好前缀的前缀子串匹配的后缀子串。为了表述起来方便,我把好前缀的所有后缀子串中,最长的可匹配前缀子串的那个后缀子串,叫作最长可匹配后缀子串;对应的前缀子串,叫作最长可匹配前缀子串。
假设最长可匹配前缀子串是 {v} ,长度是k,坏字符对应模式串中的字符的下标是 j,我们就可以把模式串一次性往后滑动 j-k 位,之后不断循环。
如何来求好前缀的最长可匹配前缀和后缀子串呢?我发现,这个问题其实不涉及主串,只需要通过模式串本身就能求解。所以,我就在想,能不能事先预处理计算好,在模式串和主串匹配的过程中,直接拿过来就用呢?
KMP算法需要提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标,我们把这个数组定义为next数组,很多书中还给这个数组起了一个名字,叫失效函数。数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可匹配前缀子串的结尾字符下标。
失效函数计算方法
核心思想:根据 next[0],next[1]…… next[i-1] 的值求解 next[i]
- 首先取出前一个最长的匹配的前缀子串,其下标就是next[i-1]
- 对比下一个字符,如果匹配,直接赋值
next[i]为next[i-1]+1,因为 i-1 的时候已经是最长 - 如果不匹配,需要递归去找次长的匹配的前缀子串,这里难理解的就是递归地方式,
next[i-1]是 i-1 的最长匹配前缀子串的下标结尾,则next[next[i-1]]是其次长匹配前缀子串的下标结尾 - 递归的出口,就是在次长前缀子串的下一个字符和当前匹配 或 遇到 -1,遇到 -1则说明没找到任何匹配的前缀子串,这时需要找pattern的第一个字符对比
所谓的次长可匹配前缀子串,就是把原最长可匹配前缀子串作为好前缀,求它的最长可匹配前缀子串
代码实现
#!/usr/bin/python
# -*- coding: UTF-8 -*-
def kmp(main, pattern):
"""
kmp字符串匹配
"""
assert type(main) is str and type(pattern) is str
n, m = len(main), len(pattern)
if m == 0:
return 0
if n <= m:
return 0 if main == pattern else -1
# 求解next数组
next = get_next(pattern)
j = 0 # j 就是好前缀的长度
for i in range(n):
# 在pattern[:j]中,从长到短递归去找最长的和后缀子串匹配的前缀子串
while j > 0 and main[i] != pattern[j]:
j = next[j-1] + 1 # 如果next[j-1] = -1,则要从起始字符取匹配
"""
1. j > 0 说明已经存在好前缀,
2. main[i] != pattern[j] 说明下一个字符并不匹配,所以去好前缀里找
最长的和后缀子串匹配的前缀子串
"""
if main[i] == pattern[j]:
if j == m-1: # 模式串匹配成功
return i-m+1
else:
j += 1
return -1
def get_next(pattern):
"""
next数组生成
注意:
理解的难点在于next[i]根据next[0], next[1]…… next[i-1]的求解
next[i]的值依赖于前面的next数组的值,求解思路:
1. 首先取出前一个最长的匹配的前缀子串,其下标就是next[i-1]
2. 对比下一个字符,如果匹配,直接赋值next[i]为next[i-1]+1,因为i-1的时候已经是最长
3. 如果不匹配,需要递归去找次长的匹配的前缀子串,这里难理解的就是递归地方式,next[i-1]
是i-1的最长匹配前缀子串的下标结尾,则 *next[next[i-1]]* 是其次长匹配前缀子串的下标
结尾
4. 递归的出口,就是在次长前缀子串的下一个字符和当前匹配 或 遇到-1,遇到-1则说明没找到任
何匹配的前缀子串,这时需要找pattern的第一个字符对比
ps: next[m-1]的数值其实没有任何意义,求解时可以不理。网上也有将next数组往右平移的做法。
"""
m = len(pattern)
next = [-1] * m
next[0] = -1
# for i in range(1, m):
for i in range(1, m-1):
j = next[i-1] # 取i-1时匹配到的最长前缀子串
while j != -1 and pattern[j+1] != pattern[i]:
j = next[j] # 次长的前缀子串的下标,即是next[next[i-1]]
# 根据上面跳出while的条件,当j=-1时,需要比较pattern[0]和当前字符
# 如果j!=-1,则pattern[j+1]和pattern[i]一定是相等的
if pattern[j+1] == pattern[i]: # 如果接下来的字符也是匹配的,那i的最长前缀子串下标是next[i-1]+1
j += 1
next[i] = j
return next
if __name__ == '__main__':
m_str = "aabbbbaaabbababbabbbabaaabb"
p_str = "abbabbbabaa"
print('--- search ---')
print('[Built-in Functions] result:', m_str.find(p_str))
print('[kmp] result:', kmp(m_str, p_str))
复杂度分析
空间复杂度很容易分析,KMP算法只需要一个额外的next数组,数组的大小跟模式串相同。所以空间复杂度是O(m),m表示模式串的长度。
KMP算法包含两部分,第一部分是构建next数组,第二部分才是借助next数组匹配。所以,关于时间复杂度,我们要分别从这两部分来分析。
我们先来分析第一部分的时间复杂度。
计算next数组的代码中,第一层for循环中i从1到m-1,也就是说,内部的代码被执行了m-1次。for循环内部代码有一个while循环,如果我们能知道每次for循环、while循环平均执行的次数,假设是k,那时间复杂度就是O(k*m)。但是,while循环执行的次数不怎么好统计,所以我们放弃这种分析方法。
我们可以找一些参照变量,i和k。i从1开始一直增加到m,而k并不是每次for循环都会增加,所以,k累积增加的值肯定小于m。而while循环里k=next[k],实际上是在减小k的值,k累积都没有增加超过m,所以while循环里面k=next[k]总的执行次数也不可能超过m。因此,next数组计算的时间复杂度是O(m)。
我们再来分析第二部分的时间复杂度。分析的方法是类似的。
i从0循环增长到n-1,j的增长量不可能超过i,所以肯定小于n。而while循环中的那条语句j=next[j-1]+1,不会让j增长的,那有没有可能让j不变呢?也没有可能。因为next[j-1]的值肯定小于j-1,所以while循环中的这条语句实际上也是在让j的值减少。而j总共增长的量都不会超过n,那减少的量也不可能超过n,所以while循环中的这条语句总的执行次数也不会超过n,所以这部分的时间复杂度是O(n)。
所以,综合两部分的时间复杂度,KMP算法的时间复杂度就是O(m+n)。
Trie树
Trie树,也叫字典树。顾名思义,它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
Trie树的基本性质:
- 根节点不包含任何信息,除根节点以外每个节点只包含一个字符。
- 从根节点到红色节点的一条路径表示一个字符串(注意:红色节点并不都是叶子节点)。
Trie树的主要操作
Trie树的构造(插入字符串):
对于一个字符串,从根开始,沿着字符串的各个字符所对应的树中的节点分支向下走,直到字符串遍历完,将最后的节点标记为红色,表示该字符串已插入Trie树。
从Trie树中查询某字符串:
从根开始按照字符串的字符顺序向下遍历Trie树,一旦发现某个节点标记不存在或者字符串遍历完成而最后的节点未标记为红色,则表示该字符串不存在,若最后的节点标记为红色,表示该字符串存在。
Trie树的存储结构
对Trie树中的每一个节点,我们都构建一个数组,该数组的长度是字符集长度,数组的下标与字符集内的字符一一对应,数组内存储的是指向子节点的指针。
如果字符集是26个小写字母,那数组长度就为26,我们在数组中下标为0的位置,存储指向子节点a的指针,下标为1的位置存储指向子节点b的指针,以此类推,下标为25的位置,存储的是指向的子节点z的指针。如果某个字符的子节点不存在,我们就在对应的下标的位置存储null。
当我们在Trie树中查找字符串的时候,我们可以通过字符的ASCII码减去 a 的ASCII码,迅速找到匹配的子节点的指针。比如 d 的ASCII码减去 a 的ASCII码是3,那子节点 d 的指针就存储在数组中下标为3的位置中。
问题与优化
如果按照经典的存储方式,用数组来存储一个节点的子节点的指针,如果字符串中包含从a到z这26个字符,那每个节点都要存储一个长度为26的数组,即便一个节点只有很少的子节点,远小于26个,比如3、4个,我们也要维护一个长度为26的数组。如果字符串中不仅包含小写字母,还包含大写字母、数字、甚至是中文,那需要的存储空间就更多了。
所以问题来了,在重复的前缀并不多的情况下,Trie树会浪费很多内存。
我们可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针。用哪种数据结构呢?我们的选择其实有很多,比如有序数组、跳表、散列表、红黑树等。
假设我们用有序数组,数组的大小为子节点的个数,数组中的指针按照所指向的子节点中的字符的大小顺序排列。查询的时候,我们可以通过二分查找的方法,快速查找到某个字符应该匹配的子节点的指针。但是,在往Trie树中插入一个字符串的时候,我们为了维护数组中数据的有序性,就会稍微慢了点。
Trie树与散列表、红黑树比较
实际上,字符串的匹配问题,笼统上讲,其实就是数据的查找问题。对于支持动态数据高效操作的数据结构,我们前面已经讲过好多了,比如散列表、红黑树、跳表等等。实际上,这些数据结构也可以实现在一组字符串中查找字符串的功能。我们选了两种数据结构,散列表和红黑树,跟Trie树比较一下,看看它们各自的优缺点和应用场景。
在刚刚讲的这个场景,在一组字符串中查找字符串,Trie树实际上表现得并不好。它对要处理的字符串有及其严苛的要求。
- 第一,字符串中包含的字符集不能太大。我们前面讲到,如果字符集太大,那存储空间可能就会浪费很多。即便可以优化,但也要付出牺牲查询、插入效率的代价。
- 第二,要求字符串的前缀重合比较多,不然空间消耗会变大很多。
- 第三,如果要用Trie树解决问题,那我们就要自己从零开始实现一个Trie树,还要保证没有bug,这个在工程上是将简单问题复杂化,除非必须,一般不建议这样做。
- 第四,我们知道,通过指针串起来的数据块是不连续的,而Trie树中用到了指针,所以,对缓存并不友好,性能上会打个折扣。
综合这几点,针对在一组字符串中查找字符串的问题,我们在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,我们都不需要自己去实现,直接利用编程语言中提供的现成类库就行了。
讲到这里,你可能要疑惑了,讲了半天,我对Trie树一通否定,还让你用红黑树或者散列表,那Trie树是不是就没用了呢?是不是今天的内容就白学了呢?
实际上,Trie树只是不适合精确匹配查找,这种问题更适合用散列表或者红黑树来解决。Trie树比较适合的是查找前缀匹配的字符串。
代码实现
#!/usr/bin/python
# -*- coding: UTF-8 -*-
class Node:
def __init__(self, c):
self.data = c
self.is_ending_char = False
# 使用有序数组,降低空间消耗,支持更多字符
self.children = []
def insert_child(self, c):
self._insert_child(Node(c))
def _insert_child(self, node):
"""
插入一个子节点
"""
v = ord(node.data)
idx = self._find_insert_idx(v)
length = len(self.children)
if idx == length:
self.children.append(node)
else:
self.children.append(None)
for i in range(length, idx, -1):
self.children[i] = self.children[i-1]
self.children[idx] = node
def has_child(self, c):
return True if self.get_child(c) is not None else False
def get_child(self, c):
"""
搜索子节点并返回
"""
start = 0
end = len(self.children) - 1
v = ord(c)
while start <= end:
mid = (start + end)//2
if v == ord(self.children[mid].data):
return self.children[mid]
elif v < ord(self.children[mid].data):
end = mid - 1
else:
start = mid + 1
# 找不到返回None
return None
def _find_insert_idx(self, v):
"""
二分查找,找到有序数组的插入位置
"""
start = 0
end = len(self.children) - 1
while start <= end:
mid = (start + end)//2
if v < ord(self.children[mid].data):
end = mid - 1
else:
if mid + 1 == len(self.children) or v < ord(self.children[mid+1].data):
return mid + 1
else:
start = mid + 1
# v < self.children[0]
return 0
def __repr__(self):
return 'node value: {}'.format(self.data) + '\n' \
+ 'children:{}'.format([n.data for n in self.children])
class Trie:
def __init__(self):
self.root = Node(None)
def gen_tree(self, string_list):
"""
创建trie树
1. 遍历每个字符串的字符,从根节点开始,如果没有对应子节点,则创建
2. 每一个串的末尾节点标注为红色(is_ending_char)
"""
for string in string_list:
n = self.root
for c in string:
if n.get_child(c) is None:
n.insert_child(c)
n = n.get_child(c)
n.is_ending_char = True
def search(self, pattern):
"""
搜索
1. 遍历模式串的字符,从根节点开始搜索,如果途中子节点不存在,返回False
2. 遍历完模式串,则说明模式串存在,再检查树中最后一个节点是否为红色,是
则返回True,否则False
"""
assert type(pattern) is str and len(pattern) > 0
n = self.root
for c in pattern:
if n.get_child(c) is None:
return False
n = n.get_child(c)
return True if n.is_ending_char is True else False
if __name__ == '__main__':
string_list = ['abc', 'abd', 'abcc', 'accd', 'acml', 'P@trick', 'data', 'structure', 'algorithm']
print('--- gen trie ---')
print(string_list)
trie = Trie()
trie.gen_tree(string_list)
trie.draw_img()
print('\n')
print('--- search result ---')
search_string = ['a', 'ab', 'abc', 'abcc', 'abe', 'P@trick', 'P@tric', 'Patrick']
for ss in search_string:
print('[pattern]: {}'.format(ss), '[result]: {}'.format(trie.search(ss)))
小结
Trie树是一种解决字符串快速匹配问题的数据结构。如果用来构建Trie树的这一组字符串中,前缀重复的情况不是很多,那Trie树这种数据结构总体上来讲是比较费内存的,是一种空间换时间的解决问题思路。
尽管比较耗费内存,但是对内存不敏感或者内存消耗在接受范围内的情况下,在Trie树中做字符串匹配还是非常高效的,时间复杂度是O(k),k表示要匹配的字符串的长度。
但是,Trie树的优势并不在于,用它来做动态集合数据的查找,因为,这个工作完全可以用更加合适的散列表或者红黑树来替代。Trie树最有优势的是查找前缀匹配的字符串,比如搜索引擎中的关键词提示功能这个场景,就比较适合用它来解决,也是Trie树比较经典的应用场景。
应用场景:搜索引擎中的关键词提示功能、输入法自动补全功能、IDE代码编辑器自动补全功能、浏览器网址输入的自动补全功能等等
AC自动机
Trie树实现敏感词过滤
单模式串匹配算法,是在一个模式串和一个主串之间进行匹配,也就是说,在一个主串中查找一个模式串。多模式串匹配算法,就是在多个模式串和一个主串之间做匹配,也就是说,在一个主串中查找多个模式串。
Trie树就是一种多模式串匹配算法。那如何用Trie树实现敏感词过滤功能呢?
我们可以对敏感词字典进行预处理,构建成Trie树结构。这个预处理的操作只需要做一次,如果敏感词字典动态更新了,比如删除、添加了一个敏感词,那我们只需要动态更新一下Trie树就可以了。
当用户输入一个文本内容后,我们把用户输入的内容作为主串,从第一个字符(假设是字符C)开始,在Trie树中匹配。当匹配到Trie树的叶子节点(包含该模式串),或者中途遇到不匹配字符的时候,我们将主串的开始匹配位置后移一位,也就是从字符C的下一个字符开始,重新在Trie树中匹配。
基于Trie树的这种处理方法,有点类似单模式串匹配的BF算法。我们知道,单模式串匹配算法中,KMP算法对BF算法进行改进,引入了next数组,让匹配失败时,尽可能将模式串往后多滑动几位。借鉴单模式串的优化改进方法,能否对多模式串Trie树进行改进,进一步提高Trie树的效率呢?这就要用到AC自动机算法了。
经典的多模式串匹配算法:AC自动机
AC自动机实际上就是在Trie树之上,加了类似KMP的next数组,只不过此处的next数组是构建在树上罢了,我们称之为Fail指针,当发现失配的字符失配的时候,跳转到Fail指针指向的位置,然后再次进行匹配操作。
AC自动机的构建,包含两个操作:
- 将多个模式串构建成Trie树
- 在Trie树上构建Fail指针
构建Fail指针
Trie树中的每一个节点都有一个Fail指针,root结点的Fail指针为空。
假设我们从root结点一路匹配到p结点,从root到p组成了一个好前缀,如果好前缀的后缀子串可以匹配某个模式串的前缀子串,那我们就把这个后缀子串叫作可匹配后缀子串,我们从所有可匹配后缀子串中,找出最长的一个,称之为最长可匹配后缀子串,那么p结点的Fail指针,就指向该最长可匹配后缀子串对应的模式串的前缀子串的最后一个节点,就是下图中箭头指向的节点。
计算每个节点的失败指针这个过程看起来有些复杂。其实,如果我们把树中相同深度的节点放到同一层,那么某个节点的失败指针只有可能出现在它所在层的上一层。
我们可以像KMP算法那样,当我们要求某个节点的失败指针的时候,我们通过已经求得的、深度更小的那些节点的失败指针来推导。也就是说,我们可以逐层依次来求解每个节点的失败指针。所以,失败指针的构建过程,是一个按层遍历树的过程。
首先root的失败指针为NULL,也就是指向自己。当我们已经求得某个节点p的失败指针之后,如何寻找它的子节点的失败指针呢?
我们假设节点p的失败指针指向节点q,我们看节点p的子节点pc对应的字符,是否也可以在节点q的子节点中找到。如果找到了节点q的一个子节点qc,对应的字符跟节点pc对应的字符相同,则将节点pc的失败指针指向节点qc。
如果节点q中没有子节点的字符等于节点pc包含的字符,则令 q=q->fail ,继续上面的查找,直到q是root为止。如果直到q=root时还没有找到相同字符的子节点,就让节点pc的失败指针指向root。
举个例子,这里有4个模式串,分别是c,bc,bcd,abcd;主串是abcd,最后构建完成之后的AC自动机就是下面这个样子:
代码实现
#!/usr/bin/python
# -*- coding: UTF-8 -*-
from trie_ import Node, Trie
from queue import Queue
class ACNode(Node):
def __init__(self, c: str):
super(ACNode, self).__init__(c)
self.fail = None
self.length = 0
def insert_child(self, c: str):
self._insert_child(ACNode(c))
class ACTrie(Trie):
def __init__(self):
self.root = ACNode(None)
def ac_automata(main: str, ac_trie: ACTrie) -> list:
root = ac_trie.root
build_failure_pointer(ac_trie)
ret = []
p = root
for i, c in enumerate(main):
while p != root and not p.has_child(c):
p = p.fail
if p.has_child(c): # a char matched, try to find all potential pattern matched
q = p.get_child(c)
while q != root:
if q.is_ending_char:
ret.append((i-q.length+1, i))
# ret.append(main[i-q.length+1:i+1])
q = q.fail
p = p.get_child(c)
return ret
def build_failure_pointer(ac_trie: ACTrie) -> None:
root = ac_trie.root
# queue: [(node, node.length) ....]
node_queue = Queue()
node_queue.put((root, root.length))
root.fail = None
while not node_queue.empty():
p, length = node_queue.get()
for pc in p.children:
pc.length = length + 1
if p == root:
pc.fail = root
else:
q = p.fail
# same as kmp
while q != root and not q.has_child(pc.data):
q = q.fail
# cases now:
# 1. q == root and q.has_child(pc.data)
# 2. q != root and q.has_child(pc.data)
# 3. q == root and not q.has_child(pc.data)
if q.has_child(pc.data):
pc.fail = q.get_child(pc.data)
else:
pc.fail = root
node_queue.put((pc, pc.length))
if __name__ == '__main__':
ac_trie = ACTrie()
ac_trie.gen_tree(['fuck', 'shit', 'TMD', '傻叉'])
print('--- ac automata ---')
m_str = 'fuck you, what is that shit, TMD你就是个傻叉傻叉傻叉叉'
print('original str : {}'.format(m_str))
filter_range_list = ac_automata(m_str, ac_trie)
str_filtered = m_str
for start, end in filter_range_list:
str_filtered = str_filtered.replace(str_filtered[start:end+1], '*'*(end+1-start))
print('after filtered: {}'.format(str_filtered))
复杂度分析
首先,我们需要将敏感词构建成AC自动机,包括构建Trie树以及构建失败指针。
我们上一节讲过,Trie树构建的时间复杂度是 O(m*len), 其中len表示敏感词的平均长度,m表示敏感词的个数。那构建失败指针的时间复杂度是多少呢? 我这里给出一个不是很紧确的上界。 假设Trie树中总的节点个数是k,每个节点构建失败指针的时候,(你可以看下代码)最耗时的环节是while循环中的 q=q->fail ,每运行一次这个语句,q指向节点的深度都会减少1,而树的高度最高也不会超过len,所以每个节点构建失败指针的时间复杂度是O(len)。整个失败指针的构建过程就是O(k*len)。 不过,AC自动机的构建过程都是预先处理好的,构建好之后,并不会频繁地更新,所以不会影响到敏感词过滤的运行效率。
我们再来看下,用AC自动机做匹配的时间复杂度是多少?
跟刚刚构建失败指针的分析类似,for循环依次遍历主串中的每个字符,for循环内部最耗时的部分也是while循环,而这一部分的时间复杂度也是O(len),所以总的匹配的时间复杂度就是O(n*len),n为主串的长度。因为敏感词并不会很长,而且这个时间复杂度只是一个非常宽泛的上限,实际情况下,可能近似于O(n),所以AC自动机做敏感词过滤,性能非常高。
直接使用Trie树做多模式串匹配的时间复杂度为O(n*len) 。
你可能会说,从时间复杂度上看,AC自动机匹配的效率跟Trie树一样啊。实际上,因为失效指针可能大部分情况下都指向root节点,所以绝大部分情况下,在AC自动机上做匹配的效率要远高于刚刚计算出的比较宽泛的时间复杂度。只有在极端情况下,如图所示,AC自动机的性能才会退化的跟Trie树一样。