在计算机诞生的那天开始,子串匹配一直就是最普遍的需求,它就像我们每天对食物的需求那样平常,围绕子串匹配这一问题,诞生了许多的凝结着智慧与心血的算法。

在开始之前,先明确几个术语:

  1. Text 文本串,即待检索的字符串,下文简称T串。
  2. Pattern 在英文中是指一种可复用的设计方式,在这里待匹配的字符串可以说是一个重复的词,下文简称P串。

Brute Force algorithm (暴力算法)

在最初,人们使用最直观的逻辑思维进行子串匹配,把 P 从 T 的头部到尾部一个个按位比较即可。比如:

很容易分析这个算法的时间复杂度是O(nm)O(nm)的,每个位置最差比较 m(P 串长度)次。

但是这种方式是低效的,可以看下面这个例子:

按照暴力算法的逻辑,在失配时,我们知道前三位是 ABC全匹配的状态,此时应该把 P 串右移到失配位置而不是把 P 串往后移动一位重新开始匹配。换句话说,对 T 串的匹配记忆没有合理利用起来。如何利用最少的比较次数获得最大的 P 串右移便成了字符串匹配算法的核心。

Knuth–Morris–Pratt algorithm (KMP 算法)

该算法由由 Donald Knuth1974年图灵奖得主、 James H. Morris、 Vaughan Pratt 于 1977 年发表。也是 KMP 算法名称的由来。
针对 T 串的匹配记忆,KMP 算法会利用起来加速匹配。整个过程是这样的,还是上面那个例子:

再次失配时,不难发现,我们不能直接像第一步那么操作了,因为此时全匹配的子串前两位与后两位是一样的,此时应该把P串的前两位AB与T串的AB(此时处于失配位置的前两位)对齐,这是一个很容易想通的逻辑。

由于对齐后 AB 是肯定已经处于匹配状态了,因此直接从上一步的失配位置开始匹配。
此时再次失配,由于 此时的全匹配子串是 AB,是没有重复前后缀的,直接把 P 串右移到失配位置,这和 1 是一个逻辑

通过上面 KMP 算法的演示过程,可以发现,最关键的就是求解根据当前全匹配的子串情况,求解最长的前后缀(也就是第2步的AB),得出了 AB 的长度,P 串的偏移长度也就可以知道了。为什么是最长,可以设想当前的全匹配子串是AABAA, 此时,AAA都是相同的前后缀,如果对齐 A 的话,后面的 AA 的第一个 A 就会丢失匹配逻辑。

“Partial match” table (部分匹配表)

部分匹配表,也可以理解成失效函数(failure function),为的就是求解上面的问题。我们不可能在匹配过程中去进行求解前后,不难发现,对于固定的 P 串,部分匹配表是完全可求的。例如上面那个例子:

如果枚举每种情况进行求解,那效率会很低,不难发现,随着全匹配串的长度增加,最长前后缀要么加 1,要么减小,是有某种递推关系的,因此可以使用动态规划的思想来解决这一问题。
下面才是 KMP 算法的核心,如何高效的求解部分匹配表,假设求解的 PMT 为 f,f(x)为最长重复前后缀的长度。

由于f(x)的定义代表最长的前后缀的长度, 因此p[f(x)]刚好可以访问当前最长前缀的下一个元素,这是十分重要的特性。想通这个就比较容易理解递推了。

考虑第 x 位置的最长前后缀f(x)f(x),有两种情况

  1. 如果 p[x]=p[f(x-1)],那么 f(x)=f(x-1)+1,这很好理解。
  2. 如果 p[x]!=p[f(x-1)],那么分情况讨论

因此递推关系如下:

