4.1 串的定义和实现
串的定义
串(String)是由零个或多个字符组成的有限序列。一般记为
S
=
‘
a
1
a
2
.
.
.
a
n
’
S=‘a_1a_2...a_n’
S=‘a1a2...an’
串中任意多个连续的字符组成的子序列称为该串的子串。当两个串的长度相等且每个对应位置的字符相等时,称这两个串是相等的。
需要注意的是,由一个或多个空格组成的串称为空格串(空格串不是空串)
串的逻辑结构和线性表类似,区别仅在于串的数据类型限定为字符集。但在基本操作上,串和线性表有很大的区别。
串的实现
1.定长顺序存储实现
类似于线性表的顺序存储结构,用一组地址连续的存储单元存储串值的字符序列。在串的定长存储序列结构中,每个串变量都只分配了一个固定长度的存储空间,也就是一个定长的数组。
#define MAXSTRLEN 255
typedef unsigned char SString[MAXSTRLEN+1];//0号单元存放串的长度
或者
typedef struct{
char ch[MAXSTRLEN]; //用来申请串空间的指针,按串长动态分配
int length;//串长度
}SString;
串的实际长度只能小于给定的MAXSTRLEN,超过部分只能舍去,称为截断。
要克服这个弊端,只能采用动态分配的方法,不限定串长的最大长度。
2.堆分配存储表示
其实笔者在实现线性表的顺序存储结构时,应用的就是堆分配存储表示。
堆分配存储表示仍然以一组地址连续的春初单元存放串值的字符序列,但是它和定长顺序存储不同,它的存储空间是在程序执行过程中动态分配得到的。
#include <iostream> //C++头文件格式,如果需要可替换成C语言的头文件
using namespace std;
#include <string.h>
#include <cstring>
#include <string>
typedef int Status;
#define error -1
//因为C语言中没有true和false关键字,虽然C++里有但是这里还是额外定义一下
#define FALSE 0
#define TRUE 1
#define MAXSTRLEN 255
typedef struct{
char *ch; //用来申请串空间的指针,按串长动态分配
int length;//串长度
}HString;
Status InitString(HString &T){
T.ch=NULL;
T.length=0;
return TRUE;
}
Status StrAssign(HString &T,char* chars){
//生成一个其值等于串常量chars的串T
if(T.ch) free(T.ch);//如果T里原来存有值,c则释放T原有空间
int i;
char* c;
for(i=0,c=chars;*c!='\0';++i,++c);//获得chars的长度i
if(!i){
//如果给的串常量为空串
T.ch=NULL;
T.length=0;
}
else{
T.ch=(char *)malloc((i+1)*sizeof(char));//需要多出一个空间存放结束控制符
if(!T.ch) return FALSE;
for(int j=0;j<i;j++){
T.ch[j]=*chars;
chars++;
}
T.ch[i]='\0';//字符串的结束控制符
T.length=i;
}
return TRUE;
}
int StrLength(HString S){
//返回S的元素个数,称为串的长度
return S.length;
}
int StrCompare(HString S,HString T){
//如果S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<0
for(int i=0;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;
}
Status Concat(HString &T,HString S1,HString S2){
//用T返回由S1和S2联接而成的新串
if(T.ch){
//如果原来的T有数据,释放T原有的旧空间
free(T.ch);
}
T.ch=(char *)malloc((S1.length+S2.length+1)*sizeof(char));//多一个空间存放结束控制符
if(!T.ch)return error;
for(int i=0;i<S1.length;i++){
T.ch[i]=S1.ch[i];
}
for(int i=0;i<S2.length;i++){
T.ch[S1.length+i]=S2.ch[i];
}
T.length=S1.length+S2.length;
T.ch[T.length]='\0';//字符串最后存放结束控制符
return TRUE;
}
Status SubString(HString &Sub,HString S,int pos,int len){
//用Sub返回串S的第pos个字符起长度为len的子串
if(pos<1||pos>S.length||len<1||len>S.length-pos+1){
//判断起始位置和取串的长度是否合法
return error;
}
if(Sub.ch){
free(Sub.ch);//释放旧空间
}
if(!len){
//如果要取的是空串
Sub.ch=NULL;
Sub.length=0;
}
else{
Sub.ch=(char *)malloc((len+1)*sizeof(char));
for (int i=0; i<len; i++) {
Sub.ch[i]=S.ch[pos-1+i];
}
Sub.ch[len]='\0';
Sub.length=len;
}
return TRUE;
}
int main() {
char c[100];
gets(c);
HString T1;
HString T2;
HString T3;
InitString(T1);
InitString(T2);
InitString(T3);
StrAssign(T1, c);
StrAssign(T2, c);
Concat(T3,T1,T2);
SubString(T1, T3, 2, 2);
return 0;
}
该分配方式可以通过笔者以前写的顺序线性表的实现来类推。
3.块链存储表示
类似于线性表的链式存储结构,在串中,我们也可以采用链表的方式存储串值。由于串的特殊性(每个元素都是一个字符),在具体实现的时候,每个结点既可以只放一个字符,也可以存放多个字符。每个结点称为”块“,整个链表称为”块链结构“。
#define CHUNKSIZE 80 //由用户定义的块大小
typedef struct Chunk{
char ch[CHUNKSIZE];
struct Chunk *next;
}Chunk; //块结点
typedef struct{
Chunk *head,*tail; //串的头尾指针
int curlen; //串当前的长度
}LString;
4.2 串的模式匹配
子串的定位操作通常称为串的模式匹配,它求的是子串在主串中的位置。
简单的模式匹配算法
最简单,最直接就是暴力搜索,算法思想如下所示:
假如我们要找到子串 a b c a c 在主串 a b a b c a b c a c b a b 中第一次出现的位置
简单模式匹配算法,最坏情况下,时间复杂度会达到 O(nm) ,n 为主串的长度,m 为模式串的长度。因为最坏的情况就是每趟匹配都是比较到模式串的最后一位才发现不同,然后指针 i 需要回溯到最初的起点的下一位。
具体代码如下:(串采用的是定长顺序存储结构来做示范)
int Index(HString S,HString T){
//返回子串T在主串S中的位置。若不存在,则函数值为0
int i,j;
if(T.length==0)//T非空
return error;
for(i=0,j=0;i<S.length&&j!=T.length;){
if(S.ch[i]==T.ch[j]){
j++;
i++;
}
else{
//回溯匹配
i=i-j+1;
j=0;
}
}
if(j==T.length){
//匹配成功
return i-T.length+1;
}
//匹配失败
return 0;
}
改进模式匹配算法(KMP算法)
如何改进模式匹配算法呢,首先,我们在暴力算法中可以注意到,我们每次在每趟的比较完毕后,总是回到最开始匹配的下一个位置,但是在该趟的比较中我们可能已经读过下面的字符了,我们回溯到最初的那个位置的下一位重新比较就有点浪费时间了,有什么办法可以将回溯的位置向后挪一挪呢?
我们可以从模式串的结构入手,因为在每趟匹配中,我们已经匹配的匹配串的子串中,前缀序列中的某个序列刚刚好是模式串的前缀,那么就可以将模式向后滑动到与这些相等字符对其的位置上。下面我们解释一下这个思路。
1、原理:
1、字符串的前缀、后缀和部分匹配值
前缀:除最后一个字符以外,字符串的所有头部子串;
后缀:除第一个字符外,字符串的所有尾部子串;
部分匹配值:字符串的前缀和后缀的最长相等前后缀长度。
例如:“ababa”
‘a’的前缀和后缀都是空值,最长相等前后缀长度为0
'ab’的前缀为{a},后缀为{b},
{
a
}
∩
{
b
}
=
∅
\{a\}\cap\{b\}= \varnothing
{a}∩{b}=∅,最长相等前后缀长度为0
‘aba’的前缀为{a,ab},后缀为{a,ba},
{
a
,
a
b
}
∩
{
a
,
b
a
}
=
{
a
}
\{a,ab\}\cap\{a,ba\}= \{a\}
{a,ab}∩{a,ba}={a},最长相等前后缀长度为1
'abab’的前缀为{a,ab,aba},后缀为{b,ab,bab},
{
a
,
a
b
,
a
b
a
}
∩
{
b
,
a
b
,
b
a
b
}
=
{
a
b
}
\{a,ab,aba\}\cap\{b,ab,bab\}= \{ab\}
{a,ab,aba}∩{b,ab,bab}={ab},最长相等前后缀长度为2
'ababa’的前缀为{a,ab,aba,abab},后缀为{a,ba,aba,baba},
{
a
,
a
b
,
a
b
a
,
a
b
a
b
}
∩
{
b
,
b
a
,
a
b
a
,
b
a
b
a
}
=
{
a
,
a
b
a
}
\{a,ab,aba,abab\}\cap\{b,ba,aba,baba\}= \{a,aba\}
{a,ab,aba,abab}∩{b,ba,aba,baba}={a,aba},最长相等前后缀长度为3
所以我们可以得到“ababa”的部分匹配值为00123
我们用模式串匹配主串,匹配失败的时候,子串向后移动的位置应该是为
移
动
位
数
=
已
匹
配
的
字
符
数
−
失
配
元
素
的
前
一
个
部
分
匹
配
值
移动位数=已匹配的字符数-失配元素的前一个部分匹配值
移动位数=已匹配的字符数−失配元素的前一个部分匹配值
例如上面简单的模式匹配的例题:
我们要找到子串 a b c a c 在主串 a b a b c a b c a c b a b 中第一次出现的位置
我们利用上述的方法很容易获得子串“abcac”的部分匹配值为00010,我们由此可以得到部分匹配值表(PM表):
字符串 | a | b | c | a | c |
---|---|---|---|---|---|
PM | 0 | 0 | 0 | 1 | 0 |
在第一趟的匹配中:
发现a与c不一样,前面的两个字符’ab’是已经匹配过了的,查部分匹配值可以知道,最后一个匹配字符’b’对应的部分匹配值为0,因此我们的子串需要向后移动的位置为2-0=2,所以我们直接将子串向后移动两格
继续进行第二趟匹配:
同理,abca已经匹配成功的了,但是断在了最后一个字母a与c匹配失败,查部分匹配值可以知道,最后一个匹配字符’a’对应的部分匹配值为1,所以我们的子串需要向后移动的位置为4-1=3,所以我们将子串向后移动三格
继续进行第三趟匹配:
匹配成功!
由上面的例子可以知道,如果对应的部分匹配值为0,那么表示已匹配相等序列中没有与之相等的前缀和后缀,此时移动的位数就达到最大
如果已匹配的相等序列中存在了相同的前缀和后缀(首尾重合)我们要注意将,当前的后缀在下一趟匹配中需要承担前缀的角色,后缀的长度为部分匹配值。类似上面的第二趟匹配中’abca’,前缀’a’与后缀’a‘重合了,所以后缀的’a’是不能忽略的,他必须要变成一次前缀的‘a’来再进行一次匹配串,所以子串只向后移动了三格。
2、进一步改进:
使用部分匹配值的时候,每当匹配失败,就去找它前一个元素的部分匹配值,这样使用起来有点不太方便,所以将部分匹配值得表向右移动一个位子,这样在哪里元素失配了就直接看它自己的部分匹配值即可,这个向右移动了一个位子的PM表称为next数组。
例如上面的PM表
字符串 | a | b | c | a | c |
---|---|---|---|---|---|
PM | 0 | 0 | 0 | 1 | 0 |
原来的公式为:
m
o
v
e
=
(
j
−
1
)
−
P
M
[
j
−
1
]
move=(j-1)-PM[j-1]
move=(j−1)−PM[j−1]
改成next数组
字符串 | a | b | c | a | c |
---|---|---|---|---|---|
next | -1 | 0 | 0 | 0 | 1 |
我们应该注意到
1)第一个元素向右移动后的空缺用了-1来填充,因为若是第一个元素就匹配失败,则只需要向右移动一位,不用计算子串移动的位数
2)我们忽略了最后一个元素在向右移动的溢出,因为在PM表中,不可能查表查到最后一个元素的,因为在匹配过程中若匹配的子串已经通过了最后一个元素,他就已经完成了我们的目标,不会再去寻找回溯值了。
这样我们的公式应该就改成了
m
o
v
e
=
(
j
−
1
)
−
P
M
[
j
]
move=(j-1)-PM[j]
move=(j−1)−PM[j]
相当于我们子串的比较指针
j
j
j 就回退到
j
=
j
−
m
o
v
e
=
j
−
{
(
j
−
1
)
−
P
M
[
j
]
}
=
n
e
x
t
[
j
]
+
1
j=j-move=j-\{(j-1)-PM[j]\}=next[j]+1
j=j−move=j−{(j−1)−PM[j]}=next[j]+1
所以我们还可以为了公式简洁,将next数组整体+1
字符串 | a | b | c | a | c |
---|---|---|---|---|---|
next | 0 | 1 | 1 | 1 | 2 |
n
e
x
t
[
j
]
next[j]
next[j]具体含义是:在子串第
j
j
j个字符与主串发生失配的时候,跳到子串的next[j]位置重新与主串当前位置进行比较。
其实说白了就是把已经匹配的后缀当下次匹配的前缀使用,next[j]就是下次匹配时已经匹配过的前缀的长度。所以下一轮的匹配可以忽略掉这个前缀部分。
next [ 1 ]=0:代表当模式串的第一个字符与主串的当前字符比较不相等时,主串当前指针应后移一位,然后重新与模式串的第一个字符比较。
next [ j ]=1:代表失配时,主串指针 i 保持不变,模式串需要从新回到第一个字符和主串的第 i 个字符比较
3、next数组如何代码实现:
思想:首先我们也可以把求next数组的问题视为模式匹配问题:
整个模式串即是主串又是模式串
具体做法:用下面做例子
j | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
字符串 | a | b | a | a | b | c | a | b | a |
next | 0 | 1 | 1 | 2 | 2 | 3 | 1 | 2 | 3 |
next[1]:首位肯定是0无疑
next[2]:看前一位next[1]=0,所以直接令next[2]=1;
next[3]:看前一位next[2]=1,因为next[2]=1,但 p 2 p_2 p2!= p 1 p_1 p1,并且进入next递归,next[ next[2] ]=next[1]=0,递归结束,next[3]=1;
next[4]:看前一位,因为next[3]=1,且 p 3 p_3 p3= p 1 p_1 p1,next[4]=next[3]+1=2
next[5]:看前一位,因为next[4]=2,但 p 4 p_4 p4!= p 2 p_2 p2,进入next递归,next[next[4]]=next[2]=1,且 p 4 p_4 p4= p 1 p_1 p1,next[5]=next[2]+1=2
next[6]:看前一位,因为next[5]=2,且 p 5 p_5 p5= p 2 p_2 p2,next[6]=next[2]+1=3
next[7]:看前一位,因为next[6]=3,但 p 6 p_6 p6!= p 3 p_3 p3,进入next递归,next[next[6]]=next[3]=1,但 p 6 p_6 p6!= p 1 p_1 p1,进入下一次递归,next[next[3]]=next[1]=0,递归结束,next[3]=1;
next[8]:看前一位,因为next[7]=1,且 p 7 p_7 p7= p 1 p_1 p1,next[8]=next[7]+1=2;
next[9]:看前一位,因为next[8]=2,且 p 8 p_8 p8= p 2 p_2 p2,next[9]=next[8]+1=3;
代码:
void get_next(HString s,int next[]){
int i=1;
int j=0;
next[1]=0;
while (i<s.length) {
if(j==0||s.ch[i]==s.ch[j]){
++i;
++j;
next[i]=j;
}
else{
j=next[j];
}
}
}
相比next数组的求解法来说,KMP的匹配算法就非常简单了,仅仅只是将暴力法中的回溯的代码修改成next数组所记录的回溯点即可
int Index_KMP(HString S,HString T,int next[]){
//返回子串T在主串S中的位置。若不存在,则函数值为0
int i,j;
if(T.length==0)//T非空
return error;
for(i=0,j=0;i<S.length&&j!=T.length;){
if(j!=0||S.ch[i]==T.ch[j]){
j++;
i++;
}
else{
//回溯
j=next[j];
}
}
if(j==T.length){
//匹配成功
return i-T.length+1;
}
//匹配失败
return 0;
}
暴力模式匹配的时间复杂度为O(mn),KMP算法的时间复杂度为O(m+n)。
KMP算法仅在主串和子串中存在大量"部分匹配"时才比普通算法快很多,主要优点是主串不会回溯。所以在一般的情况下,普通模式匹配实际的执行时间也能近视为O(m+n),因此也采用至今。
KMP算法的进一步优化
例如
模式串为m=“aaaab”,主串为s="aaabaaaaab"比较:
j | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
字符串 | a | a | a | a | b |
next | 0 | 1 | 2 | 3 | 4 |
第一轮匹配中,当
m
4
m_4
m4!=
s
4
s_4
s4时匹配失败
如果仍使用上面的next数组,我们还需要继续比较
m
3
m_3
m3!=
s
4
s_4
s4,
m
2
m_2
m2!=
s
4
s_4
s4,
m
1
m_1
m1!=
s
4
s_4
s4
但我们知道
m
4
m_4
m4=
m
3
m_3
m3=
m
2
m_2
m2=
m
1
m_1
m1=‘a’,已经知道了
m
4
m_4
m4!=
s
4
s_4
s4的情况下,我们再进行那三次比较是非常傻的行为。
那么我们如何改进我们的next数组呢?
思路:不应该出现
m
j
m_j
mj=
m
n
e
x
t
[
j
]
m_{next[j]}
mnext[j]。当出现
m
j
m_j
mj=
m
n
e
x
t
[
j
]
m_{next[j]}
mnext[j]时,如果当前匹配
m
j
m_j
mj!=
s
i
s_i
si,那么下次比较
m
n
e
x
t
[
j
]
m_{next[j]}
mnext[j]!=
s
i
s_i
si是没有比较的必要的,这必然会继续失配。
所以我们出现
m
j
m_j
mj=
m
n
e
x
t
[
j
]
m_{next[j]}
mnext[j]时,则需要进行递归,令
n
e
x
t
[
j
]
=
n
e
x
t
[
n
e
x
t
[
j
]
]
next[j]=next[next[j]]
next[j]=next[next[j]],在继续比较,直到
m
j
m_j
mj!=
m
n
e
x
t
[
j
]
m_{next[j]}
mnext[j]为止
改进后的next数组称为nextval数组
j | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
字符串 | a | a | a | a | b |
nextval | 0 | 0 | 0 | 0 | 4 |
实现代码如下:
void get_nextval(HString s,int nextval[]){
int i=1;
int j=0;
nextval[1]=0;
while (i<s.length) {
if(j==0||s.ch[i]==s.ch[j]){
++i;
++j;
if(s.ch[i]!=s.ch[j]){
nextval[i]=j;
}
else{
nextval[i]=nextval[j];
}
}
else{
j=nextval[j];
}
}
}