您的位置:首页 > 其它

字符串匹配算法 -- 暴力破解法(朴素法),RK算法,KMP算法

2016-12-21 00:38 435 查看
算法预处理时间匹配时间
朴素算法0O((n-m+1)m)
Rabin-KarpΘ(m)O((n-m+1)m)
KMP算法Θ(m)Θ(n)

术语

前缀和后缀

如果对某个字符串y∈∑*有x = wy, 则称字符串w是字符串x的前缀,记做 w ⊂ x。

如果对某个字符串y∈∑*有x = yw, 则称字符串w是字符串x的后缀,记做 w ⊃ x。

朴素字符串匹配算法

描述

朴素字符串匹配算法是通过一个循环找到所有有效偏移地址,该循环对n-m+1个可能的s值进行检测,看是否满足条件P[1..m] = T[s+1..s+m]。

图说明

如下图所示,文本T = “acaabc”,P = “aab”,在对文本T进行循环的时候,隐藏着一个循环,该循环用于逐个检测对应位置上的字符,直到所有位置都能够成功匹配或者有一个位置不能匹配为止。



伪代码

NAVIE-STRING-MATCHER(T,P)
n = T.length
m = P.length
for s = 0 to n - m
if P[1..m] == T[s + 1 .. s + m]
print "Pattern occurs with shift"


代码实现

由于实现相对简单,这里对代码不在做过多的说明,核心在于遍历文本的过程,如果成功匹配第一个元素,则遍历模板,进行内部匹配。

// 朴素字符串匹配算法
// 匹配时间为O((N-M+1)M)
// 输出文本T中子串出现的次数
int Naivie_String_matcher(string T, string P)
{
int num = 0;
int n = T.size();
int m = P.size();
for(int s = 0; s <= n - m; ++s) {
if(T[s] == P[0]) {
int k = 0;
for(int i = 0; i < m; ++i) {
if(T[s + i] == P[i]) {
++k;
}
else {
break;
}
}
if(k == m) {
++num;
}
}
}
return num;
}


Rabin-Karp算法

描述

Rabin-Karp算法又叫做RK算法(下面都用这个简称),它的预处理时间为Θ(m),并且在最坏情况下,它的运行时间为Θ((n-m+1)m)。在实际情况下,它相对于朴素算法来说,是比较好的。

RK算法主要是利用两个数相对于第三个数模等价的概念。

例如:假设

A % B = C

D % B = C

即便不能判断A一定等于D , 但是如果取模不相等,那么A一定不等于D,所以可以用来提高效率

图说明

如下图, 我们选取的字符串T为“234590314121204”和模板字符串P为“3141”。我们将m个字符串转为d进制的数字(这里我们用十进制表示),转换后的整数取模进行匹配。当寻找到模相等的时候在进行内部匹配,检测是否为合法匹配。



相应的数学公式

先思考一个问题,如何将字符串“31415”转换为十进制的31415?

我们可以容易知道

31415 = 3 × 10000 + 1 × 1000 + 4 × 100 + 1 × 10 + 5



31415 = ((((3 × 10 + 1) × 10 + 4 ) × 10) + 1 ) × 10 + 5

由上面的等式我们可以知道,给定一个模式P[1..m],假设p表示其相应的十进制值。那么我们利用霍纳法则得

p=P[m]+10(P[m−1]+10(P[m−2]+L+10(P[2]+10P[1])))

利用循环的方式我们可以简化代码,其中i为0到m

p0=0

p=10p+P[i]

对应的C++代码

for(int i = 0; i < m; ++i) {
p = (d * p + P[i]) ;
}


当文本移动的时候

例如m = 5时,T = “31245678”,t_0 = 31245, t_1= 12456

如何得到t_1的值?这是我们要思考的第二个问题。可以看得出 t_1的值为t_0去掉最高位“3”和加末尾加上“6”,因此

t1=10(t0−10000∗T[0])+T[m+1]

归纳后得

ts+1=d(ts−dm−1T[s+1])+T[s+m+1]

相应的C++代码

//h = static_cast<int>(pow(d, m - 1));
t = d * (t - T[s] * h) + T[s + m];


伪代码

RABIN-KARP-MATCHER(T,P,d,q)
n = T.length
m = P.length
h = d^(m - 1) mod q
p = 0
t0 = 0
for i = 1 to m
p = (dp + P[i]) mod q
t0 = (dt0 + T[i]) mod q

