文章目录
- 第四章 串
- 一、串的定义
- 二、串的基本操作
- 三、串的存储结构
- (一)串的顺序存储
- (二)串的链式存储
- (三)基本操作的实现
- 1.求子串SubString
- 2.比较操作StrCompare
- 3.定位操作Index
- 四、模式匹配算法
- (一)朴素模式匹配算法
- (二)KMP算法
- ※ 如何用代码实现
- ※ 求next数组(手算)
- 例1."google"
- 例2."ababaa"
- 例3."aaaab"
- (三)总结
第四章 串
所谓串,就是字符串。
一、串的定义
串,即字符串(String),是由零个或多个字符组成的有限序列。一般记为
S = ′ a 1 a 2 … … a n ′ ( n ≥ 0 ) S\ =\ '\ a_1a_2……a_n\ '\quad(n≥0) S = ′ a1a2……an ′(n≥0)
其中,
- S是串名;
- 单引号括起来的字符序列是串的值;
- 注:在不同的编程语言里,字符串也可以是用双引号括起来的字符序列(如Java、C),单引号括起来也可以(如Python)。
- 即,单引号/双引号括起来的都可以,但是千万不要认为引号是字符串的内容,它只是字符串的边界符,表示字符串的头尾。
- ai可以是字母、数字或其他字符;
- 串中字符的个数n称为串的长度。n=0时的串称为空串(用∅表示)。
例如:
S = "HelloWorld!"
T = 'iPhone 11 Pro Max?'
一些术语:
- 子串:串中任意个连续的字符组成的子序列。
- 如:‘iPhone’,'Pro M’是串T的子串。
- 当然了,既然是任意个,当然也可以是0个。即一个空串也是一个字符串的子串。
- 主串:包含子串的串。(也就是上面的S、T)
- 如:T是子串
'iPhone'
的主串。- 字符在主串中的位置:字符在串中的序号。
- 注意:字符串一般记为
S = 'a1a2......an'
,即字符在串中的序号是从1开始的。- 此外,若没有特殊说明的话,我们说某个字符在主串中的位置,一般指的是其第一次出现的位置。
- 还需要注意,空格也算一个字符。
- 如:
'1'
在T中的位置是8(第一次出现的位置)。- 子串在主串中的位置:子串的第一个字符在主串中的位置。
- 如:
'11 Pro'
在T中的位置是8。- 注意:空串和空格串是不同的。
- 如:
M = ''
,M是空串;N = ' '
,N是由三个空格字符组成的空格串。
事实上,串是一种特殊的线性表,数据元素之间呈现线性关系。
它们的区别是,
-
普通的线性表,每个数据元素可以是各种各样的数据类型,没有限制。
而串,或者说字符串,它的数据元素,一般来说就是字符。(如中文字符、英文字符、数字字符、标点字符等)。
-
普通的线性表,我们在进行增删改查等基本操作时,一般是对线性表中的某一个数据元素进行操作。
而我们对串的基本操作,如增删改查等,通常以子串为操作对象。也就是一次是对一堆字符进行操作的。
二、串的基本操作
假设有串
T = ""
,S = "iPhone 11 Pro Max?"
,W = "Pro"
基本操作:
StrAssign(&T, chars):赋值操作。把串T赋值为chars。
StrCopy(&T, S):复制操作。由串S复制得到串T。
StrEmpty(S):判空操作。若S为空串,则返回true,否则返回false。
StrLength(S):求串长。返回串S的元素个数。
ClearString(&S):清空操作。将S清为空串。
DestroyString(&S):销毁串。将串S销毁(回收存储空间)。
- 注意,清空和销毁操作是不同的。
Concat(&T, S1, S2):串联接。用T返回由S1和S2联接而成的新串。
- 例,执行Concat(&T, S, W)后,
T = "iPhone 11 Pro Max?Pro"
。- 不难想到,串联接操作,会使一个串所占用的空间增加。因此,如果你的应用场景要经常使用串的联接操作的话,那当你在设计串的存储结构的时候,应该选取一种容易扩展的存储结构。
SubString(&Sub, S, pos, len):求子串。用Sub返回串S的第pos个字符起长度为len的子串。
- 例,执行SubString(&T, S, 4, 6)后,
T = "one 11"
。Index(S, T):定位操作。若主串S中存在与串T值相同的子串,则返回它在主串S中第一次出现的位置;否则返回0。
- 实际上就是寻找
子串T
在主串S
中的位置。- 例,执行Index(S, W)后,返回值为11。
StrCompare(S, T):比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<0。
问题:这些是字符,又不是数字,怎么能比较大小?
其实就类似于英语单词的升序排列一样。
例如:
"abandon" < "aboard"
;"academic" > "abuse"
即,从第一个字符开始往后依次对比,先出现更大字符的串就更大。
但是,又遇到了一种问题,如下。
上面的意思,很好理解,但是当遇到如下情况时,该怎么处理呢?
例如:
"abstract" < "abstraction"
,它们前面部分是一样的,而后者多了一个后缀;
"abstract" < "abstract "
,它们单词部分是一样的,而后者多了一个空格。像这种情况,我们认为,长串的前缀与短串相同时,长串更大。
只有两个串完全相同时,才相等。
如:
"academic" == "academic"
以上三点,是我们由英文字母的前后顺序来理解的。实际上,在计算机中,对于英文字母,或者一些字符,它是由一个二进制数来存储的。任何数据存到计算机中一定是二进制数。一个字符与一个二进制数有一个对应规则,这就是“编码”。(ASCII字符集编码)
例如:
'a’对应的二进制编码为00010110,也就是十进制的97。
'c’对应的二进制编码为00110110,也就是十进制的99。
因此,计算机在比较’a’和’c’的大小时,并不是查英文字母表,而是直接对比其在计算机中存储的二进制数谁更大。显然是’c’更大。
同样的,空格也有它对应的字符集编码,是00000010,也就是十进制的32。
> 什么是字符集?
英文字符——ASCII字符集
中英文——Unicode字符集
为什么不是同一个字符集呢,因为ASCII字符集,是八位二进制数,其不足以表示所有中文需要的字符。
基于同一个字符集,可以有多种编码方案,如:UTF-8,UTF-16。
什么意思?
比如对于“王”这个汉字,我设计一个字符编码
10100011
来表示“王”。但是另一个人,他可能用的是
01010011
来表示“王“。这就是同一个字符集,有不同的编码方案。
字符集,就是我这一个语言体系,共需要用到哪些字符。
编码方案,就是将这些字符依次分别用什么二进制数表示。
实际上,采用不同编码方式,每个字符所占空间不同。例如ASCII编码中,每个字符占用1B,而UTF-8中,如一个汉字,它可能要占3B。但是在考研当中,一般用到的就是ASCII码,我们只需默认每个字符占1B即可。
乱码问题:
是由于软件解码方式不同导致的。
如,在原本的文件中,“王”的编码为
11001100
,而某软件在打开此文件时采用的是另一套编码规则,那么它所认为的
11001100
这个编码对应的字符,就是一个其他的字符了。整体下来,你的文件显示的内容就都是乱码了。
三、串的存储结构
我们说过,串实际上就是一种特殊的线性表。
那么我们可以参考对线性表的实现方法,来实现串。
只不过线性表中存放的都是某一种类型(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;
需要注意的是,一个char字符,只占用1B,而(在32位操作系统中)一个指针是4B。
这就意味着,用1B的空间存储实际想要的信息,用4B的空间来存储一个辅助信息。
这种情况,我们把它称为存储密度低。即实际存储的信息比例很小。
那么怎么解决这一问题呢?
我们可以让链表的每个结点存放的实际信息域为多个字符。
typedef struct StringNode {
char ch[4]; //每个结点存多个字符
struct StringNode *next;
}StringNode, *String;
此处写的是4个,实际也可以更多。
那么这样一来,每个结点中,实际存放的信息所占大小就是4B,因此存储密度就会提高。
因此,若使用链式存储来实现串的话,一般推荐采用这种方式。
通过这种方式实现,若最后一个结点存不满字符,那么你可以用一些特殊字符(如
#
,也可以用我们上面提到的'\0'
)将其填充进去。
(三)基本操作的实现
1.求子串SubString
SubString(&Sub, S, pos, len):求子串。用Sub返回串S的第pos个字符起,长度为len的子串。
#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
StrCompare(S, T):比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<0。
//比较操作
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
Index(S, T):定位操作。若主串S中存在与串T值相同的子串,则返回它在主串S中第一次出现的位置;否则函数值为0。
其实在此处,我们可以通过使用之前实现的求子串操作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算法(较高级)
什么叫字符串的模式匹配?
类似于Word中的查找功能、百度的搜索功能,你输入一段文字,之后会匹配出相应结果。
说白了就是根据你输入的字符串,去匹配相应内容。
统一一些术语:
- 从哪个字符串里面进行搜索,那个字符串就叫主串。
- 你输入的内容,叫模式串。
- 为什么叫模式串,不叫子串?因为子串必定是能够在主串中找到的一个串。而模式串只是我们试图去搜索的一个串,并不一定能够找到,因此叫模式串而不能叫子串。
- 字符串模式匹配:在主串中找到与模式串相同的子串,并返回其所在位置。
(一)朴素模式匹配算法
其核心思想就是:暴力求解。
在主串当中找出所有有可能与模式串相匹配的子串,然后将每个子串与模式串一一进行对比。这样肯定就没有遗漏地进行一遍对比。
因此,如果主串长度为n,模式串长度为m,则
朴素模式匹配算法:将主串中所有长度为m的子串依次与模式串对比,直到找到一个完全匹配的,或直到所有的子串都不匹配为止。
问题:在长度为n的主串当中,长度为m的子串共有多少个?
答:共有
n-m+1
个。
到这里,事实上我们已经发现了,这一系列操作,是和之前我们学过的串的定位操作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的第一个位置。
可以这样实现:
当后移到某个位置处,发现匹配失败,则
i = i-j+2;
//由于j能够表示“此时匹配到了第几个”,因此i-j+1可以让i回到起始位置,再加1可以到下一位置,不难理解。
j = 1;
//并且令j置为1
-
若
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;
}
分析一下此算法的时间复杂度。
设主串长度为n,模式串长度为m,则
最坏时间复杂度 =
O(nm)
例如:主串为
aaaaaaaaaaaaaaaaaaaaab
;模式串为aaaaab
。每次都要对比m个字符,且总遍历次数为n-m+1
次,时间复杂度为O((n-m+1)m)
。即O(nm-m²+m),那么它为什么能等价于O(nm)呢?
这是因为,在模式匹配的场景当中,n是主串的长度,m是模式串的长度。n一般远远大于m。
因此,nm的数量级,要比m²的数量级大得多,更比m的数量级大得多。
因此可以认为其时间复杂度为O(nm)。
- 最好时间复杂度 =
O(n)
- 例如:主串为
aaaaaaaaaaaaaaab
;模式串为caaaab
。即每次对比时,在第一个字符处就匹配失败,总遍历次数为n-m+1
次。复杂度O(n-m+1) = O(n)。- 我个人认为,还有比O(n)更乐观的情况,即第一次匹配就匹配到了模式串,那么此种情况下,时间复杂度为O(m)。(只是个人看法,且由于时间复杂度默认是按最坏时间复杂度,所以此处无所谓吧)
(二)KMP算法
由D.E.Knuth,J.H.Morris和V.R.Pratt提出,因此称为KMP算法。
它的名称是由人名得来的,而它本质上就是基于朴素模式匹配算法,进行了一个优化的算法而已。
回顾一下朴素模式匹配算法。
在每轮循环当中,一旦发现当前这个子串中某个字符不匹配,就只能转而匹配下一个子串,而且是要从头开始。
这是为什么呢。因为我们在进行匹配之前,并不知道主串里面有什么,我们只能根据下标、模式串串长,进行一个一个的对比。
即使是上一轮中对比过的相等的字符,我们也并没有记录。总之每次循环前,主串中有什么内容,都是我们不知道的,我们只知道开始匹配的下标位置,与要匹配的长度。
但是实际上,由于上一轮循环,对于字符的逐个对比,到某个字符匹配失败时结束,这一过程当中,我们必然能够通过一部分“i指向的字符与j指向的字符相等”,来得知主串当中有哪些字符。即,在遇到不匹配的字符之前的字符,一定是和模式串一致的字符。
因此,对于主串中的信息,虽然刚开始我们一无所知。但是通过模式串的部分匹配,我们可以确定主串里面前边一小部分到底是什么内容。内容就是模式串失配位置前的所有字符。
那么,根据模式串失配位置前的内容,映射到主串相应位置,之后去执行朴素模式匹配的话,它会寻找到某个位置之后再进行真正的匹配。
而且从逻辑上来讲,这一操作过程与主串是什么无关,只是与模式串本身的信息内容、失配位置相关。
例如,对于模式串
abaabc
来说,若第6个元素为失配字符,则主串对应已知内容为abaab
,对于已知的这五个字符,模式串要去进行匹配的话。其最终情况为,模式串的前两个字符ab,与主串已知部分的后两个字符ab对照上,同时,由于前两个字符已形成对照,所以游标从这两个字符后的第三个字符开始即可。因此,主串中的游标i不动,模式串中的游标j也不必从模式串的头部开始,而是从第三位开始,就可以了。第6个元素失配,可以得知下一轮循环只需执行:i不动,令j=3即可。其他位置元素失配,也可以用相同逻辑分析。
那么,对于模式串
T = 'abaabc'
,我们分析得到以下结论:
- 若第6个元素失配,则令j=3,i不动。
- 若第5个元素失配,则令j=2,i不动。
- 若第4个元素失配,则令j=2,i不动。
- 若第3个元素失配,则令j=1,i不动。
- 若第2个元素失配,则令j=1,i不动。
- 若第1个元素失配,则匹配下一个子串即可。可以令j=0,然后执行i++,j++。
可以看到,从逻辑上分析,确实效率快了一些。
因为,第一轮循环,i=1,j=1之后,如果第6个元素失配,在朴素模式匹配算法中,i必须回溯到i=2处。
而利用这个算法,i不用动,且j从某位置开始,显然可以节省大量操作。
总之,指针i从头到尾均不需要进行回溯。
但是从代码的角度该怎么实现我们的这种想法呢?
根据以上分析,可知,不论是第几个元素失配,首先i的值是不需要动的。但是问题是j应该从何处开始。
由于实际场景当中,主串往往是很长的,而模式串一般是较短的。所以我们可以先按照上述方法分析模式串的这些信息,分析完成之后,再将其进入主串去匹配,这样就会非常高效。
※ 如何用代码实现
首先,我们刚才分析模式串得到的这一系列结论,显然可以用数组来很方便的对应表示。
我们把这个数组称为
next数组
对于模式串
T = 'abaabc'
:
- 若第6个元素失配,则令j=3,i不动。
- 若第5个元素失配,则令j=2,i不动。
- 若第4个元素失配,则令j=2,i不动。
- 若第3个元素失配,则令j=1,i不动。
- 若第2个元素失配,则令j=1,i不动。
- 若第1个元素失配,令j=0,然后执行i++,j++。(为了让代码更整齐)
next数组为:
next[0] = NULL; next[1] = 0; next[2] = 1; next[3] = 1; next[4] = 2; next[5] = 2; next[6] = 3;
next[i]的下标表示第i个元素失配时,其对应的值为j应该改为几之后继续后移。
代码逻辑实现如下:
if(S[i] != T[j]){ j = next[j]; } if(j == 0){ i++; j++; }
此处再次说明一下,next数组存放什么数据,只是由模式串本身携带的信息内容的特质所决定的,和主串毫无关联。
即,若模式串已知为
T = 'abaabc'
,则无需关注主串的任何角度的信息,只需分析模式串,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;
}
之前我们分析过,朴素模式匹配算法的最坏时间复杂度为O(mn)。
而KMP算法,最坏时间复杂度为O(m + n),其中包含:求next数组时间复杂度O(m)、模式匹配过程最坏时间复杂度O(n)。(因为主串指针i是不回溯的,最坏遍历n次)。
在408考研当中,只需要学会如何手动地求next数组就可以。
※ 求next数组(手算)
例1.“google”
分析:
首先不要弄错了,next数组下标和字符串下标是一一对应的。(字符串是弃置了0号位,next数组也弃置了0号位)。首先这一点不要弄错。
所以字符串下标是16,next数组下标为next[1]next[6]。
-
首先分析next[1]
next[1]的含义是,当模式串的第一个字符发生失配时,模式串指针j应该指向什么位置?
应该是让j=0,然后执行i++,j++。
因此,对于任何一个模式串都是一样的,第一个字符不匹配时,只能匹配下一个子串。因此,next[1]均直接写0即可。
-
分析next[2]
next[2]的含义是,如果第二个字符发生失配,接下来指针j应该指向哪?
对于这个例子来说,我们应该让j=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)。