Boyer–Moore 算法
前置知识:前缀函数与 KMP 算法。
KMP 算法将前缀匹配的信息用到了极致,
而 BM 算法背后的基本思想是通过后缀匹配获得比前缀匹配更多的信息来实现更快的字符跳转。
引入
想象一下,如果我们的的模式字符串
,被放在文本字符串
的左手起头部,使它们的第一个字符对齐。
在这里做定义,往后不赘述:
的长度为
,特别地对于从 0 开始的串来说,规定
为
串最后一个字符的位置;
的长度
,
。
假如我们知道了
的第
个字符
(与
的最后一个字符对齐)考虑我们能得到什么信息:
观察 1
如果我们知道
这个字符不在
中,我们就不用考虑
从
的第
个、第
个……第
个字符起出现的情况,,而可以直接将
向下滑动
个字符。
观察 2
更一般地,如果出现在
最末尾(也就是最右边)的那一个
字符的位置是离末尾端差了
个字符,
那么就可以不用匹配,直接将
向后滑动
个字符:如果滑动距离少于
,那么仅就
这个字符就无法被匹配,当然模式字符串
也就不会被匹配。
因此除非
字符可以和
末尾的那个字符匹配,否则
要跳过
个字符(相当于
向后滑动了
个字符)。并且我们可以得到一个计算
的函数
:
不在中是上最后一个字符为出现在最末尾的那一个出现的位置,即
注意:显然这个表只需计算到
的位置
现在假设
和
最后一个字符匹配到了,那我们就看看
前一个字符和
的倒数第二个字符是否匹配:
如果是,就继续回退直到整个模式串
完成匹配(这时我们就在
上成功得到了一个
的匹配);
或者,我们也可能会在匹配完
的倒数第
个字符后,在倒数第
个字符上失配,这时我们就希望把
向后滑动到下一个可能会实现匹配的位置,当然我们希望滑动得越远越好。
观察 3(a)
在 观察 2 中提到,当匹配完
的倒数
个字符后,如果在倒数第
个字符失配,为了使得
中的失配字符与
上对应字符对齐,
需要把
向后滑动
个字符,也就是说我们应该把注意力看向之后的
个字符(也就是看向
滑动 k 之后,末段与
对齐的那个字符)。
而
,
所以我们的注意力应该沿着
向后跳
个字符。
然而,我们有机会跳过更多的字符,请继续看下去。
观察 3(b)
如果我们知道
接下来的
个字符和
的最后
个字符匹配,假设这个子串为
,
我们还知道在
失配字符
后面是与
相匹配的子串,而假如
对应失配字符前面存在
,我们可以将
向下滑动一段距离,
使得失配字符
在
上对应的字符前面出现的
(合理重现,plausible reoccurrence,以下也简称 pr)与
的
对齐。如果
上有多个
,按照从右到左的后缀匹配顺序,取第一个(rightmost plausible reoccurrence,以下也简称 rpr)。
假设此时
向下滑动的
个字符(也即
末尾端的
与其最右边的合理重现的距离),这样我们的注意力应该沿着
向后滑动
个字符,这段距离我们称之为
:
假定
为
在
上失配时的最右边合理重现的位置,
(这里只给出简单定义,在下文的算法设计章节里会有更精确的讨论),那么显然
。
所以有:
为失配字符在上对应字符的位置
于是我们在失配时,可以把把
上的注意力往后跳过
个字符
过程
箭头指向失配字符
:
没有出现
中,根据 观察 1,
直接向下移动
个字符,也就是 7 个字符:
根据 观察 2,我们需要将
向下移动 4 个字符使得短横线字符对齐:
现在char:
匹配了,把
上的指针左移一步继续匹配:
根据 观察 3(a),
失配,因为
不在
中,所以
向下移动
个字符,而
上指针向下移动
个字符:
这时
又一次匹配到了
的最后一个字符
,
上的指针向左匹配,匹配到了
,继续向左匹配,发现在字符
失配:
显然直观上看,此时根据 观察 3(b),将
向下移动
个字符,使得后缀
对齐,这种滑动可以获得
指针最大的滑动距离,此时
,即
上指针向下滑动 7 个字符。
而从形式化逻辑看,此时,
,
这样从形式逻辑上支持了进行 观察 3(b) 的跳转:
现在我们发现了
上每一个字符都和
上对应的字符相等,我们在
上找到了一个
的匹配。而只花费了 14 次对
的引用,其中 7 次是完成一个成功的匹配所必需的比较次数(
),另外 7 次让我们跳过了 22 个字符。
算法设计
最初的匹配算法
解释
现在看这样一个利用
和
进行字符串匹配的算法:
如果上面的算法
,表明
不在
中;如果返回一个数字,表示
在
左起第一次出现的位置。
然后让我们更精细地描述下计算
,所依靠的
函数。
根据前文定义,
表示在
失配时,子串
在
最右边合理重现的位置。
也就是说需要找到一个最好的
, 使得
,另外要考虑两种特殊情况:
- 当
时,相当于在
前面补充了一段虚拟的前缀,实际上也符合
跳转的原理。
- 当
时,如果
,则这个
不能作为
的合理重现。
原因是
本身是失配字符,所以
向下滑动
个字符后,在后缀匹配过程中仍然会在
处失配。
还要注意两个限制条件:
。因为当
时,有
,在
上失配的字符也会在
上失配。
- 考虑到
,所以规定
。
过程
由于理解
是实现 BoyerMoore 算法的核心,所以我们使用如下两个例子进行详细说明:
对于
,
为
,在
之前的最右边合理重现只能是
,也就是最右边合理重现位置为 -5,即
;
对于
,
为
,在
之前的最右边的合理重现是
,所以
;
对于
,
为
,在
之前的最右边的合理重现是
,所以
;
对于
,
为
,在
之前的最右边的合理重现是
,所以
;
对于
,
为
,在
之前的最右边的合理重现是
,所以
;
对于
,
为
,在
之前的最右边的合理重现是
,所以
;
对于
,
为
,又因为
,即
等于失配字符
,所以
并不是符合条件的
的合理重现,所以在最右边的合理重现是
,所以
;
对于
,
为
,同理又因为
,所以
并不是符合条件的
的合理重现,在最右边的合理重现是
,所以
;
对于
,根据
定义,
,得到
。
现在再看一下另一个例子:
对于
,
为
,在
之前的最右边合理重现只能是
,也就是最右边合理重现位置为 -8,即
;
对于
,
为
,在
之前的最右边合理重现只能是
,
;
对于
,
为
,在
之前的最右边合理重现只能是
,
;
对于
,
为
,在
之前的最右边合理重现只能是
,
;
对于
,
为
,在
之前的最右边合理重现只能是
,
;
对于
,
为
,在
之前的最右边合理重现只能是
,
;
对于
,
为
,因为
并且有
,所以在
之前的最右边的合理重现是
,
;
对于
,
为
,虽然
但是因为
,所以在
之前的最右边的合理重现是
,
;
对于
,根据
定义,
,得到
。
对匹配算法的一个改进
最后,实践过程中考虑到搜索过程中估计有 80% 的时间用在了 观察 1 的跳转上,也就是
和
不匹配,然后跳跃整个
进行下一次匹配的过程。
于是,可以为此进行特别的优化:
我们定义一个
:
为一个整数,需要满足
用
代替
,得到改进后的匹配算法:
除非和末尾字符匹配,否则至多向下滑动此时表示上没有一个字符和末尾字符匹配
其中
起到多重作用,一是类似后面介绍的 Horspool 算法进行快速的坏字符跳转,二是辅助检测字符串搜索是否完成。
经过改进,比起原算法,在做 观察 1 跳转时不必每次进行
的多余计算,使得在通常字符集下搜索字符串的性能有了明显的提升。
delta2 构建细节
引入
在 1977 年 10 月的Communications of the ACM上,Boyer、Moor 的论文[^bm]中只描述了
静态表,
构造
的具体实现的讨论出现在 1977 年 6 月 Knuth、Morris、Pratt 在SIAM Journal on Computing上正式联合发表的 KMP 算法的论文[^kmp]。
朴素算法
在介绍 Knuth 的
构建算法之前,根据定义,我们会有一个适用于小规模问题的朴素算法:
- 对于
[0, patlen)
区间的每一个位置 i
,根据 subpat
的长度确定其重现位置的区间,也就是 [-subpatlen, i]
;
- 可能的重现位置按照从右到左进行逐字符比较,寻找符合
要求的最右边
的重现位置;
- 最后别忘了令
。
实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40 | use std::cmp::PartialEq;
pub fn build_delta_2_table_naive(p: &[impl PartialEq]) -> Vec<usize> {
let patlen = p.len();
let lastpos = patlen - 1;
let mut delta_2 = vec![];
for i in 0..patlen {
let subpatlen = (lastpos - i) as isize;
if subpatlen == 0 {
delta_2.push(0);
break;
}
for j in (-subpatlen..(i + 1) as isize).rev() {
// subpat 匹配
if (j..j + subpatlen)
.zip(i + 1..patlen)
.all(|(rpr_index, subpat_index)| {
if rpr_index < 0 {
return true;
}
if p[rpr_index as usize] == p[subpat_index] {
return true;
}
false
})
&& (j <= 0 || p[(j - 1) as usize] != p[i])
{
delta_2.push((lastpos as isize - j) as usize);
break;
}
}
}
delta_2
}
|
特别地,对 Rust 语言特性进行必要地解释,下不赘述:
usize
和 isize
是和内存指针同字节数的无符号整数和有符号整数,在 32 位机上相当于 u32
和 i32
,64 位机上相当于 u64
和 i64
。
- 索引数组、向量、分片时使用
usize
类型的数字(因为在做内存上的随机访问并且下标不能为负值),所以如果需要处理负值要用 isize
,而进行索引时又要用 usize
,这就看到使用 as
关键字进行二者之间的显式转换。
impl PartialEq
只是用作泛型,可以同时支持 Unicode
编码的 char
和二进制的 u8
。
显然,该暴力算法的时间复杂度为
。
高效算法
下面我们要介绍的是时间复杂度为
,但是需要额外
空间复杂度的高效算法。
虽然 1977 年 Knuth 提出了这个构建方法,然而他的原始版本的构建算法存在一个缺陷,实际上对于某些
产生不出符合定义的
。
Rytter 在 1980 年SIAM Journal on Computing上发表的文章[^rytter]对此提出了修正,以下是
的构建算法:
首先考虑到
的定义比较复杂,我们按照
的重现位置进行分类,每一类进行单独处理,这是高效实现的关键思路。
按照重现位置由远到近,也就是偏移量由大到小,分成如下几类:
-
整个
重现位置完全在
左边的,比如
,此时
;
-
的重现有一部分在
左边,有一部分是
头部,比如
,此时
;
我们把
完全在
头部的的边际情况也归类在这里(当然根据实现也可以归类在下边),比如
,此时
;
-
的重现完全在
中,比如
,此时
。
现在来讨论如何高效地计算这三种情况:
第一种情况
这是最简单的情况,只需一次遍历并且可以顺便将
初始化。
第二种情况
我们观察什么时候会出现
的重现一部分在
左边,一部分是
的头部的情况呢?应该是
的某个后缀和
的某个前缀相等,
比如之前的例子:
的重现
,
的后缀与 pat 前缀中,有相等的,是
。
实际上对第二种和第三种情况的计算的关键都需要前缀函数的计算和和应用
那么只要
取值使得
包含这个相等的后缀,那么就可以得到第二种情况的
的重现,对于例子,我们只需要使得
,
而当
时,就是
完全在
头部的边际情况。
可以计算此时的
:
设此时这对相等的前后缀长度为
,可知
,那么在
左边的部分长度是
,
而
,所以得到
。
其后面可能会有多对相等的前缀和后缀,比如:
在
处有
,
处有
,在 $5
本页面最近更新:2025/9/7 21:50:39,更新历史
发现错误?想一起完善? 在 GitHub 上编辑此页!
本页面贡献者:Enter-tainer, minghu6, Tiphereth-A, HeRaNO, alphagocc, c0nstexpr, CCXXXI, Chrogeek, Early0v0, githuu5y5u, iamtwz, ksyx, megakite, r-value, wineee, Xeonacid, ZnPdCo
本页面的全部内容在 CC BY-SA 4.0 和 SATA 协议之条款下提供,附加条款亦可能应用