for s = 0 to n - m
if p == t(s)
if P[1..m] == T[s + 1 .. s + m]
print "Pattern occurs with shift"s
if s < n - m
t(s+1) = (d(t(s) - T[s + 1] * h) + T[s + m + 1]) mod q


代码

本人没有完全按照上面的伪代码实现,本代码在VS2013上可以成功运行,但在sublime3上有个bug,对于匹配纯数字字符串,它可以完美匹配,在字符字符串上,只能部分匹配。例如:文本为“abcdefghijdefgkldefg”,对应模板为“d”,“de”,“def”,“defgh”。。。“defghij”中“def”和“defgh”无法匹配成功。置于原因可以与我探讨。

// Rabin-Karp算法
// 预处理时间(M),匹配时间O((N-M+1)M),实际优于朴素算法
// d : 表示字符都是由d为基数表示的数字
// q : 素数,用于模计算
// 输出子串出现的次数
int Rabin_Karp_matcher(string T, string P, int d, int q)
{
int num = 0;
int n = T.size();
int m = P.size();
// 计算h
int h = static_cast<int>(pow(d, m - 1));
/*int pow_d = 1;
for(int i = 0; i < m - 1; ++i) {
pow_d *= d;
}
int h = pow_d % d;*/
// p和t0用于求出相应的子串对应的d进制整数
/*
相应数学公式(霍纳法则)
p = P[m] + d(P[m - 1] + d(P[m - 2] + d(P[m - 3] + ... + d(P[2] + dP[1])...))
可以看出一共有(m-1)个d
通过数学归纳法我们可以知道
p = (d * p + P[i]))
又因为要取模
易知p(总) % q 和每次计算的出的p % q是一样的
*/
int p = 0;
int t = 0;
for(int i = 0; i < m; ++i) {
p = (d * p + P[i]) % q;
t = (d * t + T[i]);
}

for(int s = 0; s <= n - m; ++s) {
if(p == (t % q)) {
int k = 0;
for(int i = 0; i < m; ++i) {
if(T[s + i] == P[i]) {
++k;
}
else {
break;
}
}
if(k == m) {
++num;
cout << "The position : " << s + 1 << endl;
}
}
// 前面我们之算出了T的前m个字符对应的d进制的值,如果不匹配,那么需要往下移动一个字符
// 所以我们需要去除最前面的数,在尾部加入新的数,例如m = 5时,T = "31245678",t(0) = 31245, t(1) = 12456
// 根据公式
// t(s+1) = d * (t(s) - d^(m - 1) * T[s + 1]) + T[s + m + 1]
// 其中h % q = d^(m-1)
// h 是一个具有m数位的文本窗口的高位数的数位上的数字“1”的值。
if(s < n - m) {
t = (d * (t - T[s] * h) + T[s + m]);
}
}
return num;
}


Knuth-Morris-Pratt算法

建议看之前先看一篇国外的关于KMP算法的博客:The Knuth-Morris-Pratt Algorithm in my own words

描述

KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt同时发现,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的关键在于部分匹配表的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是实现一个compute_prefix_function()函数,函数本身包含了模式串的局部匹配信息。时间复杂度O(m+n)。

当我们进行匹配的时候,假设文本T =“bacbababaabcbab”和模板 P = “ababaca”

当我们进行字符串匹配的时候,如果匹配到成功的字符,那么它将进行内循环,如下图所示



逐一匹配



检索字符



检索字符



检索字符



当匹配到不相同的时候开始进行跳步



根据部分匹配表(Partial Match Table)

char :   | a | b | a | b | a | c | a  |


index: | 0 | 1 | 2 | 3 | 4  | 6 | 7 |


value: | 0 | 0 | 1 | 2 | 3  | 0 | 1 |


查表可知,最后一个匹配字符a对应的”部分匹配值”为3

因此按照下面的公式算出向后移动的位数:移动位数 = 已匹配的字符数 - 对应的部分匹配值(value)

因为 5 - 3 等于2,所以将搜索词向后移动2位。

跳两步



分析

上图简单介绍了下KMP进行匹配时候的运行原理,现在我们来进行分析KMP算法的正确性,然后再来搞清楚怎么获取到部分匹配表。

假设模式字符P[1..q]与文本字符T[s + 1.. s + 1]匹配,s’是最小的偏移量,s’>s,那么对某些k < q ,满足

