数据结构(六)----串

目录

1.串的定义

2.串的基本操作

3.串的存储结构

(1)串的定义

•顺序存储

•链式存储

(2)求串长

(3)求子串

(4)比较串的大小

(5)定位操作

4.字符串的模式匹配

(1)朴素模式匹配算法

(2)KMP算法

•求模式串中的next数组(重点)

•练习:

(3)KMP算法的进一步优化

•求nextval数组的方法(重点)


1.串的定义

串,即字符串(String) 是由零个或多个字符组成的有限序列。一般记为 S=''。其中,S是串名,单引号括起来的字符序列是串的值(有的地方用双引号(如Java,C),有的地方用单引号(如Python));ai可以是字母、数字或其他字符;串中字符的个数n称为串的长度。n=0时的串称为空串(用表示)。

几个专业术语

**子串:**串中任意个连续的字符组成的子序列(空串也是字符串的子串)。

主串:包含子串的串。

字符在主串中的位置:字符在串中的序号(从1开始)。

子串在主串中的位置:子串的第一个字符在主串中的位置(从1开始)。

空串与空格串:

空串:单引号之间没有任何字符,如下面的M。

空格串:有多个空格字符组成的字符串,每个空格字符占1B,如下面 的N。

是一种特殊的线性表,数据元素之间呈线性关系。线性表中存放的数据对象可以是各种类型的,但是串的数据对象限定为字符集(如中文字符、英文字符、数字字符、标点字符等),串的基本操作,如增删改查等通常以**子串(多个数据对象)**为操作对象。

2.串的基本操作

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联接而成的新串

SubString(&Sub,S,pos,len):求子串。用Sub返回串S的第pos个字符起长度为len的子串。

Index(S,T):定位操作。若主串S中存在与串T值相同的子串,则返回它在主串S中第一次出现的位置;否则函数值为0。

StrCompare(S,T):比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<0。

比较规则

计算机只能存储二进制数,需要确定一个编码的规则,使得各个字符与二进制数能够一一对应起来,比较字符就是在比较二进制数的大小,例如'a'的Ascll码是97,对应的二进制就是

0110 0001,'A'的Ascll码是65,对应的二进制为0100 0001。

Ascll码表可以看:ASCII 表 | 菜鸟教程 (runoob.com)

Ascll字符集只是英文字符的集合。但是,除了英文字符外,还有很多其他字符(例如:中文,法语等),Ascll字符集中无法包含所有的字符,因为Ascll字符集中,每个字符占用的空间为1B(8bit的二进制数),只能表示2的8次方,即256个状态。

所以出现了其他字符集,如Unicode字符集,包含了中英文字符的集合。这些字符通过一个编码方案映射为二进制数(一个字符集可以有多个编码方案,例如UTF-8和UTF-16就是两种不同的Unicode编码方案)。

注:采用不同的编码方式,每个字符所占空间不同,考研中只需默认每个字符占1B即可。

1.从第一个字符开始往后依次对比,先出现更大字符的串就更大 。

2.长串的前缀与短串相同时,长串更大。

3.只有两个串完全相同时,才相等。

3.串的存储结构

(1)串的定义
•顺序存储
cs 复制代码
//定长顺序存储
#define MAXLEN 255    //预定义最大串长为255
typedef struct{
    char ch[MAXLEN];    //每个分量存储一个字符
    int length;    //串的实际长度
}SString;

//堆分配存储(用malloc函数申请的内存空间属于内存的堆区)
typedef struct{
    char *ch;    //按串长分配存储区,ch指向串的基地址
    int length;    //串的长度
}HString;

HString S;
S.ch=(char *)malloc(MAXLEN * sizeof(char));
S.length=0;

//由malloc申请的内存空间需要手动free回收,而用静态数组申请的空间是由系统自动回收的。

顺序存储的实现:

在方案一中,专门声明一个int型变量length表示字符串的长度。

在方案二中,若用ch[0]来存储字符串的长度,那么字符的位序和数组下标相同。但是一个字符只占8bit,其所能表示的数字范围为0~255。

在方案三中没有Length变量,以字符'\0'表示结尾(对应ASCIl码的 0),采用这种方式,若想知道字符串的长度,需要从头到尾扫描,扫描到'\0'才能得到字符串的长度。若经常访问字符串的长度,那么这种方案就是不太可取的。

方案四中采用了方案1与方案2的结合,即舍弃ch[0]不用,并且专门声明一个int型的变量length表示字符串的长度。下面顺序存储的代码编写都是以方案四为存储结构的。

•链式存储
cs 复制代码
typedef struct StringNode{
    char ch;    //每个结点存1个字符
    struct StringNode * next;
}StringNode,* String;

一个char的大小为1B,但是在32位计算机中,指针大小为4B,这就表示我们在存储一个字符时,只用了1B存储实际需要存储的信息,用了4B存储了辅助信息。这样存储密度就太低了。

如何解决?

cs 复制代码
typedef struct StringNode{
    char ch[4];    //每个结点存多个字符
    struct StringNode * next;
}StringNode,* String;

:若最后一个结点的字符存不满,那么可以用一些特殊的字符填充进去,如'#'或'\0'。

(2)求串长
cs 复制代码
int StringLength(SString S){
    return S.length;
}
(3)求子串

SubString(&Sub,S,pos,len):求子串。用Sub返回串S的第pos个字符起长度为len的子串。

cs 复制代码
bool SubString(String &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;

}
(4)比较串的大小

StrCompare(S,T):比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<0。

cs 复制代码
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;
}
(5)定位操作

Index(S,T) :定位操作。若主串S中存在与串T值相同的子串,则返回它在主串s中第一次出现的

位置;否则函数值为0。

cs 复制代码
int StringLength(SString S){
    return S.length;
}

bool SubString(String &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;
}

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;
}

int Index(SString S,SString T){
    int i=1,n=StrLength(S),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相等的子串 
}

4.字符串的模式匹配

字符串模式匹配 就是在主串 (被查找串)中找到与模式串(想要查找的字符串)相同的子串,并返回其所在位置。

子串:主串的一部分,一定存在

模式串:不一定在主串中找到

(1)朴素模式匹配算法

若主串长度为n,模式串长度为 m,将主串中所有长度为m的子串依次与模式串对比,直到找到一个完全匹配的子串或所有的子串都不匹配为止。

长度为n的主串中,最多有n-m+1个子串

上面说的定位操作,即Index(S,T),就是一种朴素模式匹配算法:

cs 复制代码
int Index(SString S,SString T){
    int i=1,n=StrLength(S),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相等的子串 
}

也可以直接通过数组下标实现朴素模式匹配算法

设置两个扫描指针i,j,将两个指针指向的字符依次对比,若指针指向的字符相同,就将指针一起后移。

若匹配失败,如上图所示,则主串指针 i 指向下一个子串的第一个位置,模式串指针 j 回到模式串的第一个位置。

只需要改变i,j指针的指向,就可以完成以上操作。

cs 复制代码
i=i-j+2;    //i-(j-1)相当于i回到开始的位置,再加1就是下一个字符串
j=1;    //j-(j-1)就相当于回到了1

在下一个串中匹配失败,i-j+2 指向下一个串,j继续重新指回1

若j>T.length(j指向的位置超出了模式串的长度),则当前子串匹配成功,返回当前子串第一个字符的位置 ----i-T.length

cs 复制代码
int Index(SString S,SString T){
    int i=1,j=1;
    while(i<=S.length && j<=T.length){
        if(S.ch[i]==T.ch[i]){
            ++i;++j;    //继续比较后继字符
        }
        else{
            i=i-j+2;
            j=1;    //指针回退重新开始匹配
        }
    }
    if(j>T.length)
        return i-T.length;
    else
        return 0;
}

设主串长度为n,模式串长度为m,那么最坏时间复杂度=O(nm)

若主串和模式串如下图所示, 最坏的情况,每个子串都要对比 m 个字符,共 n-m+1 个子串,复杂度=O((n-m+1)m) = O(nm-m^2+m) = O(nm),通常情况下n>>m

(2)KMP算法

在朴素模式匹配算法中,若主串与模式串不匹配,那么i就要回退到2,j回退到1。换个角度也可以理解为,将模式串往右移动了一步。

匹配失败,模式串继续右移。朴素模式匹配算法的缺点就是,当某些子串与模式串能部分匹配时,主串的扫描指针 i 经常回溯,导致时间开销增加。

所以KMP算法针对这一点进行了改进,即主串指针不回溯,只有模式串指针回溯。

若扫描到不匹配的字符,那么就将这个字符与模式串的第一个字符进行比较,即 i 指向的位置不变,j指回1。也可以理解为模式串往后移动了5步,相比于朴素匹配算法中模式串一步一步右移,这个算法的效率提高了很多。

1.若这个字符也为g,那么就让i++,j++,也就是继续匹配后一个字符。

2.若这个字符不为j,那么就 i++,j依然保持1

3.若扫描到 j =5,才发现匹配失败,也就是 i指向的字符不是"l"。

也就是以"g"开头的字符串与模式串是不匹配的,同理,因为之前扫描过的"o""o",都匹配上了(不匹配的字符之前,主串的字符一定是和模式串一致的),那么以"o"开头的字符串肯定也匹配不上。

