数据结构(2)

文章目录

四、特殊矩阵的压缩存储

(一)数组

数组是一种逻辑结构。逻辑上是把一些类型相同的数据排列成一排,然后给它附上下标,方便指出数组中的任何一个元素。

前面所用的数组严格来说是用c语言来实现数组的存储结构。
用c语言实现数组的存储结构:
在这里插入图片描述
二维数组是元素全部为一维数组的一维数组
在这里插入图片描述
二维数组的行优先存储和列优先存储问题,二维数组在内存中也是一维的,只不过我们在逻辑上将其视为二维的。

行优先存储就是先存完二维数组的一行再存下一行。
在这里插入图片描述
列优先存储就是先存完二维数组的一列再存下一列。
在这里插入图片描述
行列优先存储的考题可能会涉及到行优先存储下或者列优先存储下某一个元素前面有多少个元素。

关于行优先存储的考题:

在这里插入图片描述

(二)矩阵

考研对矩阵的一些算法操作考的比较少,所以这里只讲矩阵的存储。

设计一个矩阵的存储结构最简单就是使用二维数组即可。
在这里插入图片描述
考研涉及到的不一般的矩阵有特殊矩阵和稀疏矩阵。

教材给出的特殊矩阵的定义:相同的元素或者零元素在矩阵中的分布存在一定规律的矩阵称之为特殊矩阵、反之称之为稀疏矩阵。

国外资料的特殊矩阵的定义:0元素较多的矩阵称为稀疏矩阵,而特殊矩阵有三种,分别为对称矩阵、三角矩阵、对角矩阵。

(1)对称矩阵

考研数据结构的对称矩阵,只涉及主对角线对称矩阵,并且是方阵。
在这里插入图片描述
例题:
在这里插入图片描述
将主对角线及其以下的元素列出来,因为是对称矩阵,采用高效的存储方式,所以相同的元素我们只存储一份。也可以列出主对角线及其以上的元素而忽略对角线以下的元素。
在这里插入图片描述
答这种题一般采取存储关键位置上的元素,其余的元素可以根据这些关键位置上的元素推算出它们在一维数组中的位置。
在这里插入图片描述

(2)三角矩阵

主对角线上方的元素全部为c的元素称之为下三角矩阵,主对角线撒下方的元素全部为c的元素称为上三角矩阵。
在这里插入图片描述
以下三角矩阵为例,同样是存储关键位置的元素,然后推算出其他元素的位置。一般把c存储到一维数组的最后一位处,并且只存一份。
在这里插入图片描述
对于上三角矩阵也可以进行类似的存储,也可以采用列优先。要体现出关键元素的存储。

(3)对角矩阵

矩阵中除了c之外的元素分布在对角线附近的带状区域内,并且形成的图形是关于对角线轴对称的。这个图形沿着对角线的方向有几行就称为几对角矩阵。

最常见的题目:
在这里插入图片描述
以三对角矩阵为例,矩阵的第一行的最后一行只有两个非c元素,而剩下的都有3个非c元素,因此我们需要分成两种情况讨论。这里i行为矩阵的第i行。
在这里插入图片描述

(4)稀疏矩阵

稀疏矩阵的定义有分歧,矩阵中0元素较多就可以称为稀疏矩阵,在原版数据结构的定义中0元素还可以是其他的元素c。具体看报考学校的要求。
稀疏矩阵有多种存储方法。

①三元组表示法(顺序存储结构)

这种方法的基本单元是一个三元组(三个数据单元组成的一个整体),这三个数据单元分别是矩阵中元素的值、行标、列标。

举个例子:

  1. 先将一个矩阵存储到二维数组中去。显示行标和列标。

在这里插入图片描述
2. 创建一个5行3列的二维数组,每一行代表一个三元组,每一列代表三元组中的一个分量。一般第一行不存储矩阵中的任何一个元素,而用来存储矩阵的相关信息。第一行的三个分量分别是矩阵中非0元素的个数、所存矩阵的行数、列数。
3. 从第二行开始存储矩阵中的非0元素,一般按照行优先存储。
在这里插入图片描述

用c语言实现,maxSize为所能存储的矩阵元素的个数, + 1 是因为有一行用来存储矩阵的相关信息,这里将三元组定义为float类型,因为矩阵中的元素不一定为整型,在取某一个元素的行号或列号的时候就需要强转成int类型:
在这里插入图片描述
也可以使用结构体来定义一个结构体数组来实现这个方法:
在这里插入图片描述

②邻接表表示法(链式存储结构)
  1. 将矩阵存储到二维数组中
  2. 定义一个一维数组,一维数组的下标对应二维数组的行标,一维数组中的元素是一些指针,每个指针都指向一条链表,每一条链表中的结点都保存了矩阵中的非零元素的信息。这些信息分别是非零元素的值、非零元素的列标。

在这里插入图片描述

③十字链表表示法(链式存储结构)
  1. 把矩阵存储到一个二维数组中
  2. 创建一个十字链表的头结点,这个头结点包括五个域,分别存储矩阵元素的行数、列数、非零元素的个数、以及两个指针。这两个指针分别指向矩阵元素的行数组和列数组,这两个数组的下标分别跟矩阵元素的行标和列标对应。数组中存储的都是指针,这些指针指向表中的非零元素。
  3. 给表中的每一个非零元素都申请一个结点,元素结点类型跟头结点类型是一样的,只不过存储的内容不一样,元素结点存储的分别是非零元素所在的行号、号、值、以及两个指针。这两个指针分别指向所在行或所在列的链表的下一个结点。
  4. 将矩阵中的非零元素按照矩阵中的位置放到十字链表中的对应位置(这里只是视觉上存放到对应位置,实际上不知道在内存的哪个位置中)
    在这里插入图片描述
  5. 列数组和行数组中的指针分别连接上这些结点。
    在这里插入图片描述

在十字链表中如果要取矩阵中某一个元素的话,可以根据这个元素的行标去寻找行数组中的某一个指针,根据这个指针对链表中的结点一个一个进行查找,找到列号对应的结点之后再取出结点中所存储的值。如果找不到的话,则说明这个元素是0元素。也可以根据这个元素的列标去寻找列数组中的某一个指针,方法是一样的。

领接表比较简单,出题比十字链表多,而十字链表比较复杂,最多出认识层面上的问题,也就是如何实现十字链表的存储。

(三)广义表

(1)广义表的逻辑结构

线性表的表元素有一个特点,就是每个表元素都是一个不可再分的原子,而广义表就是在线性表的基础上,把每个表元素不可再分的约束取消掉了。也就是说,在广义表中,每一个表元素既可以是原子,也可以是广义表本身。可以理解为是一种分层的结构,也可以理解成一种递归的结构,每一个子结构和其整体有相同的结构。 在这里插入图片描述
举例说明:
在这里插入图片描述
考试时如果问取某个表的表头,一般会写一个函数的形式来提问。

在这里插入图片描述
对于一个广义表,取表头可能会得到一个原子或一个广义表,而取表尾的话必然会得到一个广义表。
在这里插入图片描述
广义表表尾没有元素的时候,取广义表表尾会得到一个空广义表。
在这里插入图片描述

(2)广义表的存储结构

①头尾链表存储结构

这个链表包含了两种结构体,分别是含有三个域的和含有两个域的结构体。

  1. 含有两个域的结构体称为原子结点,第一个域指明了这个结点是什么结点,1代表广义表结点,0代表原子结点。第二个域存储了广义表中的元素信息。
  2. 含有三个域的结构体称为广义表结点,第一个域指明了这个结点是什么结点,第二个域和第三个域存储两个指针,分别指向原子结点或广义表结点。

在这里插入图片描述
3. 对于同一层的每一个广义表元素,都为它建立一个广义表结点,同一层的广义表结点通过第三个指针域串成一行。
4. 如果当前的广义表元素对应的是原子,则建立一个原子结点存储信息,并用广义表结点中间的指针域指向这个原子结点。
5. 如果当前的广义表元素对应的是广义表,称为子广义表,则当前广义表结点中间的指针域指向这个子广义表的第一个广义表结点。这个子广义表的规则跟外边的广义表是一样的。

在这里插入图片描述
空表的话建立一个空指针即可。
在这里插入图片描述
在这里插入图片描述

②扩展线性表存储结构

