文章目录
1.字符串匹配问题的定义
- 文本(Text) 是一个长度为n的数组
T[0...n-1]
; - 模式(Pattern) 是一个长度为m且m≤n的数组
P[0...m-1]
; T
和P
中的元素都属于有有限的字母表 Σ \Sigma Σ表;- 如果 0≤s≤n-m,并且 T[s+1…s+m] = P[1…m],即对 1≤j≤m,有 T[s+j] = P[j],则说模式 P 在文本 T 中出现且位移为 s,且称 s 是一个有效位移(Valid Shift)。
- 即我们的目的是:在主串中找到子串首次出现的位置。
比如上图中,目标是找出在文本 T = abcabaabcabac 中模式 P = abaa 的出现的位置。该模式在此文本中仅出现一次,即在位移 s = 3 处,位移 s = 3 是有效位移。
2.暴力匹配算法(Brute Force)
2.1 算法流程:
假设当前文本串S匹配到i
位置(初始为0),模式串P匹配到j
位置(初始为0),则有:
- 如果当前字符串匹配成功(即S[i] == P[j]),则继续匹配下一个字符;
- 如果匹配失败(即S[i]! = P[j]),则令i = i - j +1,j = 0。相当于每次匹配失败时,i 回溯,j 被置为0。
2.2 代码实现(C++)
//使用暴力匹配的方法返回子串P在主串S中第pos个字符后的位置;
//若不存在,则函数值返回-1
//其中S[0...n-1],P[0...m-1]
int ViolentMatch(string s, string p,int pos) {
int slen = s.length();
int plen = p.length();
int i = pos;
int j = 0;
while (i < slen&&j < plen) {
if (s[i] == p[j]) { i++; j++; } //如果匹配成功,则匹配下一位
else { i = i - j + 1; j = 0; } //如果匹配失败,则i回溯,j置为0
}
if (j == plen) return i - plen;//匹配成功
else return -1; //匹配不成功
}
2.3 算法的问题
- 若主串s和子串p经常出现
"部分匹配"
的情形,则s的指针i会很容易出现回溯。
例如像s="00000000000000000001"和p="000001"这种由0和1字符组成的文本串就会很容易出现这种问题。 - 当一趟匹配过程中出现字符比较不等时,有时不需要回溯i的指针到最开始的地方,而是利用已经得到的
"部分匹配"
的结果将模式尽可能远的向右"滑动"尽可能远的一段距离后,再继续进行比较。这就需要我们下面介绍的KMP算法
。
2.4 算法的复杂度
最好的情况下:
O
(
n
+
m
)
O(n+m)
O(n+m),其中n和m分别是主串和模式的长度
最差的情况下:
O
(
n
∗
m
)
O(n*m)
O(n∗m)(经常出现"部分匹配",导致指针
i
i
i不断回溯)
3.KMP算法
3.1 引出
如2.3
所说的一样,在不匹配的时候,我们可以利用部分匹配
的那部分来获得一些文本的信息以减少回溯。
如文本串T=“ababcabcacbab”,模式P=“abcac”。
第一趟匹配:匹配到i=2,j=2时,S[2]!=P[2]。
如果按照暴力匹配方法,则i需要回溯到1但是我们一定会有S[1]!=P[0],
即如果我们利用已经匹配的那部分信息,我们会有S[1]=P[1]!=P[0]。
这就表明利用已经匹配的部分,我们可以不回溯i而是把j对应"滑动"到相应位置,即第一趟匹配后i=2,j=0
。
第二趟匹配:匹配到i=6,j=4时,我们有S[6]!=P[4].
但是,利用已匹配的部分,我们会有
- S[3]=P[1]!=P[0];
- S[4]=P[2]!=P[0];
- S[5]=P[3]==P[0];
所以,i是不用回溯的,即我们可以直接让i=6,j=1
,再继续进行匹配。
这就是,我们KMP算法解决的问题。
3.2 算法流程
KMP算法由D.E.Knuth与V.R.Pratt和J.H.Morris同时发现,所以以他们的名字来命名,叫Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP算法”。
算法流程:
假设当前文本串S匹配到i
位置(初始为0),模式串P匹配到j
位置(初始为0),则有:
- 如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),则令i++,j++,继续匹配下一个字符;
- 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。
- 这里的
j-next[j]
就相当于我们在3.1
所述的利用"已匹配"
部分得出i不回溯但是j相对向右滑动j-next[j]位。 - 而next数组的实际含义是:代表当前字符之前的字符串中,有多大长度的相同
前缀后缀
。例如next [j] = k,代表j 之前的字符串中有最大长度为k 的相同前缀后缀。 - 同时,也意味着在某个字符匹配失败后,模式串应该跳到next[j]的位置。
- 如果next [j] 等于0或-1,则跳到模式串的开头字符;
- 若next [j] = k 且 k > 0,代表下次匹配跳到j 之前的某个字符,而不是跳到开头,且具体跳过了k 个字符。
- 这里的
3.3 KMP算法实现
代码:
//使用KMP算法返回子串P在主串S中第pos个字符后的位置;
int KmpMatch(string s, string p, int pos) {
int slen = s.length();
int plen = p.length();
int i = pos;
int j = 0;
int* nextP = new int[plen];
get_next(p, nextP); //获得模式串P的next函数并存入数组next
while (i < slen&&j < plen) {
if (s[i] == p[j] || j == -1) { i++; j++; }
else j = nextP[j];
}
delete[] nextP;
if (j == plen) return i - plen;
else return -1;
}
3.4 next数组含义
3.4.1 next数组举例
以3.1
的第二趟匹配举例:i=6,j=4
此时,我们有S[6]≠P[4]。但是我们有S[2,3,4,5]=P[0,1,2,3],同时j=4之前,模式串有长度k=1的前缀,即P[0]=P[3]=‘a’,且P[3]=S[5],所以P[0]=P[5],得出我们不用匹配S[3]和P[0/1/2/3]而直接匹配S[6]和P[1]。
这里,我们找的是紧在
j
(也就是i
)之前的前缀,而没有考虑在i的起始位置 i 0 i_{0} i0到位置 i − 1 i-1 i−1中间位置的前缀,因为即使有前缀,因为如果不能接着匹配完模式串也是徒劳。
例如下面这种情况:
正如我们看到的,在 j = 0 j=0 j=0到 j = 6 j=6 j=6之间,我们能找到两个前缀:
- P[0,1]和P[2,3]
- P[0,1]和P[5,6]
如果我们选择P[0,1]和P[2,3],这下次匹配
i
i
i=6,
j
j
j=2 (因为我们有S[4,5]=P[2,3]=P[0,1]),所以就省略了一些匹配.但是我们会发现由于前缀
并不完整,这样的选择方式,虽然能匹配P[0,1]但是却肯定不能接着把模式串P匹配完。
所以,KMP算法
选择的是P[0,1]和P[5,6]这样,我们的
i
i
i不用回溯,即
i
=
9
i=9
i=9,同时有S[7,8]=P[5,6]=P[0,1],所以
j
j
j就直接等于2,即
i
=
9
,
j
=
2
i=9,j=2
i=9,j=2。此时,是有可能将模式串匹配完的,因为
i
=
9
i=9
i=9之后的S我们是不知道的。
3.4.2 next数组的定义
假设主串为
s
0
s
1
.
.
.
s
n
−
1
s_{0}s_{1}...s_{n-1}
s0s1...sn−1 ,模式串为
p
0
p
1
.
.
.
p
m
−
1
p_{0}p_{1}...p_{m-1}
p0p1...pm−1,当前匹配位置为
i
和
j
i和j
i和j,且出现失配,即
s
i
≠
p
j
s_{i}≠p_{j}
si̸=pj。
KMP算法求解的问题的是
i
i
i指针不动,
j
j
j应该向右滑动多远,即
i
i
i和哪个
j
j
j比较?
假设下次与模式串中的第
k
(
k
<
j
)
k(k<j)
k(k<j)个字符比较,则模式串前
k
−
1
k-1
k−1个字符必满足下式,且不存在
k
′
>
k
k^{'}>k
k′>k满足下式:
p
0
p
1
.
.
.
p
k
−
2
=
s
i
−
k
+
1
s
i
−
k
+
2
.
.
s
i
−
1
p_{0}p_{1}...p_{k-2} = s_{i-k+1}s_{i-k+2}..s_{i-1}
p0p1...pk−2=si−k+1si−k+2..si−1
而通过已匹配的部分有:
p
j
−
k
+
1
p
j
−
k
+
2
.
.
.
p
j
−
1
=
s
i
−
k
+
1
s
i
−
k
+
2
.
.
s
i
−
1
p_{j-k+1}p_{j-k+2}...p_{j-1} = s_{i-k+1}s_{i-k+2}..s_{i-1}
pj−k+1pj−k+2...pj−1=si−k+1si−k+2..si−1
则,我们有等式:
p
0
p
1
.
.
.
p
k
−
2
=
p
j
−
k
+
1
p
j
−
k
+
2
.
.
.
p
j
−
1
p_{0}p_{1}...p_{k-2} = p_{j-k+1}p_{j-k+2}...p_{j-1}
p0p1...pk−2=pj−k+1pj−k+2...pj−1 (*)
也就是说,当
i
i
i和
j
j
j失配时,下次
S
[
i
]
S[i]
S[i]和
P
[
k
]
P[k]
P[k]进行匹配,且
k
k
k满足
(
∗
)
(*)
(∗)式。
即失配
时,next函数的定义:
n e x t [ j ] = k next[j]=k next[j]=k表示 j j j 之前有长度为 k k k 的前缀后缀,注意 j j j 从
0
开始。如下图所示:
其中 p 0 p 1 . . . p k − 1 ( 前 缀 ) = p j − k p j − k + 1 . . . p j − 1 ( 后 缀 ) , 长 度 为 k 。 p_{0}p_{1}...p_{k-1}(前缀)=p_{j-k}p_{j-k+1}...p_{j-1}(后缀),长度为k。 p0p1...pk−1(前缀)=pj−kpj−k+1...pj−1(后缀),长度为k。
举例说明P=“abaabcaca”
j | 1 2 3 4 5 6 7 8 |
---|---|
模式串 | a b a a b c a c |
next[j] | -1 0 0 1 1 2 0 1 |
再求得next数组后,匹配如下进行:
假设
i
i
i和
j
j
j分别指示主串和模式中正比较的字符
- 若 s i = p j s_{i}=p_{j} si=pj,则 i + + , j + + i++,j++ i++,j++;
- 若
s
i
≠
p
j
s_{i} \neq p_{j}
si̸=pj,则
i
不
动
,
j
=
n
e
x
t
[
j
]
i不动,j = next[j]
i不动,j=next[j];
- 若相等,则 i + + , j + + i++,j++ i++,j++;
- 否则,
j
j
j退到下一个
n
e
x
t
next
next的位置,依次类推。
- 若 j j j退到一个next值匹配,则 i 和 j i和j i和j各自增1,继续进行匹配;
- 若
j
j
j退到了-1(即模式的第一个字符
失配
),则此时模式继续向右滑动一个位置,即 j = 0 j=0 j=0,同时从主串的下一个字符s_{i+1}和模式重新开始匹配。
注意, j j j退到-1表示主串的第 i i i个字符和模式串的第一个字符(P[0])不等,应该从主串的第 i + 1 i+1 i+1起重新匹配。
3.4.3 next函数的求解
方法:利用递推公式
由3.4.2
可知,next函数值只取决于模式串P本身,而我们可以通过3.4.2
的定义出发利用递推的方法求得next函数值。
- next[0]=-1;
- 设
n
e
x
t
[
j
]
=
k
next[j]=k
next[j]=k,则有
p
0
p
1
.
.
.
p
k
−
1
=
p
j
−
k
p
j
−
k
+
1
.
.
.
p
j
−
1
p_{0}p_{1}...p_{k-1} = p_{j-k}p_{j-k+1}...p_{j-1}
p0p1...pk−1=pj−kpj−k+1...pj−1,其中
0
≤
k
<
j
0≤k<j
0≤k<j,则
n
e
x
t
[
j
+
1
]
=
?
next[j+1]=?
next[j+1]=?
- 若
p
[
k
]
=
p
[
j
]
p[k]=p[j]
p[k]=p[j],则有
p
0
p
1
.
.
.
p
k
−
1
p
k
=
p
j
−
k
p
j
−
k
+
1
.
.
.
p
j
−
1
p
j
p_{0}p_{1}...p_{k-1}p_{k} = p_{j-k}p_{j-k+1}...p_{j-1}p_{j}
p0p1...pk−1pk=pj−kpj−k+1...pj−1pj
即 n e x t [ j + 1 ] = k + 1 = n e x t [ j ] + 1 {\color{Red}next[j+1]=k+1=next[j]+1 } next[j+1]=k+1=next[j]+1 - 若
p
[
k
]
≠
p
[
j
]
p[k]\neq p[j]
p[k]̸=p[j],则有
p
0
p
1
.
.
.
p
k
−
1
p
k
≠
p
j
−
k
p
j
−
k
+
1
.
.
.
p
j
−
1
p
j
p_{0}p_{1}...p_{k-1}p_{k} \neq p_{j-k}p_{j-k+1}...p_{j-1}p_{j}
p0p1...pk−1pk̸=pj−kpj−k+1...pj−1pj
此时,可以把求next函数值的问题也看成一个模式匹配
问题,即整个模式串既是主串又是模式串,而且在当前匹配过程中有 p 0 p 1 . . . p k − 1 = p j − k p j − k + 1 . . . p j − 1 p_{0}p_{1}...p_{k-1} = p_{j-k}p_{j-k+1}...p_{j-1} p0p1...pk−1=pj−kpj−k+1...pj−1 ( n e x t [ j ] = k next[j]=k next[j]=k)。
则当 p k ≠ p j p_{k} \neq p_{j} pk̸=pj时,将模式向右滑动让模式串中的第 n e x t [ k ] next[k] next[k]个字符与主串中的第 j j j个字符比较( j j j起始于0)。- 若
n
e
x
t
[
k
]
=
k
′
next[k]=k^{'}
next[k]=k′且
p
j
=
p
k
′
p_{j}=p_{k^{'}}
pj=pk′,则有
p 0 . . . p k ′ − 1 p k ′ = p k − k ′ . . . p k − 1 p j = p j − k ′ + 1 . . . p j − 1 p j p_{0}...p_{k^{'}-1}p_{k^{'}}=p_{k-k^{'}}...p_{k-1}p_{j}=p_{j-k^{'}+1}...p_{j-1}p_{j} p0...pk′−1pk′=pk−k′...pk−1pj=pj−k′+1...pj−1pj ( 0 ≤ k ′ < k < j 0≤k^{'}<k<j 0≤k′<k<j) (*)
此时,我们能得出 n e x t [ j + 1 ] = k ′ + 1 next[j+1]=k^{'}+1 next[j+1]=k′+1,即 n e x t [ j + 1 ] = n e x t [ k ] + 1 {\color{Red} next[j+1]=next[k]+1} next[j+1]=next[k]+1 - 同理,若 p j ≠ p k ′ p_{j} \neq p_{k^{'}} pj̸=pk′,则 j j j继续和 n e x t [ k ′ ] next[k^{'}] next[k′]比较,依次类推,直至 p j p_{j} pj和模式中的某个字符匹配成功或者不存在任何 k ′ ( 0 ≤ k ′ < j ) k^{'}(0≤k^{'}<j) k′(0≤k′<j)满足(*)式,则 n e x t [ j + 1 ] = 0 next[j+1]=0 next[j+1]=0。
- 若
n
e
x
t
[
k
]
=
k
′
next[k]=k^{'}
next[k]=k′且
p
j
=
p
k
′
p_{j}=p_{k^{'}}
pj=pk′,则有
- 若
p
[
k
]
=
p
[
j
]
p[k]=p[j]
p[k]=p[j],则有
p
0
p
1
.
.
.
p
k
−
1
p
k
=
p
j
−
k
p
j
−
k
+
1
.
.
.
p
j
−
1
p
j
p_{0}p_{1}...p_{k-1}p_{k} = p_{j-k}p_{j-k+1}...p_{j-1}p_{j}
p0p1...pk−1pk=pj−kpj−k+1...pj−1pj
注意,理解递推思想的关键是
next[j]=k
代表j
之前的字符串中有最大长度为k
的前缀后缀。
代码实现:C++
//求模式串P的next函数并存入数组next[p.size()]。
void get_next(string p, int* next) {
int plen = p.length();
int j = 0; //当前求next[j]
int k = -1;//已求得next[j]=k
next[j] = k;
while (j < plen) {
//p[j]表示后缀,p[k]表示前缀
if (k == -1 || p[j] == p[k]) { j++; k++; next[j] = k; } //next[j+1]=next[j]+1;next[1]=0;
else k = next[k];
}
}
next函数求解优化:
前面定义的next函数在某些情况下有缺陷:
例如模式串P="a a a a b"和主串S="a a a b a a a a b"进行匹配时,
当
i
=
3
,
j
=
3
i=3,j=3
i=3,j=3时,
s
[
3
]
≠
p
[
3
]
s[3]≠p[3]
s[3]̸=p[3],此时,
s
[
3
]
s[3]
s[3]还要和
p
[
n
e
x
t
[
3
]
]
=
p
[
2
]
,
p
[
n
e
x
t
[
2
]
]
=
p
[
1
]
,
p
[
n
e
x
t
[
1
]
]
=
p
[
0
]
p[next[3]]=p[2],p[next[2]]=p[1],p[next[1]]=p[0]
p[next[3]]=p[2],p[next[2]]=p[1],p[next[1]]=p[0]进行比较,但是因为
p
[
3
]
=
p
[
2
]
=
p
[
1
]
=
p
[
0
]
=
′
a
′
p[3]=p[2]=p[1]=p[0]= 'a'
p[3]=p[2]=p[1]=p[0]=′a′,所以,这3次比较多是多余的。
问题的关键是,不应该出现
p
[
j
]
=
p
[
n
e
x
t
[
j
]
]
{\color{Red} p[j] = p[next[j]]}
p[j]=p[next[j]]。因为失配时,有
s
[
i
]
≠
p
[
j
]
s[i]≠p[j]
s[i]̸=p[j],也同样会造成
s
[
i
]
≠
p
[
n
e
x
t
[
j
]
]
s[i] \neq p[next[j]]
s[i]̸=p[next[j]],所以应该避免这种情况,即出现之后要再次对next数组进行递归。
代码实现:C++
//求模式串P的next函数优化值,并存入数组nextval
void get_nextval(string p, int* nextval) {
int plen = p.length();
int j = 0;
int k = -1;
nextval[j] = k;
while (j < plen) {
//p[j]表示后缀,p[k]表示前缀
if (k == -1 || p[j] == p[k]) {
j++; k++;
if (p[j] != p[k]) nextval[j] = k;
//因为不能出现p[j] = p[ next[j ]],所以当出现时需要继续递归,k = next[k] = next[next[k]]
else nextval[j] = nextval[k];
}
else k = nextval[k];
}
3.5 算法复杂度
next数组求解复杂度:
O
(
m
)
O(m)
O(m),其中m是模式串的长度
KMP搜索的复杂度:
O
(
n
)
O(n)
O(n),因为指针
i
i
i是不回溯的
优点:主串的指针不回溯,可以边读入主串边匹配,对处理外设输入的文件很有效。
4.BM算法
KMP的匹配是从模式串的开头开始匹配的,而1977年,德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授发明了一种新的字符串匹配算法:Boyer-Moore算法
,简称BM算法
。该算法从模式串的尾部开始匹配,且拥有在最坏情况下O(N)
的时间复杂度。在实践中,比KMP算法的实际效能高。
未完待续。。。。。。
5.Sunday算法
Sunday算法
由Daniel M.Sunday在1990年提出,比BM算法效率更高,且更容易理解。
未完待续。。。。。。
推荐阅读
1.《数据结构 c语言版》 严蔚敏著
2.从头到尾彻底理解KMP. 作者:v_JULY_v
https://blog.csdn.net/v_july_v/article/details/7041827