0
点赞
收藏
分享

微信扫一扫

数据结构(四):串的定义及基本操作 | 朴素模式匹配算法 | KMP算法 | next数组的手算方法

覃榜言 2022-04-18 阅读 112

文章目录

第四章 串

一、串的定义

事实上,串是一种特殊的线性表,数据元素之间呈现线性关系。

它们的区别是,

  • 普通的线性表,每个数据元素可以是各种各样的数据类型,没有限制。

    而串,或者说字符串,它的数据元素,一般来说就是字符。(如中文字符、英文字符、数字字符、标点字符等)。

  • 普通的线性表,我们在进行增删改查等基本操作时,一般是对线性表中的某一个数据元素进行操作。

    而我们对串的基本操作,如增删改查等,通常以子串为操作对象。也就是一次是对一堆字符进行操作的。

二、串的基本操作

  • 但是,又遇到了一种问题,如下。

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

  • 以上三点,是我们由英文字母的前后顺序来理解的。实际上,在计算机中,对于英文字母,或者一些字符,它是由一个二进制数来存储的。任何数据存到计算机中一定是二进制数。一个字符与一个二进制数有一个对应规则,这就是“编码”。(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)。

举报

相关推荐

0 条评论