P[1..k] = T[s’ + 1 .. s’ + k]

的最小偏移s’>s是多少,其中s’+ k = s + q。换句话说,已知P_q⊃P_k,我们希望P_q的最长真前缀P_k也就是T_(s+q)的后缀。

即:已知一个模式P[1..m],模式P的前缀函数是函数π:{1,2,….,m}->{0,1,…,m-1},满足

π[q] = max{k:k < q 且 P_k ⊃ P_q}

即π[q]是P_q的真后缀P的最长前缀长度。

什么是前缀和后缀

要知道部分匹配表的由来,首先,要了解两个概念:”前缀”和”后缀”。

“前缀”指除了最后一个字符以外,一个字符串的全部头部组合。

“后缀”指除了第一个字符以外,一个字符串的全部尾部组合。

例如:字符串“ABCDEF”,那么它的前缀和后缀分别为

前缀:“A”,“AB”,“ABC”,“ABCD”,“ABCDE”

后缀:“BCDEF”,“CDEF”,“DEF”,“EF”,“F”

部分匹配表

算法导论书上说的有点不是很清楚,我们可以换一种思路,来得到部分匹配表。

对于模式P = “ababaca”,从左到右依次增加一个字符

当下标为0的时候

“a” 的前缀和后缀都没有,故部分匹配表里的 value=0

当下表为1的时候

“ab”的前缀为:“a”,后缀为“b”,由于前缀和后缀不相同,故 value = 0

当下标为2的时候

“aba”的前缀为:“a”,“ab”,后缀为“ba”,“a”,由于前缀和后缀有一个相同,且最长度为1,故 value = 1

当下标为3的时候

“abab”的前缀为:“a”,“ab”,“aba”,后缀为:“bab”,“ab”,“b”,由于前缀和后缀有一个相同,且最长度为2,故 value = 2

当下标为4的时候

“ababa”的前缀为:“a”,“ab”,“aba”,“abab”,后缀为:“baba”,“aba”,“ba”,“a”,前缀和后缀有两个相同,但是最长长度为3,故 value = 3

以此类推,就能得出所有的部分匹配表

代码实现

int* compute_prefix_function(string P_string)
{
int P_length = P_string.size();
int* PI = new int[P_length];
PI[0] = 0;
int k = 0;
for(int q = 1; q < P_length; ++q) {
while(k > 0 && P_string[k] != P_string[q]) {
k = PI[k];
}
if(P_string[k] == P_string[q]) {
k += 1;
}
PI[q] = k;
}

return PI;
}


整体代码

得出部分匹配表后,剩下的就是根据部分匹配表里的值,进行跳步。

关键点就是下一个有效偏移
s'= s + (q - π[q])
,这里s表示有效偏移,q表示匹配的字符长度,π[q]表示对应部分匹配表里的信息

int* compute_prefix_function(string P_string)
{
int P_length = P_string.size();
int* PI = new int[P_length];
PI[0] = 0;
int k = 0;
for(int q = 1; q < P_length; ++q) {
while(k > 0 && P_string[k] != P_string[q]) {
k = PI[k];
}
if(P_string[k] == P_string[q]) {
k += 1;
}
PI[q] = k;
}

return PI;
}

// Knuth-Morris-Pratt算法
// 预处理时间Θ(m),匹配时间Θ(N)
// 输出子串出现的次数
int Knuth_Morris_Pratt_matcher(string T, string P)
{
int num = 0;
int n = T.size();
int m = P.size();
int k = 0;
int* tb = compute_prefix_function(P);

for(int i = 0; i < n; ++i) {
if(k > 0 && T[i] != P[k]) { // 进行跳步
i += k - tb[k]; // s'= s + (q - π[q])
k = 0;
}
if(T[i] == P[k]) {
k = k + 1;
}
if(k == m) {
++num;
cout << "The position : " << i - k + 2 << endl;
k = 0;
}
}
return num;
}


拓展

字符串匹配算法还有很多,例如:Boyer-Moore algorithm,神奇的Sunday algorithm等等。

以后有机会,会把BM算法和Sunday算法加上。

参考资料

Knuth–Morris–Pratt algorithm

The Knuth-Morris-Pratt Algorithm in my own words

《算法导论》第32章

字符串匹配的KMP算法
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: