一、字符串
串:又称字符串,是由零个或多个字符组成的有限序列。
字符串通常用双引号括起来,例如S=”abcdefg”,S为字符串的名字,双引号里面的内容为字符串的值。
串长:串中字符的个数,例如S的串长为6。
空串:零个字符的串,串长为0。
子串:串中任意个连续的字符串组成的子序列,称为该串的子串,原串称为子串的主串。子串在主串中的位置,用子串的第一个字符串在主串中出现的位置表示。
注意:空格也算一个字符。
空格串:全部由空格组成的串为空格串。
注意:空格串不是空串。
字符串的存储可以使用顺序存储和链式存储两种方式。
1、字符串的顺序存储
顺序春初使用一段连续的空间存储字符串。可以预先分配一个固定长度Maxsize的空间,在这个空间中存储字符串。
顺序存储又有三种方式:
(1)以’\0’表示字符串结束
在C、C++、Java语言中,通常用’\0’表示字符串结束,’\0’不算在字符串长度内。
这样做有一个问题,如果想知道串的长度,需要从头到尾遍历一遍,如果经常需要用到串的长度,每次遍历一遍复杂性较高,因此可以考虑将字符串的长度存储起来方便使用。
(2)在0空间存储字符串长度
下标为0的空间不使用,因此可以预先分配Maxsize+1的空间,在下标为0的空间中存储字符串长度。
(3)结构体变量存储字符串的长度。
除上述方法之外,也可以将字符串长度存储在结构体中。
typedef struct{
char ch[Maxsize];//字符型数组
int length;//字符串的长度
}SString;
这样做也有一个问题,串的运算如和并、插入、替换等操作,容易超过最大长度,出现溢出,可以采用动态分配空间的方法,其结构体定义如下:
typedef struct{
char *ch;//指向字符串指针
int length;//字符串的长度
}SString;
2、字符串的链式存储
和顺序表一样,顺序存储的串在插入和删除操作时,需要移动大量元素,因此也可以采用链表的形式存储。
单链表存储字符串时,虽然插入和删除非常容易,但是这样做也有一个问题:一个节点只存储一个字符,如果需要存储的字符特别多,会浪费很多空间,因此也可以考虑一个节点存储多个字符的形式。
但是这样做也有一个大问题:如在第2个字符之前插入一个元素,就需要将b和c后移,那么这种后移还要跨过第二个节点,如“蝴蝶效应”,一直波及到最后一个节点。
因此字符串很少使用链式存储结构,还是使用顺序存储结构更灵活一些。
二、模式匹配BF算法
匹配模式:字串的定位运算称为串的模式匹配或串匹配。
假设两个串S、T,设S为主串,也称为正文串;T为字串,也称为模式。主串S查找与模式T匹配的子串,如果查找成功,返回匹配的子串第一个字符在主串中的位置。
最笨的方法就是穷举所有S的所有子串,判短是否与T匹配,该算法称为BF算法。
算法步骤:
1.从S第一个字符开始,与T第一个字符比较,如果相等,继续比较下一个字符,否则则转向下一步。
2.从S第二个字符开始,与T第一个字符比较,如果相等,继续比较下一个字符,否则转向下一步。
3.从S第三个字符开始,与T第一个字符比较,如果相等,继续比较下一个字符,否则转向下一步。
……
4.如果T比较完毕,则返回T在S中第一个字符出现的位置;
5.如果S比较完毕,则返回0,说明T在S中未出现。
完美图解:
例如:S=’abaabaabeca’,T=’abaabe’,求子串T在主串S中的位置。
(1)从S第一个字符开始:i=1,j=1,比较两个字符是否相等,如果相等,则i++,j++;如果不等,则转向下一步,如下图。
(2)i退回到i-j+2的位置,j回退到1的位置,即i-j+2=6-6+2,即i从S第2个字符开始,j从T的第1个字符开始。比较两个字符是否相等,如果相等,则i++,j++;如果不等,则转向下一步,如下图。
解释:为什么i要退回i-j+2的位置呢?如果本趟开始的位置为a,那么下一趟开始的位置就是a的下一个字符b的位置,这个位置正好师i-j+2。
(3)i回退到i-j+2的位置,i=2-1+2=3,即从S第3个字符开始,j=1,比较两个字符是否相等,如果相等,则i++,j++;如果不等,则转向下一步。
(4)i回退到i-j+2的位置i=4-2+2=4,即从S第4个字符开始,j=1,比较两个字符是否相等,如果相等,则i++,j++;如果不等,则转向下一步。
(5)T比较 完毕,返回子串T在主串S中第一个字符出现的位置,即i-m=10-6,m为T的长度。
因为串的匹配模式没有插入、合并等操作,不会发生溢出,因此可以采用第二种字符串顺序存储方法,用0空间存储字符串长度。
代码实现:
int Index_BF(SString S,SString T,int pos)//BF算法
{
//求T在主串S种第pos个字符之后第一次出现的位置
//其中,T非空,1≤pos≤s[0],是s[0]存放s串的长度
int i=pos,j=1,sum=0;
while(i<=s[0]&&j<=T[0])//
{
sum++;
if(S[i]==T[i])//如果相等,则比较后面的字符串
{
i++;
j++;
}
else
{
i=i-j+2;//i回退到上一轮开始比较的下一个字符
j=1;//j回退到第1个字符
}
}
count<<"一共比较了"<<sum<<"次"<<endl;
if(j>T[0])//匹配成功
{
return i-T[0];//i减去T的长度
}
else
{
return 0;
}
}
算法复杂度分析:
设S、T串的长度分别为n、m,则BF算法的时间复杂度分为以下两种情况:
(1)最好情况
在最好的情况,每一次匹配都在第一次比较发现不等。
假设第i次匹配成功,则前i-1次匹配都进行了1次比较,一共i-1次,第i次匹配成功时进行了m次比较,则总的比较次数时i-1+m。在匹配成功的情况下,最多需要n-m+1次匹配,即模式串正好在主串的最后端。假设每一次匹配成功的概率均等,概率pi=1/(n-m+1),则在最好的情况下,匹配成功的平均次数为:
最好情况的平均复杂度为O(n+m)。
(2)最坏情况
在最坏情况下,每一次匹配都比较到T的最后一个字符发现不等,回退重新开始,这样每次匹配都需要比较m次。
假设第i次匹配成功,则前i-1次匹配都进行了m次比较,第i次匹配成功时也进行了m次比较,则总的比较次数为i×m。在匹配成功的情况,最多需要n-m+1次匹配,即模式串正好在主串的最后端。假设每次匹配成功的概率均等,概率pi=1/(n-m+1),在最坏的情况下,匹配成功的平均次数是:
最坏的情况下的平均时间复杂度是O(n×m)。
三、模式匹配KMP算法
实际上,完全没必要从S的每一个字符开始穷举每一种情况,对该算法进行了改进,提出了KMP算法。
从S第一个字符开始:i=1,j=1,比较两个字符是否相等,如果相等,则i++,j++;第一次匹配不相等。
按照BF算法,如果不等,则i回退到i-j+2,j回退到1,即i=2,j=1。
其实i不用回退,让j回退到第3个位置,接着比较即可。
是不是像T向右滑动了一段距离?为什么呢?
因为T串中开头的两个字符和i指向的字符前两个字符一模一样。
这样j就可以回退到第3个位置继续比较了,因为前面两个字符已经相等了。
那怎么知道T开头的两个字符和i指向的字符前面两个字符一模一样?难道还要比较?我们发现i指向的字符前面的两个字符和T中j指向的字符前面两个字符一模一样,因为他们一直相等,i++、j++才会走到当前的位置。
也就是说,我们不必判断开头的两个字母和i指向的字符前面,只需要在T本身比较就可以了。假设T中当前j指向的字符前面所有字符与为T’,只需要比较T’的前缀和T’的后缀即可。
前缀是从前向后取若干个字符,后缀是从后先前取若干个字符。注意:前缀和后最不可以取字符串本身。如果串的长度为n,前缀和后缀的长度最多达到n-1。
判断T’=”abaab”的前缀和后缀是否相等,并找出相等前缀后缀的最大长度。
- 长度为1:前缀’a’,后缀’b’,不等。
- 长度为2:前缀为’ab’,后缀为’ab’,相等。
- 长度为3,前缀为’aba’,后缀为“aab”,不等。
- 长度为4,前缀为’abaa’,后缀为“baab”,不等。
相等前缀后缀的最大长度为l=2,则j就可以回退到l+1=3个位置继续比较了。因此,当i、j指向的字符不等时,只需要求出T’的相同前缀后缀的最大长度为l,i不变j回退到l+1的位置继续比较即可。
现在可以写出通用公式,next[j]表示j需要回退的位置,T=’t1t2…tj-1’,则:
根据公式很容易求出T=’abaabe’的next[]数组,如下图:
解释如下:
- j=1:根据公式next[1]=0
- j=2:T’=’a’,没有前缀后缀,next[2]=1。
- j=3:T’=’ab’,前缀后缀不等next[3]=1。
- j=4:T’=’aba’,前缀为’a’,后缀为’a’,相等且l=1:前缀为“ab”,后缀为“ba”,不等;因此next[4]=l+1=2。
- j=5:T’=’abaa’,前缀为’a’,后缀为’a’,后缀为’a’,相等且l=1;前缀为’ab’,后缀为‘aa’,不等;前缀为’aba’,后缀为’baa’,不等因此next[5]=l+1=2。
- j=5:T’=’abaab’,前缀为’a’,后缀为’a’,后缀为’a’,相等且l=1;前缀为’ab’,后缀为‘ab’, 相等且l=2;前缀为’ abaa’,后缀为’ baab’,不等因此next[6]=l+1=3。
这样找所有的前缀和后缀比较,是不是也是暴力穷举?那怎么办呢?
可以用动态规划递推。
首先大胆假设,我们已经知道了next[j]=k,T’=’t1t2…tj-1’,那么T’的相等前缀、后缀最大长度为k-1。
那么next[j+1]=?
考查以下两种情况。
(1)tk=tj,那么next[j+1]=k+1,即相等前缀和后缀的长度比next[j]多1。
(2)tk≠tj,但两者不相等时,我们又开始了这两个串的模式匹配,回退找next[k]=k’的位置,比较tk’与tj是否相等。
如果tk’=tj相等,则next[j+1]=k’+1。
如果tk’不等于tj,则继续回退找next[k’]=k’’,比较tk’’与tj是否相等。
一直循环,直到找到next[1]=0停止。
代码实现:
求解next[]的代码实现如下:
void get_next(SString T,int next[])//求模式串T的next函数值
{
int j=1,k=0;
next[1]=0;
while(j<T[0])//T[0]为模式串T的长度
{
if(k==0||T[j]==T[k])
{
next[++j]=++k;
}
else
{
k=next[k];
}
}
}
有了next[]数组,就很容易进行模式匹配了,当S[i]≠T[j]时,i不动,j回退到next[j]的位置继续比较即可。
代码实现:
KMP算法的代码实现如下:
void Index_KMP(SString S,SString T,int pos,int next[]//求模式串T的next函数值
{
//利用模式串T的next函数求T在主串s中第pos个字符之后的位置
//其中,T非空,1≤pos≤s[0],s[0]位模式中S的长度
int i=pos,j=1;
while(i<=S[0]&&j<=T[0])
{
if(j==0||S[i]==T[j])//继续比较后面的字符
{
i++;
j++;
}
else
{
j=next[j];//模式串向右移动
}
}
if(j>T[0])//匹配成功
{
return i-T[0];
}
else
{
return 0;
}
}
算法复杂度分析
设S、T串的长度分别为n、m。KMP的算法特点时:i不回退,当S[i]≠T[j]时,j回退到next[j],重新开始比较。最坏的情况下扫描整个S串,其时间复杂度为O(n)。计算next[]数组需要扫描整个T串,其时间复杂度为O(m),因此总的时间复杂度为O(n+m)。
需要注意的时,尽管BF算法最坏情况下时间复杂度为O(n×m),KMP算法的时间复杂度为O(n+m)。但是实际运用中,BF的时间复杂度一般为O(n+m),因此仍然有很多地方使用BF算法进行模式匹配。只有在主串和主串有很多部分匹配的情况下,KMP才显得更优越。
四、改进的KMP算法
在KMP算法中,next[]求解非常方便、迅速,但是也有一个问题,当si≠tj时,j回退到netx[j](k=next[j]),然后si和tk比较。这样确实没错,但如果tk=tj,这次比较就没必要了,因为刚才就是因为si≠tj才hui'tui的,那么肯定si≠tk,完全没必要再比了,如下图:
再向前回退,找到下一个位置next[k],继续比较就可以了,当si≠tj时,本来应该j回退到netx[j](k=next[j]),sj与tk比较。但是如果tk=tj,则不需要比较了,继续回退到下一个位置next[k],减少了一次无效比较。
修改程序:
求解next[]的改进代码实现如下:
void get_next(SString T,int next[])//求模式串T的next函数值
{
int j=1,k=0;
next[1]=0;
while(j<T[0])//T[0]为模式串T的长度
{
if(k==0||T[j]==T[k])
{
j++;
k++
if(T[j]==T[k])
{
next[j]=next[k];
}
else
{
next[j]=k
}
}
else
{
k=next[k];
}
}
}
算法复杂度分析
设S、T的长度为n、m,改进的KMP算法只是在求解next[]从常数上的改进,并没有降解,因此算法复杂度依然时O(n+m)。
五、字符串的应用——病毒检测
题目:疫情爆发,专家发现发现一种新型环状病毒,这种病毒的DNA序列是环状的,而人类的DNA序列是线性的。专家把人类和病毒的DNA表示为字母组成的字符串序列,如果在某个患者的DNA中发现这种环状病毒,说明该患者已经被感染病毒,否则没有感染。
解题思路:
该问题属于字符串的模式匹配问题,可以用前面讲的BF或KMP算法求解,这里需要对环状病毒进行处理,然后调用模式匹配算法即可。
如何处理环状病毒呢?
1、环形处理
使用循环存储的方式,类似循环队列或循环链表的处理方式。假设病毒的DNA长度为m,依次从环状存储空间中每一个下标开始,取m个字符作为病毒序列,
例如,病毒序列为aabb,如下图所示。从每个下标开始取4个字符。
- 从0下标取4个字符:aabb。
- 从1下标取4个字符:abba。
- 从2下标取4个字符:bbaa。
- 从3下标取4个字符:baab。
这4个序列都是病毒序列的变种。
2、线性处理
将病毒序列扩大两倍,依次从每个下标开始,取m个字符,作为病毒序列。例如:病毒序列:aabb。将病毒序列扩大两倍。从每个下标(1、2、3、4)开始取4个字符,分别为aabb、abba、bbaa、baab,这4个序列都是病毒序列的变种。
算法步骤:
- 首先对环状病毒进行处理(环形处理或线性处理)
- 依次把每一个环状病毒变种作为子串,把患者DNA序列作为主串,进行模式匹配。一旦匹配成功,立即结束,返回已感染病毒。
- 重复运行第二步。
- 如果检验所有病毒变种都为匹配成功,返回未感染病毒。
完美图解:
例如:患者的DNA序列为eabbacab,病毒DNA序列为aabb,检验患者是否感染病毒。
- 首先采用线性处理,将病毒序列扩大两倍。
- 从下标1开始取4个字符,为aabb,与患者的DNA序列ecabbacab进行模式匹配,未匹配成功。
- 从下表2开始取4个字符,为abba,与患者的DNA序列eabacab进行模式匹配,匹配成功:eabbacab,返回该患者已感染该病毒
代码实现:
bool Virus_detection(SString S,SString T)//病毒检测
{
int i,j;
SString temp;//temp记录病毒变种
for(i=T[0]+1,j=1;j<=T[0]i++,j++)//将T扩大一倍,T[0]为病毒长度
{
T[i]=T[j];
}
for(i=0;i<T[0];i++)//依次检测T[0]个病毒变种
{
temp[0]=T[0];//病毒变种长度为T[0]
for(j=1;j<T[0];j++)
{
temp[j]=T[i+j];
}
if(Index_KMP(S,temp,1))//检测到病毒
{
return 1;
}
}
return 0;//未检测到病毒
}
算法复杂度分析:假设病毒DNA序列长度为m,则一共有m个变种,需要进行m次模式匹配,每次模式匹配如果使用KMP算法,其时间复杂度为O(n+m),则种的时间复杂度是O(m×(n+m))。