但是以g开头的子串是可能与模式串进行匹配的,但是在代码中只是判断了i指向的字符与"l"是不匹配的,所以我们可以间接判断,i指向的字符是不是"o",因为"g"后面是"o"。

所以若j=5 时发生不匹配,则应让 j 回到 2。

4.若 j=4 时发生不匹配,则应让 j 回到1。但是其实这里 i 指向字符一定不是 "g" 了

那么让j回到1,继续重新匹配,就会在开头进行一次没有必要的对比

但是相比于朴素匹配算法而言,他已经少对比了2次了。改进方法在KMP算法的改进中会讲。

**5.**若 j=3 时发生不匹配,则应让j回到1

相比于朴素算法而言,少进行了一次对比

6.若j=2时发生不匹配,则应让 j 回到1

总结:

当j=k且发现字符不匹配时,令j=next[k]。例如,当j=5发现不匹配时,那么就让j=next[5],即2。

**注:**当j=1,即第1个字符匹配失败时,先令j=0,再进行i++,j++,就可以使i往后扫描字符,而j保持1。看代码应该能更好理解。

所以KMP算法的关键根据模式串的内容设计出合适的数组next。next数组只和模式串有关,和主串没有关系,也就是说只要模式串不变,不管主串是什么,next数组都是一样的。

代码如下:

cs 复制代码
int Index_KMP(SString S,SString T,int next[]){
    int i=1,j=1;
    while(i<=S.length && j<=T.length){
        if(j==0 || S.ch[i]==T.ch[j])    
//刚开始j=1,若j=1时,两个字符不匹配,那么跳转到else
//else中,j=next[j],j=next[1]=0,若j=0,那么++i,让i指针向后扫描,并且让j指针保持1
        {
            ++i;
            ++j;    //继续比较后继字符

        }
        else
            j=next[j];    //模式串向右移动
    }
    if(j>T.length)
        return i-T.length;    //匹配成功
    else
        return 0;

}

相比于朴素模式匹配,KMP算法修改了两个部分,当主串和模式串不匹配时,不需要主串指针i回溯,只需要修改模式串中指针 j 即可。

•求模式串中的next数组(重点)

当模式串的第 j 个字符匹配失败时,令模式串跳到 next[j] 再继续匹配。

如下图所示,若j=6时不匹配,应该让next[6]=3,即让 j 指向3,或者说模式串向右移动3位。对比主串中i指向的字符和c是否能匹配。

同理,当j=7时,匹配失败,将模式串往右移动2位,让j=5,看i指向的字符能否与"a"匹配(next[7]=5)。

若刚开始i指向的字符"a"与"c"不匹配,并且将模式串直接向右移动了4位,即next[7]=3,如下图所示:

那么继续往后检查,当i指向字符"c"时,发现a,c并不匹配。

但是按照刚开始的方案,即符号串往右移动2位,那么字符应该是匹配的。

所以只要模式串中有更长的部分能匹配,那么就只考虑更长的情况:

特殊的情况上面已经说过了,即j=1时匹配失败,那么只能紧接着匹配下一个子串,先令j=0,然后让i++,j++,i向后继续扫描,而j=1。所以对任何一个字符串而言,next[1]都为0。

我们将以下红框部分称为串的前缀包含第一个字符,且不包含最后一个字符的子串。

串的后缀包含最后一个字符,且不包含第一个字符的子串。

得出结论: 当第 j 个字符匹配失败,由前1~j-1 个字符组成的串记为S,则:next[j]= S的最长相等前后缀长度+1。并且next[1]是固定为0的。

例如上图,最长相等前后缀=4,那么next[j]=4+1=5。

练习

模式串:'ababaa'

1.next[1]=0

2. next[2]表示当匹配到第2个字符时匹配失败,在第2个字符之前组成的串'a'。'a'不存在前缀和后缀,所以其最长前后缀长度=0,所以也可以总结next[2]一定是1

**3.**next[3]表示当匹配到第3个字符时匹配失败,第3个字符之前组成的串是'ab',其最长相等前后缀长度也为0,所以next[3]=1

**4.**同理第4个字符前面的字符串"aba",其最长相等前后缀长度为1,所以next[4]=1+1=2

**5.**第5个字符前面的字符串"abab",所以next[5]=2+1=3

6.第6个字符前面的字符串为"ababa",所以next[6]=3+1=4

所以模式串"ababaa"的next数组如下:

代码实现如下:

cs 复制代码
//求模式串T的next数组
void get_next(SString T,int next[]){    T为模式串
    int i=1,j=0;
    next[1]=0;
    while(i< T.length){   
        if(j==0 || T.ch[i]=T.ch[j]){    
            ++i; ++j; 
    //若pi=pj,则next[j+1]=next[j]+1  
            next[i]=j;
        }
        else
            //否则令j=next[j],循环继续
            j=next[j];
    }
}
//while循环中i<T.length,也就是i的范围是1~m,所以可以认为这个函数的时间复杂度为O(m)

//KMP算法
int Index_KMP(SString S,SString T){
    int i=1,j=1;
    int next[T.length+1];
    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];    //模式串向右移动
    }
    if(j>T.length)
        return i-T.length;    //匹配成功
    else
        return 0;
}
//在KMP算法中,i由1~n,再加上调用的get_next函数,所以KMP算法的时间复杂度为O(n+m)

朴素模式匹配算法中,当某些子串与模式串能部分匹配时,主串的扫描指针 i 经常回溯,导致时间开销增加。最坏时间复杂度O(nm)。 而KMP算法中,根据模式串T求出next数组,当子串和模式串不匹配时,利用next数组进行匹配,主串指针 i 不回溯,模式串指针 j=next[j] 。算法最坏时间复杂度:O(n+m), 其中,求 next 数组时间复杂度O(m) ,模式匹配过程最坏时间复杂度 O(n)

如果不会经常出现子串与模式串部分匹配问题,那么KMP算法就显先不出很大的优势。

(3)KMP算法的进一步优化

KMP算法是通过next数组进行匹配的。下面例子中,j指向的字符是a,那么说明主串中的第3个字符一定不是a。

当j=3的字符与主串不匹配时,让j指针指向1,那么j指向的字符a与主串中第3个字符肯定是不匹配的。

所以当j=1的字符与匹配失败的字符相等时,直接让next[3]=0,跳过中间next[3]--->next[1]--->0这一步。

同理:

当j=5时,模式串与主串的值不匹配,即主串的第5个字符不是"b",那么让next[5]=2。

但是由于第2个字符与匹配失败的这一字符相等,所以让 j 指向第2个位置后,接下来的匹配一定是不成功的。这次匹配失败后,next[2]=1,让j=1。

所以可以直接跳过注定匹配失败的那一步,直接让next[5]=1,这样就能少做一次对比。

所以总结下来,如果next数组所指的字符与原本失配的字符相等的话,就可以对next数组优化,如果不等的话,则next数组的值是不变的。

在KMP算法的优化中,我们只优化了next数组,所以在进行KMP算法中,将nextval[]数组替换next[]数组即可。

•求nextval数组的方法(重点)

若模式串ababaa的next数组如下图所示:

nextval[1]=0; //nextval[1]的值直接写0

如果next[j]所指的字符,与j所指的字符不相同,就让nextval[j]=next[j],例如下图,当前j所指的字符是b,而其next[2]所指的字符是a,两者不同,那么nextval[2]=1 不变。

如果j指向的字符与next[j]所指的字符相同,例如下图,j=3时,j所指的字符是a,其next[3]所指的字符也是a,那么就让nextval的值优化为next[1]的值0。

nextval[j]=nextval[next[j]]; //nextval[3]=nextval[next[3]]

以此类推:

j=5时指向的是a,next[5]也是a,那么其nextval[5]等于0,不再等于1了,根据已经优化的数组来。

最后得到:

代码如下:

cs 复制代码
nextval[i]=0;
for (int j=2;i<=T.length;j++){
    if(T.ch[next[j]]==T.ch[j])
        nextval[j]=nextval[next[j]];
    else
        nextval[j]=next[j];
}
相关推荐
wheeldown3 小时前
【数据结构】选择排序
数据结构·算法·排序算法
yanlou2337 小时前
KMP算法,next数组详解(c++)
开发语言·c++·kmp算法
躺不平的理查德7 小时前
数据结构-链表【chapter1】【c语言版】
c语言·开发语言·数据结构·链表·visual studio
阿洵Rain7 小时前
【C++】哈希
数据结构·c++·算法·list·哈希算法
Leo.yuan8 小时前
39页PDF | 华为数据架构建设交流材料(限免下载)
数据结构·华为
半夜不咋不困8 小时前
单链表OJ题(3):合并两个有序链表、链表分割、链表的回文结构
数据结构·链表
忘梓.9 小时前
排序的秘密(1)——排序简介以及插入排序
数据结构·c++·算法·排序算法
y_m_h11 小时前
leetcode912.排序数组的题解
数据结构·算法
1 9 J11 小时前
数据结构 C/C++(实验三:队列)
c语言·数据结构·c++·算法
921正在学习编程11 小时前
数据结构之二叉树前序,中序,后序习题分析(递归图)
c语言·数据结构·算法·二叉树