一、string类下的库函数使用
大家都知道,如果是在时间复杂度要求不高的情况下,我们使用一些优秀的库函数也是很好的,这样可以大大减小编程所消耗的时间,在很多赛事中,时间非常的宝贵!
1.1 string.find()函数与string::npos参数
这个find()函数用于处理:
判断模式串str1是否是父串str2的子串
这里就涉及到一个参数:string::npos
这个npos是一个常数,用来表示不存在的位置,它的取值实际上是由实现来决定的,一般情况下取-1
那么也就是说,string::npos可以用于检查find()的返回值是否表示匹配失败
然而,其他情况下,find()函数的返回值返回的是第一次出现的位置!
而且!这个位置是从0开始的!
代码如下:
#include <iostream>
#include <string>
using namespace std;
int main(){
string str1, str2;
cin >> str1 >> str2;
if(str1.find(str2) != string::npos)// 父串.find(模式串)
cout << "YES" << endl;
else
cout << "NO" << endl;
return 0;
}
1.2 find()函数的其他版本:rfind()函数
这个是倒着查询的
代码就把上面那个find()改成rfind()就可以了
实验结果如下:返回的是最后一次出现的第一个字符的位置
二、发现问题:如果要在父串中多此查询同一子串呢?
这个时候我们貌似发现,好像string类下没有对应的库函数可以使用,而且不断查询也会带来一个问题,那就是,模式串是否会在父串中重叠呢?比如:
父串:zyzyzyz
子串:zyz
这样查询出来的次数应该得分情况来说,如果允许重叠的话,这样出现的次数就是3,否则就是2
那么这样还能用我们的库函数吗?于是,只能开始学习新的算法:
3.1追根溯源
一开始我们是如何去在父串中匹配模式串的呢?
显然是一种很笨拙的方法:BF算法
算法思想
此算法的思想是直截了当的:将主串S中某个位置i起始的子串和模式串T相比较。即从 j=0 起比较 S[i+j] 与 T[j],若相等,则在主串 S 中存在以 i 为起始位置匹配成功的可能性,继续往后比较( j逐步增1 ),直至与T串中最后一个字符相等为止,否则改从S串的下一个字符起重新开始进行下一轮的"匹配",即将串T向后滑动一位,即 i 增1,而 j 退回至0,重新开始新一轮的匹配。
举例说明
例如:在串S=”abcabcabdabba”中查找T=” abcabd”(我们可以假设从下标0开始):先是比较S[0]和T[0]是否相等,然后比较S[1] 和T[1]是否相等…我们发现一直比较到S[5] 和T[5]才不等。
如图:
很明显在S[5]处失配了!
当这样一个失配发生时,T下标必须回溯到开始,S下标回溯的长度与T相同,然后S下标增1,然后再次比较。如图:
这次立刻发生了失配,T下标又回溯到开始,S下标增1,然后再次比较。如图:
哎,这个例子没举好,烦得很,又失配了!所以T下标又回溯到开始,S下标增1,然后再次比较。这次T中的所有字符都和S中相应的字符终于匹配了。函数返回T在S中的起始下标3。如图:
核心代码
#include <iostream>
#include <string>
using namespace std;
int main(){
string str1, str2;
cin >> str1 >> str2;
int i = 0, j = 0;
while(str1[i + j] != '\0' && str2[j] != '\0'){
if(str1[i + j] == str2[j])
j++;
else
j = 0, i++;
}
if('\0' == str2[j])
cout << i << endl;
else
cout << "NO" << endl;
return 0;
}
总结反思
这里很麻烦的地方就是回溯,刚才大家伙也都看到了,我们的回溯其实是这样的:
对于每一个父串的位置i,我们都跑一遍模式串中的位置j,只要当前位置能匹配(当前的 i + j 和 j 能够匹配上)我们就继续检查模式串的下一个,由于我们的检查方式是:str1[i + j] == str2[j]
,所以每次回溯都只需要让模式串的位置归零即可!如果不这样的话,那就是模式串回溯归零,父串退回已经匹配的全部位置!
这样处理的劣势在于:
会造成没有意义的回溯,比如:
父串S: ababbab
模式串T:abb
我们可以容易发现第一次需要回溯的位置是S[2] != T[2]
如果按照朴素算法,我们就需要父串的下标回溯到S[1] = b
,而模式串就需要回溯到最初的起点:T[0] = a
这个其实就是一个没有意义的回溯,因为这样回溯立马又得回溯了(因为刚回溯好,就失配了!)所以我们设想,能不能让每一次的回溯都是有意义的回溯呢?这样是不是就可以大大减去没有意义的开销了呢?没错,是这样的!我们需要对每一次有意义的回溯点,做存储(预处理),然后让每一次的回溯都是回溯到有意义的回溯点!
3.2他来了他来了,KMP向你走来了!
举例说明
还是相同的例子,在S=”abcabcabdabba”中查找T=”abcabd”,如果使用KMP匹配算法,当第一次搜索到S[5] 和T[5]不等后,S下标不是回溯到1,T下标也不是回溯到开始,而是根据T中T[5]==’d’的模式函数值(next[5]=2,为什么?后面讲),直接比较S[5] 和T[2]是否相等,因为相等,S和T的下标同时增加;因为又相等,S和T的下标又同时增加……最终在S中找到了T。如图:
这样处理的好处是什么呢?
第一:只回溯模式串的下标,不必管父串
第二:减去了模式串的无意义回溯,可以回溯到一个最优的位置(Next表预处理)
核心思想
KMP算法的核心思想是利用已经得到的部分匹配信息来进行后面的匹配过程。看前面的例子。为什么T[5] = 'd'
的模式函数值为Next[5] = 2
,其实这个‘2’表示T[5] = 'd'
的前面有2个字符和开始的两个字符相同,且T[5] = 'd'
不等于开始的两个字符之后的第三个字符(T[2] = 'c'
).如图:
3.3KMP算法的核心:求Next表
我们这个Next表是用于存储当前最大匹配规模,可以用于维护下一步失配时的回溯必定是最优回溯!
3.3.1Next表的含义:
Next[i] = k表示的是在Next[0] ~ Next[i - 1]的子串中,前k个字符组成的子串(Next[0] ~ Next[k - 1])与后k个字符组成的子串(Next[i - k] ~ Next[i - 1])是匹配的!而这个匹配的规模,是最大的!
3.3.2Next表的算法:
这个咱也不废话,直接上代码:
#include <iostream>
#include <cstring>
using namespace std;
const int maxn = 205;
char str[maxn];
int nex[maxn];
void GetNext(){
int i = 0, j = -1;// i指向后缀,j指向前缀
int len = strlen(str);
nex[0] = -1;
while(i < len - 1){
if (str[i] == str[j] || -1 == j)
nex[++i] = ++j;
else
j = nex[j];// 同样也需要回溯
}
}
int main(){
cin >> str;
GetNext();
for(int i = 0;i < strlen(str);i++)
cout << nex[i] << ' ';
return 0;
}
这里需要注意三个点:
第一:千万不要把Next表的数组名写成:“next”因为在Linux下,会命名冲突!一般编译是检查不出来的,但是很多OJ平台是Linux下的,所以最好是不要写“next”,避免编译错误带来的罚时!
第二:制作Next表的过程也是需要回溯的,因为我们制作的Next表不仅是能够适应模式串中某子串的,而是要适应整个模式串的!随着预处理的模式串长度增长,前后缀的匹配长度并不一定增长,所以也有可能出现前后缀不匹配的情况,此时便只能去查询上一次最优的匹配状态(即回溯!)
第三:这个Next表的制作是针对模式串的
3.3.3一个完整的KMP算法代码
#include <iostream>
#include <string>
using namespace std;
const int maxn = 1005;
string str, str1;
int nex[maxn];
void GetNext(){
int i = 0, j = -1, len = str1.length();
nex[0] = -1;
while(i < len - 1){
if(str1[i] == str1[j] || -1 == j)
nex[++i] = ++j;
else
j = nex[j];
}
}
int KMP(){
GetNext();
int i = 0, j = 0, len1 = str.length(), len2 = str1.length();
while(i < len1 && j < len2){
if(str[i] == str1[j] || -1 == j){
i++;
j++;
}
else
j = nex[j];
}
if(j >= len2)
return i - len2;
else
return -1;
}
int main(){
getline(cin,str);
getline(cin,str1);
cout << KMP();
return 0;
}
这个代码就不必我再做赘述了!比BF算法高效的地方就在于每次回溯都是最优回溯方案,分摊复杂度下来就是O(n),我们再思考一下,如果我们的模式串不止一个呢?如果是多模式下的匹配呢?那就要建立字典树,结合KMP算法,也就是大名鼎鼎的AC自动机。
四、KMP算法的应用举例4.1 模式串多次出现在父串中
算法改进分析
我们只需要修改回溯,原本在我们完成一次匹配之后,就返回匹配成功的首元素下标,这样就导致了只能去匹配一次(并且是第一次),那么我们设想,只要我们设定在父串未被检查完的情况下,我们都要一直去匹配。那么就算是我们匹配成功了一次,也必须要回溯,然后去继续检查匹配!那么怎样回溯才会避免重复、冗余的检查呢?我们知道,只要我们的父串不回溯,就不会出现冗余的检查,所以只需要回溯一次即可!
对应习题与代码
例题1:系OJ1480
#include <iostream>
#include <cstring>
using namespace std;
const int maxn = 1005;
char str[maxn], str1[maxn], str2[maxn];
int nex[maxn], ans[maxn], idx, l;
void GetNext(){
int i = 0, j = -1, len = strlen(str1);
nex[0] = -1;
while(i < len - 1){
if(str1[i] == str1[j] || -1 == j)
nex[++i] = ++j
else
j = nex[j];
}
}
void KMP(){
GetNext();
int i = 0, j = 0, len1 = strlen(str), len2 = strlen(str1);
while(i < len1 && j < len2){
if(str[i] == str1[j] || -1 == j){
i++;
j++;
if(j >= len2){
ans[idx++] = i - len2;
j = nex[j];
}
}
else
j = nex[j];
}
}
int main(){
cin >> str >> str1 >> str2;
KMP();
for(int i = 0;i < idx;i++){
for(int j = l;j < ans[i];j++)
cout << str[j];
l = ans[i] + strlen(str1);
for(int k = 0;k < strlen(str2);k++)
cout << str2[k];
}
for(int i = l;i < strlen(str);i++)
cout << str[i];
return 0;
}
在这个题目里,我们把每一次匹配成功的首下标储存起来,然后再去统一替换。
例题2:系OJ1077
#include <iostream>
#include <string>
using namespace std;
const int maxn = 1e6 + 5;
string t, m;
int ans, nex[maxn];
void GetNext(){
int i = 0, j = -1;
nex[0] = -1;
while(i < m.length()){
if(-1 == j || m[i] == m[j])
nex[++i] = ++j;
else
j = nex[j];
}
}
void KMP(){
int i = 0, j = 0;
GetNext();
for(i = 0;i < t.length();i++){
while(j >= 0 && t[i] != m[j])
j = nex[j];
if(t[i] == m[j] || -1 == j)
j++;
if(m.length() == j){
ans++;
j = nex[j];
}
}
}
int main(){
cin >> t >> m;
KMP();
cout << ans;
return 0;
}
处理模式串在父串中有重叠的多次匹配问题!
基础KMP就写到这!后面我再多做些题目,再做整理!然后我们继续扩展!
五、Sunday算法
启发与反思
我们在学习KMP算法的时候,如果是面临比较简单的匹配问题,KMP算法和string库函数的功能一样,复杂度相近,那我们为何还去选择KMP算法呢?如果我们遇到的问题,对时间复杂度的要求更高的时候,该怎么办呢?经过查资料得知:字符串匹配有三种常用的算法,分别是KMP算法、BM算法和Sunday算法。其中,KMP算法的复杂度与库函数无差、BM算法比KMP稍微快上个三五倍、而Sunday算法则是比BM还要快!我们来学习一下Sunday算法。
算法思想
Sunday算法的思路是模式串从前往后查询,如果发现失配,也需要回溯,不过这个回溯和KMP不同。
Sunday算法的回溯思路是:
回溯方案一:如果父串中对应模式串长度的下一个字符未在模式串中出现,则后移长度:模式串长度 + 1;
回溯方案二:如果父串中对应模式串长度的下一个字符未在模式串中出现,则后移长度为:模式串中最右出现的位置到模式串尾部的距离 + 1;
举例说明
父串:S = "substring searching"
子串:T = "search"
第一步:i = 0, j = 0,此时很明显S[i] = 's', T[j] = 's',S[i] = T[j];
于是我们继续看
第二步:i = 1, j = 1,此时发生了失配,因为S[i] = 'u',T[j] = 'e',S[i] != T[j];
,发生失配后,检查模式串规模 + 1的字符:‘i’,这个字符’i’并没有出现在模式串中,于是我们移动的规模是:模式串长度(6) + 1 = 7
第三步:
我们移动到了字符’i’的下一个之后,继续开始匹配(j = 0,回溯)
发现立马就匹配不上了,于是我们还是去查找模式串规模的下一个字符:‘r’
发现这个字符在模式串中出现了,于是我们移动该字符在模式串中出现的位置到模式串尾部的距离(2) + 1 = 3;
第四步:
再次移动,发现成功匹配!!
偏移量表:shift数组预处理
代码实现shift表的制作:
void GetShift(){
int len = str2.length();
for(int i = 0;i < 256;i++)
shift[i] = -1;
for(int i = 0;i < len;i++)
shift[str2[i]] = i;// hash 算法
}
注释:
因为i位置处的字符可能在pattern中多处出现(如下图所示),而我们需要的是最右边的位置,这样就需要每次循环判断了,非常麻烦,性能差。这里有个小技巧,就是使用字符作为下标而不是位置数字作为下标。这样只需要遍历一遍即可,这貌似是空间换时间的做法,但如果是纯8位字符也只需要256个空间大小,而且对于大模式,可能本身长度就超过了256,所以这样做是值得的(这也是为什么数据越大,BM/Sunday算法越高效的原因之一)。
完整的Sunday算法代码
#include <iostream>
#include <string>
using namespace std;
int shift[256];
string str1, str2;
void GetShift(){
int len = str2.length();
for(int i = 0;i < 256;i++)
shift[i] = -1;
for(int i = 0;i < len;i++)
shift[str2[i]] = i;// hash 算法
}
int Sunday(){
GetShift();
int len1 = str1.length(), len2 = str2.length(), i = 0;
if(0 == len1)
return -1;
while(i <= len1 - len2){
int j = i, k = 0;
while(j < len1 && k < len2 && str1[j] == str2[k])
j++, k++;
if(len2 == k)
return i;
else{
if(i + len2 < len1)
i += (len2 - shift[str1[i + len2]]);
else
return -1;
}
}
return -1;
}
int main(){
cin >> str1 >> str2;
cout << Sunday();
return 0;
}
总结一下:
在office的字符串操作中(诸如一些快捷键 Ctrl + H)使用的算法是BM算法,但是BM算法又复杂,性能上也就比KMP算法高个三四倍,我们就不研究了,而是直接研究这个1990年才发明出来的更加先进的算法:Sunday算法