1、串的定义
串(string)是由零个或多个字符组成的有限序列,又名叫字符串。
一般记为s=“a1a2……an”(n>0),其中,s是串的名称,用双引号(有些书中也用单引号)括起来的字符序列是串的值。注意双引号不属于串的内容。ai(1≤i≤n)可以是字母、数字或其他字符,i就是该字符在串中的位置。串中的字符数目n称为串的长度,定义中谈到"有限"是指n是一个有限数值。零个字符的串称为空串(null string),它的长度为零,可以直接用双引号“”号表示,也可以用希腊字母Φ表示。所谓序列,说明串的相邻字符之间存在前驱和后继的关系。
空格串:指包含空格的串,例如:" ",和空串不同,长度不为零。其中可以有多个空格。
字串与主串:串中任意个数的连续字符组成的子序列称为该串的子串,相应地,包含子串的串称为主串。
字串在主串中的位置就是字串中的第一个字符在主串中的序号。
如“over”是“lover”的字串,其在主串中的位置为1。注意从0开始计数。
2、串的比较
串的比较是通过组成串的字符之间的编码来进行的,而字符的编码指的是字符在对应字符集中的序号。
常用的编码规则有ASCII编码和Unicode编码等。
要在c语言中比较两个字符串是否相等,必须是它们串的长度以及它们各个对应位置的字符都相等时,才算是相等。即给定两个字符串s=“a1a2……an”,t=“b1b2……bm”,当且仅当n=m,且a1=b1,a2=b2,……an=bm时,认为s=t。
对于两个不相等的串,判断它们大小的定义如下:
3、串的抽象数据类型
串的逻辑结构和线性表很相似,但由于串针对的是字符集,故串的基本操作和线性表有很大的区别。线性表更关注的是单个元素的操作,比如查找一个元素,插入或删除一个元素,但串中跟多的是查找查找字串位置,得到指定位置的字串、替换字串等操作。
Index的实现算法:
/*串操作*/
/*T为非空串.若主串S中第pos个字符之后存在与T相等的子串,*/
/*则返回第一个这样的子串在S中的位置,否则返回0*/
/*这里的pos从1开始计数*/
int Index(String S,String T,int pos)
{
int n, m, i;
String sub;
if (pos > 0)
{
n = StrLength(S);/*获得S的长度*/
m = StrLength(T);/*获得T的长度*/
i = pos;
while (i <= n - m + 1)
{/*满足查找范围*/
SubString(sub,S,i,m);/*从S中获得第i个位置起长度为m的子串*/
if (StrCompare(sub, T) != 0)/*sub与T不相等*/
i++;
else/*sub与T相等*/
return i;/*返回i*/
}
}
return 0;/*S中的第pos个位置之后不存在与与T相等的子串,返回0*/
}
4、串的存储结构
串的存储结构与线性表相同,分为两种。
(1)串的顺序存储结构
串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列的。按照预定义的大小为每个定义的串变量分配一个固定长度的存储区。一般是用定长数组来定义。
既然是定长数组,就存在一个预定义的最大串长度,一般可以将实际的串长度值保存在 数组的0下标位置,有的书中也会定义存储在数组中最后一个下标位置。但也有编程语言规定在串值后面加一个不计入串长度的结束标志符,比如“\0”来表示串值的终结,这时,若想知道此时串的长度,只需遍历计算一下就知道了,但其实这还是需要占用一个空间,如下图。
上面提及的串的顺序存储方式是有问题的,因为字符串的操作,比如两串的连接Concat、新串的插入StrInsert、以及字符串的替换Replace,都有可能使串序列的长度超过了数组的长度MaxSize。
(2)串的链式存储
对于串的链式存储结构,与线性表是相似的,但由于串结构的特殊性,结构中的每个元素数据是一个字符,如果也简单地应用链表 存储串值,一个结点对应一个字符,就会存在很大的空间浪费。因此,一个结点可以存放一个字符,也可以考虑存放多个字符。最后一个结点若是未被占满时,可以用“#”或其他非串值字符补全,如下图:
当然,这里一个结点存多少的字符才合适就变得很重要,这直接影响到串处理的效率,需要根据实际情况做出选择。
但串的链式存储结构除了在连接串与串操作时有一定的方便之外,总的来说不如顺序存储灵活,性能也不如顺序存储结构好。
疑问:这里没有提到顺序存储结构存在的添加或删除元素时存在的要移动元素的问题,这对于串来说,难道不重要?
5、朴素的模式匹配算法
在主串中对子串进行定位操作通常称做串的模式识别。
假设我们要从主串S="goodgoogle"中找到T="google"这个子串的位置,我们通常需要下面的步骤:
简单地说,就是对主串地每一个字符作为子串开头,与要匹配的字符串进行匹配。对主串做大循环,每个字符开头做T的长度的小循环,直到匹配成功或全部遍历为止。
前面我们已经用串的其他操作实现了模式匹配的算法Idex。现在考虑不用串的其他操作,而是只用基本的数组来实现同样的算法。注意我们假设主串和子串的长度分别存在S[0]和T[0]中。实现代码如下:
/*串的顺序存储结构*/
typedef struct
{
char data[MAXSIZE];
int len;
}String;
/*返回子串T在主串S中第pos个位置之后的位置,若不存在,则函数返回值为0*/
/*T非空,1≤pos≤StrLength(S)*/
Status Index(String* S, String* T, int pos)
{
int i=pos;/*主串的下标位置*/
int j=1;/*子串的下标位置*/
while (j<=S->data[0]&&i<=T->data[0])/*循环的条件:两串的下标在各自的范围内*/
{
if (S->data[i] == T->data[j])/*两字母相等则继续*/
{
i++;/*继续匹配下一个字符*/
j++;
}
else/*指针后退开始重新匹配*/
{
i = i - j + 2;/*i返回到上次匹配首位的下一位*/
/*i-j+1为上次匹配的首位,其下一位为i-j+2*/
j = 1;/*j退回到子串T的首位*/
}
}
if (j > T->data[0])/*在主串中找到子串T*/
return i - T->data[0];/*返回T在S中的第一个字符下标。注意i和j都是在加一后才退出*/
else
return 0;
}
6、KMP模式匹配算法
该模式匹配算法,可以大大避免重复遍历(如有多个0和1重复字符的字符串)的情况,我们把它称为克努特——莫里斯——普拉特算法,简称KMP算法。
(1)KMP模式匹配算法原理
其中,p为T串中的字符,Max中的等式表示T串中前缀和后缀中相等的最大字符数量。假如T=“abcabcad"中,当j等于8时,前缀"abca”=后缀“abca”,故k为5,即next[8]=5。前缀中p的下标一定是从1开始,后缀中p的下标一定是以j-1结束,前缀与后缀元素个数相等。
(2)next数组值推导
(3)KMP模式匹配算法实现
/*通过计算返回子串T的数据*/
/*next数组中保存的是当T->data[j]!= S->data[i]时,应该用T串中的next[j]个字符继续和 S->data[i]比较*/
void get_next(String* T, int* next)
{
int i, j;
i = 1;
j = 0;
next[1] = 0;
while (i < T->data[0])/*此处T->data[0]表示串T的长度*/
{ /*小于,所以后缀的最后一个字符的下标是j-1*/
if (0==j || T->data[i] == T->data[j])/*T->data[i]表示后缀的单个字符*/
/*T->data[j]表示前缀的单个字符*/
{ /*0==j条件是为了保证当T->data[i]!= T->data[j]时,*/
/*能够比较T中下一个前缀与后缀。*/
++i; /*如果第i个元素有前缀和后缀相同,第i+1个元素也有前缀和后缀相同,*/
++j; /*则会连续进入该分支*/
next[i] = j; /*这三条语句的顺序保证next中的元素值比前缀后缀中相同元素数量多1*/
}
else
j = next[j];/*若字符不相同,则j值回溯*/
/*当T->data[i]!= T->data[j]时,经过若干次该语句后,*/
/*最终会使得0==j,从而可以进入if分支,继续比较T中下一个前缀与后缀*/
}
}
/*返回子串T在主串S中第pos个位置之后的位置,若不存在,则函数返回值为0*/
/*T非空,1≤pos≤StrLength(S)*/
int Index(String* S, String* T, int pos)
{
int i = pos;/*i用于主串S当前位置下标值,若pos不为1,则从pos位置开始匹配*/
int j = 1;/*j用于子串T中当前位置下标值*/
int next[255];/*定义一next数组*/
get_next(T,next);/*对T进行分析,得到next数组*/
while (i <= S->data[0] && j <= T->data[0])/*i不超出主串范围且j不超出主串范围*/
{
if (0==j||S->data[i] == T->data[j])/*主串S在i位置的字符与子串在j位置的字符相等*/
{ /*0==j条件是为了保证当S->data[i]!= T->data[j]时,*/
/*能够比较S中下一个i*/
++i;
++j;
}
else/*指针后退,重新开始匹配*/
j = next[j];/*j回溯,i不回溯*/
/*当S->data[i]!= T->data[j]时,回溯等到j值,继续比较S->data[i]和T->data[j],*/
/*若S->data[i]!=T->data[j],则该分支最终会使得0==j,
从而重新从T串的第一个字符开始比较,即继续比较S->data[i]和T->data[1]*/
}
if (j > T->data[0])/*说明在主串S中找到子串T*/
return i - T->data[0];/*返回子串T在主串S中的位置*/
else
return 0;
}
S中的i不用回溯的原因:如果S中的i有必要回溯,则说明T中的前缀和后缀有相应元素相等,此时,j的回溯就相当于i的回溯,所以i没必要回溯。
get_next的时间复杂度为O(m),Index的时间复杂度为O(n),故总的时间复杂度为O(n+m),相比于原来的O((n-m+1)*m),效率提高了。
需要强调的是,KMP算法仅当模式与主串之间存在许多“部分匹配”的情况下才体现出它的优势,否则两者差异并不明显。
(4)KMP模式匹配算法改进
假设取代的数组为nextval,代码如下:
/*通过计算返回子串T的数据*/
/*nextval数组中保存的是当T->data[j]!= S->data[i]时,应该用T串中的nextval[j]个字符继续和 S->data[i]比较*/
void get_nextval(String* T, int* nextval)
{
int i, j;
i = 1;
j = 0;
nextval[1] = 0;
while (i < T->data[0])/*此处T->data[0]表示串T的长度*/
{ /*小于,所以后缀的最后一个字符的下标是j-1*/
if (0 == j || T->data[i] == T->data[j])/*T->data[i]表示后缀的单个字符*/
/*T->data[j]表示前缀的单个字符*/
{ /*0==j条件是为了保证当T->data[i]!= T->data[j]时,*/
/*能够比较T中下一个前缀与后缀。*/
++i;
++j;
if (T->data[i] != T->data[j])/*如果当前字符与前缀字符不相等,*/
nextval[i] = j; /*则当前的j为nextval在i位置的值*/
else /*如果相等,*/
nextval[i] = nextval[j];/*则将前缀字符的nextval赋值给nextval在i位置的值*/
}
else
j = nextval[j];/*若字符不相同,则j值回溯*/
/*当T->data[i]!= T->data[j]时,经过若干次该语句后,*/
/*最终会使得0==j,从而可以进入if分支,继续比较T中下一个前缀与后缀*/
}
}
Index中只需将get_next改为get_nextval即可。
(5)nextval数组推导