空表的存储方法:
在这里插入图片描述
可以发现,这种存储结构每个结点第一个域跟上一种存储结构一样也是指明这个结点的类型,1为广义表结点,0为原子结点。不同的是这种存储结构中只有一种结构体,既可以作为原子结点也可以作为广义表结点。
在这里插入图片描述
在广义表的每一层中,先建立一个广义表结点,然后为本层的每一个元素都建立一个结点,把这些结点通过第三个指针域串成一行,然后检查为每一个元素建立的结点,如果这个结点对应的元素是原子,则在第二个域保存原子信息;如果这个结点对应的元素是广义表,则从这个结点的中间指针域引出一个指针,指向这个广义表的第一个结点。
在这里插入图片描述
在这里插入图片描述

五、字符串模式匹配

(一)字符串基础

  1. 串是特殊的线性表,把线性表中的每一个元素限制为字符
  2. 串的存储结构也有顺序存储结构和链式存储结构,但是考研一般不考链式存储结构。
  3. 考研中从常用的两种顺序存储结构

在这里插入图片描述

在这里插入图片描述
存储一个长度为L的串,并为这个串分配一个L+1的存储空间,用结构体的指针指向这片存储空间的首地址。
在这里插入图片描述
分配好空间之后我们就可以用来存取东西了,其操作和数组是一样的,用完之后调用free函数释放存储空间。free函数的参数是分配的空间的首地址,malloc函数返回的也是分配的空间的首地址:
在这里插入图片描述
变长存储结构的优点:
如果遇到一个很长的串,当前分配的存储空间不够用,采取变长存储结构只需要free当前分配的存储空间,再申请一块更大的存储空间即可。如果采用定长存储结构,需要重新设计结构体。

变长存储结构会比定长存储结构考察的多。因此一般没要求的话都会采用变长存储结构。

(二)串的基本操作

  1. 赋值操作
//str:用串的结构体(变长存储结构)定义的一个变量,定义为引用型,引用我们要使字符串发生改变
//ch:指向我们申请好的一片连续的存储空间的首地址,等价于一个数组
//用存在于数组中的数组赋值给我们的目标串
int strAssign(Str &str,char *ch)
{
	//如果将要被赋值的串已经指向一片存储空间,就释放这片存储空间
	if(str.ch) free(str.ch);
	int len = 0;
	char *c = ch;
	//确定我们赋值目标串的长度,当c来到数组中的最后一个字符也就是'\0'循环就结束
	while(*c)
	{
		++len;
		++c;
	}
	//拿来赋值的字符串是个空串
	if(len == 0)
	{
		str.ch = NULL;
		str.length = 0;
		return 1;
	}
	else
	{
		//要为结束标记也分配一个存储空间
		str.ch = (char*)malloc(sizeof(char) * (len + 1));
		//如果为NULL,表明申请存储空间失败,返回0,赋值失败
		if(str.ch == NULL)
			return 0;
		else 
		{
			c = ch;
			//循环len + 1次,把结束标记也复制过来
			for(int i = 0; i <= len;++i;++c)
				str.ch[i] = *c;
			str.length = len;
			return 1;
		}
	}
}
  1. 取串长度
int strLength(Str str)
{
	return str.length;
}
  1. 串比较
    在这里插入图片描述
int strCompare(Str s1,Str s2)
{
	for(int i = 0; i < s1.length && i <s2.length; i++)
		if(s1.ch[i] != s2.ch[2])
			return s1.ch[i] - s2.ch[i];
	return s1.length - s2.length;
}
  1. 求子串
//pos 为起点
//len 为所要取的长度
int getSubString(Str &substr,Str str,int pos,int len)
{
	//不合法的情况
	if(pos < 0 || pos >= str.length 
	   || len < 0 || len > str.length - pos)
	   return 0;
	if(substr.ch)
	{
		free(substr.ch);
		substr.ch = NULL;
	}
	if(len == 0)
	{
		substr.ch = NULL;
		sub.length = 0;
		return 1;
	}
	else
	{
		substr.ch = (char*)malloc(sizeof(char) * (len +1));
		int i = pos;
		int j = 0;
		while(i < pos + len)
		{
			substr.ch[j] = str.ch[i];
			i++;
			j++;
		}
		substr.ch[j] = '\0';
		substr.length = len;
		return 1;
	}
}
  1. 串清空
int clearString(Str &str)
{
	if(str.ch)
	{
		free(str.ch);
		str.ch = NULL;
	}
	str.length = 0;
	return 1;
}
  1. 串连接
int concat(Str &str,Str str1,Str str2)
{
	if(str.ch)
	{
		free(str.ch);
		str.ch = NULL;
	}
	str.ch = (char*)malloc(sizeof(char) * (str1.length + str2.length + 1));
	//申请存储空间失败
	if(!str.ch) return 0;
	int i = 0;
	while(i <str1.length)
	{
		str.ch[i] = str1.ch[i];
		++i;
	}
	int j = 0;
	//把结束标志也存储过来
	while(i <=str2.length)
	{
		str.ch[i+j] = str2.ch[i];
		++j;
	}
	str.length = str1.length + str2.length;
	return 1;
}

(三)KMP算法易懂版

实现:快速地从主串中找到想要的子串。

举例说明:
从某个主串中快速查找出我们想要的子串,这个子串叫做模式串。

那么如何快速查找呢?

  1. 先将模式串和主串左端对齐,比较指针逐一扫描对比主串跟模式串的每一个字符。扫描到有字符不相等的情况则停止。

在这里插入图片描述
2. 这个时候比较指针左边的部分模式串和模式串是一样的,且在模式串两端能够找到匹配的两个子串,我们称之为模式串的公共前后缀。找到最长的公共前后缀。
3. 往前移动模式串,使得公共前后缀的前缀直接移动到原先后缀所在的位置。(关键)

在这里插入图片描述
4. 比较指针继续往后扫描并比较主串和模式串的字符相不相等。
5. 当再次发现主串和模式串字符不匹配的时候,寻找比较指针左边模式串的公共前后缀,然后执行第三步。

在这里插入图片描述
6. 执行完第三步之后,若模式串超出主串的范围,则匹配失败,主串中不含有我们所要找的子串。若模式串仍为超出主串的范围,则继续执行第四步,直到找到我们所要的子串或者模式串超出主串的范围。

在这里插入图片描述

对于模式串,只要找出模式串的某一段公共前后缀,就能前后移动,就能与任意的主串进行KMP算法匹配。因此我们单独研究模式串的相关信息。

以这个模式串为例,对模式串进行研究:

  1. 将模式串放在一个数组中,从数组下标一开始存储,也可以从零开始存,不过大多数是从一开始存。

在这里插入图片描述

  1. 由于模式串可能与任意一个子串进行KMP算法,因此在任意一个字符上都有可能存在不匹配的情况。
  2. 当模式串的一号位不匹配时,模式串的一号位要与主串的下一位进行比较。(也就是模式串和指针都往前移动一位)

在这里插入图片描述
在这里插入图片描述

  1. 当模式串的二号位不匹配时,主串中发生字符不匹配的位置我们称为当前位,我们要使模式串的一号位与主串中的当前位进行比较。(模式串前移一位)(当指针指向模式串除了一号位的位置,模式串往前移动之后指针左边的模式串长度就是公共前后缀的长度,这里也就是0)

在这里插入图片描述
在这里插入图片描述

  1. 当模式串的三号位不匹配时,要使指针左边的模式串长度为公共前后缀长度,也就需要使模式串的一号位与主串的当前位进行比较。

在这里插入图片描述

  1. 按照规律,当模式串的四号位不匹配时,公共前后缀长度为1,模式串往前移动,使得原先前缀的位置到达后缀的位置,也就是指针左边模式串长度为1的位置。使得模式串的二号位与子串的当前位进行比较。

在这里插入图片描述
在这里插入图片描述

  1. 当模式串的五号位不匹配时,使模式串三号位与主串当前位比较。

在这里插入图片描述
在这里插入图片描述

  1. 能够发现规律,就是每次模式串与主串不匹配时,重新比较需要使模式串的第i位与主串的当前位进行比较,i为当前指针左边模式串的最大公共前后缀长度+1

除了第一句话也就是模式串第一位不匹配的情况不一样,其他几位都是有规律的。
在这里插入图片描述
我们将第一句话标记为0,其他标为i。翻译成代码就是当标记为0时,就按照第一句话进行操作,当标记不为0时,按照另一句话进行操作,只不过操作的参数不一样而已。
在这里插入图片描述
我们将每一句话第一个数字都拿出来作为所要执行的操作的代号,然后结合数组下标,将数字都存放到一个数组中,这样根据这个数组所提供的信息,我们这个模式串任何一个字符发生不匹配,就知道下一步该如何做了。这个数组称为next数组
在这里插入图片描述
在这里插入图片描述