f(x)={f(x1)  +  1p[f(x1)]=p[x]f(f(x1)    1)+1  p[f(x1)]p[x]  and  p[f(f(x1)    1)]  =  p[x]0p[f(x1)]p[x]  and  p[f(f(x1)    1)]    p[x]f(x)=\left\{\begin{array}{lc}f(x-1)\;+\;1&p\lbrack f(x-1)\rbrack=p\lbrack x\rbrack\\f(f(x-1)\;-\;1)+1\;&p\lbrack f(x-1)\rbrack\neq p\lbrack x\rbrack\;and\;p\lbrack f(f(x-1)\;-\;1)\rbrack\;=\;p\lbrack x\rbrack\\0&p\lbrack f(x-1)\rbrack\neq p\lbrack x\rbrack\;and\;p\lbrack f(f(x-1)\;-\;1)\rbrack\;\neq\;p\lbrack x\rbrack\end{array}\right.

KMP算法

看到这里,KMP 算法已经可以写出来了,可以试一试手了

28. 找出字符串中第一个匹配项的下标

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
/**
* @param {string} haystack
* @param {string} needle
* @return {number}
*/
var strStr = function (haystack, needle) {
// KMP
let pmt = new Array(needle.length).fill(0);
for (let i = 1; i < needle.length; i++) {
if (needle[pmt[i - 1]] === needle[i]) {
pmt[i] = pmt[i - 1] + 1;
} else {
if (needle[pmt[pmt[i - 1] - 1]] === needle[i]) {
pmt[i] = pmt[pmt[i - 1] - 1] + 1;
} else {
pmt[i] = 0;
}
}
}
let i = 0;
let j = 0;
while (i < haystack.length) {
if (haystack[i] !== needle[j]) {
// 这里需要判断是否要进行失配偏移,否则会干扰正常的迭代
if (j > 0) {
j = pmt[j - 1];
} else {
i++;
}
} else {
// 已经匹配到p串最后一位,程序退出
if (j === needle.length - 1) {
return i - j;
}
i++;
j++;
}
}
return -1;
};

KMP算法-时间复杂度

求解部分匹配表用了O(m)O(m),匹配用了O(n)O(n),整体时间复杂度为O(n)+O(m)O(n)+O(m)

Boyer-Moore algorithm (BM 算法)

KMP 算法发布的同年, Robert S. Boyer 与 J Strother Moore 设计了该算法。它不需要对被搜索的字符串中的字符进行逐一比较,而会跳过其中某些部分。

与 KMP 算法不同的是它是从 p 串末尾开始比较的,如果最后一个字符不匹配,那么就没必要继续比较前一个字符。如果最后一个字符未在 P 中出现,那么我们可以直接跳过 T 的 n 个字符,比较接下来的 n 个字符,n 为 P 的长度。如果最后一个字符出现在 P 中,那么跳过的字符数需要进行计算(也就是将 P 整体往后移),然后继续前面的步骤来比较。通过这种字符的移动方式来代替逐个比较是这个算法如此高效的关键所在。

目前大多数的软件查找大多使用该算法。下面来看 BM 算法的过程:

可以发现失配的”S”未曾出现在 P 串中,因此可以把 p 串移动到失配位置后面(”S”不可能与 p 串中的任何一个字母相等)

到这里可以总结出 BM 算法的第一条规则:The bad-character rule (坏字符规则)。

  1. T 串的失配字符如果未出现在 p 串中,那么 p 串右移到失配位置后面。
  2. 如果出现过,对齐即可。

到这里可以介绍第二种规则了,The good-suffix rule (好后缀规则)。
好后缀指的就是全匹配的蓝色部分,直观感觉就是把 p 串往后移动到一个可以和好后缀重新匹配的位置。

Shift rules Table (转移规则表)

与 KMP 算法类似,BM 算法也是需要预处理的,两种规则的偏移数值是可以预先求出来的。

The good-suffix rule (好后缀规则)

好后缀规则主要借鉴了这篇Boyer-Moore: Good suffix rule的思路。

情况 1

一个较为复杂的例子:

考虑当前情况,后缀AB匹配成功,D匹配失败,如果对齐3号AB,那3号AB前的D又会使得算法进入一次重复的匹配(虽然位置不一样),这种好后缀的偏移可以在预处理的时候处理掉。

此时 i 指针的偏移为 8,等于AB的长度(2)+2号AB对齐所需要的偏移(6) = 8
为什么会出现需要移动到 2 号 AB 的情况?
仔细观察可以发现,3 号 AB 无法使用是因为,三号 AB 是相同最长后缀BADBA的后缀,换句话说,因为BADBA才是相同最长后缀,因此它的子后缀(例如:B, BAD, BADB…)全都会陷入 3 号 AB 的情况。

情况 2

如果好后缀未曾出现过,那又该怎么偏移呢?例如这个例子:

此时 i 指针的偏移为 8,等于BDB的长度(3)+DB对齐所需要的偏移(5) = 8

相反的,如果没有前缀与任一子后缀匹配,那就只能偏移到 p 串后面了。像这样:

此时 i 指针的偏移为 8,等于BDB的长度(3)+p 串长度(7) = 10

好后缀规则算法

对于从后往前迭代的情况下(i 从 0->n),求解从后往前的任一位置出发能构成的最长的且与后缀等长的子串。记为 Z(i),

对于任一 i, 如果 Z(i)>0,就意味着:偏移长度i可以对齐Z(i)长度的好后缀,(并且此时好后缀左侧的第一个字符也不相等,也就规避了3号AB的问题)。
与此同时,如果有多个 Z 值相等,应该使用较小的偏移,例如上面的 2号AB的例子,此时不应该对齐 i 较大的1号AB,否则会漏掉匹配机会。

借助 Z 函数,可以填充最终好后缀规则的情况 1。

Z-Algorithm (Z 算法)

而上面这个 Z 函数就是将 p 串翻转后进行 Z-Algorithm(Z 算法),也常常被称为扩展 KMP,感觉和 KMP 没啥关系 😥。Dan Gusfield 在他的书《Algorithms on Strings, Trees, and Sequences》中讨论了 Z 值。尽管有一些争议,但有人声称 Dan Gusfield 是 Z 算法的发明者。Z 值是 Z 算法中的一个重要概念,用于计算字符串中匹配前缀的最长子串的长度。

在 Z 算法 中,对于一个字符串 S={S0,S1,,Sn1}S = \{S_0, S_1, ···, S_{n-1}\},定义Z(i)Z(i)表示从位置 ii 开始的 SS 的后缀Ti={Si,Si+1,,Sn1}T_i = \{S_i, S_{i+1}, ···, S{n-1}\}SS的最长公共前缀的长度。

通常,暴力算法很好理解,每个位置往后枚举,最终时间复杂度是O(n2)O(n^2)。这样的构建方式和 KMP 算法想要解决的问题是一样的:没有利用匹配记忆。如何利用匹配记忆,直接看算法流程:

例如求解位置 i 的 Z 值时,暴力往后求解出 Z(i),那从 i 到i+Z(i)的区间就是此时的 Z-Box

记录 Z-Box 的范围有啥用呢?

例如求解位置 i+1 的 Z 值时,i+1 位置面临的情况和 1 位置的情况是一致的。

那直接让 Z(i+1) = Z(1)可以吗?答案是也不行。

如果 Z(1)的值使得[i+1]偏移仍在Z-Box的范围内,那情况就是完全一致的,相反的,如果 Z(1)的值使得[i+1]的偏移超过了Z-Box的范围,那就需要继续往后暴力求解。

对于 0 的情况,有些方法是当 0 处理,有些是等于整个字符串长度,还是按实际情况需要给值。Z-Box 实现就用两个指针即可。这里留下一个算法执行流程可视化的网站:Z Algorithm

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
function zFunc(str) {
const z = Array(str.length).fill(0);
let l = 0,
r = 0;
for (let i = 1; i < str.length; i++) {
// 是否在Z-Box中
if (i < r) {
// Z-Box中剩余的长度够Z[i-l]
if (z[i - l] < r - i) {
z[i] = z[i - l];
continue;
} else {
// 至少可以复用r-i个字符
z[i] = r - i;
}
}
// 暴力求解
while (i + z[i] < str.length && str[z[i]] === str[i + z[i]]) {
z[i]++;
}
l = i;
r = i + z[i];
}
return z;
}
前缀匹配

倒序 p 串算完 Z 函数,填充完情况 1,怎么处理情况 2 呢?
再次观察 Z 函数可以发现,当i+Z(i)等于 p 串长度时,意味着 p 串偏移 i 后有 Z(i)长度的重复串,此时 Z(i)长度的重复串刚好是p串的前缀

构建偏移表

到此可以构建出 i 指针的偏移表了 gsr,gsr(i)代表好后缀长度为i时,i指针需要偏移的长度

考虑到 i 指针的偏移需要先偏移好后缀的长度,因此初始化表的时候加上索引。[p.len, p.len+1, …, p.len+p.len]

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
41
42
43
44
45
46
47
48
49
50
51
52
53
function buildGSR(needle) {
// 初始化为所有的好后缀情况都偏移到p串后面
const gsr = _.range(needle.length, 2 * needle.length);
// 无好后缀,只移动最差的1位
gsr[0] = 1;
const z = zReverseFunc(needle);
for (let i of _.range(1, z.length)) {
if (z[i]) {
// 情况1
gsr[z[i]] = Math.min(gsr[z[i]] || Infinity, i + z[i]);
}
}
for (let i of _.range(1, z.length)) {
if (z[i] + i === needle.length) {
// 此时是一个前缀
// 情况2
for (let j of _.range(z[i] + 1, gsr.length)) {
gsr[j] = Math.min(gsr[j], i + j);
}
}
}
return gsr;
}

function zReverseFunc(str) {
const n = str.length;
const z = Array(n).fill(0);
let l = 0,
r = 0;
for (let i = 1; i < n; i++) {
// 是否在Z-Box中
if (i < r) {
// Z-Box中剩余的长度够Z[i-l]
if (z[i - l] < r - i) {
z[i] = z[i - l];
continue;
} else {
z[i] = r - i;
}
}
// 暴力求解
// 这里从后往前取值,避免翻转字符串
while (
n - 1 - (i + z[i]) >= 0 &&
str[n - 1 - z[i]] === str[n - 1 - (i + z[i])]
) {
z[i]++;
}
l = i;
r = i + z[i];
}
return z;
}

The bad-character rule (坏字符规则)

一张哈希表记录下当出现失配字符时,i 指针需要偏移的值(其实就是该字符到末尾的位置),需要注意的是,p 串重复出现的字符总是需要优先采用靠右的字符去对齐(和 1 号 AB 是一个道理),因此从左往右迭代 p 串,覆盖更新即可。
可以参考这篇 Boyer-Moore: Bad character rule

1
2
3
4
5
6
7
8
9
10
import _ from "lodash";

function buildBCR(needle) {
const bcr = {};
for (let i of _.range(needle.length)) {
const c = needle[i];
bcr[c] = needle.length - 1 - i;
}
return bcr;
}

BM算法

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/**
* @param {string} haystack
* @param {string} needle
* @return {number}
*/
var strStr = function (haystack, needle) {
// BM algo
const bcr = {};
for (let i of _.range(needle.length)) {
const c = needle[i];
bcr[c] = needle.length - 1 - i;
}

// 初始化为所有的好后缀情况都偏移到p串后面
const gsr = _.range(needle.length, 2 * needle.length);
// 无好后缀,只移动最差的1位
gsr[0] = 1;
const z = zReverseFunc(needle);
for (let i of _.range(1, z.length)) {
if (z[i]) {
gsr[z[i]] = Math.min(gsr[z[i]] || Infinity, i + z[i]);
}
}
for (let i of _.range(1, z.length)) {
if (z[i] + i === needle.length) {
// 此时是一个前缀
for (let j of _.range(z[i] + 1, gsr.length)) {
gsr[j] = Math.min(gsr[j], i + j);
}
}
}
let i = needle.length - 1,
j = needle.length - 1;
while (i < haystack.length) {
if (haystack[i] !== needle[j]) {
// 选择偏移位数多的方案
i += Math.max(
gsr[needle.length - 1 - j],
bcr[haystack[i]] === undefined ? needle.length : bcr[haystack[i]],
);
j = needle.length - 1;
} else {
if (j === 0) {
return i;
}
i--;
j--;
}
}
return -1;
};

// 倒序Z算法
const zReverseFunc = (str) => {
const n = str.length;
const z = Array(n).fill(0);
let l = 0,
r = 0;
for (let i = 1; i < n; i++) {
// 是否在Z-Box中
if (i < r) {
// Z-Box中剩余的长度够Z[i-l]
if (z[i - l] < r - i) {
z[i] = z[i - l];
continue;
} else {
z[i] = r - i;
}
}
// 暴力求解
while (
n - 1 - (i + z[i]) >= 0 &&
str[n - 1 - z[i]] === str[n - 1 - (i + z[i])]
) {
z[i]++;
}
l = i;
r = i + z[i];
}
return z;
};
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
41
42
43
44
45
46
47
48
49
50
51
52
53
[Boyer-Moore string search](https://source.chromium.org/chromium/chromium/src/+/main:v8/src/strings/string-search.h;drc=fa67bc861debe561f482e5023096ced07cf33f45;bpv=1;bpt=1;l=297)
//---------------------------------------------------------------------
// Boyer-Moore string search
//---------------------------------------------------------------------

template <typename PatternChar, typename SubjectChar>
int StringSearch<PatternChar, SubjectChar>::BoyerMooreSearch(
StringSearch<PatternChar, SubjectChar>* search,
base::Vector<const SubjectChar> subject, int start_index) {
base::Vector<const PatternChar> pattern = search->pattern_;
int subject_length = subject.length();
int pattern_length = pattern.length();
// Only preprocess at most kBMMaxShift last characters of pattern.
int start = search->start_;

int* bad_char_occurence = search->bad_char_table();
int* good_suffix_shift = search->good_suffix_shift_table();

PatternChar last_char = pattern[pattern_length - 1];
int index = start_index;
// Continue search from i.
while (index <= subject_length - pattern_length) {
int j = pattern_length - 1;
int c;
while (last_char != (c = subject[index + j])) {
int shift = j - CharOccurrence(bad_char_occurence, c);
index += shift;
if (index > subject_length - pattern_length) {
return -1;
}
}
while (j >= 0 && pattern[j] == (c = subject[index + j])) j--;
if (j < 0) {
return index;
} else if (j < start) {
// we have matched more than our tables allow us to be smart about.
// Fall back on BMH shift.
index += pattern_length - 1 -
CharOccurrence(bad_char_occurence,
static_cast<SubjectChar>(last_char));
} else {
int gs_shift = good_suffix_shift[j + 1];
int bc_occ = CharOccurrence(bad_char_occurence, c);
int shift = j - bc_occ;
if (gs_shift > shift) {
shift = gs_shift;
}
index += shift;
}
}

return -1;
}

BM算法-时间复杂度

Boyer-Moore算法的时间复杂度很有趣,因为它不遵循通常的规则。在最佳情况下,即每次字符比较都导致不匹配的情况下,该算法的时间复杂度为O(nm)O(\frac{n}{m}) ,其中nn 是文本串的长度,而 mm 是搜索串的长度。这是因为在每次不匹配后,算法可以跳过 m 个字符,导致 nm\frac{n}{m} 次比较。

在最坏情况下,时间复杂度可以是 O(mn)O(mn),即当p串所有字符都相同且匹配结果出现在文本末尾时发生。然而,在实际情况下,这种情况不太可能发生。平均而言,Boyer-Moore算法表现良好,特别适用于长模式和大量文本,使其成为最有竞争力的字符串搜索算法之一。