5.2 串的定义
今天我们就是来研究"串"这样的数据结构。先来看定义。
串( string )是由零个或多个字符组成的有限序列,又名叫字符串 。
一般记为s= "a1a2......an"(n>0),其中,s是串的名称,用双引号(有些书中也用单引号)括起来的字符序列是串的值,注意单双引号不属于串的内容。ai(1<=i<=n)可以是字母、数字或其他字符,i就是该字符在串中的位置。
串中的字符数目n称为串的长度
,定义中谈到"有限"是指长度n是一个有限的数值。
零个字符的串称为空串 (null string)
,它的长度为零,可以直接用两双引号“""”表示,也可以用希腊字母"φ" 来表示。所谓的序列,说明串的相邻字符之间具有前驱和后继的关系 。
还有一些概念需要解释。
空格串,是只包含空格的串。注意它与空串的区别,空格串是有内容有长度的,而且可以不止一个空格。
子串与主串, 串中任意个数的连续字符组成的子序列称为该串的子串,相应地,包含子串的串称为主串。
子串在主串中的位置就是子串的第一个字符在主串中的序号。
就像"over"、"end" 、"lie" 其实可以认为是"lover"、"friend"、"believe"这些单词字符串的子串。
5.3 串的比较
两个数字,很容易比较大小。2比1大,这完全正确,可是两个字符串如何比较?比如"silly"、 "stupid" 这样的同样表达"愚蠢的"的单词字符串,它们在计算机中的大小其实取决于它们挨个字母的前后顺序。 它们的第一个字母都是"s" ,我们认为不存在大小差异,而第二个字母,由于 "i" 字母比"t"字母要靠前,所以 "i"<"t",于是我们说 "silly" < "stupid"。
事实上,串的比较是通过组成串的字符之间的编码来进行的,而字符的编码指的是字符在对应字符集中的序号。
计算机中的常用字符是使用标准的ASCII 编码,更准确一点,由7位二进制数表示一个字符,总共可以表示128个字符。后来发现一些特殊符号的出现,128个不够用,于是扩展 ASCII 码由8位二进制数表示一个字符,总共可以表示256个字符,这已经足够满足以英语为主的语言和特殊符号进行输入、存储、输出等操作的字符需要了。可是,单我们国家就有除汉族外的满、回、藏、蒙古、维吾尔等多个少数民族文
字,换作全世界估计要有成百上千种语言与文字,显然这 256 个字符是不够的,因此后来就有了Unicode 编码,比较常用的是由16位的二进制数表示一个字符 ,这样总共就可以表示216个字符,约是65万多个字符,足够表示世界上所有语言的所有字符了。当然,为了和 ASCII码兼容,Unicode的前256个字符与 ASCIl码完全相同。
所以如果我们要在C语言或者其他语言中比较两个串是否相等,必须是它们串的长度以及它们各个对应位置的字符都相等时,才算是相等。即给定两个串s= "a1a2……an", t="b1b2……bm" ,当且仅当n=m,且al=bl,a2=b2,……,an=bm时,我们认为s=t。
给定两个串: s="a1a2……an",t= "b1b2……bm",当满足以下条件之一时,s<t。
1.n<m , 且ai=bi (i=l,2,……,n)。
例如当s= "hap", t= "happy",就有 s<t。因为t比s多出了两个字母。
2.存在某个k<= min(m,n)时 ,使得ai=bi(i=l,2,……,k - l ) , ak<bk。
例如当 s= "happen",t="happy", 因为两串的前4个字母均相同,而两串第5个字母(k 值) ,字母e的 ASCII码是101,而字母y的 ASCII码是121,显然e<y,所以 s<t。
5.4 串的抽象数据类型
我们来看一个操作Index的实现算法。
/**
* t为非空串 。 若主串 s中第 pos个字符之后存在与 t相等的子串.
* 则返回第一个这样的子串在s中的位置,否则返回-1
* @param s 主串
* @param t 子串
* @param pos 查找的位置
* @return 角标索引
*/
static int index(String s,String t,int pos){
int n,i;
String sub;
if(pos > 0){
n = s.length();
i = pos;
while(i <= n-3){
sub = s.substring(i, i+3);
if(!t.equals(sub)){
i++;
}else{
return i;
}
}
}
return -1;
}
5.5 串的存储结构
串的存储结构与线性表相同,分为两种。
5.5.1 串的顺序存储结构
串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列的。按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区。一般是用定长数组来定义。
既然是定长数组,就存在一个预定义的最大串长度, 一般可以将实际的串长度值保存在数组的 0下标位置,有的书中也会定义存储在数组的最后一个下标位置。但也有些编程语言不想这么干,觉得存个数字占个空间麻烦。它规定在串值后面加一个不计入串长度的结束标记字符,比如"\0"来表示串值的终结,这个时候,你要想知道此时的串长度,就需要遍历计算一下才知道了,其实这还是需要占用一个空间,何必呢。
刚才讲的串的顺序存储方式其实是有问题的,因为字符串的操作,比如两串的连接Concat、新串的插入如Insert,以及字符串的替换 Replace,都有可能使得串序列的长度超过了数组的长度 MaxSize。
5.5.2 串的链式存储结构
对于串的链式存储结构,与线性表是相似的,但由于串结构的特殊性,结构中的每个元素数据是一个字符,如果也简单的应用链表存储串值,一个结点对应一个字符,就会存在很大的空间浪费 。因此,一个结点可以存放一个字符,也可以考虑存放多个字符,最后一个结点若是未被占满时,可以用"#"或其他非串值字符补全,如图5-5-3 所示。
当然,这里一个结点存多少个字符才合适就变得很重要,这会直接影响着串处理的效率,需要根据实际情况做出选择 。
但串的链式存储结构除了在连接串与串操作时有一定方便之外,总的来说不如顺序存储灵活,性能也不如顺序存储结构好。
5.6 朴素的模式匹配算法
记得我在刚做软件开发的时候,需要阅读一些英文的文章或帮助。此时才发现学习英语不只是为了过四六级,工作中它还是挺重要的。而我那只为应付考试的英语,早已经忘得差不多了。于是我想在短时间内突击一下,很明显 ,找一本词典从头开始背不是什么好的办法。要背也得背那些最常用的,至少是计算机文献中常用的,于是我就想自己写一个程序,只要输入一些英文的文档,就可以计算出这当中所用频率最高的词汇是哪些。把它们都背好了,基本上阅读也就不成问题了。
当然,说说容易,要实现这一需求,当中会有很多困难,有兴趣的同学,不妨去试试看。不过,这里面最重要其实就是去找-个单词在一篇文章(相当于一个大字符串)中的定位问题。
这种子串的定位操作通常称做串的模式匹配
, 应该算是串中最重要的操作之一。
假设我们要从下面的主串 S="goodgoogle"中,找到 T="google"这个子串的位置。我们通常需要下面的步骤。
1.主串S第一位开始,S与T前三个字母都匹配成功,但S第四个字母是d而T的是g。第一位匹配失败。如图 5-6-1 所示,其中竖直连线表示相等,闪电状弯折连续表示不等。
2.主串S第二位开始,主串S首字母是o,要匹配的 T 首字母是g,匹配失败,如图5-6-2所示。
5.主串S第五位开始,S与T,6个字母全匹配,匹配成功,如图 5-6-5 所示。
简单的说,就是对主串的每一个字符作为子串开头,与要匹配的字符串进行匹配。对主串做大循环,每个字符开头做T的长度的小循环,直到匹配成功或全部遍历完成为止。
前面我们已经用串的其他操作实现了模式匹配的算法Index。现在考虑不用串的其他操作,而是只用基本的数组来实现同样的算法。注意我们假设主串S和要匹配的子串T的长度存在S[O]与T[O]中。Java实现代码如下:
/**
* t为非空串 。 若主串 s中第 pos个字符之后存在与 t相等的子串.
* 则返回第一个这样的子串在s中的位置,否则返回-1
* 1<=pos<=s.length()
* @param s 主串
* @param t 子串
* @param pos 查找的位置
* @return 角标索引
*/
static int index(String[] s,String[] t,int pos){
int i = pos;/*i用于主串s中当前位置下标,若pos不为1则从pos位置开始匹配*/
int j = 1;/*j用于子串t中当前位置下标*/
while(i <= Integer.parseInt(s[0]) && j <= Integer.parseInt(t[0]))
{
if(s[i].equals(t[j])){/*两个字母相等*/
++i;
++j;
}else{
i = i - j + 2;//退回上次匹配首位的下一位
j = 1;//退回到子串t的首尾
}
}
if(j > Integer.parseInt(t[0])){//当j大于子串t的长度时,说明再主串s中存在该子串t,返回子串第一个字符的索引
return i - Integer.parseInt(t[0]);
}else
return -1;
}
分析一下,最好的情况是什么?那就是一开始就匹配成功,比如"googlegood"中去找"google" ,时间复杂度为 O(1)。稍差一些,如果像刚才例子中第二、 三、四位一样,每次都是首字母就不匹配,那么对T串的循环就不必进行了,比如"abcdefgoogle" 中去找"google"。 那么时间复杂度为O(n+m),其中n为主串长度,m为要匹配的子串长度。根据等概率原则,平均是(n+m )/2次查找,时间复杂度为O(n+m)。
那么最坏的情况又是什么?就是每次不成功的匹配都发生在串T的最后一个字符。举一个很极端的例子。主串为 s= "00000000000000000000000000000000000000000000000001",而要匹配的子串为T= "0000000001",前者是有49个"0"和1个"1"的主串,后者是9个"0"和1个"1"的子串。在匹配时,每次都得将t中字符循环到最后一位才发现:哦,原来它们是不匹配的。这样等于T串需要在S串的前40个位置都需要判断10次,并得出不匹配的结论,如图 5-6-6 所示。
直到最后第41个位置,因为全部匹配相等,所以不需要再继续进行下去,如图5-6-7 所示。 如果最终没有可匹配的子串,比如是T="0000000002",到了第41位置判断不匹配后同样不需要继续比对下去。因此最坏情况的时间复杂度为O((n-m+1)*m)。
不要以为我这只是危言耸听,在实际运用中,对于计算机来说,处理的都是二进位的0和1的串,一个字符的ASCII码也可以看成是8位的二进位01串,当然,汉字等所有的字符也都可以看成是多个0和1串。再比如像计算机图形也可以理解为是由许许多多个0和1的串组成。所以在计算机的运算当中,模式匹配操作可说是随处可见,而刚才的这个算法,就显得太低效了。
引用《大话数据结构》作者:程杰