以下笔记来自阅读:Knuth D E , Morris J H , Pratt V R . Fast Pattern
Matching in Strings[J]. Siam Journal on Computing, 1977,
6(2):323-350.
文本编辑器软件经常需要在一串字符中查找以匹配特定的模式,这些模式也是一串字符,我们有时需要查找到所有匹配的位置,也有时候,仅仅需要获取第一个匹配位置。例如,c
a t e n a r y 包含模式 t e n 。
最直接的查找方式是在文本的每一个位置开始查找,如果没有匹配,就放弃该次查找。但是这种方式可能非常低效,举个例子,比如我们需要查找
a a a a a a a a a a a a a a b 中出现的 a a a a a a a b。模式是
anb, 文本是 a2nb,我们将需要进行 (n+1)2
次比较。另外,这种传统处理方式在遍历输入字符串的时候需要回退,这将给我们频繁用到的缓存操作带来麻烦(想像从磁盘读取数据到内存缓存)。
算法背后的想法从这个角度更会容易理解:想像我们将模式字符串放置在输入字符串之上,
并且按照某种方式向右滑动模式字符串。比如:从 b a b c b a b c a b c a a b
c a b c a b c a c a b c 中查找 a b c a b c a c a
b。首先,我们将模式字符串放到最左侧,准备匹配输入字符串的第一个字符:
1 2 3
a b c a b c a c a b b a b c b a b c a b c a a b c a b c a b c a c a b c ↑
箭头指向当前的输入字符,由于其指向 b,与 a
不匹配,我们向右滑动一位,移动到下一个输入字符:
1 2 3
a b c a b c a c a b b a b c b a b c a b c a a b c a b c a b c a c a b c ↑
a b c a b c a c a b b a b c b a b c a b c a a b c a b c a b c a c a b c ↑
此时我们已经匹配了除第四个字符以外的前三个字符,所以我们知道,输入字符串的最后四个字符是
a b c x
(x≠a)。我们不需要记录之前扫描过的字符串,因为我们目前在模式字符串中的位置就提供了重建这些字符串的足够信息。在上述的例子中,无论
x 是什么字符,只要不是 a,
我们可以直接将模式字符串右移4位,因为一位、两位、三位的移动都不可能匹配字符串。
1 2 3
a b c a b c a c a b b a b c b a b c a b c a a b c a b c a b c a c a b c ↑
很快我们遇到另一个部分匹配,这次在模式字符串的第八个字符。
1 2 3
a b c a b c a c a b b a b c b a b c a b c a a b c a b c a b c a c a b c ↑
现在,我们知道输入字符串的的最后八个字符是:a b c a b
c a x (x≠c),模式字符串因此应该右移三位。
1 2 3
a b c a b c a c a b b a b c b a b c a b c a a b c a b c a b c a c a b c ↑
在每步的扫描过程中,我们要么移动输入文本的指针,要么移动模式字符串,两者都最多能移动
n 次;因此,在构建 next 表后,最多需要 2n
步。当然模式字符串并没有真正的移动,只需要维护好指针 j
即可。
算法实现
模式匹配的过程可以描述为如下形式:
1 2 3 4 5 6 7 8 9
place pattern at left; while pattern not fully matched and text not exhausted do begin while pattern character differs from current text character do shift pattern appropriately; advance to next character of text; end;
便于描述,让我们假设输入文本用数组 text[1:n]
描述,模式字符串用 pattern[1:m] 描述。我们假设 m >
0 ,也就是模式字符串不为空。k 和 j
是整数变量,text[k] 是当前的文本字符,pattern[j]
是当前模式字符串字符;模式字符串目前放置在输入文本 p + 1 到
p + m 的位置上,k = p +
j。于是,上面的描述,可以简化为:
1 2 3 4 5 6 7
j := k := 1; while j ≦ m and k ≦ n do begin while j > 0and text[k] ≠ pattern[j] do j := next[j]; k := k + 1; j := j + 1; end;
如果最后程序结束 j >
m,则找到了模式字符串的第一个匹配位置,k - m 到 k -
1。但是如果 j ≦ m,
表明输入文本已经遍历完,但是没有发现匹配。这个程序有一个有趣的特性,内部循环的
j := next[j]; 操作执行不如外面的 k := k + 1
频繁;实际上,内部循环一般情况都执行的不频繁,因为外面的文本指针移动要比模式字符串的右移频繁的多。
为了严格证明上述程序的正确性,使用如下不变关系(invariant
relation):Let p = k - j
(恰好位于和模式字符串对齐的前一个文本字符),我们有 text[p +
i] = pattern[i] for 1 ≦ i < j (当j >
0时,我们已经匹配了前面的j - 1个字符); 但是对于 0 ≦ t < p 有
text[t + i] ≠ pattern[i] for some i, 1 ≦ i ≦
m(在 p 之前不可能有对模式的完全匹配)。
程序要想正确是建立在能够计算 next
表之上的,只有这样,我们才能在做了 j := next[j]
的变换之后,依然保持上面的关系成立。现在,让我们看看如何计算
next 表。当程序设置 j := next[j],我们知道 j >
0, 输入文本包含 text[k] 的最后 j 个字符是:
1
pattern[1] ... pattern[j - 1] x , x ≠ pattern[j]
我们想要的是找到最少的右移步数,并且有可能匹配当前的输入字符。换句话说,我们想要找到最大的
i (i < j) 能够使得最后的 i 个输入字符是:
1
pattern[1] ... pattern[i] x , pattern[i] ≠ pattern[j]
(如果没有这样的 i,next[j] =
0)。按照如上 next[j] 的定义,很容易可以得到:text[t +
1]...text[k] ≠ pattern[1]...pattern[k - t], for k - j ≦ t < k -
next[j]。因此前述的不变关系得到了保持,我们程序的正确性也得到了保证。
现在让我们来解决一直推迟的一个问题,如何计算 next[j]。
如果在定义 next[j] 时我们不要求 pattern[i] ≠
pattern[j],这个问题将变得简单一些,因此我们先优先考虑这个简化的场景。
让 f[i] 代表满足如下条件的最大的 i (i < j):
j := 1; t := 0; next[1] := 0; while j < m do begin comment t = f[j] while t > 0and pattern[j] != pattern[t] do t := next[t] t := t + 1; j := j + 1; if pattern[j] = pattern[t] then next[j] := next[t]; else next[j] := t; end
这个程序的时间复杂度是 O(m),
原因和之前模式匹配算法 O(n)的一样:内部循环中的t :=
next[t] 永远将上层的模式字符串向右移动,因此最多能够执行 m
次。(另一个不同的证明复杂度与 m
线性相关的方法是变量t从0开始,增加了 m - 1
次的1;t值永远都是正值,因此降低t值的这个操作 t := next[t],
最多也就能执行 m - 1 次)
v1.5.2