文章目录
第四章 串
一、串的定义
事实上,串是一种特殊的线性表,数据元素之间呈现线性关系。
它们的区别是,
-
普通的线性表,每个数据元素可以是各种各样的数据类型,没有限制。
而串,或者说字符串,它的数据元素,一般来说就是字符。(如中文字符、英文字符、数字字符、标点字符等)。
-
普通的线性表,我们在进行增删改查等基本操作时,一般是对线性表中的某一个数据元素进行操作。
而我们对串的基本操作,如增删改查等,通常以子串为操作对象。也就是一次是对一堆字符进行操作的。
二、串的基本操作
-
但是,又遇到了一种问题,如下。
-
只有两个串完全相同时,才相等。
-
以上三点,是我们由英文字母的前后顺序来理解的。实际上,在计算机中,对于英文字母,或者一些字符,它是由一个二进制数来存储的。任何数据存到计算机中一定是二进制数。一个字符与一个二进制数有一个对应规则,这就是“编码”。(ASCII字符集编码)
> 什么是字符集?
三、串的存储结构
我们说过,串实际上就是一种特殊的线性表。
那么我们可以参考对线性表的实现方法,来实现串。
只不过线性表中存放的都是某一种类型(ElemType)的数据元素,但是串里面我们只能存放char型数据元素。
(一)串的顺序存储
#define MAXLEN 255 //预定义最大串长为255
//静态数组实现串(定长顺序存储)
typedef struct {
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
}SString;
静态数组的缺点就是它的缺点:长度不可变。
用静态数组实现串,因此它也叫串的定长顺序存储。
如果我们想让它长度是可变的,我们可以用动态数组实现(堆分配存储),如下所示。
//动态数组实现(堆分配存储)
typedef struct {
char *ch; //按串长分配存储区,ch指向串的基地址
int length; //串的长度
}HString;
void test(){
HString S;
S.ch = (char *)malloc(MAXLEN * sizeof(char)); //用完需要手动free
S.length = 0;
//...
}
malloc方式申请的存储空间,在内存中是在堆区当中的,因此这种方法实现的,叫堆分配存储。同时,堆区中分配的内存空间需要手动的free释放。
无论是用哪种方式申请空间,在进行存储时,有如下几种不同的方案:
- 方案一:从
ch[0]
开始存储串的内容,再另外设一个变量length
存放串的长度。 - 方案二:
ch[0]
充当length,从ch[1]
开始存储串的内容。- 方案二的优点是,字符的位序和数组下标相同。(当然,单对于这一点来讲,就有点无关紧要了,并不是一个多么大的优点)
- 方案二的缺点:需要注意的是,由于此处length是用
ch[0]
来充当的,因此其类型必然为char
,那么它能表示的数字范围只有0~255
。
- 方案三:不会明确的存储length为多少,而是以字符
'\0'
表示结尾(对应ASCII码的0)。- 可想而知,这个方案有一个缺点,当我们想知道它有多长的时候,我们得从头到尾扫描,看看什么时候遇到
'\0'
,停止扫描并记录其长度。所以当我们经常需要访问串的长度的话,方案三就是不太可取的。
- 可想而知,这个方案有一个缺点,当我们想知道它有多长的时候,我们得从头到尾扫描,看看什么时候遇到
- 方案四:将
ch[0]
弃置不用,从ch[1]
开始存储串的内容,再另外设一个变量length
存放串长。- 这种方案兼具方案一、二的优点,因此我们在后续的讲解中,也默认使用这种方案。
(二)串的链式存储
和线性表的链式存储的一样的,只不过我们每个结点保存的数据的类型为char
。
typedef struct StringNode {
char ch; //每个结点存1个字符
struct StringNode *next;
}StringNode, *String;
这种情况,我们把它称为存储密度低。即实际存储的信息比例很小。
那么怎么解决这一问题呢?
typedef struct StringNode {
char ch[4]; //每个结点存多个字符
struct StringNode *next;
}StringNode, *String;
(三)基本操作的实现
1.求子串SubString
#define MAXLEN 255 //预定义最大串长为255
typedef struct {
char ch[MAXLEN]; //每个分量存储一个字符
int length; //串的实际长度
}SString;
//求子串
bool SubString(SString &Sub, SString S, int pos, int len) {
//子串范围越界
if(pos+len-1 > S.length) return false;
for(int i = pos; i < pos+len; i++) {
Sub.ch[i-pos+1] = S.ch[i];
}
Sub.length = len;
return true;
}
2.比较操作StrCompare
//比较操作
int StrCompare(SString S, SString T) {
for(int i=1; i<=S.length && i<=T.length; i++){
if(S.ch[i] != T.ch[i]){
return S.ch[i] - T.ch[i];
}
}
//扫描过的所有字符都相同,则长度长的串更大
return S.length - T.length;
}
3.定位操作Index
其实在此处,我们可以通过使用之前实现的求子串操作SubString(&Sub, S, pos, len)
来帮助我们完成,将要检查的子串T,和主串S的所有子串依次对比即可,而且,比较两个串是否相等,也可以使用我们之前已经实现的比较操作(StrCompare(S, T))来完成。
int Index(SString S, SString T) {
int i = 1;
int n = StrLength(S);
int m = StrLength(T);
SString sub; //用于暂存子串
while(i <= n-m+1) {
SubString(sub, S, i, m);
if(StrCompare(sub, T) != 0) i++;
else return i; //返回子串在主串中的位置
}
return 0; //S中不存在于T相等的子串
}
四、模式匹配算法
实际上,在408考研当中,我们需要掌握两种字符串的模式匹配算法
- 朴素模式匹配算法
- KMP算法(较高级)
统一一些术语:
- 从哪个字符串里面进行搜索,那个字符串就叫主串。
- 你输入的内容,叫模式串。
- 为什么叫模式串,不叫子串?因为子串必定是能够在主串中找到的一个串。而模式串只是我们试图去搜索的一个串,并不一定能够找到,因此叫模式串而不能叫子串。
- 字符串模式匹配:在主串中找到与模式串相同的子串,并返回其所在位置。
(一)朴素模式匹配算法
其核心思想就是:暴力求解。
在主串当中找出所有有可能与模式串相匹配的子串,然后将每个子串与模式串一一进行对比。这样肯定就没有遗漏地进行一遍对比。
到这里,事实上我们已经发现了,这一系列操作,是和之前我们学过的串的定位操作Index(S, T)
是一致的,只是换了个马甲。
因此,我们所谓的朴素模式匹配算法,就可以使用之前的串定位操作来进行实现,如下。
//和上面写过的那个是一模一样的内容
int Index(SString S, SString T){
int i=1;
int n = StrLength(S);
int m = StrLength(T);
SString sub; //用于暂存子串
while(i <= n-m+1) {
SubString(sub, S, i, m);
if(StrCompare(sub, T) != 0) i++;
else return i; //返回子串在主串中的位置
}
return 0; //没有匹配到
}
接下来,我们不借助字符串的基本操作,而是直接通过数组下标来实现朴素模式匹配算法。
设置两个扫描指针,i和j。
-
令i指向主串S的第一个字符,j指向模式串T的第一个字符,依次进行匹配(若其指向字符相等,则后移;若其指向的字符不相等,则匹配失败)
-
若当前子串匹配失败,则主串指针i指向下一个子串的第一个位置,模式串指针回到模式串j的第一个位置。
-
若
j > T.length
,则当前子串匹配成功,返回当前子串第一个字符的位置——i - T.length
int Index(SString S, SString T){
int i = 1;
int 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(n)
- 例如:主串为
aaaaaaaaaaaaaaab
;模式串为caaaab
。即每次对比时,在第一个字符处就匹配失败,总遍历次数为n-m+1
次。复杂度O(n-m+1) = O(n)。 - 我个人认为,还有比O(n)更乐观的情况,即第一次匹配就匹配到了模式串,那么此种情况下,时间复杂度为O(m)。(只是个人看法,且由于时间复杂度默认是按最坏时间复杂度,所以此处无所谓吧)
- 例如:主串为
(二)KMP算法
但是实际上,由于上一轮循环,对于字符的逐个对比,到某个字符匹配失败时结束,这一过程当中,我们必然能够通过一部分“i指向的字符与j指向的字符相等”,来得知主串当中有哪些字符。即,在遇到不匹配的字符之前的字符,一定是和模式串一致的字符。
因此,对于主串中的信息,虽然刚开始我们一无所知。但是通过模式串的部分匹配,我们可以确定主串里面前边一小部分到底是什么内容。内容就是模式串失配位置前的所有字符。
那么,根据模式串失配位置前的内容,映射到主串相应位置,之后去执行朴素模式匹配的话,它会寻找到某个位置之后再进行真正的匹配。
而且从逻辑上来讲,这一操作过程与主串是什么无关,只是与模式串本身的信息内容、失配位置相关。
可以看到,从逻辑上分析,确实效率快了一些。
总之,指针i从头到尾均不需要进行回溯。
但是从代码的角度该怎么实现我们的这种想法呢?
根据以上分析,可知,不论是第几个元素失配,首先i的值是不需要动的。但是问题是j应该从何处开始。
※ 如何用代码实现
此处再次说明一下,next数组存放什么数据,只是由模式串本身携带的信息内容的特质所决定的,和主串毫无关联。
即,KMP算法的主要步骤是:
- 根据模式串T,求出next数组。(进行匹配前要进行的一个预处理)
- 利用next数组进行匹配。(主串指针i不回溯)
此处,我们暂时不关心next数组怎样用代码来求出。我们暂时通过手算的方式人工将其求出。
我们先来关注,得到next数组之后,利用next数组进行匹配的代码该如何实现。如下所示:
int Index_KMP(SString S, SString T, int next[]) {
int i = 1;
int j = 1;
while(i<=S.length && j<=T.length){
if(j==0 || S.ch[i] == T.ch[j]]){
i++;
j++; //继续比较后继字符
} else {
j = next[j]; //模式串j按照next数组进行移动
}
}
if(j > T.length) return i - T.length; //匹配成功
else return 0;
}
在408考研当中,只需要学会如何手动地求next数组就可以。
※ 求next数组(手算)
例1.“google”
分析:
首先不要弄错了,next数组下标和字符串下标是一一对应的。(字符串是弃置了0号位,next数组也弃置了0号位)。首先这一点不要弄错。
所以字符串下标是16,next数组下标为next[1]next[6]。
-
首先分析next[1]
因此,对于任何一个模式串都是一样的,第一个字符不匹配时,只能匹配下一个子串。因此,next[1]均直接写0即可。
-
分析next[2]
事实上,对于任何一个模式串都一样,第2个字符不匹配时,应该常识匹配模式串的第1个字符。因此,next[2]均直接写1即可。
-
分析next[3]
如下图所示,
可见,这种情况,应该将模式串移动到
即此模式串对应的next[3] = 1
。
-
分析next[4]
同理,可知,
next[4] = 1
。 -
分析next[5]
同理可知,next[5] = 2
。
-
分析next[6]
同理可知,
next[6] = 1
。
例2.“ababaa”
- 第一步:
- next[1]直接写0
- next[2]直接写1
- 第二步:
- 依次分析next[3]至next[6]
- 分析结果如下:
- next[3] = 1
- next[4] = 2
- next[5] = 3
- next[6] = 4
例3.“aaaab”
分析结果如下:
- next[1] = 0
- next[2] = 1
- next[3] = 2
- next[4] = 3
- next[5] = 4
(三)总结
手算求next数组步骤:
- next[1]都写0,next[2]都写1
- 之后的next:在不匹配的位置前,划一根分界线。将模式串一步一步往后退,直到分界线“能对上”,或模式串完全跨过分界线为止。此时j指向哪,next数组值就是多少。
KMP算法的最坏时间复杂度为O(m + n)。其中m是求next数组造成的时间复杂度,n是利用next数组去匹配的时间复杂度。
而朴素模式匹配算法最坏时间复杂度为O(mn)。