(四)KMP算法普通版

  1. 给定一个主串和模式串,假如要使我们在主串中找到模式串,我们一般的做法都是使用穷举法,将模式串和主串从第一个字符开始,逐字符对齐,然后进行比较。

在这里插入图片描述
2. 每次比较我们都需要留下一个标记,指向这一次主串开始与模式串字符进行匹配的初始位置。然后当出现不匹配的情况时,从这个标记的下一个位置开始让主串与模式串重新进行匹配,并且让这个标记指向这一次进行匹配的初始位置。

在这里插入图片描述
在这里插入图片描述

  1. 重复匹配直至成功或者失败

在这里插入图片描述

我们可以注意到,在上述的穷举法匹配过程中,会反复出现一种状态,就是主串和模式串对应的某个字符不匹配,但是在这个字符之前的所有字符都已经匹配了。这里挑其中一个比较明显的情况来讲。将这一步不匹配的情况记录下来,比较指针i的位置到主串的第五位时发生不匹配。

在这里插入图片描述
然后继续,将i和j调整到下一轮匹配的初始位置。继续进行匹配。
在这里插入图片描述
当比较指针i再一次来到主串的第五位时(从上一次来到第五位后的第一次),我们先暂时不做比较。把这个状态也记录下来。
在这里插入图片描述
对比记录下来的两个状态,他们的共同点是i都停留在同一个位置,i所指位置之前的部分上下匹配。将这两种状态放入一个表中,两种状态称为SK、SK+1。
在这里插入图片描述
回想一下,穷举法过程中,每当i所指的字符与模式串中的字符不匹配的时候,之后所进行的一系列操作都是为了解决i处的不匹配,使得i能够往后移,进而推进整个过程。状态SK+1比状态SK更好,因为状态SK+1有可能能够解决i处不匹配的问题,而SK不行。(这样的状态说广泛一点就是,从发生i所指的字符与模式串中的字符不匹配的状态,到i重新回到这个位置的状态)而状态SK到状态SK+1之间的所有状态都不如SK+1好,因为这之间的状态要么是不匹配,要么是即将来到状态SK+1。
综上,如果我们能够省略状态SK到状态SK+1之间的状态,使得状态SK直接跳到状态SK+1,这样算法的效率就能提高了。 现在我们可以将这个过程看成重复的由SK推进至SK+1的过程。表现在匹配过程中也就是模式串往前移动到某个位置,并且这个移动操作只看模式串即可,因为要完成这个移动操作所需要的位置信息,全部落在了和模式串相匹配的那部分字符串中。

去掉主串(去掉主串的原因是模式串相对于主串比较短、并且也能得到我们所需要的信息),我们也能看出来模式串需要移动到哪一个位置。给模式串发生不匹配位置之前的子串起名为F串,并且从F串左部和右部找一段相等的子串,分别称为FL和FR,只要使得FL前移到与FR重合的位置,就是满足要求的位置。
在这里插入图片描述
这样我们就得到一个通用的规律:
在这里插入图片描述
需要注意的是,假如F串的左部和右边有不止一对的FL和FR,我们应该取较长的 那一对。因为较长的那一对是在穷举过程模式串右移过程中先出现的,是可能能够完成匹配的状态,而如果选用较短的那一对,可能会跳过一些可能匹配的状态。如图,模式串往右移的过程中,较长那对是先重合的,而较短那对是后重合的。(注:上面那条是主串的前部分、下面那条是模式串的前部分)
在这里插入图片描述
在这里插入图片描述
回到这一个表格,虽然我们前面所讲的都是将模式串进行移动,但是实际上模式串是存储在一个数组内的,在内存中根本就不会移动。我们所做的移动操作实际上是使模式串与主串从第三个位置进行比较。也就是i指针不需要回朔,移动j指针指向模式串的第三个位置,比较i指针指向的字符与j指针指向的字符。

在这里插入图片描述
那么,如何不经过比较,直接前移一步就使得模式串中的FL和FR重合?显然是不可能的。我们可以将模式串中每个元素与主串不匹配的所有情况下的模式串前移的增量记录下来(j应该重新调整的位置记录下来)。记录完之后,当以后模式串中任何一个字符与主串发生不匹配的时候,我们都可以通过查表来指导模式串应该前移到哪个位置(j应该重新调整到哪个位置)。这个表我们称为next数组。

下面从一个例子给出next数组的求法:
在这里插入图片描述
一般对于这种题目,当拿到模式串之后,需要画出一个表:

在这里插入图片描述
next数组完善示例:
在这里插入图片描述
next数组的功能:当模式串中第j个位置与模式串中第i个位置发生不匹配时,应从模式串中第next[j]个位置与主串第i个位置重新比较。特殊的,当j = 1时,next[j] = 0,此时j不需要动,i需要加一,即移动i指向主串的下一个字符,继续进行比较。

小规模问题(选择、简答):
在这里插入图片描述
求得的next数组:
在这里插入图片描述
在这里插入图片描述

(五)KMP算法代码

先写出利用穷举法解决从主串查找模式串的代码

//简单模式匹配算法、朴素模式匹配算法
//返回值为int ,如果在主串中找到了子串的话就返回第一个字符所在的位置
//找不到就返回一个不会用到的值作为标记
int naive(Str str, Str substr)
{
	//辅助变量,i指向主串,j指向模式串
	//k为标记,用于存储每轮比较主串比较的元素的初始位置
	//初值为1代表模式串跟主串都是从数组下标1开始存储
	int i = 1,j =1,k = i;
	//开始循环
	while(i <= str.length && j <= substr.length)
	{
		if(str.ch[i] == substr.ch[i])
		{
			++i;++j;
		}
		else
		{
			i = ++k;
			j = 1;
		}
	}
	//判断是否成功找到,当j超过模式串长度即为找到,此时k指在这个子串首字母所在位置
	if(j > substr.length)
		return k;
	else 
		return 0;
}

将上面这段代码修改为KMP算法的代码,假设next数组已经求出来了:

//简单模式匹配算法、朴素模式匹配算法
//返回值为int ,如果在主串中找到了子串的话就返回第一个字符所在的位置
//找不到就返回一个不会用到的值作为标记
int KMP(Str str, Str substr, int next[])
{
	//辅助变量,i指向主串,j指向模式串
	//初值为1代表模式串跟主串都是从数组下标1开始存储
	int i = 1,j =1;
	//开始循环
	while(i <= str.length && j <= substr.length)
	{
		//通过next数组,j有可能为0;
		//判断主串与模式串的字符相不相等
		if(j == 0 || str.ch[i] == substr.ch[i])
		{
			++i;++j;
		}
		//某个字符不匹配,通过next数组来调整j指针的位置
		else
		{
			j = next[j];
		}
	}
	//判断是否成功找到,当j超过模式串长度即为找到,此时k指在这个子串首字母所在位置
	if(j > substr.length)
		return i - substr.length;
	else 
		return 0;
}

求解next数组:
当遇到很长的模式串的时候,我们如果想要一个个去求解next数组的值的话,显然是不太可能的,那我们应该如何求呢?

这里有一个长度为m的模式串P,p的下标代表每个字符在模式串中的位置,Pj-t+1到Pj是一个长度为t的子串:
在这里插入图片描述
将这个模式串复制一份,凸显其1到t位置上的字符。
在这里插入图片描述
然后将这两端长度为t的子串对齐,假设子串的前(t-1)个字符匹配,最后一个字符不匹配;由于这两串本身就处于同一个模式串中,且一个在前一个在后,所以它们的前(t-1)个字符也就是一对FL和FR,可知next[j] = t - 1 + 1 = t:
在这里插入图片描述
如果这两个子串最后两个字符Pj和Pt相等的话,那么就可以得到next[j+1] = t + 1 = next[j] + 1。
在这里插入图片描述
如果这两个子串最后两个字符Pj和Pt不相等的话,我们将上边的那一个模式串称为假主串,下边的模式串称为假模式串,这时候的情形跟我们之前利用穷举法解决问题时出现的状态SK跟SK+1,所以现在的问题就是如何从SK状态跳到SK+1状态,就是利用next数组来使假模式串跳到合适的位置,来解决掉Pj位置处的不匹配。
由于我们是已知next[j]来求next[j+1]的值,也就是1到j位置的next数组的值我们都已经知道了,1到t位置的next数组的值也知道了。 因此我们可以查next[t]来知道下面那个假模式串往什么位置跳(t重新指向新的位置,t=next[t],t可能需要进行多次赋值操作,直到Pj位置的不匹配问题被解决)。:
在这里插入图片描述
解决掉Pj位置不匹配的情况后,就变成我们讨论的第一种情况。 t在赋值过程中有可能等于0,表示假模式串无论怎么移动也找不到相重合的FL跟FR,即FL和FR的长度都为0,所以此时next[t+1] = 0 + 1 = 1。
在这里插入图片描述
综上,可以得到一般规律:
在这里插入图片描述
将求解next数组的过程翻译成代码:

//参数为模式串和next数组,由于一般不会执行失败,所以返回值为void
//由next[j]求得next[j+1]的值
void getNext(Str substr, int next[])
{
	int j = 1,t = 0;
	next[1] = 0;
	while(j < substr.length)
	{
		if(t == 0 || substr.ch[j] == substr.ch[t])
		{
			next[j+1] = t + 1;
			++t;
			++j; 
		}
		else
			t = next[t];
	}
}

(六)改进KMP算法

在这里插入图片描述
当模式串第五个位置发生不匹配,会执行以下一系列的操作,由于前五个字符都是相等的,所以接下来五次每一次都会不匹配。此时的next[5]直接赋值为0即可。对next数组进行改进,改进后的next数组称为nextval数组,能够减少不必要的比较。
在这里插入图片描述
如何求解nextval数组呢?
看以下的例子,第一行为next数组下标,第二行为next数组的值,第三行为模式串对应的字符,假设Pj = Pd = Pc = Pb,由于next[j] = d,因此当j位置的字符发生不匹配时,比较Pj和Pd,如果相等则根据next[d]找到Pc,比较Pj和Pc,如果相等则根据next[c]找到Pb,比较Pj和Pb······重复这个操作,找到与Pj不等的字符Pa,则nextval[j] = a。
在这里插入图片描述
假设在j后面某一个位置有一个字符Pk,且next[k] = j,Pj = Pk。
假设我们按照前面所说的步骤,当k位置发生字符不匹配时,比较Pj与Pk,而Pj又与Pk相等,由next[j]我们找到Pd,比较Pk与Pd,等价于Pj与Pd进行比较,这里就回到了上面那一步所做过的事情,因此nextval[k] = nextval[j] = nextval[next[k]]。
在这里插入图片描述
综上,我们得到了求解nextval数组的一般规律:
在这里插入图片描述
求解nextval数组的代码,将求next数组的代码稍作修改,在求解next数组的过程顺便把nexval数组求解出来:

void getNextVal(Str substr, int nextval[],int next[])
{
	int j = 1,t = 0;
	next[1] = 0;
	nextval[1] = 0;
	while(j < substr.length)
	{
		if(t == 0 || substr.ch[j] == substr.ch[t])
		{
			next[j+1] = t + 1;
			if(substr.ch[j+1] != substr.ch[next[j+1]])
				nextval[j+1] = next[j+1];
			else
				nextval[j+1] = nextval[next[j+1]];
			++t;
			++j; 
		}
		else
		//使用nextval数组来调整t的位置
			t = nextval[t];
	}
}

改进之后我们发现根本没有必要使用next数组,再次进行修改:

void getNextval(Str substr, int nextval[])
{
	int j = 1,t = 0;
	nextval[1] = 0;
	while(j < substr.length)
	{
		if(t == 0 || substr.ch[j] == substr.ch[t])
		{
			if(substr.ch[j+1] != substr.ch[t+1])
				nextval[j+1] = t+1;
			else
				nextval[j+1] = nextval[t+1];
			++t;
			++j; 
		}
		else
		//使用nextval数组来调整t的位置
			t = nextval[t];
	}
}

六、树

(一)树的基础知识

树是一种分支结构,逻辑上是一对多的关系,树结构体现了一种明显的递归特性,每一个子结构和其父结构都是类似的。

  • 结点:树结构中体现数据信息和逻辑关系的单元
  • 结点的度:结点所引出的分支的个数

在这里插入图片描述

  • 树的度:树中所有结点的最大分支数。

在这里插入图片描述

  • 叶子节点:度为0的结点就是叶子节点

在这里插入图片描述

  • 双亲结点:与某个结点有直接关系的上一层结点。
  • 孩子结点:与某个结点有直接关系的下一层结点。孩子结点没有次序之分。


孩子存储结构,存储了某个结点到其孩子的关系,数组中存储了树的每个结点,拥有孩子结点的每个结点中有一个指针指向一条链表,链表中的每一个结点存储了孩子结点的位置信息。
在这里插入图片描述

(二)二叉树的逻辑结构和存储结构

(1)逻辑结构

给树加上两个限制条件:

  1. 每个结点最多只有两个孩子结点。
  2. 给孩子结点规定次序,分别是左孩子结点和右孩子结点。

这样的树就称为二叉树。
在这里插入图片描述

在这里插入图片描述
满二叉树:除了最底层结点之外所有结点都有左右两个孩子结点
在这里插入图片描述
完全二叉树:对一棵满二叉树,从最底层开始,从右往左删除结点得到的二叉树 就是完全二叉树。把一层删除完之后又得到一棵满二叉树。满二叉树可以看成特殊的完全二叉树。
在这里插入图片描述
完全二叉树的高频考点:
求完全二叉树的高度(深度)

在这里插入图片描述
先求满二叉树高度与结点的关系,结点个数 n = 2^h - 1
高度与结点的关系为h = log2(n+1):
在这里插入图片描述
这样可以求出完全二叉树的高度与结点之间的关系。
当结点的个数n满足2^(h - 1) - 1 < n <= 2^h - 1时,二叉树的高度为h。对n的满足条件进行变换,可以得到完全二叉树的高度与结点之间的关系,h等于log2n向下取整+1 :
在这里插入图片描述
对n的满足条件进行另外一种变换,也能得到完全二叉树的高度与结点的关系,h等于log2(n+1)向上取整:
在这里插入图片描述
综上,求完全二叉树的高度的公式为,n为结点数,也适用于满二叉树:
在这里插入图片描述

二叉树的性质:

  1. 总分支数 = 总结点数 - 1(对任何树都适用)(除了根结点每个结点的生成都伴随着一个分支)
  2. 二叉树的结点可以分为单分支结点、双分支结点、叶子结点(零分支结点)
  3. 假如一棵二叉树的叶子结点数为N0,单分支结点数为N1,双分支结点数为N2,那么二叉树的总结点数 = N0 + N1 + N2。总分支数 = N1 + 2N2。而总分支数 = 总结点数 - 1,则N0 + N1 + N2 - 1 = N1 + 2N2,则N0 = N2 + 1。也就是叶子结点数等于双分支结点数加一

利用这个性质我们可以求得二叉树的空分支数(每个结点能够挂上两个结点,没有挂上结点的地方就称为空分支),将二叉树每个空分支的地方都加上一个结点,将原来的二叉树中的每一个结点都变为双分支结点,利用公式可以求得此时的叶子结点数,而此时的叶子结点数即原先二叉树的空分支数。

在这里插入图片描述

对于一般的树,假设一棵树叶子结点数为N0,单分支结点数为N1,双分支结点数为N2,三分支结点数为N3,m分支结点为Nm(这棵树度为m)。能够得到叶子结点数与其他分支结点数的关系。
在这里插入图片描述

(2)顺序存储结构

将一棵树的所有结点按照从上到下,从左到右的顺序填入数组中,填入数组中,我们能够发现以下规律。这种规律只适用于完全二叉树。并且这个规律会随树的结点的开始编号发生改变。不同的学校开始编号的规定可能不太一样。
在这里插入图片描述
对于一般的二叉树这个规律就不适用了。
在这里插入图片描述

(3)链式存储结构

二叉链表存储结构:
在这里插入图片描述
二叉链表存储结构的C语言实现,定义一个结构体,有三个域,存储两个指针和数据信息,两个指针分别指向左孩子结点和右孩子结点:
在这里插入图片描述
对于一般的树,对原先树的结构进行转变,使得某个结点只与它的一个孩子结点直接联点,将它的孩子结点串成一串,这样就能够使用二叉链表存储结构来存储:
在这里插入图片描述
结构体的结构没有变化,只不过对指针我们作了新的规定,每个结点的两个指针分别指向它的孩子结点和兄弟结点。由于规定发生变化,我们也把这种结构称为树的孩子兄弟存储结构。
在这里插入图片描述

(三)树与二叉树的相互转换

这里的转换,实际上是把树的逻辑结构转换成长得像二叉树的逻辑结构(实际上表示的还是一棵树,实际上的逻辑结构还是树)。二叉树的每一个结点的两个分支都是孩子结点,而树转换之后每一个结点的两个分支分别是孩子结点、兄弟结点。实现这样的转换的目的是使得我们为二叉树设计的存储结构能够被树所使用。

