在平常的代码编写中,我们常常碰见字符串匹配问题,而很多时候我们用的仅仅是最简单的也是最容易想到的朴素算法,其实还有很多比较好的方法值得我们去探索,这篇文章来介绍三种算法,朴素算法,rabin—karp算法,还有KMP算法
先声明下文可能用到的变量
Text:文本内容 长度为n
target:等待匹配的内容 长度为m
朴素算法
朴素算法又被成为暴力算法,就是单个比较,如果有一个位置发生不匹配,则Text的指针后移一位,然后从头开始和我们的target继续进行比较。
时间复杂性O(m*(n-m))=O(m*n)
/*
朴素算法:将目标字符串与待匹配的字符串从第一个字母开始匹配,直到找到或者直到最后匹配失败
@Text: 文本内容
@target:等待匹配内容
返回值 :第一次查找到目标字符串的位置
*/
using namespace std;
int BruteForce(char *Text, char *target) {
start = clock();//计时器的开始
int TLen = strlen(Text);//Text字符串的长度
int tLen = strlen(target);//target字符串的长度
//循环嵌套,逐个判断字符串是否匹配
// 最多的匹配次数是(TLen - tLen)*tLen
int i, j;
for (i = 0;i <= TLen - tLen; i++) {
bool equals = true;
for (j = 0;equals&&j < tLen; j++) {
bruseNum++;
if (Text[i + j] != target[j]) {
equals = false;
}
}
//如果匹配成功就返回当前字符串的第一个位置,同时输出调用该方法所使用的时间
if (equals) {
finish = clock();
// cout << finish+" " << endl; //可以得到该算法结束的时间
double runTime = (double)(finish - start) / 1000;
cout << "Bruse算法" << "位置是" << i << endl;
cout<<"运行时间是" << runTime << "s" <<"比较次数是"<<bruseNum<<"次"<< endl;
return i;
}
}
//如果匹配结束之后依旧没有成功,则返回-1,同时输出运行时间;
finish = clock();
double runTime = (double)(finish - start) / 1000;
cout << "Bruse算法" << "查找失败" << endl;
cout << "运行时间是" << runTime << "s" << "比较次数是" << bruseNum << "次" << endl;
return -1;
}
Rabin-Karp算法
Rabin-Karp算法的优势在于他比较新颖,我们所能想到的匹配字符串的方式就是每一个字符分别匹配,但是这个方法将字符串成功的转化为一个数值,从而能够一次就比较出来结果。
Rabin-karp算法是通过一个自己构造的Hash函数对字符串进行运算,从而得到它所对应的hash值。但是由于hash冲突的存在,当hash值相同的时候,还是需要朴素算法来进行必要的比较,所以时间复杂性为O(m*n)。但是现实中hash冲突出现的可能性不是很大,所以相比较而言,复杂性还是比较小的,仅仅为O(m+n)
这里我们详细的说一下过程
将字符串转化为数值需要一个H,在我的理解中这个就类似于一个进制的东西,比如说我们平时的二进制串1101.他们之间的进制就是2、这里我们可以自己给H设定,然后用于最后的hash计算。首先是对于我们的target字符串的hash值的计算。比如说这里的字符串是abc
则计算过程如下
Hash(a)=a*H
Hash(ab)=((a*H)+b)*H=(Hash(a)+b)*H
Hash(abc)=(((a*H)+b)*h+c)*H=(Hash(ab)+c)*H
//Rabin—Karp算法的辅助算法,通过hash函数的构造,返回一个字符串所对应的hash值
//@text : 要求hash值的字符串
//@len : 所求字符串的长度
//@int D: 相邻两个字符之间的基数,也就是进制数
//返回值: 字符串text 所对应的hash值
using namespace std;
int hashResult(char *text, int len, int D) {
int result = 0;
for (int i = 0;i < len;i++) {
result = D*result + text[i];
rabinNum++;
}
return result ;
}
上面这个是用于最开始的时候的target串的hash值和在Text串中从文章起始位置开始于target串相同长度的串的hash值的计算。
针对于Text串,我们后期的计算过程我称之为“掐头加尾”
比如说我们现在的字符串为abcd,然后我们已经知道了abc的hash值,我们当然可以用同样的方式来进行计算bcd的值,但是这样我们之前abc的值得计算对于后面的计算就没有什么作用了,所以有一个比较好的方式就是我们利用之前的计算过程进行计算
H(bcd)=H(abc)-a*D+d
这里的D指的是a所在位置的单位,比如说这里的D就等于H^2
由于我们hash值得结果可能会很大,所以我们往往对这个结果进行对一个较大质数的取模运算。但是这样也就意味着及时hash值是相同的(也就是我们常说的hash冲突问题),我们同样需要对这个串进行利用朴素算法的比较。
时间复杂性
我们最开始的target串的hash值得计算的时间复杂性其实就是O(m)。如果不存在hash冲突,我们后期的时间花费主要在于文本内容hash值得计算和比较上,时间复杂性为O(n) 总的来说,在这种情况下,时间复杂性应该为O(m+n)
但是由于hash冲突的存在,我们每次后期的比较不仅仅包括文本字符串的hash值得计算和比较,还要包括当我们的hash值相同的时候,我们要进行另一次的hash的判断
这就意味着我们的时间复杂性变成了O(3m+m*(n-m))=O(m*n)
/*
Rabin-Karp算法:将字符串转化为数值进行比较(字符串需要一一对应的进行比较,而转化为数值之后一次就可以比较结束)
@Text :文本内容
@target:等待匹配的内容
@int D :相邻两个字符之间的基数,也就是进制数
返回值 target在Text中第一次出现的位置(如果不存在,则返回-1)
*/
int rabinkarp(char *Text, char *target, int D) {
start = clock(); //计算调用Rabin-karp算法的计时器的开始
//cout << start + " "; //可以用于输出调用Rabin-Karp算法的开始时间
int TLen = strlen(Text); //得到文本内容Text的长度
int tLen = strlen(target);//得到等待匹配字符串的长度
int tHash = hashResult(target, tLen, D);//通过调用辅助函数得到目标字符串的hash值
int THash = hashResult(Text, tLen, D);//通过调用辅助函数得到等待匹配字符串的hash值
//得到最高位所对应的值d,用于之后字符串继续匹配的时候向后移动需要减去数据的基值。
int d = 1;
for (int i = 1;i < tLen; i++) {
d = d*D;
rabinNum++;
// cout << "d=" << d << endl;
}
//通过for循环不断在Text中截取长度为tLen的字符串,并通过辅助获取他们对应的hash值
for (int j = 0;j <= TLen - tLen;j++) {
//通过前面得到的hash值的判断进行分情况讨论
bool find = true;
//由于存在hash碰撞的问题,所以即使hash值是相同的,仍然需要用朴素算法进行判断
if (THash == tHash) {
for (int k = 0;k < tLen;k++) {
rabinNum++;
if (Text[j + k] != target[k]) {
find = false;
break;
}
}
}
//如果hash值不相同,那么字符串后移,然后通过“前减后加”进行新一轮的匹配
else {
THash = ((THash - Text[j] * d)*D + Text[j + tLen]);
find = false;
rabinNum++;
}
//每次比较之后都要进行必要的判断,如果找到,那么返回数据。
if (find) {
finish = clock();
double runTime = (double)(finish - start) / 1000;
cout << "RabinKarp算法" << "位置是" << j << endl;
cout << "运行时间是" << runTime << "s" << endl;
cout<<"比较次数是" << rabinNum << "次" << endl; return j;
}
}
//如果字符串匹配结束依旧没有找到,那么返回-1;
finish = clock(); //得到调用Rabin-Karp算法的结束时间
//cout << finish + " " << endl; //可以输出调用Rabin-Karp算法的结束时间
double runTime = (double)(finish - start) / 1000;
cout << "RabinCarp算法" << "查找失败" << endl;
cout << "运行时间是" << runTime << "s" << "比较次数是" << rabinNum << "次" << endl;
return -1;
}
KMP算法
复杂性kmp算法绝对是需要聪明人才能想出来的方法(当然在这个算法的成长过程中,有着无数的人已经为之优化),但是我们只要会用就好了嘛
这个算法的主要优点在于构造了一个next数组,然后当文本内容和我们的等待匹配的字符串发生不匹配的时候,可以进行跳动,从而能够节省很多时间。
我们先来看一下我们的next数组到底有什么用吧
这个是文本内容
a b c a b c a b c d a b
target
a b c a b c d a b
这里的next数组
0 0 0 0 1 2 3 0 1 这两个串前6个是一样的,也就是说在第七个字符上出现错误,这个时候我们要是按照朴素算法就要从下面这个位置重新开始比较,
a
b
cabca
bcdab
a
bcabcd
ab
但是有了这个next数组呢我们发现是第七个出现问题next【7】=3 然后呢就是说
abcabc
a
bcdab
abc
a
bc
d
ab 将我们target的第三个字符与我们原来不匹配的字符相比较,从而省去了好多浪费的时间。进而提高了效率
那我们的next数组是怎么构造的呢,有两种方法
方式一:比较笨的方法来进行计算
首先我们先了解一下怎么去算这个next数组中的值,这个值就是说对于当前位置之前的字符串,以第一个字符开头的字符串和以最后一个字符结束的字符能重合的小于自身长度的最大长度的值。
用个例子来演示一下
就拿我们之前的那个字符串0 1 2 3 4 5 6 7 8
a b c a b c d a b
从2号位置开始,就是说2c,他前面的串是ab,长度为2,所以如果如果长度为1,则判断a是否等于b,a!=b,所以是0
对3a而言,他前面的数据是abc,所以长度可以判断的有1,2.当长度为1的时候,判断a是否等于c。a!=c,当长度为2的时候 ab!=bc,所以说还是0
拿六号位的d来说。他前面的数据是abcabc。
可能相等的长度为1~5.当长度为1的时候,判断a是否等于c。a!=c,所以不行
当长度为2的时候,我们要判断的是ab是否等于bc,不相等,所以不行
当长度为3的时候,我们要判断的是abc=abc,相等,所以值暂时为3
当长度为4的时候,我们要判断的是abca是否等于cabc,不相等,所以值仍然为3
当长度为5的时候,我们要判断的是abcab是否等于bcabc,不相等,所以值为3
其他的相似,可以这样进行计算
我们来看一下时间复杂性吧,从上面可以看出,next数组从第三位之后开始,每个都要比较n-2次,而且n-2次分别要比较1~n-2。所以可以得到复杂性为
=o(n^3)时间复杂性比较大
方式二:一个类似于递归的方法进行计算next数组
同样是刚才的那个字符串
1 2 3 4 5 6 7 8 9
a b c a b c d a b
0 0 0 0 1 2 3 0 1
我们在算完5b也就是next【5】=1之后算next【6】,这个时候其实只要看next【i】=j就是字符串charAt【1】是否等于charat【i】=charAt【5】,
如果说等于,那么next【6】就是在next【5】的基础上加1.
如果说不等于,那就看5号位置所在的那个i是否与charat【next【next【j】】】相同,直道算到位置0为止,从而得到刚才的next数组
所以时间复杂性为 最少其实是m,而最多为m*m.所以复杂性可以是 @(m)。当然也可以写成O(m).
/*
KMP算法的辅助算法,得到一个next数组,用于比较的时候可以后移多次,或者说后移多个单位
next数组的生成原则
next【i-1】+1 (charAt(i-1)==charAt(next[i-1]))
next【i】= next【next【i-1】】 (charAt(i-1)!=charAt(next[i-1]) && i-1>0 )
next【i】= 0 (i=0)
@Text 等待处理的数据
返回值 :Text对应的next数组
*/
int* getNext(char *Text) {
//得到字符串的长度
int len= strlen(Text);
//生成一个数组
int *next=new int[len+1];
next[0] = next[1] = 0;
int j = 0;
for (int i = 1;i<len;i++)//i表示字符串的下标,从0开始
{
KmpNum++;
//j在每次循环开始都表示next[i]的值,同时也表示需要比较的下一个位置
while (j > 0 && Text[i] != Text[j]) {
j = next[j];
KmpNum++;
}
if (Text[i] == Text[j]) {
j++;
KmpNum++;
}
next[i + 1] = j;
KmpNum++;
}
return next;
}
有了next数组,我们在与文本框比较的时候就比较从容了,当我们在匹配的过程中发生不匹配现象的时候,我们可以找到当前位置的next值,从而将文本内容的当前位置与等待匹配的字符串的第next【i】位进行比较。然后依次往后循环,知道匹配到等待匹配字符串为0.当依旧不匹配的时候,将文本内容的字符串后移一位进行比较。从而进行不断的比较
那我们后期比较的时间复杂性呢
我们可以看到,我们的文本内容的字符串的长度是n,而她没有重复比较,所以有人说时间复杂性为O(n)
可是我们要看到,对于文本内容来说,当一个位置与我们的target串不匹配的时候,我们要看到他可能会匹配好几个位置,这个怎么能不考虑在内呢?
所以这个的时间复杂性应该是O(n),
/*
KMP算法:通过辅助函数针对于target进行分析,生成一个跳步运算的表格,从而可以进行跳步运算
@Text :文本内容
@target:等待匹配的内容
返回值 :target在Texgt中首次出现的位置,如果始终没有匹配成功,那么就返回-1
*/
using namespace std;
int KMP(char *Text, char *target) {
start= clock(); //调用KMP算法的开始时间
//cout << start + " "; //输出调用KMP算法的开始时间
int *next = getNext(target);//通过调用辅助函数得到target所对应的next数组
int tLen = strlen(target);//得到Text文本的长度
int TLen = strlen(Text); //得到target文本的长度
int j = 0;
for (int i = 0;i<TLen;i++)
{
KmpNum++;
//j在每次循环开始都表示next[i]的值,同时也表示需要比较的下一个位置
//j其实就是最后要进行比较的位置,如果说j=3,那么就是说当前位置的字符串直接和等待匹配的字符串的第三位数进行比较,
//从而减少了第0。1。2三个位置的比较
while (j > 0 && Text[i] != target[j]) {
j = next[j];
KmpNum++;
}
if (Text[i] == target[j]) {
j++;
KmpNum++;
}
//j和target的长度是相同的,则匹配成功,从而输出时间,返回位置
if (j == tLen) {
finish = clock();
double runTime = (double)(finish - start) / 1000;
cout << "KMP算法" << "位置是" << i-j+1 << endl;
cout << "运行时间是" << runTime << "s" << "比较次数是" << KmpNum << "次" << endl;
//cout << "KMP算法" << "位置是" << i-j+1 << "运行时间是" << runTime << "s" << endl;
return (i-j+1);
}
}
//如果匹配结束之后没有找到,则查找失败并返回-1;
finish = clock();
//cout << finish + " " << endl;
double runTime = (double)(finish - start) / 1000;
cout << "KMP算法" << "查找失败" << endl;
cout << "运行时间是" << runTime << "s" << "比较次数是" << KmpNum << "次" << endl;
return -1;
}
所以说这个KMP算法的总的时间复杂性就是O(m+n)
总结一下三种算法
算法 时间 | 预处理时间 | 匹配时间 |
朴素算法 | 0 | O(m*n) |
Rabin-Karp算法 | O(m) | O(m*n) |
KMP算法 | O(m) | O(n) |
以上就是这三种算法了
如果你也是从头开始学字符串匹配,希望我写下来的这些东西能帮到大家,大家加油啦~