概念
- 模式匹配的定义:对于一个长度为
n
的文本字符串
s[1..n] ,且长度为 m 的模式字符串t[1..m] 。其中满足 m≤n 。 s 和t 都来自有限字符集合 Σ 。(例如小写英文字母集 Σ={a,b,c,d,...,z} )如果 s[r+1..r+m]=t[1..m] ,则 t 在s 中出现,其中偏移为 r 称为有效偏移。模式匹配问题需要求解到所有的rk ,使 t 在s 以 rk 的有效偏移出现。
朴素(Brute-Force)算法
既然模式匹配的目标是得到这样的集合 {r|s[r..r+m]=t[1..m]∧∃r∈[1,n]}
因此朴素算法的核心思想是,穷举从
[1,n]
所有的
r
的可能取值,判断
因此可以得到如下的算法(为了通用起见和隐藏实际的地址指针,这里选择标准库的std::string
类型来描述字符串。):
/**
* @brief 字符串模式匹配暴力算法
* @param const std::string & source 文本串
* @param const std::string & pattern 模式串
* @return std::vector<int> 找到的模式串位于文本串的起始位置,没找到则为空
*/
std::vector<int> naive_string_match(const std::string & source, const std::string & pattern){
std::vector<int> result;
int i, j;
int end = source.length() - pattern.length() + 1;
//确保i + j不会越过source的末尾
for(i = 0; i < end; i++){
for(j = 0; j < pattern.length(); j++)
if(source[i + j] != pattern[j])
break;
if(j == pattern.length())
result.push_back(i);
}
return result;
}
注意外层循环条件end
的取值是
n−m+1
,因为如果是取
n−m
,则最后一趟循环时
i=n−m−1
,故source[i + j]
的取值的最大值为
(n−m−1)+m=n−1
,无法对齐最后一个字符,可能出现漏判的情况。
对其进行时间复杂度分析,最内层循环执行
m
次,外层循环执行
KMP算法
Knuth, Morris, Pratt算法,简称KMP算法,是一种高效的模式匹配算法,其核心在于先对子串进行预处理,得到一个辅助的前缀函数 π(x) ,从而充分利用已知信息,避免主串指针的回溯,以提高效率。其时间复杂度: Θ(m) 的预处理和 Θ(n) 的匹配。
有限自动机(DFA)
对于KMP算法的理解,介绍一下有限自动机(deterministic finite-state automaton, DFA)是很有帮助的。
一个有限自动机 M=(Q,q0,A,Σ,δ) 。
其中 Q 是所有可能的状态集合,q0∈Q 表示自动机的初始状态, A⊆Q 表示自动机的终态集合(也就是自动机被接受的所有状态), Σ 是所有输入的字符集合, δ 是一个状态转移函数,是一个由 Q 和Σ 的笛卡儿积( Q×Σ )到 Q 的函数。
自动机从q0 状态开始。对于任意的状态 q ,每读取一个字符a ,其状态就由 q 变为δ(q,a) 。
自动机的状态转换类似于时序电路中的状态,下一个状态依赖于当前状态和输入。
DFA的模式匹配
对于一个任意的模式串
t[1..m]
,都可以构造一个有限自动机
M
。
假设模式串ababaca
。 字符集
Σ={a,b,c}
。
则由该模式串产生的自动机
M
共有a
,ab
,aba
,abab
,ababa
,ababac
,ababaca
。
因此
状态0,如果输入为a
,则进入状态1,反之依然为状态0;
状态1,如果输入为b
,则进入状态2,输入为a
,进入状态1,输入为c
,进入状态0。
状态2,如果输入为a
,则进入状态3,如果输入为b
或c
,则进入状态0.
…(以此类推。)
上图对上例的字符集
Σ={a,b}
的字符串abababc
进行模式匹配构造的自动机的状态图。7为接受状态。
这样在匹配的过程中,只需要自动机进行到第7个状态,则完成匹配。
利用自动机进行匹配的核心在于:状态
q
满足方程
计算转移函数,设
i
处状态为
δ(q,x)=σ(t[1..q]x)
,
σ
是后缀函数,满足
σ(y)
是
y
的后缀的
当
t[i+1]=x
时,很明显
δ(q,x)=q+1
。反之,当
t[i+1]≠x
时,则取最长的子串
p′
的长度,满足
p′
既是
t[1..q]
的前缀又是
t[1..q]x
的后缀(注意两边有区别)。后缀相当于是目前走过的部分,而
t[1..q]
的前缀,则保证了走到当前位置时,已经与模式串的前些位相匹配。
以上面例子的状态5为例,
i=5
(1)
x=c
时,与
x=t[6]
匹配,则
δ(5,a)=6
(2)
x=b
时,
t[6]=a
不匹配,则状态需要回退,考虑新加进的字符b
以后,能回退的最大长度,则需要找到前缀与新加进字符b
以后的后缀相等的字符串的长度,很明显未加入b
时,ababa
,而键入b
后得到ababab
,因此取的字符串为abab
,长度为4,即
δ(5,b)=4
。
(3)
x=a
时,同(2)相类似,可得
δ(5,c)=1
。
由此可见,计算 δ 是一个模式串自我(部分)匹配的过程。
由DFA到KMP
对于自动机来说,因为 δ 函数的输入是一个二元笛卡儿积,故存储预处理生成状态转移函数需要 O(|Σ|m) 的额外空间,且最好的时间为 O(|Σ|m) 。利用KMP算法能够将空间复杂度和时间复杂度显著改善为 O(m) 。
在KMP算法中,引入了辅助的前缀函数 π 代替存储状态转移函数 δ ,利用 π 的值,可以在常数时间(摊还意义)计算出 δ 的值。
定义 π(q)=max{k:k<q∧t[1..k]⊐t[1..q]}
其中 x⊐y 表示 x 是y 的后缀
需要注意的是,与 δ 不同的地方在于后缀部分并不包含当前读取的字符,因为 π 函数本身不包括当前字符的信息(为了减少 |Σ| 项必然不能包括字符信息。)。
满足了上面的 π(q) 的定义,可以得出,对于 s[i]≠t[q] 时,可以将 s[i] 与 t[π(q)+1] 进行比较(定义保证了前后缀相同且长度最大,因此比较下一个字符),如此往复,直到能匹配上(自动机回退到某一状态)或 q=0 为止(自动机回退到初始状态)。实际上这是一个动态计算 δ 函数的过程。因此不难得出KMP的主函数。
下面的问题在于如何求取 π 。 π 是预先计算出来存放在数组中的。求取 π 实际也是自我匹配过程。
π
满足
π(1)=0
的初始条件。可以设
k
用来指示满足要求的最长前缀的长度,于是在遍历
同样以ababaca
为例,已经求得
π(1)=π(2)=0,π(3)=1,π(4)=2
,于是求
π(5)
时,
k=2
,
t[5]=t[3]
,于是
π(5)=k+1=3
。求
π(6)
时,
t[6]≠t[4]
,也就是说加入c
字符后得到的后缀abac
与同样长度的前缀abab
不等,于是应该回退,
k=π(k)=1
。此时也就是说,在未加入字符c
之前,在长度为
k=3
的情况不满足时,依然有次小的
k=1
满足,这时候依然需要判断当前字符c
加入后能否满足。显然ab
≠
ac
。再次重复上面的操作,直到
k=0
,也就是他们的前缀子串与后缀子串都为空串
ε
。此时分别在前缀子串和后缀子串末尾加入字符a
与c
,产生的新串并不等,于是依然有
k=0
。所以
π(6)=0
。
求前缀函数的方法也明确以后,就不难写出KMP的匹配算法了。
/**
* @brief 计算KMP的前缀函数数组
* @param const std::string & pattern 模式字符串
* @return std::vector<int> 前缀函数数组
*/
std::vector<int> kmp_prefix_func(const std::string & pattern){
std::vector<int> result;
int k = 0; //满足条件的最大前缀数组长度
result.push_back(0); //初始条件
for(int i = 1; i < pattern.length(); i++){
while(k > 0 && pattern[k] != pattern[i]){
//C/C++数组下标以0开始,pattern[k]就是前缀数组要新加入的字符
k = result[k - 1];
}
if(pattern[k] == pattern[i]){
k++;
}
result.push_back(k);
}
return result;
}
/**
* @brief KMP字符串模式匹配主函数
* @param const std::string & text 文本字符串
* @param const std::string & pattern 模式字符串
* @return std::vector<int> 所有找到的下标(以0开始)
*/
std::vector<int> kmp_matcher(const std::string & text, const std::string & pattern){
std::vector<int> prefix = kmp_prefix_func(pattern); //计算前缀函数
std::vector<int> result;
int q = 0; //设置状态初始值
for(int i = 0; i < text.length(); i++){
while(q > 0 && text[i] != pattern[q]){
//C/C++数组下标以0开始,pattern[q]就是前缀数组要新加入的字符
q = prefix[q - 1];
}
if(pattern[q] == text[i]){
q++;
}
if(q == pattern.length()){
result.push_back(i - (q - 1)); //找到了,加入进返回值数组
q = prefix[q - 1]; //更新状态,查找下一个
}
}
return result;
}