举个例子,将如图的树转换成“二叉树”
在这里插入图片描述
保留每个结点与孩子结点的一条联系,然后将孩子结点连接起来即可:
在这里插入图片描述

在这里插入图片描述
调整一下形态:
在这里插入图片描述
那么要如何转换回去呢?只要把删除的联系再补上即可。通过某个结点找到与它直接联系的孩子结点,再通过孩子结点找到它的兄弟结点,让兄弟结点与这个结点连接上一条关系,然后再删除兄弟结点之间的联系即可。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(四)森林与二叉树的转换

将多棵树放在一起,就构成了森林。这里要实现森林转化为一棵二叉树。

这里给出一个森林,森林中的每一棵树都是普通的树,注意第三棵树不是二叉树,它的孩子结点没有次序之分在这里插入图片描述
转化的步骤是:

  1. 先将森林中的每一棵树转化为二叉树

在这里插入图片描述

  1. 将每一棵转化后的二叉树通过右分支串联起来,森林则成功转化成为二叉树了:

在这里插入图片描述

那么二叉树又该如何转化回森林呢?

  1. 将拿到的二叉树的根结点的右分支删去,就会得到两棵二叉树,一棵根结点的右分支是空的二叉树和一棵根结点的右分支不为空的二叉树。然后把右分支不为空的二叉树的根结点的右分支删去,又得到两棵二叉树,重复这个步骤,直至得到的两棵二叉树的右分支都为空则停止。这样就得到了一排二叉树。
  2. 将得到的二叉树转化为原来的树。

(五)遍历

二叉树的遍历是关键

(1)二叉树的遍历

对于层与层之间,我们采用从上到下的顺序,对于层内,我们采用从左到右的顺序对二叉树进行遍历,这样的遍历方式称为层次遍历,也叫广度优先遍历。
在这里插入图片描述
沿着根结点左边的分支,一直走到底,当遇到空分支的时候需要绕回来,返回上一个结点,然后从下一个分支继续尝试,如果还是空分支,还要绕回来,返回上一个结点,再返回上一层。重复操作,直至结束。这样的遍历方式称为深度优先遍历方式。

能够发现每一个结点都有三个箭头指向它。也就是按此路径遍历的话,每一个结点我们都会遇到三次。如果我们规定在第几次遇到结点的时候进行访问的话,也就是规定访问时机,那就可以得到不同的访问序列。
在这里插入图片描述
第一次来到结点的时候进行访问,得到的序列为A1、A2、A4、A5、A3、A6
第二次来到结点的时候进行访问,得到的序列为A4、A2、A5、A1、A6、A3
第三次来到结点的时候进行访问,得到的序列为A4、A5、A2、A6、A3、A1
我们把这三种序列分别称为先序遍历序列、中序遍历序列、后序遍历序列。

两种版本说明先序、中序、后序遍历。
在这里插入图片描述
在这里插入图片描述

(2)树的遍历

树的层次遍历(广度优先遍历)与二叉树的层次遍历是一样的。
树的深度优先遍历与二叉树的深度优先遍历有一些区别,指向每个结点的箭头数不一定是三个,也就是经过每个结点的次数可能不一样(需要注意的是,树的叶子结点只有一个空分支,代表这个结点没有孩子结点了)。对于树来说,只有先序遍历和后序遍历,第一次来到结点时就访问得到的序列是先序遍历,最后一次来到结点时才访问得到的序列是后序遍历。
在这里插入图片描述
对树的先序遍历和后序遍历不同版本的描述:
在这里插入图片描述
在这里插入图片描述
如果将树转化成二叉树的话,转化前和转化后的先序遍历是一样的。转化前的后序遍历和转化后的中序遍历是一样的。
在这里插入图片描述

在这里插入图片描述

(3)森林的遍历

在这里插入图片描述
在这里插入图片描述
如果将森林转化为二叉树后,跟树转化为二叉树的情况一样,森林的先序遍历跟转化成的二叉树的先序遍历是一样的,森林的后序遍历跟转化成的二叉树的中序遍历是一样的。
在这里插入图片描述

(六)二叉树深度优先遍历递归代码

二叉树遍历代码的框架

void r(BTNode *p)
{
	if(p != NULL)
	{
		//(1)
		r(p->lChild);
		//(2)
		r(p->rChild);
		//(3)
	}
}

指针p会经过每一个结点:
在这里插入图片描述
先序遍历,蓝色点为执行visit函数:
在这里插入图片描述
中序遍历:
在这里插入图片描述
后序遍历:
在这里插入图片描述

(七)二叉树深度优先遍历非递归代码

先补充一下递归的知识:
当执行递归函数的时候,执行完一层函数是如何返回到上一层的呢?在调用递归函数进入下一层时,系统做了一个保护现场的操作,当执行完一层的代码,系统又做了一个恢复现场的操作,使得能够返回到上一层。
在这里插入图片描述
能够发现,第一次执行保护现场操作的地方是最后一个执行恢复现场的,这是栈的特性,在调用函数的时候,系统会自动帮我们建立一个系统栈,然后帮我们完成保护现场和恢复现场这一系列的操作。
在这里插入图片描述

现在我们要自己建立一个栈,来模拟系统栈帮我们完成的事情,也就是实现二叉树深度优先遍历非递归的代码。非递归的代码也是有三种,分别是先序、中序、后序。

(1)先序遍历非递归化

  1. 准备一个栈和一棵二叉树
  2. 入栈根结点。
  3. 如果栈不空,就出栈一个元素并对其进行访问,并把其左右孩子结点(如果存在)按照右左的顺序入栈。
  4. 重复执行步骤3。直至栈空即遍历结束。

先序遍历非递归化代码实现:

typedef struct BTNode
{
	int data;
	BTNode *lchild;
	BTNode *rchild;
}BTNode


//参数为二叉树结点类型的指针,一般传入根结点
void preorderNonrecursion(BTNode *bt)
{
	if(bt != NULL)
	{
		//辅助栈,存储指向结点的指针
		BTNode *Stack[maxSize];
		int top = -1;
		//遍历指针
		BTNode *p = NULL;
		//开始遍历
		Stack[++top] = bt;
		//当栈不空时继续操作
		while(top != -1) 
		{
			//出栈一个结点
			p = Stack[top--];
			//访问这个结点
			Visit(p);
			//判断其左右孩子是否存在,存在则将它们入栈,右左顺序
			if(p->rchild != NULL)
				Stack[++top] = p->rchild;
			if(p->lchild != NULL)
				Stack[++top] = p->lchild;
		}
	}
}

(2)后序遍历非递归化

先序遍历和后序遍历有着密切的联系,我们进行先序遍历的顺序是根左右,而后序遍历是左右根,将后序遍历逆过来也就是逆后序遍历是根右左。可以发现先序遍历和逆后序遍历的差别在于遍历左右子树的先后之分,因此我们很容易得根据先序遍历的代码来得到逆后序遍历代码,只需要将代码遍历左右子树的顺序修改一下即可,得到逆后序遍历序列之后,利用辅助栈即可得到后序遍历序列。

在这里插入图片描述
后序遍历非递归化代码实现:

//参数为二叉树结点类型的指针,一般传入根结点
void postorderNonrecursion(BTNode *bt)
{
	if(bt != NULL)
	{
		//辅助栈,存储指向结点的指针
		BTNode *Stack1[maxSize];
		int top1 = -1;
		//辅助栈,保存逆后序遍历序列
		BTNode *Stack12[maxSize];
		int top2 = -1;
		//遍历指针
		BTNode *p = NULL;
		//开始遍历
		Stack[++top1] = bt;
		//当栈不空时继续操作
		while(top != -1) 
		{
			//出栈一个结点
			p = Stack[top1--];
			//出栈后进入另外一个栈
			Stack2[++top2] = p;
			//判断结点的左右孩子结点是否存在,存在则入栈,左右顺序
			if(p->lchild != NULL)
				Stack[++top1] = p->lchild;
			if(p->rchild != NULL)
				Stack[++top1] = p->rchild;
		}
		//出栈辅助栈元素,由逆后序序列得到后序遍历序列
		while(top2 != -1)
		{
			p = Stack[top2--];
			Visit(p);
		}
	}
}

(3)中序遍历非递归化

  1. 从根结点开始,沿着左孩子指针一直往左走,将途经的结点全部入栈,直到左孩子指针为空。
  2. 出栈一个结点,访问这个结点并判断它的右孩子是否为空。
  3. 若右孩子不为空,入栈右孩子结点,从这个结点开始,沿着左孩子指针一直往左走,将途经的结点全部入栈,直到左孩子指针为空。回到步骤2。
  4. 若右孩子为空,回到步骤2。
  5. 重复以上操作直至栈空。

