如果要在字符串 A(长度n)中查找字符串 B(长度m),那么:
n>m
BF 算法中的 BF 是 Brute Force 的缩写,中文叫作暴力匹配算法,也叫朴素匹配算法。
BF 算法的思想在主串中,检查起始位置分别是 0、1、2…n-m 且长度为 m 的 n-m+1
个子串,看有没有跟模式串匹配的。极端情况下,算法的时间复杂度是O(n*m)
虽然理论上时间复杂度较高,但在实际开发中挺常用的:
O(n*m)
高很多所以,在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了,处理小规模的字符串很好用。
RK 算法的全称叫 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的。RK 算法是 BF 算法的改进,它巧妙借助了哈希算法,让匹配的效率有了很大的提升。
每次检查主串与子串是否匹配,需要依次比对每个字符,时间复杂度高,引入哈希算法,时间复杂度立刻就会降低。
RK 算法的思路:
哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了。
但是,通过哈希算法计算子串的哈希值时,需要遍历子串中的每个字符。尽管模式串与子串比较的效率提高了,但是,算法整体的效率并没有提高。
继续改进,优化哈希算法的设计。
假设要匹配的字符串的字符集中只包含 K 个字符,用一个 K 进制数来表示一个子串,这个 K 进制数转化成十进制数,作为子串的哈希值。
比如要处理的字符串只包含
a~z
这 26 个小写字母,就用二十六进制来表示一个字符串。把a~z
这 26 个字符映射到 0~25 这 26 个数字。
在十进制的表示法中,一个数字的值是通过下面的方式计算出来的:
\[657=6×10^2+5×10^1+7×10^0\]
对应到二十六进制,一个包含 a 到 z 这 26 个字符的字符串,计算哈希的时候,只需要把进位从 10 改成 26 就可以:
\[cba=c×26^2+b×26^1+a×26^0=2×26^2+1×26+0×1=1353\]
这就是改进后的哈希算法,对应的哈希值就是26进制数转为10进制数后的字面量。这种哈希算法有一个特点,在主串中,相邻两个子串的哈希值的计算公式有一定关系。
相邻两个子串 s[i-1]
和 s[i]
(i 表示子串在主串中的起始位置,子串的长度都为 m),对应的哈希值计算公式有交集,可以使用 s[i-1]
的哈希值很快的计算出 s[i]
的哈希值。
h[i-1]
对应子串s[i,i+m-2]
的哈希值 × 26 ==h[i]
对应子串s[i,i+m-2]
的哈希值注意,\(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算法基于进制来表示一个字符串的,这样的哈希算法是没有散列冲突的,也就是说,一个字符串与一个二十六进制数一一对应,不同的字符串的哈希值肯定不一样。
实际上,为了能将哈希值落在整型数据范围内,可以牺牲一下,允许散列冲突。如上面的哈希算法不基于进制求字符串,而是将每一个字符求和作为字符串的哈希值。这种哈希算法产生的哈希值的数据范围就相对要小很多了,但是冲突概率也就高很多。
稍微优化一下,将每一个字母从小到大对应一个素数,而不是 1,2,3……这样的自然数,这样冲突的概率就会降低一些。
当存在散列冲突后,哈希值虽然是相同,但是两者本身并不匹配,此时再对比一下子串和模式串本身就好了。
所以,哈希算法的冲突概率要相对控制得低一些,如果存在大量冲突,就会导致 RK 算法的时间复杂度退化,效率下降。极端情况下,如果存在大量的冲突,每次都要再对比子串和模式串本身,那时间复杂度就会退化成 O(n*m)
。一般情况下,冲突不会很多,RK 算法的效率还是比 BF 算法高的。
BM(Boyer-Moore)算法是一种非常高效的字符串匹配算法,有实验统计,它的性能是著名的KMP 算法的 3 到 4 倍。
BM 算法,本质上就是在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。
BM 算法包含两部分,分别是:
从模式串的末尾往前倒着匹配,当发现某个字符没法匹配时,把这个没有匹配的字符叫作坏字符(主串中的字符)。
si
xi
xi
记作 -1
那模式串往后移动的位数就等于 si-xi
,注意,这里说的下标,都是字符在模式串的下标。
注意:如果坏字符在模式串里多处出现,在计算
xi
时,选择最靠后的那个,因为这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。
利用坏字符规则,BM 算法在最好情况下的时间复杂度非常低,是 \(O(n/m)\)。
只使用坏字符规则还是不够的。因为根据 si-xi 计算出来的移动位数,有可能是负数,比如主串是 aaaaaaaaaaaaaaaa,模式串是 baaa。不但不会向后滑动模式串,还有可能倒退。所以,BM 算法还需要用到“好后缀规则”。
好后缀规则跟坏字符规则的思路很类似。从模式串的末尾往前倒着匹配,把已经匹配的字符串叫作好后缀,记作{u}
。
{u}
在模式串中查找{u}
相匹配的子串{u*}
,就将模式串滑动到子串{u*}
与主串中{u}
对齐的位置{u}
的子串,那一步步滑动模式串,当模式串滑动到前缀与主串中{u}
的后缀有部分重合,并重合的部分相等时,就有可能会存在完全匹配的情况{v}
,然后将模式串滑动到如图所示的位置
- 所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串就包括 c, bc
- 所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a,ab
当模式串和主串中的某个字符不匹配的时候,分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。这可以避免根据坏字符规则,计算得到的往后滑动的位数,有可能是负数的情况。
坏字符规则:
当遇到坏字符时,要计算往后移动的位数 si-xi
,其中 xi
的计算是重点。
假设字符串的字符集不是很大,每个字符长度是 1 字节,用大小为 256 的数组,来记录每个字符在模式串中出现的位置。数组的下标对应字符的 ASCII 码值,数组中存储这个字符在模式串中出现的位置。
好后缀规则:
好后缀规则中最核心的内容:
因为好后缀也是模式串本身的后缀子串,所以,在模式串和主串正式匹配之前,通过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置。
后缀子串:
后缀子串的最后一个字符的位置是固定的,下标为 m-1
,只需要记录后缀子串的长度,通过长度,可以确定一个唯一的后缀子串。
引入变量 suffix
数组:
k
:表示后缀子串的长度{u}
相匹配的子串{u*}
的起始下标值如果模式串中有多个子串跟后缀子串{u}
匹配,那 suffix
数组中存储模式串中最靠后的那个子串的起始位置,也就是下标最大的那个子串的起始位置。
还要在好后缀的后缀子串中,查找最长的能跟模式串前缀子串匹配的后缀子串。引入boolean 类型的 prefix
数组,来记录模式串的后缀子串是否能匹配模式串的前缀子串。
计算并填充这两个数组:拿下标从 0 到 i 的子串(i 可以是 0 到 m-2)与整个模式串,求公共后缀子串。如果公共后缀子串的长度是 k,就记录 suffix[k]=j
(j 表示公共后缀子串的起始下标)。如果 j 等于 0,也就是说,公共后缀子串也是模式串的前缀子串,就记录 prefix[k]=true
。
在模式串跟主串匹配的过程中,遇到不能匹配的字符时,根据好后缀规则,计算模式串往后滑动的位数。
假设好后缀的长度是 k。先拿好后缀,在 suffix
数组中查找其匹配的子串。如果 suffix[k]
不等于 -1
(-1 表示不存在匹配的子串),那就将模式串往后移动 j-suffix[k]+1
位(j 表示坏字符对应的模式串中的字符下标)。如果 suffix[k]
等于 -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。
好后缀的后缀子串 b[r, m-1]
(其中,r 取值从 j+2 到 m-1)的长度 k=m-r
,如果 prefix[k]
等于 true,表示长度为 k 的后缀子串,有可匹配的前缀子串,这样我们可以把模式串后移 r 位。
如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,就将整个模式串后移 m 位。
内存消耗,整个算法用到了额外的 3 个数组,其中:
suffix
数组和 prefix
数组的大小跟模式串长度 m 有关执行效率:基于当前版本,在极端情况下,预处理计算 suffix 数组、prefix 数组的性能会比较差。比如模式串是 aaaaaaa 这种包含很多重复的字符的模式串,预处理的时间复杂度就是 \(O(m^2)\)。只使用好后缀规则的 BM 算法效率会下降一些。
KMP 算法跟 BM 算法的本质是一样的,全称是 Knuth Morris Pratt 算法。
在模式串和主串匹配的过程中,把不能匹配的那个字符仍然叫作坏字符,把已经匹配的那段字符串叫作好前缀。
当遇到坏字符的时候,把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于拿好前缀的后缀子串,跟模式串的前缀子串在比较。
KMP 算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位?
只要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。假设最长的可匹配的那部分前缀子串是{v}
,长度是 k。把模式串一次性往后滑动 j-k 位,相当于,每次遇到坏字符的时候,就把 j 更新为 k,i 不变,然后继续比较。
KMP 算法提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。把这个数组定义为 next
数组,或者叫失效函数(failure function)。
数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可以匹配前缀子串的结尾字符下标。
搜索引擎的搜索关键词提示功能,底层最基本的原理就是Trie 树这种数据结构。
与单模式匹配算法相比,多模式匹配算法只需要扫描一遍主串,就能在主串中一次性查找多个模式串是否存在,从而大大提高匹配效率。
Trie 树,也叫“字典树”,是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
字符串匹配问题也可以用:散列表、红黑树,或者上面的字符串匹配算法来解决,但是,Trie 树在这个问题的解决上,有它特有的优点。
假设有6个字符串:how
,hi
,her
,hello
,so
,see
,需要在里面多次查找某个字符是否存在。如果每次查找,都是拿要查找的字符串跟这 6 个字符串依次进行字符串匹配,那就太没有效率了。
Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。
最后构造出如下图所示的Trie树:
具体的Trie树构造过程如下图所示,相当于往Trie树中插入一个字符串,当所有字符串插入完成,Trie树就构造好了。
当在 Trie 树中查找一个字符串时:
如下图所示,绿色的路径就是在 Trie 树中匹配的路径。
如下图所示,绿色的路径,是字符串“he”匹配的路径。但是,路径的最后一个节点“e”并不是红色的。也就是说,“he”是某个字符串的前缀子串,但并不能完全匹配任何字符串。
Trie 树主要有两个操作:
Trie 树是一个多叉树,经典的存储方式,借助散列表的思想,通过一个下标与字符一一映射的数组,来存储子节点的指针,如下图所示。
nil
type TrieNode struct {
data rune
children [26]*TrieNode
}
在 Trie 树中查找字符串时,通过字符的 ASCII 码减去“a”的 ASCII 码,迅速找到匹配的子节点的指针。
如果要在一组字符串中,频繁地查询某些字符串,用 Trie 树会非常高效。
O(n)
(n 表示所有字符串的长度和)构建好 Trie 树后,在其中查找字符串的时间复杂度是 O(k)
,k 表示要查找的字符串的长度。
Trie 树是一种非常独特的、高效的字符串匹配方法(空间换时间),在实现的时候,用数组来存储一个节点的子节点的指针。
26×5byte=130byte
如果字符串中不仅包含小写字母,还包含大写字母、数字、甚至是中文,那需要的存储空间就更多了。
Trie 树的本质是避免重复存储一组字符串的相同前缀子串,但是,在重复的前缀并不多的情况下,Trie 树不但不能节省内存,还有可能会浪费更多的内存。
可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针。比如:
Trie 树的变体有很多,都可以在一定程度上解决内存消耗的问题。比如,缩点优化:
这样可以节省空间,但却增加了编码难度。
字符串的匹配问题,其实就是数据的查找问题。
对于支持动态数据高效操作的数据结构,比如散列表、红黑树、跳表等, 也可以实现在一组字符串中查找字符串的功能。
在上面的场景中,在一组字符串中查找字符串,Trie 树实际上表现得并不好。它对要处理的字符串有及其严苛的要求。
综合这几点,针对在一组字符串中查找字符串的问题,在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,不需要自己去实现,直接利用编程语言中提供的现成类库就行了。
基于 Trie 树的这种处理方法,有点类似单模式串匹配的 BF 算法。
AC 自动机算法,全称是 Aho-Corasick 算法。
Trie 树跟 AC 自动机之间的关系,就像单串匹配中朴素的串匹配算法,跟 KMP 算法之间的关系一样,只是针对的是多模式串。
AC 自动机是在 Trie 树之上,加了类似 KMP 的 next 数组,只不过此处的 next 数组是构建在树上。
AC 自动机的构建,包含两个操作:
构建好 Trie 树之后,如何在它之上构建失败指针?如下示例:有 4 个模式串,分别是 c,bc,bcd,abcd;主串是 abcd。
Trie 树中的每一个节点都有一个失败指针,它的作用和构建过程,跟 KMP 算法中的 next 数组极其相似。
假设沿 Trie 树走到 p 节点(下图中的紫色节点),那 p 的失败指针就是从 root 走到紫色节点形成的字符串 abc
与所有模式串前缀匹配的最长可匹配后缀子串,就是箭头指的 bc 模式串
最长可匹配后缀子串:字符串 abc 的后缀子串有两个 bc,c,拿它们与其他模式串匹配,如果某个后缀子串可以匹配某个模式串的前缀,就把这个后缀子串叫作可匹配后缀子串。从可匹配后缀子串中,找出最长的一个,就是最长可匹配后缀子串。
计算每个节点的失败指针:把树中相同深度的节点放到同一层,那么某个节点的失败指针只有可能出现在它所在层的上一层。
像 KMP 算法那样,当求某个节点的失败指针时,通过已经求得的、深度更小的那些节点的失败指针来推导。
可以逐层依次来求解每个节点的失败指针。所以,失败指针的构建过程,是一个按层遍历树的过程。
q=q->fail
(fail 表示失败指针)通过按层来计算每个节点的子节点的失效指针,最后构建完成之后的 AC 自动机就是下面这个样子:
AC 自动机构建完成后,在 AC 自动机上匹配主串。
在匹配过程中,主串从 i=0 开始,AC 自动机从指针 p=root 开始,假设模式串是 b,主串是 a。
b[i]
的子节点 x,就更新 p 指向 x,这个时候通过失败指针,检测一系列失败指针为结尾的路径是否是模式串b[i]
的子节点,那失败指针就派上用场了,让 p=p->fail
,然后继续这 2 个过程Trie树构建的时间复杂度是O(m*len)
,len表示敏感词的平均长度,m表示敏感词的个数
假设 Trie 树中总的节点个数是 k,每个节点构建失败指针的时候,最耗时的环节是 while 循环中的 q=q->fail
,每运行一次这个语句,q 指向节点的深度都会减少 1,而树的高度最高也不会超过 len,所以每个节点构建失败指针的时间复杂度是 O(len)
。整个失败指针的构建过程就是 O(k*len)
。
for 循环依次遍历主串中的每个字符,for 循环内部最耗时的部分也是 while 循环,而这一部分的时间复杂度也是 O(len)
,所以总的匹配的时间复杂度就是 O(n*len)
。
因为敏感词并不会很长,时间复杂度可能近似于
O(n)
,所以 AC 自动机做敏感词过滤,性能非常高。
实际上,因为失效指针可能大部分情况下都指向 root 节点,所以绝大部分情况下,在 AC 自动机上做匹配的效率要远高于上面理论的时间复杂度。只有在极端情况下,如图所示,AC 自动机的性能才会退化的跟 Trie 树一样。