目录
一、串的定义
1.1相关概念与特点
(1)相关概念
概念引入:
字符串简称串,计算机上非数值处理的对象基本都是字符串数据。我们常见的信息检索系统 (如搜索引擎)、文本编辑程序(如Word)、问答系统、自然语言翻译系统等,都是以字符串数据 作为处理对象的。本章详细介绍字符串的存储结构及相应的操作。
基本概念:
串( string)是由零个或多个字符组成的有限序列,又名叫字符串。
一般记为: S=′a1a2...an′(n>=0)。
其中, S 是串名,单引号括起来的字符序列是串的值; an 可以是字母、数字或其他字符;串中字符的个数 n称为串的长度。
其他概念:
空串: n=0 时的串称为空串。
空格串:是只包含空格的串。注意它与空串的区别,空格串是有内容有长度的,而且可以不止一个空格。
子串与主串:串中任意个数的连续字符组成的子序列称为该串的子串,相应地,包含子串的串称为主串。子串在主串中的位置就是子串的第一个字符在主串中的序号。
(2)特点
串的逻辑结构和线性表极为相似,区别仅在于串的数据对象限定为字符集。
在基本操作上,串和线性表有很大差别。线性表的基本操作主要以单个元素作为操作对象,如查找、插入或删除某个元素等;而串的基本操作通常以子串作为操作对象,如查找、插入或删除一个子串等。
二、串的表示和实现
2.1 串的定长顺序存储
(1)结构体定义
类似于线性表的顺序存储结构,用一组地址连续的存储单元存储串值的字符序列。按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区,则可用定长数组如下描述
#define MAXLEN 255 //预定义最大串长为255
typedef struct{
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
}SString;
其中,MAXLEN表示串的最大长度,ch是存储字符串的一维数组,每个分量存储一个字符。length 表示字符串的当前长度。为了便于说明问题,后面算法描述当中所用到的顺序存储的字符串都是从下标为1的数组分量开始存储的,下标为0的分量闲置不用。
(2)串的连接
相对而言比较简单,可参考c语言中<string.h>下有关字符串的一系列相关源码,此处不再做整体思路介绍,只做简单的注释
int Concat(SString *T,SString S1,SString S2)
//用T返回S1和S2联接而成的新串。若未截断返回1,若截断返回0
{
int i = 1,j,uncut = 0;
if(S1[0] + S2[0] <= MAXSTRLEN) //未截断
{
for (i = 1; i <= S1[0]; i++)//赋值时等号不可丢
(*T)[i] = S1[i];
for (j = 1; j <= S2[0]; j++)
(*T)[S1[0]+j] = S2[j]; //(*T)[i+j] = S2[j]
(*T)[0] = S1[0] + S2[0];
uncut = 1;
}
else if(S1[0] < MAXSTRLEN) //截断
{
for (i = 1; i <= S1[0]; i++)//赋值时等号不可丢
(*T)[i] = S1[i];
for (j = S1[0] + 1; j <= MAXSTRLEN; j++)
{
(*T)[j] = S2[j - S1[0] ];
(*T)[0] = MAXSTRLEN;
uncut = 0;
}
}
else
{
for (i = 0; i <= MAXSTRLEN; i++)
(*T)[i] = S1[i];
/*或者分开赋值,先赋值内容,再赋值长度
for (i = 1; i <= MAXSTRLEN; i++)
(*T)[i] = S1[i];
(*T)[0] = MAXSTRLEN;
*/
uncut = 0;
}
return uncut;
}
(3)串的子串
返回串S的第pos个字符起长度为len的子串
int SubString(SString *Sub,SString S,int pos,int len)
//用Sub返回串S的第pos个字符起长度为len的子串
//其中,1 ≤ pos ≤ StrLength(S)且0 ≤ len ≤ StrLength(S) - pos + 1(从pos开始到最后有多少字符)
//第1个字符的下标为1,因为第0个字符存放字符长度
{
int i;
if(pos < 1 || pos > S[0] || len < 0 || len > S[0] - pos + 1)
return 0;
for (i = 1; i <= len; i++)
{
//S中的[pos,len]的元素 -> *Sub中的[1,len]
(*Sub)[i] = S[pos + i - 1];//下标运算符 > 寻址运算符的优先级
}
(*Sub)[0] = len;
return 1;
}
2.2 串的堆分配存储
(1)结构体定义
多数情况下,串的操作是 以串的整体形式参与的,串变量之间的长度相差较大,在操作中串值长度的变化也较大,为串变量设定固定大小的空间不够合理。
在C语言中,存在一个称之为“堆”(Heap)的自由存储区,可以为每个新产生的串动态分配一块实际串长所需的存储空间,若分配成功,则返回一个指向起始地址的指针,作为串的地址,同时为了以后处理方便,约定串长也作为存储结构的一部分。这种字符串的存储方式也称为串的堆式顺序存储结构,定义如下:
typedef struct{
char *ch; //按串长分配存储区,ch指向串的基地址
int length; //串的长度
}HString;
(2)初始化串、串的清空与销毁
//初始化串
InitString(HeapString *S)
{
S->length=0;
S->str='\0';
}
// 清空串
void StrClear(HeapString *S)
/*清空串,只需要将串的长度置为0即可*/
{
S->str='\0';
S->length=0;
}
//销毁串
void StrDestroy(HeapString *S)
{
if(S->str)
free(S->str);
}
(3)串的长度
int StrEmpty(HeapString S)
/*判断串是否为空,串为空返回1,否则返回0*/
{
if(S.length==0) /*判断串的长度是否等于0*/
return 1; /*当串为空时,返回1;否则返回0*/
else
return 0;
}
int StrLength(HeapString S)
/*求串的长度操作*/
{
return S.length;
}
(4)串的赋值
void StrAssign(HeapString *S,char cstr[])
/*串的赋值操作*/
{
int i=0,len;
if(S->str)
free(S->str);
for(i=0;cstr[i]!='\0';i++); /*求cstr字符串的长度*/
len=i;
if(!i)
{
S->str=NULL;
S->length=0;
}
else
{
S->str=(char*)malloc((len+1)*sizeof(char));
if(!S->str)
exit(-1);
for(i=0;i<len;i++)
S->str[i]=cstr[i];
S->length=len;
}
}
(5)串的插入
int StrInsert(HeapString *S,int pos,HeapString T)
/*串的插入操作。在S中第pos个位置插入T分为三种情况*/
{
int i;
if(pos<0||pos-1>S->length) /*插入位置不正确,返回0*/
{
printf("插入位置不正确");
return 0;
}
S->str=(char*)realloc(S->str,(S->length+T.length)*sizeof(char));
if(!S->str)
{
printf("内存分配失败");
exit(-1);
}
for(i=S->length-1;i>=pos-1;i--)
S->str[i+T.length]=S->str[i];
for(i=0;i<T.length;i++)
S->str[pos+i-1]=T.str[i];
S->length=S->length+T.length;
return 1;
}
(6)串的删除
int StrDelete(HeapString *S,int pos,int len)
/*在串S中删除pos开始的len个字符*/
{
int i;
char *p;
if(pos<0||len<0||pos+len-1>S->length)
{
printf("删除位置不正确,参数len不合法");
return 0;
}
p=(char*)malloc(S->length-len); /*p指向动态分配的内存单元*/
if(!p)
exit(-1);
for(i=0;i<pos-1;i++) /*将串第pos位置之前的字符复制到p中*/
p[i]=S->str[i];
for(i=pos-1;i<S->length-len;i++) /*将串第pos+len位置以后的字符复制到p中*/
p[i]=S->str[i+len];
S->length=S->length-len; /*修改串的长度*/
free(S->str); /*释放原来的串S的内存空间*/
S->str=p; /*将串的str指向p字符串*/
return 1;
}
(7)串的比较
int StrCompare(HeapString S,HeapString T)
/*串的比较操作*/
{
int i;
for(i=0;i<S.length&&i<T.length;i++) /*比较两个串中的字符*/
if(S.str[i]!=T.str[i]) /*如果出现字符不同,则返回两个字符的差值*/
return (S.str[i]-T.str[i]);
return (S.length-T.length); /*如果比较完毕,返回两个串的长度的差值*/
}
(8)串的连接
int StrCat(HeapString *T,HeapString S)
/*将串S连接在串T的后面*/
{
int i;
T->str=(char*)realloc(T->str,(T->length+S.length)*sizeof(char));
if(!T->str)
{
printf("分配空间失败");
exit(-1);
}
else
{
for(i=T->length;i<T->length+S.length;i++) /*串S直接连接在T的末尾*/
T->str[i]=S.str[i-T->length];
T->length=T->length+S.length; /*修改串T的长度*/
}
return 1;
}
2.3 串的链式存储
类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性(每个元素只有一个字符),在具体实现时,每个结点既可以存放一个字符, 也可以存放多个字符。每个结点称为块,整个链表称为块链结构。图(a)是结点大小为4 (即每个结点存放4个字符)的链表,最后一个结点占不满时通常用“#”补上;图(b)是结点大小为1的链表。
图2.3-1 串的链式存储方式
三、经典实例
3.1 串的模式匹配算法
子串的定位操作通常称为串的模式匹配,它求的是子串(常称模式串)在主串中的位置。这里采用定长顺序存储结构,给出一种不依赖于其他串操作的暴力匹配算法。
加入给定字符串ababcabcacbab,查找abcac在其主串中的位置,其相关流程如下:
图3.1-1 模式匹配流程
模式匹配算法本质上是一种暴力遍历算法。在上述算法中,分别用计数指针 i 和 j 指示主串S 和模式串T中当前正待比较的字符位置。
算法思想为:
从主串S的第一个字符起,与模式T的第一个字符比较, 若相等,则继续逐个比较后续字符;否则从主串的下一个字符起,重新和模式的字符比较;
以此类推,直至模式T 中的每个字符依次和主串S中的一个连续的字符序列相等,则称匹配成功,函数值为与模式T中第一个字符相 等的字符在主串S中的序号,否则称匹配不成功,函数值为零。
代码实现入下:
int Index(SString S, SString T)
{
int i = 1, j = 1;
while(i <= S.length && j <= T.length)
{
if(S.ch[i] == T.ch[j])
{
++i; ++j; //继续比较后继字符
}
else
{
//指针后退重新开始匹配
i = i-j+2;
j = 1;
}
}
if(j > T.length)
{
return i - T.length;
}
else
{
return 0;
}
}
简单的模式匹配算法的最坏时间复杂度为O(nm),其中 n 和 m 分别为主串和模式串的长度。
3.2 KMP算法(重点)
(1)经典KMP
KMP算法的关键是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是实现一个next()函数,函数本身包含了模式串的局部匹配信息。时间复杂度为O(m+n)。
next数组概念:一个字符串中最长的相同前后缀的长度加一。
图3.2-1 next数组求解图
下面先直接给出KMP的算法流程:
假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置
如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;
如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。
换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值,即移动的实际位数为:j - next[j],且此值大于等于1。
要了解子串的结构,首先要弄清楚几个概念:前缀、后缀和部分匹配值。
前缀指除最后一个字符以外,字符串的所有头部子串;
后缀指除第一个字符外,字符串的所有尾部子串;
部分匹配值则为字符串的前缀和后缀的最大公共前后缀长度。
举如下简单例子:
字符串 abcdab
前缀的集合:{a,ab,abc,abcd,abcda}
后缀的集合:{b,ab,dab,cdab,bcdab}
那么最长相等前后缀即为ab,最长部分匹配值就为2
字符串:abcabfabcab中最长相等前后缀是abcab,最长部分匹配值就为5
再比如 ′ababa′
′a′ 的前缀和后缀都为空集最大公共前后缀长度长度为0。
′ab′ 的前缀为 { a } ,后缀为 { b } , { a } ∩ { b } = NULL,最大公共前后缀长度长度为0。
′aba′ 的前缀为{a,ab}, 后缀为 { a , ba } , { a , a b } ∩ { a , b a } = { a }, 最大公共前后缀长度长度为1
′abab′ ,前缀∩后缀,{a,ab,aba}∩{b,ab,bab}={ab},最大公共前后缀长度长度为2。
′ababa′ ,前缀∩后缀,{a,ab,aba,abab}∩{a,ba,aba,baba}={a,aba}, 公共元素有两个,最大公共前后缀长度长度为3。
回到最初的问题,主串为′abacabcacbab′,子串为′abcac′。
利用上述方法容易写出子串′abcac′的最大公共前后缀长度为00010,将最大公共前后缀长度值写成数组形式,就得到了最大公共前后缀长度(Partial match,PM)的表。
图3.2-2 最大公共前后缀长度
在这里插入图片描述
下面用PM表来进行字符串匹配:
第一趟匹配过程:
发现 c与a不匹配,前面的2个字符′ab′是匹配的,查表可知,最后一个匹配字符b对应的部分匹配值为0,因此按照下面的公式算出子串需要向后移动的位数:
移动位数=已匹配的字符数−对应最大公共前后缀长度
因为2−0=2,所以将子串向后移动2位。
如下进行第二趟匹配:
第二趟匹配过程:
发现 c与 b不匹配,前面4个字符′abca′是匹配的,最后一个匹配字符a对应的部分匹配值为4−1=3,将子串向后移动3位。
如下进行第三趟匹配:
第三趟匹配过程:
子串全部比较完成,匹配成功。整个匹配过程中,主串始终没有回退,故KMP算法可以在O(n+m)的时间数量级上完成串的模式匹配操作,大大提高了匹配效率。
某趟发生失配时,如果对应的部分匹配值为0,那么表示已经匹配相等序列屮没有相等的前后 缀,此时移动的位数最大,直接将了串首字符后移到主串i位置进行下一趟比较;如果已匹配相 等序列中存在最大相等前后缀(可理解为首尾重合),那么将子串向右滑动到和该相等前后缀对 齐(这部分字符下一趟显然不需要比较),然后从主串的i位置进行下一趟比较。
(2)KMP改进
使用部分匹配值时,每当匹配失败,就去找它前一个元素的部分匹配值,这样使用起来有些不方便,所以将PM表右移一位,这样哪个元素匹配失败,直接看它自己的部分匹配值即可。将上例中字符串′abac′的PM表右移一位,就得到了next数组
我们注意到:
- 第一个元素右移以后空缺的用-1来填充,因为若是第一个元素匹配失败,则需要将子串向右移动一位,而不需要计算子串移动的位数。
- 最后一个元素在右移的过程中溢出,因为原来的子串中,最后一个元素的部分匹配值是其下一个元素使用的,但显然已没有下一个元素,故可以舍去。
有时为了使公式更加简洁、计算简单,将next数组整体+1。
因此,上述子串的next数组也可以写成
最终得到子串指针变化公式 j =next[j]。
next[ j ]的含义是:在子串的第j个字符与主串发生失配时,则跳到子串的next[ j ]位置重新与主串当前位置进行比较。
通过分析,可以知道,除第一个字符外,模式串中其余的字符对应的next数组的值等于其最大公共前后缀长度加上1
//求解next
void get_next(String T, int *next){
int i = 1, j = 0;
next[1] = 0;
while (i < T.length){
if(j==0 || T.ch[i]==T.ch[j]){ //ch[i]表示后缀的单个字符,ch[j]表示前缀的单个字符
++i; ++j;
next[i] = j; //若pi = pj, 则next[j+1] = next[j] + 1
}else{
j = next[j]; //否则令j = next[j],j值回溯,循环继续
}
}
}
//kmp
int Index_KMP(String S, String T){
int i=1, j=1;
int next[255]; //定义next数组
get_next(T, next); //得到next数组
while(i<=S.length && j<=T.length){
if(j==0 || S.ch[i] == T.ch[j]){ //字符相等则继续
++i; ++j;
}else{
j = next[j]; //模式串向右移动,i不变
}
}
if(j>T.length){
return i-T.length; //匹配成功
}else{
return 0;
}
}
(3)KMP优化
前面定义的next数组在某些情况下尚有缺陷,还可以进一步优化。子串 ′aaaab′在和主串′aaabaaaaab′进行匹配时:
显然后面3次用一个和p4相同的字符跟S4比较毫无意义,必然失配。
比较毫无意义。那么如果出现了这种类型的应该如何处理呢?
如果出现了,则需要再次递归,将next[j]修正为 next[next[j]],直至两者不相等为止,更新后的数组命名为nextval。计算next数组修正值的算法如下,此时匹配算法不变。
简单来说。它是在计算出next值的同时,如果a位字符与它next值指向的b位字符相等,则该a位的nextval就指向b位的nextval值,如果不等,则该a位的nextval值就是它自己a位的next的值。
void get_nextval(String T, int *nextval){
int i = 1, j = 0;
nextval[1] = 0;
while (i < T.length){
if(j==0 || T.ch[i]==T.ch[j]){ //ch[i]表示后缀的单个字符,ch[j]表示前缀的单个字符
++i; ++j;
if(T.ch[i] != T.ch[j]){ //若当前字符与前缀字符不同
nextval[i] = j; //则当前的j为nextval在i位置的值
}else{
//如果与前缀字符相同
//则将前缀字符的nextval值给nextval在i位置上的值
nextval[i] = nextval[j];
}
}else{
j = nextval[j]; //否则令j = next[j],j值回溯,循环继续
}
}
}
四、小结
- 串是内容受限的线性表,它限定了表中的元素为字符。串有两种基本存储结构:顺序存储和链式存储,但多采用顺序存储结构。串的常用算法是模式匹配算法,主要有BF算法和KMP 算法。BF算法实现简单,但存在回溯,效率低,时间复杂度为O(m*n)。KMP算法对BF算法 进行改进,消除回溯,提高了效率,时间复杂度为O(m+n)。
- 多维数组可以看成是线性表的推广,其特点是结构中的元素本身可以是具有某种结构的数据,但属于同一数据类型。一个n维数组实质上是n个线性表的组合,其每一维都是一个线性表。数组一般采用顺序存储结构,故存储多维数组时,应先将其确定转换为一 维结构,有按“行”转换和按“列”转换两种。科学与工程计算中的矩阵通常用二维数组 来表示,为了节省存储空间,对于几种常见形式的特殊矩阵,比如对称矩阵、三角矩阵和 对角矩阵,在存储时可进行压缩存储,即为多个值相同的元只分配一个存储空间,对零元 不分配空间。