中序遍历代码实现:

void inorderNorecursion(BTNode *bt)
{	
	if(bt != NULL)
	{
		BTNode *Stack[maxSize];int top = -1;
		BTNode *p = NULL;
		p = bt;
		//开始遍历
		//判断栈是否为空或遍历指针是否为空,当两者同时为空时循环结束
		while(top != -1 || p != NULL)
		{
			//指针p沿着左走直至左孩子指针为空,将途经的结点入栈
			while(p != NULL)
			{
				Stack[++top] = p;
				p = p->lChild;
			}
			if(top != -1)
			{
				//出栈一个结点并访问它
				p = Stack[top--];
				Visit(p);
				//将遍历指针指向这个结点的右孩子,准备判断它是否为空
				p = p->rChild;
			}
		}
	}
}

(八)二叉树层次遍历(广度优先遍历)代码

二叉树的层次遍历代码的实现需要利用队列来实现。

  1. 入队根结点。
  2. 若队列不为空,则出队一个元素并访问,检测其左右孩子结点是否存在。若存在则将其孩子结点依次入队。
  3. 重复步骤二,直至队列空。
void level(BTNode *bt)
{
	if(bt != NULL)
	{
		//队头队尾指针,定义一个辅助队列
		int front,rear;
		BTNode *que[maxSize];
		front = rear = 0;
		BTNode *p;
		//根结点入队
		rear = (rear + 1)% maxSize;
		que[rear] = bt;
		//开始遍历
		while(front != rear)
		{
			//出队一个结点并访问
			front = (front + 1)% maxSize;
			p = que[front];
			Visit(p);
			//判断结点的左右孩子是否存在,存在则入队
			if(p->lChild != NULL)
			{
				rear = (rear + 1)% maxSize;
				que[rear] = p->lChild;
			}
			if(p->rChild != NULL)
			{
				rear = (rear + 1)% maxSize;
				que[rear] = p->rChild;
			}
		}
	}
}

(九)树的深度优先遍历代码

(1)先序遍历

树的存储结构有一个孩子存储结构,这种存储结构需要按照以下来取出某个结点的孩子结点。
在这里插入图片描述

typedef struct Branch
{
	int cIdx;
	Branch *next;
}

typedef struct TNode
{
	int data;
	Branch *first;
}

