一、串的定义
串(string)是由零个或多个字符组成的有限序列,又名叫字符串。
一般记为s=”a1a2……an”(n≥0),其中s是字符串的名称,用双引号括起来的字符序列是串的值,引号不属于串的内容。ai(1 ≤ i ≤ n)可以是字母、数字或其他字符,i就是该字符在串中位置。串中的字符数目n称为串的长度,定义中谈到“有限”是指长度n是一个有限的数值。两个字符的串称为空串(null string),它的长度为零,可以直接用双引号“”表示。所谓序列,说明串的相邻字符之间具有前驱后继的关系。
其他一些概念。
空格串,是只包含空格的串。注意它和空串的区别,空格串是有内容有长度的,而且可以不止一个空格。
子串与主串,串中任意个数的连续字符组成的子序列称为该串的字串,相应地,包含子串的串称为主串。
子串在主串中的位置就是子串第一个字符在主串中的序号。
二、串的比较
对于两个串不相等时,如何判定它们的大小呢。我们这样定义:
给定两个串:s = “a1a2…….an”,t = “b1,b2……bm”,当满足以下条件之一时,s < t。
存在某个k < min(m,n),使得ai = bi(i = 1, 2 , ….., k -1),ak < bk
假如当s = “happen” ,t = “happy”,因为两串的前4个字符均相同,而两串的第5个字母(k值),字母e的ASCII码时101,而字母y的ASCII码时121,显然e < y,所以s < t。n < m,且ai = bi (i = 1, 2, …… , n)
例如当 s = “hap”,t = “happy”,就有s < t,因为t比s多处了两个字母。
三、串的抽象数据类型
串的逻辑结构和线性表很相似,不同之处在于串针对的是字符集,也就是串中的元素都是字符。
因此,对于串的基本操作与线性表所有很大差别的。线性表更关注的是单个元素的操作,比如查找一个元素,插入或删除一个元素,但串中更多的是查找子串的位置、得到指定位置的子串、替换子串等操作。
ADT 串(string)
Data
串中元素仅由一个字符组成,相邻元素具有前驱和后继关系。
Operation
StrAssign(T,*chars):生成一个其值等于字符串常量chars的串T。
strCopy(T,S):串S存在,由串S复制得串T
ClearString(S):串S存在,将串清空
StringEmpty(S):若串S为空,返回true,否则false
StrLength(S):返回串S的元素个数,即串的长度
StrCompare(S,T):若S>T,返回值大于0;若S=T,返回0,若S<T,返回值0
Concat(T,S1,S2):用串T返回S1和S2连接而成的新串
SubString(Sub,S,pos,len):串S存在,1≤pos≤StrLength(S)
且0≤len≤StrLength(S)-pos+1,用Sub
返回值S的第pos个字符起长度为len的子串
Index(S,T,pos):串S和串T存在,T是非空串1≤ pos ≤StrLength(S)
若主串S中存在和串T相同的子串,则返回它在主串S中
第pos个字符后第一个出现的位置,否则返回0
Replace(S,T,V):串S、T和V存在,T是非空串。用V替换主串中出现的
所有与T相等的不重叠的子串
StrInset(S,pos,T):串S和T存在,1 ≤ pos ≤StrLength(S) + 1
在串S的第pos个字符之前插入串T
StrDelete(S,pos,len):串S存在,1≤pos≤StrLengthS-len+1
从串S中删除第pos个字符起长度为len的子串。
对于不同的高级语言,对串的基本操作会有不同的定义方法,所以同学们用某个语言操作字符串时,需要先查看它的参考手册关于字符串的基本操作有哪些。
不过还好,不同语言除方法名称外,操作的实质其实都是类似的。
我们来看一个操作Index的实现算法。
//T为非空串。若主串S中第pos个字符之后存在于T相等的子串
//则返回第一个这样的子串在S中的位置,否则返回0
int Index(String S,String T,int pos)
{
int n,m,i;
String sub;
if(pos > 0)
{
n = StrLength(S);//得到主串S的长度
m = StrLength(T);
i = pos;
while(i <= n-m+1)
{
SubString(sub,S,i,m);//取主串第i个位置,m长度的子串
if(StrCompare(sub,T) != 0)//如果两串不相等
i++;
else //如果相等
return i; //返回i值
}
}
return 0;//若无子串与T相等,返回0
}
当中用到了strLength,SubString,StrCompare等基本操作结构来实现。
四、串的存储结构
串的存储结构与线性表相同,分为两种。
1.串的顺序存储结构
串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列。按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区。一般是用定长数组来定义。
既然是定长数组,就存在一个预定义的最大串长度,一般可以将实际的串长度值保存在数组的0下表位置,有的书中也会定义存储在数组的最后一个下标位置。但有些编程语言,它规定串值后面加一个不计入串长度的结束标记字符,比如“\0”,这个时候,你想要知道此时的串长度,就需要遍历计算一下才知道了,起始这还是需要占用一个空间的。
刚才将的串的顺序存储结构其实是有问题的,因为字符串的操作,比如量串的连接Concat,新串的插入StrInsert,以及字符串的替换Replace,都有可能使得串序列的长度超过了数值的长度MAXSIZE。显然,此时无论是上溢提示报错,还是对多出来的字符串截尾,都不是什么好办法。但字符串操作中,这样情况比比皆是。
于是对于字符串的顺序存储,有一些变化,串值的存储空间可在程序执行过程中动态分配而得。比如在计算机中存在一个自由存储区,叫做“堆”。这个堆可由C语言的动态分配函数malloc()和free()来管理。
2.串的链式存储结构
对于串的链式存储结构,与线性表是相似的,但由于串结构的特殊性,结构中的每个元素是一个字符,如果也简单的应用链表存储串值,一个结点对应一个字符,就会存在很大的空间浪费。因此,一个结点可以存放一个字符,也可以考虑存放多个字符,最后一个结点若是未被占满,可以用“#”或其他非串值字符不全。
当然这里一个结点存放多少个字符变得合适显得很重要,这会直接影响串的处理的效率。
但串的链式存储结构除了在连接字符串与串操作时有一定方便之外,总的来说不如顺序存储灵活、性能也不如顺序存储的好。
五、朴素的模式匹配算法
子串的定位操作通常称为串的模式匹配。应该算是串中最重要的操作之一。
假设我们要从下面的主串S = “goodgoogle”中,找到T=”google”这个子串的位置。
最简单的朴素想法就是,对主串的每一个字符作为子串开头,与要匹配的字符串进行匹配。对主串作大循环,每个字符开头做T长度的小循环,知道匹配完成为止。
前面我们已经用串的其他操作实现了模式匹配的算法Index。现在考虑不用其他操作,而是只用基本的数组来实现同样的算法。
//返回子串T在主串S中第pos个字符之后的位置。若不存在,则函数返回0
//T非空,0≤pos≤strLength(S)
int Index(String S,String T,int pos)
{
int i = 0;
int j = 0;
int SLen = 0;
int TLen = 0;
while(s[i] != '\0')//计算S长度
{
SLen++;
}
while(T[i] != '\0')//子串T的长度
{
TLen++;
}
int i = pos;
while(i <= SLen - TLen && j <= TLen)
{
if(S[i] == T[i])
{
i++;
j++;
}
else
{
i = i - j + 1;//匹配失败回到开始的地方的下一个位置
j = 0;//子串回到原来位置
}
}
if(j > TLen)
{
return i - TLen;
}
else
return 0;
}
分析一下,最好的情况时什么?那就是一开始就成功,此时时间复杂度为O(1)。
稍差一点,如果像”abcdedgood”中查找”good”。那么时间复杂度就是O(n + m),n为主串长度,m为要匹配的子串长度。根据等概原则,平均是(n + m) / 2次查找,时间复杂度为O(n+m)。
那么最坏的情况又是什么?就是每次不成功的匹配都发生在串T的最后一个字符,比如主串S = 0000000000000000000000000000000000001,而要匹配的子串为T = 0000001。前者是有49个“0”和1个“1”,后者是9个“0”和1个“1”。这样等于前40个位置要循环40 * 10次。知道第41个位置,这里也要匹配10次。
因此最坏时间复杂度为O[(n-m+1)*m]。
但是这种情况在计算机中很常见,因为在计算机中,处理的都是二进制位的0和1的串,一个字符ASCII码也可以看成是8位的二进制01串。所以在计算机中,模式匹配操作使用刚才的算法显得太低效了。
六、KMP模式匹配算法
D.E.Knumth、J.H.Morris和V.R.Pratt发现一个模式匹配算法,可以大大简化避免重复遍历的情况,我们把它称为克努特—莫里斯—普拉特算法,简称KMP算法。
1.KMP模式算法原理
假设主串S = “abcdefgab”,其实还可以更长一些,我们要匹配的T = “abcdex”,那么如果用前面的朴素算法的话,前5个字母,两个串完全相等。直到第6个字母,“f”与“x”不等。
如果按照之前的朴素匹配模式算法,应该继续比较主串S中当2,3,4,5,6时,首字母与子串T的首字符均不等。
可是仔细观察发现。对于要匹配的子串T来说,“abcdex”首字母“a”与后面的串“bcdex”中任意一个字符都不相等。也就是说,既然“a”不与后面的子串中任意一字符相等,那么也可能与主串的后面第2到第5位相等。因此朴素模式匹配算法的接下来的2~5步都是多余。
注意这里是KMP算法的关键。如果我们知道子串中首字母“a”与子中后面的字符均不相等。而子串的第二位“b”与主串的第二位“b”相等,那么就意味着,子串中的“a”不需要判断也知道他们是不可能相等的了。
同理,后面的“c”、“d”、“e”也确定是不相等的。
接下来下面一个例子,假设S = “abcababca”,T = “abcabx”。对于开始判断“abcabx”。对于开始的判断,前5个字符完全相等,第6个字符不等。此时,根据刚才的经验,T的首字符“a”和T的第二位字符“b”、第三位字符“c”均不等,所以不需要做判断。
因为T的首位“a”与第四位“a”相等,第二位“b”与第五位“b”相等。而在①时,第四位的“a”与第五位的“b”已经与主串S中相应位置比较过了,是相等,因此可断定,T的首字符“a”、第二位的字符“b”与S的第四位字符、第五位字符也不需要比较了,肯定也是相等的。
也就是说,对于在子串中有与首字符相等的字符,也可以省略一部分不必要的判断步骤。
对比上面两个步骤,我们发现,在第一步比较时候,我们的i值,也就是主串当前位置的下标是6。即我们在朴素的匹配算法中,主串i值是不断地回溯来完成的,而我们发现,这种回溯其实是可以不需要的——KMP算法让这没必要的回溯发生。
既然i可以不会素,那么只需要考虑j就行了。通过我们观察也发现,我们提到了T串的首字符与自身后面字符的比较,发现如果有相等字符,j值的变化就会不相同,也就是说,这个j值的变化与主串其实没什么关系,关键在于T串结构是否有重复的问题。
比如T = “abcdex”,当前串中没有任何重复的字符,所以j就由6变成了1。而T= “abcabx”,前缀的“ab”与最后的“x”之前串的后缀“ab”是相等的。因此j就由6变成了3,因此,我们可以得出规律,j的值的多少取决于当前字符之间的串的前后缀的相似度。
我们把T串的各个位置的j值变化定义成一个数组next,那么netx长度就是T的长度,我们可以得到下面的函数定义:
next[j] = 0 ,当j = 1时
next[j] = Max {k | 1 < k < j,且’p1… pk-1’=’pj-k+1…pj-1’}当此集合非空
next[j] = 1,其他情况
2.next数组值推导
1.T = “abcdex”
j | 123456 |
---|---|
模式串T | abcdex |
next[j] | 011111 |
1). j =1,next[i] = 0
2).j = 2, “a”,属于 next[2] =1
3)j = 3,j由1到j- 1就是”ab”,next[3] = 1
4)同理,没有重复元素,T 串的next[j] = 011111
2.T = “abcabx”
j | 123456 |
---|---|
模式串T | abcabx |
next[j] | 011123 |
1) j = 1,next[1] = 0
2) j = 2,next[2] = 1
3) j = 3,next[3] = 1
4) j = 4,next[4] = 1
5) j = 5, abca,此时前缀字符串“a”与后缀字符“a”相等,由公式’p1…pk-1’ = ‘pj-k+1…pj-1’,由p1 = p4,推出k = 2,因此next[5] = 2。
6) j = 6,abcab,由前缀字符“ab”与后缀字符“ab”相等,next[6] = 3
所以根据经验得到如果前后缀一个字符相等,k = 2,两这个字符k= 3,n个相等就是n + 1。
3.KMP模式匹配算法是现
//通过计算返回子串T的next数组
void get_next(String T,int *next)
{
int i,j;
i = 1;
j = 0;
next[1] = 0;
while(i < T[0])//T[0]表示串T的长度
{
if(j == 0|| T[i] == T[j])//T[i]表示后缀的单个字符
{ //T[j]表示前缀的单个字符
i++;
j++;
next[i] = j;
}
else
j = next[j];//若字符不相同,则j值回溯
}
}
这段代码的目的就是为了计算出当前要匹配的T的next时更要注意
//返回子串T在主串S中第pos字符之后的位置,若不存在,则函数返回0
//T非空,1≤pos≤StrLength(S)
int Index_KMP(String S,String T,intpos)
{
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[0] && j <= T[0])//若i小于S的长度且j小于T长度
//循环继续
{
if(j == 0 || S[i] == T[j])//两字母相等则继续
//相对于朴素算法增加了j = 0判断
{
i++;
j++;
}
else
{
j = next[i];
}
}
if(j > T[0])
return i - T[0];
else
return 0;
}
相对于朴素匹配算法增加的代码,改动不算大,关键去掉了i值回溯的部分。对于get_next函数来说,若T的长度为m,因只涉及到简单的单循环,其时间复杂度O(m),而由于i的值的不回溯,使得index_KMP算法效率得到了提供,while循环的时间复杂度为O(n)。因此整体复杂度O(m+n)。相较于朴素匹配算法的O((n-m + 1) * m)来说,是要好一些。
这里也需要强调,KMP算法仅当模式与主串之间存在许多“部分匹配”的情况下才体现出它的优势。
4.KMP模式匹配算法的改进
后来有人发现,KMP算法还是有缺陷的,如果主串S= “aaaabcde”,子串T = “aaaaax”,next数组值会为012345,在开始时,”b”与“a”不相等,j = next[5] = 4。此时“b”与第四位的”a”也不相等,同理与第3、2、1的“a”都不相等。直到next[1] =0时。
我们发现b与第2、3、4、5位“a”比较完全是多余的,因为2、3、4、5位字符都与首位的“a”相等。因此我们对next函数进行改良。
void get_nextval(String T,int *nextval)
{
int i , j;
i = 1;
j = 0;
nextval[1] = 0;
while(i < T[0])//此处T[0]表示T的长度
{
if(j == 0 || S[i] == T[j])//T[i]表示后缀的单个字符
//T[j]表示前缀单个字符
{
i++;
j++;
if(T[i] != T[j])//若当前字符与前缀组不同
nextval[i] = j;//则当前j为nextval在i位置的值
else
nextval[i] = next[j];//如果前缀字符相同
//则将前缀字符nextval值赋给nextval在i位置的值
}
else
j = nextval[j];
}
}
总结改进的KMP算法,它是在计算next值的同时,如果a位字符与它next值指向的b位字符相等,则该a为的nextval就指向b位的nextval。如果不等,则该a位的nextval值就是它自己a位的next的值。