void preOrder(TNode *p,TNode tree[])
{
	if(p != NULL)
	{
		Visit(p);
		Branch *q;
		q = p->first;
		while(q != NULL)
		{
			preOrder(&tree[q->cIdx],tree);
			q = q->next;
		}
}

(2)后序遍历

树的后序遍历只需要修改访问结点的时机即可,遍历跟先序遍历是一样的。

void postOrder(TNode *p,TNode tree[])
{
	if(p != NULL)
	{
		Branch *q;
		q = p->first;
		while(q != NULL)
		{
			postOrder(&tree[q->cIdx],tree);
			q = q->next;
		}
		Visit(p);
}

(十)树的层次(广度优先)遍历代码

树的层次遍历与二叉树的层次遍历差别不大,都是利用队列来完成遍历,唯一的区别是树的结点的孩子结点不局限于2个。

void level(TNode *tn,TNode tree[])
{
	if(tn != NULL)
	{
		//队头队尾指针,定义一个辅助队列
		int front,rear;
		TNode *que[maxSize];
		front = rear = 0;
		TNode *p;
		//根结点入队
		rear = (rear + 1)% maxSize;
		que[rear] = tn;
		//开始遍历
		while(front != rear)
		{
			//出队一个结点并访问
			front = (front + 1)% maxSize;
			p = que[front];
			Visit(p);
			//找到第一个孩子结点
			Branch *q = p->first;
			//将这个孩子结点的兄弟结点依次入队
			while(q != NULL)
			{
				rear = (rear + 1)% maxSize;
				que[rear] = &tree[q->cIdx];
				q = q->next;
			}
		}
	}
}

(十一)线索二叉树

前面所讲的二叉树的深度优先遍历和广度优先遍历,从一个分支结构导出一个线性结构,遍历出来的序列可以看成一个线性结构,序列中的每一个结点除了首尾结点之外都有前驱和后继结点。

二叉树的深度优先遍历的过程中,我们总是需要走一些重复的路,解决这些重复的方法就是在二叉树的每一个结点都引出直接指向它的前驱结点或后继结点的路径。

二叉树我们一般使用二叉链表来存储,有些结点可能存在着空指针,比如叶子结点就存在着两个空指针,我们可以将这些空指针利用起来指向它们所在结点的前驱或后继结点(其所在的遍历序列的前驱、后继结点)。这就是线索二叉树,能够更方便二叉树进行深度优先遍历的存储结构。

由于深度优先遍历有先序、中序、后序三种,得到的遍历序列也就有三种,那么每一个结点在每一个遍历序列中的前驱和后继结点也有可能是不一样的,由于我们是利用空指针来指向它们所在结点的前驱或后继结点,不同的遍历方式空指针的指向也会有所不同,因此根据遍历方式的不同,我们会得到三种不同的线索二叉树。用这些空指针来指向其结点前驱和后继结点的过程称为把二叉树线索化。

(1)中序线索二叉树(考的最多)

中序线索二叉树的逻辑结构:

如何将二叉树中序线索化呢?中序遍历二叉树,在遍历的过程中让空指针(空分支)指向合适的位置。如果一个结点有左空指针,则指向其前驱结点,如果有右空指针,则指向其后继结点。

在这里插入图片描述
中序线索二叉树的存储结构:

我们之前所用来存储二叉树的存储结构是二叉链表,其中所用到结构体并不适用于我们中序线索二叉树的存储,因为可能不知道某个结点的孩子指针到底是指向其孩子还是指向其前驱后继结点。因此我们需要对这个结构体进行稍微的修改。
在这里插入图片描述
给结构体增加两个整型变量,也就是两个标签,规定当lTag = 0时,lChild指针指的就是其左孩子结点,当lTag = 1时,lChild指针指的就是其前驱结点。当rTag = 0时,rChild指针指的就是其右孩子结点,当rChild = 1时,rChild指针指的就是其后继结点。
在这里插入图片描述
在这里插入图片描述
中序线索化代码实现,访问一个结点,如果这个结点左孩子指针为空,则将这个指针规定为线索,指向其前驱结点,如果其前驱结点的右指针为空,这将这个指针规定为线索,指向该结点。我们将二叉树中序遍历过程中访问结点的操作变为连接线索的操作。:

//p,遍历指针,初值一般指向根结点
//pre指针,指向当前结点的前驱结点,初值为空
void inThread(TBTNode *p, TBTNode *&pre)
{
	if(p != NULL)
	{
		inThread(p->lChild,pre);
		//中序遍历过程中,如果某结点的左孩子指针为空
		//则将左孩子指针规定为线索,连接其前驱结点
		if(p->lChild == NULL)
		{
			p->lChild = pre;
			p->lTag = 1;
		}
		//如果前驱结点不为空且前驱结点右孩子指针为空
		//则将右孩子指针规定为线索,连接其后继结点
		if(pre != NULL && pre->rChild == NULL)
		{
			pre->rChild = p;
			pre->rTag = 1;
		}
		//此次访问的结点作为新的前驱结点
		pre = p;
		//p指针移动到当前访问的结点的右孩子结点
		inThread(p->rChild,pre);
	}
}

(2)前序线索二叉树

在这里插入图片描述
将二叉树前序遍历过程中访问结点的操作变为连接线索的操作。在末尾处两个递归函数的入口增加了判断,结点的左指针和右指针不是线索的时候才进入递归函数,否则回出现死循环:
在这里插入图片描述
考点:

  1. 如何在前序线索二叉树上执行遍历操作?找到根结点,不断找每个结点的后继节点。
  2. 如何找一个结点的后继结点?如果这个结点的左指针不空且不为线索,那左指针则指向其后继节点;如果这个结点的左指针空而右指针不空,则右指针指向其后继节点,不管这个右指针是线索还是普通的右指针。

在前序线索二叉树上执行前序遍历的代码:

在这里插入图片描述

(3)后序线索二叉树

在这里插入图片描述
代码实现就是将线索化操作的那块代码搬到两个递归入口的后面:
在这里插入图片描述
考点(规律):
在这里插入图片描述

(4)三种线索二叉树的比较

  1. 对于前序线索二叉树来说,找某个结点的后继结点很简单,找前驱结点就不那么简单了。
  2. 对于中序线索二叉树来说,找某个结点的前驱和后继结点都比较方便
  3. 对于后序线索二叉树来说,找某个结点的前驱和后继结点都比较麻烦
  4. 对于线索二叉树,考研大部分考如何找某个结点的后继结点

在这里插入图片描述

(十二)哈夫曼树

(1)哈夫曼树的认识

哈夫曼树的应用:编码问题

计算机存储字符是通过存储字符的对应的编码,假如我们现在有一串字符,采用三位二进制数来编码它们
在这里插入图片描述
假设现在有一个字符串,利用我们规定的编码规则来对这个字符串进行编码,能够得到这么长一串编码串,如果要对编码串进行解码的话,只需要从左往右扫描,每三位二进制数进行一次查表替换即可
在这里插入图片描述
我们发现,按照以上的编码规则对字符串进行编码后得到的编码串有点太长了,我们需要找个方法又能正确地编码又能缩短编码串。观察字符串,发现各个字符出现的次数不一样,为了缩短编码串,我们只要使出现次数多的字符的编码短一些,使出现次数少的字符的编码长一些,这样就能达到缩短编码串的长度了。

具体该如何做呢?
先建立一个表,统计每个字符出现的次数。
在这里插入图片描述
然后对每一个字符建立一个结点,每个结点都会有一个字符出现次数的标记。
在这里插入图片描述
这样我们就得到一个集合,包含五个结点。然后每次从集合中挑选两个出现次数最小的结点,把它们从集合删除,并且这两个结点构造成一个新结点,并且这个 结点的出现次数是这两个被删除结点的出现次数的和,把新结点并入集合中。重复这个操作。我们就能构建出一棵二叉树,二叉树的叶子结点就是我们所要编码的字符,给二叉树的每一个左分支标记上0,给每一个右分支标记上1,然后规定新的编码规则:从根结点走到叶子所经过的分支上的代码组成了叶子结点上的字符的编码。
在这里插入图片描述
则新的编码规则以及得到的编码串,长度缩短的目的达到了。那么这种编码规则解码如何操作呢?也是需要借助这棵二叉树,从左往右扫描编码串,每扫描一位,就把这一位编码当成一个路径指示标,按照这个编码的指示,从根结点开始走一步,继续扫描直到走到了叶子结点,就解码出叶子结点所在的字符。
在这里插入图片描述

我们所用来编码的这棵二叉树就叫哈夫曼树。对每个字符的编码称为前缀码。编码后得到的编码串称为哈夫曼编码。

在这里插入图片描述

(2)哈夫曼树的相关定义

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
权值即出现次数
在这里插入图片描述
在这里插入图片描述
哈夫曼树的特点:
在这里插入图片描述

  • 哈夫曼树的每个初始结点最终都称为了叶结点,且权值越小的结点到根结点的路径长度越大
  • 哈夫曼树在构造过程中新建了n-1个结点(双分支结点),因此哈夫曼树的总结点数是2n-1
  • 哈夫曼树中不存在度为1的结点

(3)哈夫曼n叉树(n>2)

构造一棵哈夫曼n叉树与前面构造哈夫曼二叉树是一样的,只不过是每次选取两个权值最小的结点进行合并变成了每次选取n个权值最小的结点进行合并。

构造一棵哈夫曼三叉树:
在这里插入图片描述
对于哈夫曼二叉树,给出的结点只要大于2个即可构造,但是对于哈夫曼n叉树就不一样了,可能存在结点不够用的情况。例如有4个结点,想要构造一棵哈夫曼三叉树,显然是不行的。这个时候我们需要补上1个权值为0的结点,才能够构造哈夫曼三叉树。增加的结点并不会影响树的带权路径长度。
在这里插入图片描述

(十三)根据遍历序列确定二叉树

  1. 给定一个遍历序列,不能确定唯一的一棵二叉树。
  2. 给定先序遍历序列和中序遍历序列,能确定唯一的一棵二叉树
  3. 给定后序遍历序列和中序遍历序列,能确定唯一的一棵二叉树
  4. 给定层次遍历序列和中序遍历序列,能确定唯一的一棵二叉树
  5. 给定先序遍历序列和后序遍历序列,不能确定唯一的一棵二叉树

可以发现中序遍历序列是很重要的,是用来确认左右子树的关键。

(1)已知先序遍历序列和中序遍历序列

给定先序遍历序列和中序遍历序列,根据这两个序列来推出二叉树。
由于先序遍历序列的第一个字符肯定是根结点,所以先画出根结点A:
在这里插入图片描述
有了根结点,根据中序遍历序列就能得出左右子树的结点是哪些,在中序遍历序列中,根结点左边的结点就是二叉树左子树的结点,根结点右边的结点就是二叉树右子树的结点:
在这里插入图片描述
再根据先序遍历序列就能得到左右子树的根结点:
在这里插入图片描述
再根据中序遍历序列能够知道左右子树的根结点的左右子树,发现结点C没有左结点只有右结点,所以剩下部分都是结点C的右子树:
在这里插入图片描述
再看先序遍历序列结点F是C的右子树的根结点,然后根据中序遍历序列能够得出左结点为G右结点为H
在这里插入图片描述
总结起来就是:通过先序序列找到根结点,然后通过中序序列将遍历序列划分为两部分,然后从这两部分取出子树根结点,链接在根结点下,然后对得到的两部分做同样的操作,重复下去。这是一个递归的过程。

代码实现:

//返回一个指针,指向二叉树的根结点
//pre:先序序列   in:中序序列
//L1、R1:为先序序列控制范围的变量,代表当前操作的pre数组的元素下标范围为L1到R1
//L2、R2:为中序序列控制范围的变量,代表当前操作的in数组的元素下标范围为L2到R2
BTNode *CreateBT(char pre[],char in[],int L1,int R1,int L2,int R2)
{
	//递归出口,处理序列长度为0
	if(L1 > R1) return NULL;	
	//建立根结点,先序序列也就是pre数组的第一个元素
	BTNode *s = (BTNode*)malloc(sizeof(BTNode));
	s->lChild = s->rChild = NULL;
	s->data = pre[L1];
	//到中序遍历序列中找到根结点的位置,中序遍历序列被划分为两部分
	int i;
	for(i = L2;i <= R2;i++)
		if(in[i] == pre[L1])
			break;
	
	//递归入口
	s->lChild = CreateBT(pre,in,L1+1,L1+i-L2,L2,i-1);
	s->rChild = CreateBT(pre,in,L1+i-L2+1,R1,i+1,R2);
	return s;
}

其中递归入口参数确定方法:
中序遍历序列中的左右两部分在先序遍历序列中虽然顺序不一样,但是是连续的。
在这里插入图片描述
在这里插入图片描述

(2)已知后序遍历序列和中序遍历序列

在已知先序遍历序列和中序遍历序列确定二叉树的时候,先序遍历序列的作用就在于确定根结点和左右子树的根结点,现在将先序遍历序列换成后序遍历序列也同样能达到这样的效果。

给出例子:
先找出根结点,也就是后序遍历序列的最后一个结点。
在这里插入图片描述
根据中序遍历序列得到左右子树的结点,然后根据后序遍历序列得到左右子树的根结点:
在这里插入图片描述
重复操作,得到二叉树:
在这里插入图片描述
代码实现:

//返回一个指针,指向二叉树的根结点
//pre:后序序列   in:中序序列
//L1、R1:为后序序列控制范围的变量,代表当前操作的post数组的元素下标范围为L1到R1
//L2、R2:为中序序列控制范围的变量,代表当前操作的in数组的元素下标范围为L2到R2
BTNode *CreateBT2(char post[],char in[],int L1,int R1,int L2,int R2)
{
	//递归出口,处理序列长度为0
	if(L1 > R1) return NULL;	
	//建立根结点,后序序列也就是post数组的最后一个元素
	BTNode *s = (BTNode*)malloc(sizeof(BTNode));
	s->lChild = s->rChild = NULL;
	s->data = post[R1];
	//到中序遍历序列中找到根结点的位置,中序遍历序列被划分为两部分
	int i;
	for(i = L2;i <= R2;i++)
 		if(in[i] == post[R1])
			break;
	
	//递归入口
	s->lChild = CreateBT2(post,in,L1,L1+i-L2-1,L2,i-1);
	s->rChild = CreateBT2(post,in,L1+i-L2,R1-1,i+1,R2);
	return s;
}

(3)已知层次遍历序列和中序遍历序列

层次遍历序列中也是第一个元素为根结点,通过根结点将中序遍历序列分为两部分:
在这里插入图片描述
中序遍历序列分为两部分之后,层次遍历序列也相应分为两部分,根据层次遍历序列我们能够得到根结点左右子树的根结点,也就是左右两部分的第一个。
在这里插入图片描述
再看中序遍历序列,B又将左子树部分划分为两个部分,由于着两个部分只有一个结点,所以分别是左右结点:
在这里插入图片描述
C将右子树部分划分为两个部分,不过这里只有右边一个部分,根据中序遍历序列能够知道F是右边部分的根结点,并且左右各有一个结点:
在这里插入图片描述
代码实现,这里的代码实现跟已知先序遍历序列和中序遍历序列应该是差不多的,不同点在于中序遍历序列划分出来的两部分在层次遍历序列是不连续的,而在先序遍历序列是连续的。为了在划分出不连续的两部分的层次遍历序列中挑出左右子树的根结点,我们把这两部分保持次序保存在两个数组中,然后从中挑出对应的子树根结点:
在这里插入图片描述

//将查找元素的代码提取出来
int search(char arr[],char key, int L,int R)
{
	int idx;
	for(idx = L; idx <= R; ++idx)
	{
		if(arr[idx] == key) return idx;
		return -1;
	}
}

//从层次遍历序列中挑出左右子树元素并存入数组的方法
//subLevel:用来存储层次序列中左子树部分或右子树部分元素的数组
//level:层次遍历序列
//in:中序遍历序列
//n:层次遍历序列的长度
//L,R:为中序序列控制范围的变量,代表当前操作的in数组的元素下标范围为L到R
//调整LR能够使方法查找左子树的元素或者右子树的元素
//方法逻辑:循环level数组,判断level数组是否包含in数组中L到R范围内的元素,
//是的话则存入新的数组
void getSubLevel(char subLevel[],char level,char in[],
				int n,int L,int R)
{
	int k = 0;
	for(int i = 0; i < n; i++)
	{
		if(search(in,level[i],L,R) != -1)
			subLevel[k++] = level[i];
	}
}

//返回一个指针,指向二叉树的根结点
//level:层次序列   in:中序序列
//n:层次序列的长度
//L、R:为中序序列控制范围的变量,代表当前操作的in数组的元素下标范围为L到R
BTNode *CreateBT3(char level[],char in[],int n,int L,int R)
{
	//递归出口,处理序列长度为0
	if(L > R) return NULL;	
	//建立根结点,先序序列也就是pre数组的第一个元素
	BTNode *s = (BTNode*)malloc(sizeof(BTNode));
	s->lChild = s->rChild = NULL;
	s->data = level[0];
	//到中序遍历序列中找到根结点的位置,中序遍历序列被划分为两部分
	int i = search(in,level[0],L,R);
	//新建两个数组,用以存放层次遍历中左右子树的元素
	int LN = i-L;char LLevel[LN];
	int RN = R-i;char RLevel[RN];
	
	//挑出层次遍历中左右子树元素两部分,存入数组中
	getSubLevel(LLevel,level,in,n,L,i-1);
	getSubLevel(RLevel,level,in,n,i+1,R);
	
	
	//递归入口
	s->lChild = CreateBT3(LLevel,in,LN,L,i-1);
	s->rChild = CreateBT3(Rlevel,in,RN,i+1,R);
	return s;
}

(十四)根据遍历序列估计二叉树

  1. 先序遍历和后序遍历结果相同的二叉树为? 只有根结点的树。

画一棵抽象的树,写出它的先序遍历序列和后序遍历序列
在这里插入图片描述
可知先序遍历序列和后序遍历序列相同的树只有根结点:
在这里插入图片描述
2. 前序遍历序列和中序遍历序列相同的二叉树为?右单分支树。

画一棵抽象树,把前序遍历序列和中序遍历序列写出来:
在这里插入图片描述
发现删掉左子树之后前序遍历序列和中序遍历序列就相同了,这里的没有左子树不止是根结点没有左子树,而是所有的结点都没有左子树,也就是这棵二叉树是一棵右单分支树:
在这里插入图片描述
比如:
在这里插入图片描述

  1. 中序遍历和后序遍历结果相同的二叉树为?LTR LRT,删去右子树即可,即左单分支树:


4. 前序遍历和后序遍历结果相反的树为?①TLR LRT ② TLR LRT ③TLR LRT,有三种情况,也就是说,对于这种树的某个结点,没有左子树或没有右子树或者没有左右子树。

只有一个叶子结点的树/高度和结点数相同的树:
在这里插入图片描述
5. 前序遍历序列和中序遍历序列结果相反的二叉树为? TLR LTR ,也是说删去右子树,也就是左单分支树。
6. 中序遍历序列和后序遍历序列相反的二叉树为?LTR LRT,删去左子树,也就是右单分支树。

(十五)根据表达式建立二叉树

(1)手工建树

根据一个表达式建立一棵二叉树,需要存储操作数、运算符、操作次序
在这里插入图片描述
首先通过给表达式加上一些括号,把操作次序明确出来:
在这里插入图片描述
然后根据所有的操作数来创建一组叶子结点,然后以每一层括号内的操作数和运算符来建立一棵子二叉树:
在这里插入图片描述
第一层括号:
在这里插入图片描述
第二层括号:
在这里插入图片描述
第三层括号:

在这里插入图片描述

(2)利用栈建树

利用栈建树跟利用栈进行中缀表达式求值的过程十分类似,利用栈进行中缀表达式求值有一个步骤是弹出两个操作数进行运算,得出结果再压入操作数栈中,而利用栈建树就将这个步骤中的运算改为建立一棵子二叉树,并把二叉树的根结点压入操作数栈中。

在这里插入图片描述

  1. 准备两个栈,一个存储操作数,另一个存储运算符
  2. 从左至右扫描表达式
  3. 遇到操作数则入操作数栈
  4. 遇到运算符,如果栈空或者该运算符优先级大于运算符栈栈顶运算符的优先级或者运算符栈栈顶元素为左括号的话则入运算符栈;如果栈不为空且判断结果为小于的话,则出栈一个运算符和两个操作数构建一棵子树(运算符为根结点,操作数为孩子结点,按照入栈顺序分左右),并且让子树的根结点入操作数栈,然后继续判断该运算符优先级是否大于运算符栈栈顶运算符的优先级,重复操作。

在这里插入图片描述
5. 遇到左括号的话则直接入运算符栈
6. 遇到右括号的话,将运算符栈中从栈顶元素到左括号之间的运算符全部出栈,每出栈一个运算符和两个操作数构建一棵子树并把根结点入操作数栈,最后将左右括号都扔掉。

在这里插入图片描述
在这里插入图片描述
7. 若扫描到表达式的尾部时,运算符栈扔不为空,则将运算符栈中的运算符全部出栈,每出栈一个运算符和两个操作数构建一棵子树并把根结点入操作数栈。需要注意的是,如果操作数栈中出栈的操作数包含之前建立的子树的根结点,则此时不是构建一棵新的子树,而是在之前的基础上构建一棵新的子树。

在这里插入图片描述
在这里插入图片描述
对这棵二叉树进行先序遍历得到的遍历序列正好是表达式的前缀表达式形式,对这棵二叉树进行后序遍历正好是表达式的后缀表达式形式。也就是我们又学会一种中缀表达式转前缀表达式和后缀表达式的方法,即对存储中缀表达式的二叉树进行先序遍历和后序遍历。
在这里插入图片描述
利用栈建树的代码实现几乎不考,有兴趣可以模仿中缀表达式求值代码来写。

(3)利用树来求表达式的值(代码)

当我们已经有表达式转换而成的二叉树时,我们该如何利用这棵二叉树来求表达式的值呢?

这里需要一个求子表达式值的函数(之前表达式求值那块讲过的)

int calSub(float opand1,char op,float opand2,float &result)
{
	if(op == '+')result = opand1 + opand2;
	if(op == '-')result = opand1 - opand2;
	if(op == '*')result = opand1 * opand2;
	if(op == '/')
	{
		if(fabs(opand2) < MIN) return 0;
		else result = opand1/opand2;
	}
	return 1;
}

利用递归来解决计算表达式的值的问题,所使用的是后序遍历的框架。

float cal(BTNode *root)
{
	//如果为叶子结点,直接返回叶子结点所存储的值
	if(root->lChild == NULL && root->rChild == NULL)
		return root->data - '0';
	//不是叶子结点则是存储了运算符的结点
	else
	{
		//递归求左子树和右子树的值
		float opand1 = cal(root->lChild);
		float opand2 = cal(root->rChild);
		
		//存储计算结果
		float result;
		//利用左右子树的值和根结点所存储的运算符进行运算
		calSub(opand1,root->data,opand2,result);
		return result;
	}
}
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值