数据结构 线性表

线性表的定义和基本操作

在这里插入图片描述

线性表的定义

  • 什么是线性表?
    线性表(Liner List)是具有相同数据类型的n(n≥0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则其一般表示为
    L=(a1,a2,…,ai,ai+1,…,an)
    在这里插入图片描述
  • 我们在这个定义中需要注意这样几点:
  1. 线性表当中的各个数据元素,它们的数据类型都是相同的。这一点意味着各个数据元素所占的存储空间是一样大的,这一特性可以帮助计算机快速地找到某一个具体的数据元素。
  2. 线性表是一个序列,所谓的序就是只有次序,各个数据元素之间有先后次序。
  3. 线性表中数据元素的数量是有限的。
  • 需要注意的概念:
    ai是线性表中的“第i个”元素在线性表中的位序
    a1是表头元素;an是表尾元素
    除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继

线性表的基本操作

  • InitList(&L):初始化表。构建一个空的线性表L,分配内存空间。
  • DestroyList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间。
  • ListInsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e。
  • ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。
  • LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字值的元素。
  • GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。

其它常用操作:

  • Length(L):求表长。返回线性表L的长度,即L中数据元素的个数。
  • PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值。
  • Empty(L):判空操作。若L为空表,则返回true,否则返回false。

Tips:

  1. 对数据的操作(记忆思路):创销、增删改查
  2. C语言函数的定义:<返回值类型>函数名(<参数1类型>参数1,<参数2类型>参数2,…)
  3. 实际开发中,可根据实际需求定义其它的基本操作
  4. 函数名和参数的形式、命名都可改变
  5. 什么时候要传入引用“&”:对参数的修改结果需要“带回来”

对于5而言,有以下代码:
在这里插入图片描述

  • main函数里首先定义了一个变量x,x的值为1,然后输出x的值,接下来调用test()函数,把x这个变量作为参数,传到test()这个函数里边,然后在test()里边对x的值进行修改,把它变成1024,修改之后打印x的值。接下来,test函数运行结束之后再一次打印x的值。运行结果为:在调用test()之前,x的值为1,而在test函数的内部,x的值被改成了1024,但是test函数执行结束又返回main函数的时候,x的值又变成了1.所以test函数对x的值虽然进行了修改,但是这个修改的结果,没有带回到main函数,这背后的原因是在main函数里边定义了一个变量x,初始值为1,然后在调用test函数的时候,其实test函数里边的x是main函数里边x的一个复制品。这两个变量虽然都叫x,但是在内存当中它们其实是两份不同的数据,所以test函数当中把x值改成了1024,改的其实是上面这一份的数据,所以test运行结果又回到main函数之后。
    再来看一下,把参数改成引用类型,也即是在参数名前面加一个引用符号,代码如下:
    在这里插入图片描述

  • 在test()中把x的值改成了1024,然后再返回到main函数的时候,打印出的值也是1024。也即是,如果我们把参数改成引用类型,那么test函数中对参数的修改就被带回到main函数当中了。

  • 为什么要实现对数据结构的基本操作?

  1. 团队合作编程,定义的数据结构要让别人能够很方便地使用(封装)
  2. 将常见的操作或运算封装成函数,避免重复工作,降低出错风险

在这里插入图片描述

线性表的顺序表示

在这里插入图片描述

顺序表的定义

  • 用顺序存储的方式实现线性表
    顺序存储:把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。
    在这里插入图片描述
  • 顺序表的实现
  1. 静态分配
    所谓静态分配,就是指数组的定义方式来实现一个顺序表
#define MaxSize 10		//定义最大长度
typedef struct{
	ElemType data[MaxSize];		//用静态的“数组”存放数据元素
		int length;		//顺序表的当前长度
}SqList;		//顺序表的类型定义(静态分配方式)
  • 代码中定义了一个静态数组,长度为MaxSize,这是我们宏定义的一个常量,另外还定义了一个length的变量,用于表示当前这个顺序表的实际长度到底是多少。MaxSize的值决定了顺序表最多可以存放多少数据元素,而length的值表示的是当前顺序表当中已经存入了多少元素。如果从内存的视角来看的话,当声明了一个data数组的时候,那么其实就是在内存当中开辟了一整片的连续空间。接下来看一段具体的代码:
#include <stdio.h>
#define MaxSize 10		//定义最大长度
typedef struct{
	int data[MaxSize];		//用静态的“数组”存放数据元素
	int length;		//顺序表的当前长度
}SqList;		//顺序表的类型定义

//基本操作——初始化一个顺序表
void InitList(SqList &L){
	for(int i=0; i<MaxSize; i++)
		L.data[i]=0;		//将所有数据元素设置为默认初始值
	L.length=0;		//顺序表初始长度为0
}

int main() {
	SqList L;		//声明一个顺序表
	InitList(L);		//初始化顺序表
	//...后续操作
	return 0;
}
  • 我们定义了一个顺序表,这个顺序表是用于存放整数的,也即是数据元素的数据类型为int。我们定义了一个叫做data的数组,静态的数组用于存放数据元素,最多只能存10个。在main函数里首先声明一个SqList,也即是声明一个顺序表,在执行这句代码的时候,其实计算机就会在内存当中给这个顺序表分配它所需的空间。首先是存放data数组的一整片连续空间,这片空间的大小应该是10乘以每一个数据元素的大小。由于我们的数据元素是int型,所以每个数据元素的大小就应该是四个字节。除了data之外,也需要分配一个存放length这个变量的空间,由于也为int型,故也是四个字节。main函数调用了InitList,所以接下来就会开始执行这个函数,首先是一个for循环,把data这个数组当中所有数据元素的值置为0,也即是给各个数据元素设置一个默认的初始值。除此之外,还需要把length的值置为0,因为刚开始顺序表当中没有存入任何一个数据元素,所以此时顺序表的当前长度应该是0,这便是对顺序表的一个初始化工作。
    接下来要探讨的问题是,如果不给data数组设置一个默认初始值的话,会发生声明情况,我们把初始化这一部分的代码去掉,也即是在对一个顺序表进行初始化到时候,只设置它的length变量的值。再在main函数里添加一个for循环,把data这个数组全部打印出来,代码及运行结果如下:
    在这里插入图片描述
  • 可以看到,data这个数组中的元素,有的为0,而有的则很奇怪。产生这种奇怪现象的原因是内存当中会有遗留的脏数据,也即是当我们在声明这个顺序表的时候,虽然系统在背后给我们分配了这么一大片的内存空间,但是这一片内存空间之前存的是什么数据其实我们并不知道,所以如果我们不给这些数据元素设置默认值的话,那么会因为之前遗留下来的脏数据而导致我们的这个数组当中出现一些奇怪的数据。给各个数据元素设置默认值这一步其实是可以省略的,原因在于我们在main函数里打印顺序表当中的内容,这个操作其实是违规的,我们就不应该按照这样的方法来访问顺序表,因为顺序表中定义了一个变量为length,length表示的是它当前的长度,所以当我们在访问顺序表当中的各个数据元素的时候,不应该从第一个元素访问到最后一个元素,而应该是访问到顺序表当中当前实际已经存储的最后一个元素。由于刚开始length的值是0,所以如果用稍微正规一些的写法的话,那么for循环当中的语句是不会被执行的。既然内存当中会有脏数据,所以当我们声明length这个变量的时候,length的初始值把它设为0这一步就肯定不能省略,因为我们无法预知在这一小片的内存区域内之前存放的到底是什么数据。
  • 接下来需要思考的问题是,如果声明的数组长度不够,存满了怎么办?
    这个时候只能放弃治疗,因为顺序表的表长在刚开始确定后就无法更改(存储空间是静态的)
    那也有可能会说,既然这样,刚开始就申请一大片连续的存储空间,把这个数组的长度设大一点不就行了么?
    如果采用这种方式的话,存在的问题就是很浪费
  • 从这里就应该能够体会到静态分配这种实现方式存在一定的局限性。主要就是顺序表的容量是不可调的,无法更改。如果要让顺序表的大小可变的话,我们可以采用动态分配的这种实现方式。
  1. 动态分配
    如果采用动态分配来实现顺序表的话,那么我们需要定义一个指针,这个指针是指向了顺序表当中的第一个数据元素。另外,由于动态分配方式当中顺序表的容量大小是可以变的,所以我们需要再增加一个变量MaxSize,表示顺序表的最大容量是多少。除了最大容量之外,当然也需要用length这个变量来记录顺序表的当前长度,也即是此时顺序表当中实际已经存放了多少数据元素,代码如下:
#define InitSize 10		//顺序表的初始长度
typedef struct{
	ElemType *data;		//指示动态分配数组的指针
	int MaxSize;		//顺序表的最大容量
	int length;		//顺序表的当前长度
} SeqList;		//顺序表的类型定义(动态分配方式)
  • C语言当中提供了malloc和free这两个函数来分别实现动态地申请一片内存空间和释放一片内存空间。malloc函数所实现的事情是申请一整片的连续内存空间,这一整片的内存空间,肯定有个起始的内存地址,所以malloc函数执行结束之后,它会return一个指向这一整片存储空间开始地址的指针。既然malloc函数是申请一整片的连续存储空间,那么到底会申请多大的空间呢?这是由malloc函数的参数所指明的。接下来用一个具体的代码来看一下顺序表的动态分配在背后发生了一些什么事情。我们定义了一个顺序表,这个顺序表的数据元素类型是int类型,data这个指针指向了顺序表当中的第一个数据元素,然后我们实现了一个函数InitList,用于初始化一个动态分配方式实现的顺序表,然后再实现一个函数,用于动态地增加这个顺序表的长度,然后我们再main函数里调用这些相关的操作。需要注意的是InitList里面使用到了malloc函数,增加动态数组的长度,或者说增加顺序表长度,这个函数里边又使用到了malloc和free这两个函数,malloc和free包含在了头文件中,代码如下:
#include <stdio.h>

#define InitSize 10		//顺序表的初始长度
typedef struct{
	ElemType *data;		//指示动态分配数组的指针
	int MaxSize;		//顺序表的最大容量
	int length;		//顺序表的当前长度
} SeqList;		//顺序表的类型定义(动态分配方式)

void InitList(SeqList &L){
	//用malloc函数申请一片连续的存储空间
	L.data=(int *)malloc(InitSize*sizeof(int));
	L.length=0;
	L.MaxSize=InitSize;
}

//增加动态数组的长度
void IncreaseSize(SeqList &L, int len){
	int *p=L.data;
	L.data=(int *)malloc((L.MaxSize+len)*sizeof(int));
	for(int i=0; i<L.length; i++){
		L.data[i]=p[i];		//将数据复制到新区域
	}
	L.MaxSize=L.MaxSize+len;		//顺序表最大长度增加len
	free(p);		//释放原来的内存空间
}

int main(){
	SeqList L;		//声明一个顺序表
	InitList(L);		//初始化顺序表
	//...往顺序表中插入几个元素...
	IncreaseSize(L, 5);
	return 0;
}
  • 首先,在main函数里声明一个顺序表,执行完这句代码之后,其实计算机会在内存当中开辟一小片空间,这片存储空间存放了这个顺序表当中的几个变量。函数一开始会调用malloc函数,malloc函数会申请一整片连续的存储空间,这片存储空间的大小应该是能够存得下10个int类型的数据,接下来malloc函数会返回一个指针,我们把这个指针的类型转换成统一的指针类型,然后把malloc返回的这些指针的值赋给data。除了data之外,我们还需要把顺序表的当前长度length设为0,把顺序表的最大容量设为初始值。接下来我们省略了一些代码,可以往这个顺序表当中插入数据,把顺序表填满,此时length的值应该是10,MaxSize的值也应该是10 ,再往后,如果还想存入一些数据的话,这个顺序表的大小就不够了,所以我们实现了一个函数动态地增加这个数组的长度,或者说增加这个顺序表的长度,参数len表示需要拓展的长度,参数中传入5,也即是想要让这个顺序表可以再多存入5个数据元素。IncreaseSize函数中,我们定义了一个指针p,把顺序表的data指针的值赋给这个p,也即是说这个p指针和data是指向了同一个位置,接下来调用malloc函数,申请一整片的内存空间,这片空间的大小应该能够存得下当前的所有的数据元素,同时还可以再多存5个新的数据元素。由于malloc申请的内存空间是另一片内存空间,而这片内存空间此时并没有往里面存任何数据,接下来,我们让data这个指针指向新的一片空间,然后再用一个for循环,把以前的那一片内存空间的数据挪过来。最后要做的一件事情是调用free函数,free函数会把p这个指针所指向的一整片存储空间给释放掉,把它归还给系统。由于p这个变量是局部于函数的变量,所以当函数执行结束之后,存储p这个变量的这些内存空间会被系统自动回收。所以便用malloc实现了一个动态数组的扩展,或者说顺序表的扩展。由于我们需要把数据复制到新的区域,因此,虽然动态分配这种方式可以让顺序表的大小能够灵活地改变,但是,其实时间开销还是很大的。

  • 顺序表的特点:

  1. 随机访问,即可以再O(1)时间内找到第i个元素
  2. 存储密度搞,每个节点只存储数据元素
  3. 拓展容量不方便(即便采用动态分配的方式实现,拓展长度的时间复杂度也比较高)
  4. 插入、删除操作不方便,需要移动大量元素

在这里插入图片描述

顺序表上基本操作的实现

在这里插入图片描述

  • ListInsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e。第i个位置指的是位序,也即是从1开始的。假设我们用静态分配方式实现了一个顺序表如下:
#define MaxSize 10		//定义最大长度
typedef struct{
	ElemType data[MaxSize];		//用静态的“数组”存放数据元素
	int length;		//顺序表的当前长度
}SeqList;		//顺序表的类型定义
  • 这个顺序表总共可以存10个元素,假设在某一个时刻,这个数据结构当中包含了5个数据元素,此时如果要进行一个插入操作,往线性表的第3个位置插入一个数据元素的话,逻辑上来看,进行这个操作之后,插入元素就变成了插入位置前一个元素的后继结点,后一个元素的前驱结点。由于我们的线性表是用顺序表的方式实现的,所以需要用存储位置上的相邻关系来体现这种数据元素之间的逻辑关系,因此,如果要在第三个位置插入元素的话,那么需要把后面的3个元素都依次往后移,然后再把要插入的元素插入到第3个位置。所以,如果我们在顺序表的第i个位置插入一个元素的话,那么这个顺序表当中第i个位置以及第i个位置以后的那些元素都得往后移一位,接下来看一下怎么用代码实现这个事情。
#define MaxSize 10		//定义最大长度
typedef struct{
	int data[MaxSize];		//用静态的“数组”存放数据元素
	int length;		//顺序表的当前长度
}SeqList;		//顺序表的类型定义

void ListInsert(SqList &L,int i,int e){
	for(int j=L.length;j>=i;j--)		//将第i个元素及之后的元素后移
		L.data[j]=L.data[j-1];
	L.data[i-1]=e;		//在位置i处放入e
	L.length++;		//长度加1
}

int main {
	SqList L;		//声明一个顺序表
	InitList(t);		//初始化顺序表
	//...此处省略一些代码,插入几个元素
	ListInsert(L, 3, 3);
	return 0;
}
  • 我们定义了一个顺序表,这个顺序表当中存放的数据元素都是int类型,也就是存放一个一个的整数,然后我们用ListInsert函数实现了插入操作,在main函数里,我们会声明一个顺序表,并且对它进行初始化的操作,我们省略了一些代码,这些代码会往顺序表当中存入一些数据。假设我们存入了5个数据元素,所以此时顺序表的长度应该是5.接下来会调用ListInsert函数来实现插入操作,这个函数实现的事情是往第3个位置插入数据元素3.之前我们说过,首先会把后续的数据元素都分别往后移,所以我们使用了for循环,刚开始,j这个变量的值等于顺序表的长度,也即是等于5,只要j大于等于i,也即是只要大于等于3的话,这个循环就会一直继续,每一轮循环结束之后j的值会减1。第一次执行这个循环里面的语句的时候,j的值为5.所以代码中做的事情是把data[4]的数据放到data[5]的位置,也即是把数据元素往后挪一位。执行完这一步时候,j的值会进行减减的操作,j的值就变成了4.所以下一轮循环做的事情是把data[3]这个数据元素放到data[4]的位置。所以第二轮循环会把5这个数据元素往后挪一位,第三轮循环也是一样的,会把4这个数据元素往后挪一位。这个循环结束之后,我们就可以往第三个位置插入我们的数据元素,也即是3这个数字。值得注意的是我们的函数参数i表示的是线性表的位序,是从1开始的,但是实际对应到我们的数组的时候,数组的下标是从0开始的,所以我们要把数据元素放在第3个位置,实际上应该是要把它放在数组下标为2的位置。接下来由于多了一个数据元素,所以顺序表的长度length就应该加1,到此为止我们就实现了插入这个基本操作。我们还需考虑插入元素时的合法性,如判断i的范围是否有效以及当存储空间已满时,不能插入。我们可以在代码中添加相关的语句以反馈插入操作的成功与否:
#define MaxSize 10		//定义最大长度
typedef struct{
	int data[MaxSize];		//用静态的“数组”存放数据元素
	int length;		//顺序表的当前长度
}SeqList;		//顺序表的类型定义

bool ListInsert(SqList &L,int i,int e){
	if(i<1||i>L.length+1)		//判断i的范围是否有效
		return false;
	if(L.length>=MaxSize)		//当前存储空间已满,不能插入
		return false;
	for(int j=L.length;j>=i;j--)		//将第i个元素及之后的元素后移
		L.data[j]=L.data[j-1];
	L.data[i-1]=e;		//在位置i处放入e
	L.length++;		//长度加1
	return true;
}
  • 以上我们定义了一个bool型的变量,刚开始需要先进行i值的合法性判断,如果i的值小于1或者大于length+1的话,那么说明此次想要插入的位置本身就不合法,在这种情况下就会返回一个false,在接收到false这个返回值的时候,就可以知道此次调用失败了。如果此时顺序表已经存满了,在这种情况下,此次的插入操作也应该是失败的,所以也需要返回一个false。只有当这两个条件都满足之后,才可以进行插入操作,插入成功之后,再返回一个true。
  • 插入操作的时间复杂度
bool ListInsert(SqList &L,int i,int e){
	if(i<1||i>L.length+1)		//判断i的范围是否有效
		return false;
	if(L.length>=MaxSize)		//当前存储空间已满,不能插入
		return false;
	for(int j=L.length;j>=i;j--)		//将第i个元素及之后的元素后移
		L.data[j]=L.data[j-1];
	L.data[i-1]=e;		//在位置i处放入e
	L.length++;		//长度加1
	return true;
}
  • 数据结构 绪论中,我们知道,要分析时间复杂度的话,应该关注一段代码当中最深层次循环的语句。在以上代码中只有for这个循环,所以我们来看一下这个for循环的循环次数和问题规模n之间的关系。这里的问题规模n指的是线性表的表长,或者说是这个顺序表的表长。之前提到过,时间复杂度分为最好、最坏和平均这样的三种情况,那什么情况下时间复杂度会最低呢?

  • 如果此时我们把数据元素插入到顺序表的表尾位置,那其它数据元素是不需要移动的,也即是for循环的次数是0次,这种肯定是执行最快的一种情况。也即是当i等于length+1,就意味着此次我们要插入到这个顺序表的最后面那个位置。这种情况下,代码一直往下执行,for循环就不用循环。而其它的语句都只需要执行一次,所以这种情况下只需要常数级的时间就可以完成这个操作。

  • 那最好的另一个极端是最坏,如果此时我们是要把新元素插入到表头的位置,那么就需要把原有的这n个元素全部都往后移动,也即是for循环会循环n次。所以,最坏时间复杂度应该是O(n)这样的数量级。

  • 最后再来看平均时间复杂度,我们假设如果此次要插入的新元素,插入到任何一个位置的概率都是相同的,也即是此时传入的参数i取得1,2,3一直到length+1取得这个合法范围内的任何一个数字,概率是相同的,也即是总共有n+1个位置,是新元素有可能插入的。因此,这个元素插入到任何一个位置的概率都应该是1/(n+1)。假设此次要插入的是第一个位置,根据之前的分析,需要把后面的n个元素先全部循环地往后移一位,因此i等于1的时候需要循环n次,而当i等于2的时候,除了第一个元素之外,后面的n-1个元素都需要依次往后移一位,所以就需要进行n-1次循环。再往后的话,以此类推,当i=n+1,也即是此次要插入到表尾位置的时候,这种情况下,循环的次数应该是0次。由于i取的每一个值的概率都是相同的,所以也就意味着需要循环n次的概率是p这么多,需要循环n-1次的概率也是p,以此类推。我们把p这个因子提出来,然后p的值是n+1,然后里边的1一直加到n,其实就是一个简单的等差数列求和,最后得到的结果是n/2,也就意味着平均来看这个循环的次数应该是需要平均循环n/2次。相应的平均时间复杂度就应该是O(n/2),再一化简就应该是O(n)这样的一个时间复杂度。

  • 以上就是最好最坏和平均这样的三种情况,接下来我们再看一下顺序表的删除操作怎么实现。

  • ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。

  • 如果要删除一个元素的话,那么我们需要把这个元素后面的那些元素都依次往前移一位,同时把length的值减1,代码实现如下:

bool ListDelete(SqList &L,int i,int &e){
	if(i<1||i>L.length)		//判断i的范围是否有效
		return false;
	e=L.data[i-1];		//将被删除的元素赋值给e
	for(int j=i;j<L.length;j++)		//将第i个位置后的元素前移
		L.data[j-1]=L.data[j];
	L.length--;		//线性表长度减1
	return true;
}

int main() {
	SqList L;		//声明一个顺序表
	InitList(L);		//初始化顺序表
	//...此处省略一些代码,插入几个元素
	int e = -1;		//用变量e把删除的元素“带回来”
	if (ListDelete(L, 3, e))
		printf("已删除第3个元素,删除元素值为=%d\n", e);
	else
		printf("位序i不合法,删除失败\n");
	return 0;
}
  • 这个删除操作当中有三个参数,第一个是要删除哪个顺序表,第二个是要删除这个顺序表当中的第几个数据元素,第三个参数是一个引用型的参数,用这个参数把此次删除的数据元素返回。看一下具体实现过程:假设通过之前的一系列执行,已经建立了一个顺序表,它里边存了总共6个数据元素,如果此时想要使用这个基本操作删除一个数据元素的话,首先需要定义一个和顺序表当中存储的这些数据元素同类型的一个变量。我们的顺序表当中存储的数据元素类型都是int类型,所以我们定一个int型的变量e,并且给它设置一个初始值-1,声明了这个变量e就意味着内存当中会开辟一小片的空间用于存放e这个变量相关的数据。由于我们给它设了一个初始值,所以这片区域里边存储的数据内容是-1这个值。接下来调用删除这个基本操作,要删除L这个顺序表当中的第三个元素,然后把此次删除的那个元素用e这个变量给返回。首先在我们的这个删除操作当中,我们进行了一个i的合法值判断,因为此时可以被删除的数据元素肯定是已经存在的这些数据元素当中的某一个。所以如果i的值落在了这个区间之外的话,那么就应该给它return一个false,也即是给这个函数的使用者一个反馈,告知这个删除操作失败了。所以我们用一个if语句来接收这个函数的返回值,如果它此次返回了false的话,那说明我们的这次调用就失败了。由于此时想要删除的是第三个元素,所以这个i的值是合法的,因此接下来会执行接下去的语句。该代码会把此次要删除的这个数据元素的值复制到e这个变量所对应的内存区域当中。接下去就是执行一个for循环,把后面的数据元素依次往前移一位,最后length的值减1,也即是由6变成了5。由于这个删除操作成功了,所以会给这个函数的调用者返回一个true,也即是这个if的条件是满足的,所以接下来会执行printf语句,打印出结果。
  • 值得注意的是这样的的两个地方,首先,我们定义的删除操作e这个变量,它是引用型的变量,我们加了引用符号。由于加了引用符号,所以在这个函数里边处理的这个变量e,其实和mainj函数里边定义的变量e在内存当中对应的是同一份数据。而如果这个e变量不是引用型,把引用符号去掉的话,那么在main函数里面,它声明了一个局部与main函数的变量e,并且又调用了删除的函数。那由于这个参数不是引用类型的,所以这个函数里边,它所处理的这个变量e,其实是main函数里面这个变量e的一个复制品。这两个变量虽然名字都是e,但是在内存当中,它们对应的其实是不同的两份数据。所以如果我们没有加这个引用符号的话,那这个函数里边,把此次删除的数据元素的值赋给变量e,其实是赋给了另一个位置。而main函数里面的这个变量e,其实它的值依然是保持-1没变。所以参数去掉引用符号的话,那么相应打印的e的值应该还是保持-1。同样地,L前面也加了引用符号,如果这个参数L不加引用符号的话,那么main函数里边定义的顺序表,其实是对应的一份数据。如果去掉这一引用符号,那么在删除函数里面处理的所谓的这个L,其实是那一份数据的一个复制品。同样的道理,在这一份数据的复制品上面执行一系列删除相关的这些逻辑操作,但是返回main函数之后,main函数里边定义的顺序表L,其实它的数据依然是没变的。其次,我们在进行删除操作的时候,把这些元素依次地往前移一位,是先移动前面的元素,再移动后面的元素,这是for循环里面的逻辑。但是在我们的插入操作当中,当我们需要把元素往后移的时候,我们是先把后面的元素先往后移,然后再移前面的元素。
  • 删除操作的时间复杂度
bool ListDelete(SqList &L,int i,int &e){
	if(i<1||i>L.length)		//判断i的范围是否有效
		return false;
	e=L.data[i-1];		//将被删除的元素赋值给e
	for(int j=i;j<L.length;j++)		//将第i个位置后的元素前移
		L.data[j-1]=L.data[j];
	L.length--;		//线性表长度减1
	return true;
}
  • 同样地,在这个问题当中,所谓的问题规模n指的是线性表,或者说顺序表的表长。那其实删除操作和插入操作是非常类似的,如果我们删除的是最后一个元素的话,那么其余的元素是不需要移动位置的,也即是for这个循环的循环次数为0次。因此,如果我们删除的是最后一个元素的话,那这就是最好的情况,只需要在常数级的时间内就可以运行结束。
  • 那最坏的情况应该是删除表头元素,这种情况下需要把后续的n-1个元素全部依次往前移一位,而每移动一个元素,就会多一次循环。所以如果i=1,也即是删除第一个元素,或者说是删除表头元素的话,那么循环次数就应该是n-1次。所以最坏的情况,时间复杂度都应该是O(n)这个数量级的。
  • 最后再来看平均的情况,同样地,我们假设删除任何一个元素的概率是相同的,然后i的合法值范围应该是1到length,也即是1到n,因为总共有n个元素,要删除的肯定是这n个元素当中的其中一个,并且删除每一个元素的概率都是1/n,我们用p来表示。如果i=1删除的是第一个元素的话,那么需要循环n-1次。如果删除的是第二个元素的话,那么需要把第二个元素之后的n-2个元素都依次往前移一位,也即是需要循环n-2次,那之后的以此类推。如果i=n,也即是此次删除的是最后一个数据元素的话,那么循环的次数就应该是0次。因此,需要循环n-1次,n-2次,一直到循环1次,循环0次,它们的概率都是p。那我们把循环的次数和每一种循环次数发生的概率p进行一个相乘相加就可以得到一个平均情况下的循环次数(n-1)/2。(n-1)/2应该是O(n)这样的一个数量级,所以删除操作的平均时间复杂度也是O(n)。

在这里插入图片描述

  • 顺序表的查找

在这里插入图片描述

  • 顺序表的查找分为两种查找,一种是按位查找,一种是按值查找。这里会分别介绍怎么用代码实现,并且会分析这个代码的时间复杂度。

  • GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。

#define MaxSize 10		//定义最大长度
typedef struct{
	ElemType data[MaxSize];		//用静态的“数组”存放数据元素
	int length;		//顺序表的当前长度
}SqList;		//顺序表的类型定义(静态分配方式)

ElemType GetElem(SqList L, int i){
	return L.data[i-1];
}
  • 首先来看按位查找怎么实现,对一个线性表进行按位查找就是要从这个线性表L当中取得第i个元素。如果这个线性表是用顺序表的方式实现,并且是用静态分配的方式实现的话,那么所有的数据元素就是存放在data这个数组当中。在这种情况下,想要获得第i个数据元素其实非常简单,唯一需要注意的是第i个元素所对应的数组下标应该是i-1,因为这个位序是从1开始的,而数组下标是从0开始的,ElemType这个元素的返回值和数据元素的类型是相同的。如果想让代码健壮性更强一些的话,还可以判断一下i的值是否合法。
  • 如果采用动态分配方式实现顺序表的话,那么data这个变量其实是一个指针,这个指针指向了顺序表当中的第一个数据元素,存储这个顺序表所需要的内存空间是用malloc函数申请的一整片的连续空间。虽然data这个变量是一个指针,但是同样可以用这种数组下标的方式访问相应的元素。
#define InitSize 10		//顺序表的初始长度
typedef struct{
	ElemType *data;		//指示动态分配数组的指针
	int MaxSize		//顺序表的最大容量
	int length;		//顺序表的当前长度
}SqList;		//顺序表的类型定义(静态分配方式)

ElemType GetElem(SqList L, int i){
	return L.data[i-1];
}
  • 顺序表按位查找的这个操作只需要一个return语句,都没有任何的循环,也没有递归调用,所以按位查找这样的操作,它的时间复杂度就应该是O(1),这也是之前提到过的顺序表随机存取的特性。能够实现随机存取的基础就在于顺序表当中所有的数据元素在内存里面都是连续存放的,并且这些数据元素的数据类型相同,也即是每一个数据元素所占用的内存空间是一样大的,所以我们只需要知道一个顺序表的起始地址,然后再知道每一个数据元素的大小,就可以立即找到第i个元素所存放的位置。这是按位查找,接下来看按值查找。

  • LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字的元素。

  • 按值查找操作就是要找到这个线性表L当中有没有哪个数据元素和我们传入的这个参数e是相等的,如果能找到这样的数据元素的话,那么就要返回这个数据元素的存放位置,这个基本操作的实现也很简单。

#define InitSize 10		//顺序表的初始长度
typedef struct{
	ElemType *data;		//指示动态分配数组的指针
	int MaxSize		//顺序表的最大容量
	int length;		//顺序表的当前长度
}SqList;		//顺序表的类型定义(静态分配方式)

//在顺序表L中查找第1个元素值等于e的元素,并返回其位序
int LocateElem(SqList L,ElemType e){
	for(int i=0;i<L.length;i++)
		if(L.data[i]==e)
			return i+1;		//数组下标为i的元素值等于e,返回其位序i+1
	return 0;		//退出循环,说明查找失败
}
  • 传入参数e,执行一个for循环,从顺序表最开始的那个元素依次往后检索,判断这个顺序表当中的各个数据元素和我们传入的这个数据元素e是否相等,如果相等的话,那么返回这个数据元素的位序。由于我们返回的是位序,变量i指的是数组下标,所以我们在返回的时候需要将数据下标加1。
  • 接下来分析一下按值查找这个操作的时间复杂度,要算时间复杂度的话,我们需要关注的是最深层循环语句的执行次数,也即是循环了几次。时间复杂度分为最好、最坏和平均3种情况,最好的情况肯定就是如果要找的这个值刚好和表头元素的值相同的话,那这个循环只需要执行一次,所以最好时间复杂度应该是O(1)常数级的。由于我们检索这个数据元素,是从头到尾一个一个往后检索的,如果要查找的这个值,它是最后一个数据元素的话,那么循环的次数就需要n次,需要把n个数据元素全部扫描一遍才可以找到目标,所以最坏时间复杂度应该是O(n)。而平均时间复杂度,我们可以先假设要找的这个目标元素出现在任何一个位置的概率都相等。总共有n个元素,也即是出现在任何一个位置的概率都是1/n,而如果这个目标元素在第1位的话,那么循环只需要循环1次,在第2位的话,循环2次,以此类推,如果在第n位,那就循环n次。因此,平均来看,平均所需要的循环次数就应该是这个循环的次数乘以这种情况发生的概率,然后相乘相加,最终得到的结果应该是(n+1)/2。因此,平均时间复杂度应该是O(n)。
    在这里插入图片描述

线性表的链式表示

单链表的定义

在这里插入图片描述

  • 用链式存储实现的线性表统称为链表,而链表又可以具体分为单链表、双链表、循环链表和静态链表。

在这里插入图片描述

  • 单链表可以有两种实现方式,分别是带头结点的和不带头结点的。单链表的一个结点需要存放数据元素,同时还需要包含一个指向下一个结点的指针。由于每一个结点,它只包含一个指针,所以它才叫单链表。我们已经知道顺序表的优点是可以随机存取,并且存储密度高。但是也存在缺点,由于各个数据元素要求在物理上是连续存放的,所以如果用顺序表的方式来实现这种线性结构的话,那就会要求使用大片的连续空间。如果要拓展顺序表的容量的话,是很不方便的,而单链表就可以很好地解决这个问题。
  • 单链表中的各个结点在物理上可以是离散存放的,所以当我们要拓展单链表长度的时候,其实只需要在内存当中随便取一块区域,把它作为存放新结点的区域就可以了。因此,采用这种链式存储的方式,那改变容量就很方便。不过,如果采用这种方式的话,那么我们要找到某一个位序的结点,那么我们只能从第1个结点开始,利用指针的信息依次往后寻找,直到找到我们想要的那个结点,也即是单链表这种实现方式不支持随机存取。接下来看一下怎么用代码定义一个单链表:
struct LNode{		//定义单链表结点类型
	ElemType data;		//每个结点存放一个数据元素
	struct LNode *next;		//指针指向下一个结点
};

struct LNode * p=(struct LNode *) malloc(sizeof(struct LNode));
  • 不难知道,单链表是由一个一个结点组成的,而一个结点当中需要有一片空间是用于存放数据元素的,还需要有另一片空间是存放指向下一个结点的指针,所以我们可以定义一个struct类型的结构体,用于表示一个结点。这个结点当中有一个叫data的变量,用于存放这个数据元素,我们把它称之为数据域。另外,还需要定义一个指向下一个结点的指针,这个指针变量的名字叫next,我们把这个变量成为指针域。有了这个结构体的定义之后,如果我们想要往这个单链表当中增加一个新的结点,那么我们是不是就可以用malloc函数来申请一片存储这个结点的空间,并且用指针p来接收malloc函数的返回值,让它指向这个结点的起始地址。之后就可以设计一些代码逻辑,把p结点插入到这个单链表当中。按照我们这儿的这种写法,以后当我们想要定义一个新的结点的时候,或者想要定义一个指向结点的指针的时候,是不是都得写(struct LNode *) malloc(sizeof(struct LNode)),也即是每次都得带上struct这个关键字,这样写有点麻烦,所以可以使用typedef这个C语言的关键字。用这个关键字可以把这个数据类型给重命名,把它的名字稍微缩短一些,达到简化的效果,接下来看一下怎么初始化一个单链表。
  • 我们先看不带头结点的单链表
typedef struct LNode{		//定义单链表结点类型
	ElemType data;		//每个结点存放一个数据元素
	struct LNode *next;		//指针指向下一个结点
}LNode, *LinkList;

//初始化一个空的单链表
bool InitList(LinkList &L){
	L = NULL;		//空表,暂时还没有任何结点
	return true;
}

void test(){
	LinkList L;		//声明一个指向单链表的指针
	//初始化一个空表
	InitList(L);
	//...后续代码...
}
  • 首先,声明一个指向单链表的指针L。其实本质上这个指针,它是指向某一个结点的。只不过这里我们想强调的是,它指向的是一个单链表,所以我们用LinkList这个别名来定义。执行这一句之后,内存当中会开辟一小片用于存放这个头指针L。再往后执行初始化函数,这个初始化函数很简单,就是把L的值设为NULL,用这样的方式表示当前它是一个空表。做这个操作是为了防止这一小片内存当中以前有遗留的脏数据。另外,当我们传入这个指针变量的时候,我们是传入了它的引用(&L),因为如果没有这个引用符号的话,那么在这个函数里边,修改的所谓的L其实是这个头指针L的一个复制品。对于这种不带头结点的单链表,判断它是否为空的依据就是看它的头指针L此时是不是等于NULL,如果等于NULL的话,说明此时是空的。当然,这段代码还可以写得更简洁一些,直接return(L==NULL),因为这个条件判断的运算结果本身就是true或者false,所以直接把它return就可以了。这是不带头结点的情况,接下来看一下带头结点的情况。
typedef struct LNode{		//定义单链表结点类型
	ElemType data;		//每个结点存放一个数据元素
	struct LNode *next;		//指针指向下一个结点
}LNode, *LinkList;

//初始化一个单链表(带头结点)
bool InitList(LinkList &L){
	L = (LNode *) malloc(sizeof(LNode));		//分配一个头结点
	if (L==NULL)		//内存不足,分配失败
		return false;
	L->next = NULL;		//头结点之后暂时还没有结点
	return true;
}

void test(){
	LinkList L;		//声明一个指向单链表的指针
	//初始化一个空表
	InitList(L);
	//...后续代码...
}
  • 前面的步骤一样,先声明一个指针L,指向一个单链表,在它的初始化函数当中,会用malloc申请一片可以存得下这样的一个结点,并且把malloc返回的地址赋给L,也即是头指针L是指向了这个结点。接下来需要把L这个指针指向的结点当中,next这个指针域设为NULL。这个头结点是不存数据的,我们加这个头结点只是为了之后再实现某一些基本操作的时候会更方便一些。对于这种带头结点的单链表,要判断它是否为空的话,那我们要判断的就是这个头结点的next指针域它是否等于NULL,如果它等于NULL的话,那它就是空的。
  • 单链表的这两种实现方式有什么区别呢,简单来说就是如果不带头结点的话,写代码会更麻烦一些,如果带头结点的话,写代码会更方便一些。所以大多数情况下,我们都会用带头结点的这种方式来实现我们的代码。如果不带头结点的话,那么头指针它所指向的下一个结点,这个结点就是实际用于存放数据的结点,而如果带头结点的话,那么头结点它所指向的这个结点,把它称为头结点,这个头结点是不存放实际的数据元素的,只有这个头结点之后的下一个结点才会用于存放数据。
    在这里插入图片描述

单链表上基本操作的实现

  • 单链表的插入删除
    在这里插入图片描述
  • ListInsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e。
  • 如果单链表是带头结点的,那么怎么实现按位序插入这个操作。我们要在第i歌位置插入一个制定的元素e。那这样的话,我们是不是需要找到第i-1个结点,因为我们肯定需要把第i-1个结点的next指针给修改。比如说,如果i=2,也即是我们要在第2个位置插入一个新结点的话,那么我们就需要先找到第1个结点,然后用malloc申请一个新的结点,往这个结点里存入数据元素e。接下来,对指针进行修改,这样的话,这个新的结点是不是就变成了第2个结点。具体的代码实现如下:
typedef struct LNode{
	ElemType data;
	struct LNode *next;
}LNode, *LinkList;

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ElemType e){
	if(i<1)
		return false;
	LNode *p;		//指针p指向当前扫描到的结点
	int j=0;		//当前p指向的是第几个结点
	p = L;		//L指向头结点,头结点是第0个结点(不存数据)
	while(p!=NULL && j<i-1){
		p=p->next;
		j++;
	}
	if(p==NULL)		//i值不合法
		return false;
	LNode *s = (LNode *)malloc(sizeof(LNode));
	s->data = e;
	s->next = p->next;
	p->next = s;		//将结点s连到p之后
	return true;		//插入成功
}
  • 函数当中传入了一个单链表,然后这个单链表是带头结点的。指定了此次要插入的位置位序,并且给出了新的结点当中要存放的数据元素。如果i=1,也即是要在表头的位置插入一个新元素的话,如果i<1,那说明这个i的值不合法,因为i表示的是位序,位序是从1开始的,所以如果传入的这个参数本身不合法的话,直接return false,表示插入失败。由于此时我们的i=1,因此会声明一个指针p,然后这个指针p指向了和L相同的位置,也即是指向了头结点。我们定义了一个变量j,j表示的是当前p所指向的是第几个结点。头结点可以看成是第0个结点,所以此时j的值应该是0。虽然这儿我们把它称之为第0个结点,但其实单链表当中实际存放的是后面那些结点,而那些结点的编号,它是从1开始的,所以我们不允许i的值小于1。接下来就会开始执行while循环,此时p!=NULL,这是满足的,但是j<i-1这个条件不满足,因此这个循环不会执行,它会直接跳到下面。接下来将会申请一个新的结点空间,然后把参数e存到新的结点里面,接下来它会让s指向的这个结点的next这个指针等于p结点的next指针指向的位置,最后一句会让p结点的next指针指向新的结点s。这样的话,我们是不是就实现了在第1个位置插入数据元素e这个操作。由于i=1,while循环直接被跳过了,所以这种情况下只需要O(1)这样的时间复杂度就可以执行完成。
  • 接下来分析另一种情况,如果i=3,此时i-1就应该是2,前面的操作会导致j的值等于0,然后p指向头结点,然后接下来开始while循环。此时p!=NULL,同时j也小于2,所以会执行循环里面的代码,让p=p->next,也即是让它指向下一个结点,所以j的值就变成了1。由于此时j依然是小于2的,所以第二轮循环的条件也是满足的,因此还会再执行循环里面的代码,p会指向再下一个结点,同时j的值会再加1,也即是变成2。由于此时条件已经不满足了,所以循环结束后会执行后续的语句。再往后的话,和之前一样,也即是申请一个结点,然后把这个指针给依次修改。这样的话,我们就在第3个位置插入了数据元素e。
  • 再下一种情况,如果i的值等于5的话,那么首先p同样是会先指向头结点,然后i-1点值就应该是4。j的值刚开始也是0,第一次执行这个循环,p会往后移1位,然后j的值变为1,第二次循环p又会指向再下一个结点,然后j的值变为2,以此类推。当j=4的时候,条件不满足,然后就可以跳出循环执行之后的语句。值得注意的是,j的值表示的是当前p指向的是第几个结点,而执行这个循环的本质原因,最终的目的是想要找到第i-1个结点,因为我们要插入的是第i个结点,所以只要我们找到第i-1个结点,然后就可以用后续的代码把新结点连到第i-1个结点之后就可以了。继续往后分析,malloc会申请一个新的结点,然后把数据元素填到里边,接下来会让s的next指向p的next,而由于p结点以前是指向NULL的,所以进一步的运算,s结点也就指向NULL,最后再让p的next指向新的结点。这样的话,我们就在第5个位置,也即是表尾的位置加入了一个新的结点。由于此次要找的这个结点是最后一个结点,所以while循环的循环次数是最多的,因此把新结点插到表尾的这种操作是最坏的情况,这种情况下它的时间复杂度就应该是O(n),这儿的问题规模n指的是这个表的长度。
  • 最后再来分析i=6的情况,假设此时这个表的实际长度其实只有4,所以在这种情况下,如果插入新结点的话,那最多插入第5个结点,不可能直接插入第6个结点,来看一下代码如何处理这个问题。首先还是一样的,p指向了头结点,然后j的值刚开始是0,然后i-1点值是5。经过第一次循环,p会往后移1位,然后j的值变成了1,第二次循环p再往后移1位,j的值变为2,第三次循环j的值变为3,第四次循环j的值变为4。此时p!=NULL,这个条件是满足的,同时也小于5,这个条件也是满足的,所以还会进行第五次的循环,第五次循环p指针会指向当前结点的next,也即是NULL,j的值变为5。再往后的条件检查,p此时是不是等于NULL,这个条件已经不满足了,所以while循环结束就可以执行if语句。p=NULL说明第i-1个结点是不存在的,现在连i-1个结点都找不到,更不可能插入第i个结点了,所以就直接return一个false。因此,如果i的值太大的话,那么最终会因为条件不满足而跳出循环。
  • 如果单链表是不带头结点的,可以先找到第i-1个结点,然后把新结点放到第i-1个结点之后。由于不带头结点,所以不存在所谓的第0个结点。因此,如果此时我们要在第一个位置插入元素的话,那么我们需要对这种情况进行特殊的处理,具体实现代码如下:
typedef struct LNode{
	ElemType data;
	struct LNode *next;
}LNode, *LinkList;

bool ListInsert(LinkList &L, int i, ElemType e){
	if(i<1)
		return false;
	if(i==1){		//插入第1个结点的操作与其它结点操作不同
		LNode *s = (Lnode *)malloc(sizeof(LNode));
		s->data = e;
		s->next=L;
		L=s;		//头指针指向新结点
		return true;
	}
	LNode *p;		//指针p指向当前扫描到的结点
	int j=1;		//当前p指向的是第几个结点
	p = L;		//p指向第1个结点(注意:不是头结点)
	while(p!=NULL && j<i-1){		//循环找到第i-1个结点
		p=p->next;
		j++;
	}
	if(p==NULL)		//i值不合法
		return false;
	LNode *s = (LNode *)malloc(sizeof(LNode));
	s->data = e;
	s->next = p->next;
	p->next = s;		//将结点s连到p之后
	return true;		//插入成功
}
  • 我们对i=1对情况进行了特殊的处理,在这种情况下,首先也是会用malloc申请一个新的结点,接下来把e写到里边,再往后,会让新结点的next指针指向L所指向的结点,最后,需要修改头指针L,让L指向新的结点,然后return true,表示插入成功。所以可以看到,如果这个单链表,它是不带头结点的单链表的话,当我们插入或者当我们删除第一个结点,也即是第一个元素的时候,我们肯定要更改这个头指针的指向,但是如果带头结点的话,那头指针肯定永远都是指向那个头结点的。所以,由于不带头结点的情况下,对第一个结点的操作需要专门写一段逻辑来处理。如果i不等于1而大于1的话,那么其实后续的处理就和带头结点的情况是一样的。唯一需要注意的是,我们把j的值设置为1,表示p指针刚开始指向的结点是第一个结点。
  • 接下来要探讨的是怎么实现后插操作,也即是给定一个结点,在这个结点之后插入一个数据元素e。由于单链表的这个链接指针只能往后寻找,所以给定一个结点p的话,那其实p之后的那些结点我们都是可知的,可以用循环的方式来把它们都给找出来。但是p结点之前的那些结点,我们是没有办法知道的。我们要实现的是在p结点之后插入数据元素e。
typedef struct LNode{
	ElemType data;
	struct LNode *next;
}LNode, *LinkList;

//后插操作:在p结点之后插入元素e
bool InsertNextNode(LNode *p, ElemType e){
	if (p==NULL)
		return false;
	LNode *s = (LNode *)malloc(sizeof(LNode));
	if (s==NULL)		//内存分配失败
		return false;
	s->data = e;		//用结点s保存数据元素e
	s->next = p->next;
	p->next = s;		//将结点s连到p之后
	return true;		//插入成功
}
  • 首先,用malloc申请一片空间,我们加了一个条件判断,如果malloc函数执行的结果,返回的值是一个NULL的话,那么说明此次内存分配失败,这种情况其实是有可能出现的,比如说内存已经满了之类的。如果此次内存分配成功的话。那么会接着执行后续的代码,把数据元素e填到新结点当中,然后像之前那样修改各个指针,这样的话就完成了把数据元素e插入到p结点之后这个操作。显然这段代码并没有循环什么的,它的时间复杂度为O(1)。
  • 之前我们在第i个位置插入数据元素e这个基本操作,做的就是先找到第i-1个结点,然后就是在这个结点之后插入数据元素e,所以在实现后插操作之后,我们就可以调用函数完成了。
typedef struct LNode{
	ElemType data;
	struct LNode *next;
}LNode, *LinkList;

//后插操作:在p结点之后插入元素e
bool InsertNextNode(LNode *p, ElemType e){
	if (p==NULL)
		return false;
	LNode *s = (LNode *)malloc(sizeof(LNode));
	if (s==NULL)		//内存分配失败
		return false;
	s->data = e;		//用结点s保存数据元素e
	s->next = p->next;
	p->next = s;		//将结点s连到p之后
	return true;		//插入成功
}

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ElemType e){
	if(i<1)
		return false;
	LNode *p;		//指针p指向当前扫描到的结点
	int j=0;		//当前p指向的是第几个结点
	p = L;		//L指向头结点,头结点是第0个结点(不存数据)
	while(p!=NULL && j<i-1){
		p=p->next;
		j++;
	}
	return InsertNextNode;
}
  • 接下来要看的是前插操作,也即是给定一个结点p,要在这个结点p之前插入一个新的数据元素e。我们的单链表它只能往后找,不能往前找,所以对于给定的结点p,它之前有哪些结点我们是看不到的。我们其实可以传入一个头指针,如果给了头指针的话,那整个链表的所有信息我们就都可以知道了。那这样的话,我们要在结点p之前插入一个新的元素,那我们是不是可以一次遍历各个结点,然后找到这个结点p的前驱结点,然后在这个前驱结点中后插入数据元素e,这样就可以了。显然,如果用这种方式实现的话,那时间复杂度应该是O(n)这个数量级。但是有的时候那些结点可能真的就是看不到,也即是如果它不传这个头指针的话,那刚才的思路就没办法实现了。接下来看另一种实现方式:
//前插操作:在p结点之前插入元素e
bool InsertPriorNode(LNode *p, ElemType e){
	if (p==NULL)
		return false;
	LNode *s = (LNode *)malloc(sizeof(LNode));
	if (s==NULL)		//内存分配失败
		return false;
	s->next = p->next;
	p->next = s;		//新结点s连到p之后
	s->data = p->data;		//将p中元素复制到s中
	p->data = e;		//p中元素覆盖为e
	return true;		//插入成功
}
  • 要在p的前面插入一个数据元素e,首先申请一个新的结点,然后把这个结点作为p结点的后继结点。s->data=p->data会把p结点当中以前存放的数据元素给复制过来,然后再下一句会把此次要新插入到数据源e放到p结点里面。虽然我们没办法找到p结点的前驱结点,但是用这样的方式在逻辑上也可以实现同样的效果,并且,这种实现方式,它的时间复杂度为O(1)。而前面要找它的前驱结点的思路,时间复杂度是O(n),因为必须要循环遍历各个结点,以上便是和插入相关的所有的操作的实现,接下来我们要探讨的是怎么删除一个结点。
  • ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。
  • 如果此次要删除第i个位置的元素的话,那我们是不是也需要找到它的前驱结点,因为我们需要更改它前驱结点的next指针。对于删除操作的实现,我们只探讨带有结点的这种情况。同样的,头结点我们要把它看作第0个结点。所以我们要删除的是第一个结点的话,那么按照刚才我们提出的这种思路,我们要找到第i-1个结点,也即是第0个结点,然后把第0个结点的next指针指向再往后的一个结点,然后我们还需要用free函数把第i个结点给释放掉。接下来我们看一下具体代码的实现:
typedef struct LNode{
	ElemType data;
	struct LNode *next;
}LNode, *LinkList;

bool ListDelete(LinkList &L, int i, ElemType &e){
	if(i<1)
		return false;
	LNode *p;		//指针p指向当前扫描到的结点
	int j = 0;		//当前p指向的是第几个结点
	p = L;		//L指向头结点,头结点是第0个结点(不存数据)
	while(p!=NULL && j<i-1){		//循环找到第i-1个结点
		p == p->next;
		j++;
	}
	if(p==NULL)		//i值不合法
		return false;
	if(p->next = NULL)		//第i-1个结点之后已无其它结点
		return false;
	LNode *q = p->next;		//令q指向被删除结点
	e = q->data;		//用e返回元素的值
	p->next = q->next;		//将*q结点从链中“断开”
	free(q);		//释放结点的存储空间
	return true;		//删除成功
}
  • 分析一下,如果i=4,首先需要根据之前的逻辑找到第3个结点,也即是此次要删除的这个结点的前驱结点,这些逻辑和插入操作其实是一样的,最后p会指向第3个结点。接下来会定义一个指针q,q指针指向p结点的next,也即是指向了第i个结点,接下来会把q结点的这个数据元素复制到变量e里边。注意这个变量e需要把此次删除的结点值带回到函数的调用者哪儿,所以e这个参数是引用类型的。再往后,p的next要指向q的next,也即是指向NULL。最后,调用free函数把q结点给释放掉。这样的话,我们就删除了第4个结点。由于需要依次循环地来找到第i-1个结点,所以这个算法的最坏时间复杂度和平均时间复杂度应该是O(n)这个数量级。而此次要删除的是第一个结点的话,那么是不需要进行这个循环的。所以最好的情况,时间复杂度应该是O(1)这个数量级。
  • 接下来我们看一下怎么删除指定的一个结点,按照之前的思路,如果要删除结点p的话,是不是还需要修改它的前驱结点的next指针,同样的问题发生了,我们没办法找到它的前驱结点,除非像刚才一样,要么就是传入一个头指针,从链表头一次往后寻找p的前驱结点。第二种方法呢,就是类似于刚才前插操作的实现。
bool DeleteNode(LNode *p){
	if(p==NULL)
		return false;
	LNode *q = p->next;		//令q指向*p的后继结点
	p->data = p->next->data;		//和后继结点交换数据域
	p->next = q->next;		//将*q结点从链中“断开”
	free(q);		//释放后继结点的存储空间
	return true;		//删除成功
}
  • 首先声明一个指针q指向p的后继结点,然后我们把p的后继结点的这个数据元素复制到p结点的数据域里面,然后再让p结点的next指针指向q结点之后的位置。当然,q结点之后有可能是一个实际的结点,也有可能是NULL,但是无所谓。最后还需要把q结点释放掉,将内存归还给系统,这种实现方式的时间复杂度也是O(1)。
  • 接下来问题来了,我们考虑一种极限情况,如果此次要删除的这个p结点,它刚好就是这个单链表的最后一个结点的话,那看下代码,首先,q指针是指向了p的next,也即是指向了NUL,再往后执行到p->data = p->next->data的时候,是不是就会出错了,因为此时q结点并没有指向某一个具体的结点,所以想在q结点里边取得它的data域,是不是就会出现空指针的错误,所以这段代码其实它是有bug的。如果说p结点刚好是最后一个结点的话,我们只能从表头开始,依次往后寻找,找到它的前驱,也即是用之前提到的那种比较土的思路来解决这个问题。这个时候大家是不是可以体会到,单链表只能单向地来搜索各个结点,而不能逆向地搜索,是不是有时候会确实不太方便,如果说各个结点还有往前的指针呢,情况是不是就不一样了,像这种可以双向搜索的链表就是双链表。
    在这里插入图片描述
  • 单链表的查找
    在这里插入图片描述
  • 单链表的查找分为按位查找和按值查找,这里我们探讨的所有的代码都是机遇带头结点的单链表。
  • GetElem(L,i):按位查找。获取表L中第i个位置的元素值。
  • LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字值的元素。
  • 所谓按位查找,即是要找到L这个单链表当中的第i个结点,把这个结点给返回。在之前的按位插入和按位删除这两个基本操作里面,其实已经实现了按位查找相关的代码逻辑,只不过我们是找到第i-1个结点。代码类似,如果我们要找到第i个结点的话,改成i就可以了。
//按位查找,返回第i个元素(带头结点)
LNode * GetELem(LinkList L, int i){
	if(i<0)
		return NULL;
	LNode *p;		//指针p指向当前扫描到的结点
	int j = 0;		//当前p指向的是第几个结点
	p = L;		//L指向头结点,头结点是第0个结点(不存数据)
	while(p!=NULL && j<i){		//循环找到第i个结点
		p = p->next;
		j++;
	}
	return p;
}
  • 首先要判断一下i的值是否小于0,如果小于0的话,返回一个NULL,因为我们这儿讨论的是带头结点的这种情况,所以我们可以把头结点认为是第0个结点。因此,如果此次传入的参数i=0的话,那么首先经过一系列执行,p指针会指向头结点,而j的值等于0,i的值此时也是0,所以条件是不满足的,因此会直接跳过循环,直接返回当前p指向的结点,也即是返回头结点,这是一种极端情况。
  • 再来看另一种极端情况,如果i的值大于链表的实际长度,假设实际长度为4,i=8,那么来分析一下代码。首先p指向了头结点,然后j的值刚开始是0。第一轮循环之后,p指针指向下一个结点,j的值变为1,第二轮循环p再往后指一个结点,j的值变为2,第三轮循环p指向第三个结点,然后j的值变为3,以此类推。接下来,循环的条件p!=NULL不满足,于是循环结束,返回p指针此时指向的值,也即是返回一个NULL。所以当i值不合法的时候,它最终返回的值就是一个NULL。因此,如果调用这个基本操作的话,只需要判断一下此次的返回值是不是等于NULL,就可以知道此次的按位查找操作到底是不是执行成功了。接下来,如果i=3点话,分析起来也是一样的。很显然,按位查找这个操作,它的平均时间复杂度应该是O(n)这个数量级。所谓平均情况就是指此次输入的这个i值,它取的合法范围内的任何一个数字的概率都等可能的这种情况,具体的算法其实和顺序表的按位查找那种分析方法是一样的。
  • 既然我们在这实现了按位查找的基本操作,那之前按位插入和按位删除是不是就可以直接调用我们的基本操作来实现
  • 接下来是怎噩梦实现按值插入,也即是给定一个数据元素e,然后看一下在点链表当中,有没有哪个结点的值是等于e的。
//按值查找,找到数据域==e的结点
LNode * LocateELem(LinkList L, ElemTYpe e){
	LNode *p = L->next;
	//从第1个结点开始查找数据域为e的结点
	while(p!=NULL && p->data != e)
		p = p->next;
	return p;		//找到后返回该结点指针,否则返回NULL
}
  • 假设这儿的所谓ElemType,它是int型的变量,单链表中第二个数据结点的元素值为8,这次传入的e这个变量,它等于8。首先会让一个p指针指向头结点的下一个结点,也即是指向第一个数据结点,之后进行while循环,此时p!=NULL是满足的,并且p这个结点的数据域,它的值不等于e的值,也即是不等于8,因此会让p指针指向下一个结点。接下来要进行第二轮循环,但是由于p结点当中存储的这个数据,它的值和e的值是相等的,所以玄幻条件不满足,因此执行之后的一句,也即是return这个p指针,会跳出循环,把这个p结点给返回。这样的话,我们就找到了一个和给定的元素相等的结点。
  • 再来看一个不能找到结点的情况,如果e的值此次传入的是6,而单链表中数据结点的元素值不含6,和刚才一样,通过while循环的执行,p指针会一次一次地往后移,一直移到最后面的位置,当p指向NULL的时候,p!=NULL得不到满足,于是跳出while循环,然后返回p,也即是返回一个NULL。当函数的调用者接收到NULL的时候,就说明并不存在数据域等于6的结点。
  • 由于按值查找操作只能从第一个结点开始,用循环依次地往后扫描p指针,所以很显然这个算法它的时间复杂度应该是O(n)这个数量级。
  • 最后再来看一下怎么求一个单链表的长度,其实实现的核心是一样的,即是让p指针依次往后移,然后用一个变量依次累加来记录这个表到底有多长,最后再返回这个值。
//求表的长度
int Length(LinkList L){
	int len = 0;		//统计表长
	LNode *p = L;
	while(p->next != NULL){
		p = p->next;
		len++;
	}
	return len;
}
  • 由于求表长这个操作也需要用while循环,让p指针从头到尾依次扫描,因此它的时间复杂度肯定也是O(n)这个数量级。
    在这里插入图片描述
  • 单链表的两种建立方法,分别为尾插法和头插法。
    在这里插入图片描述
  • 概括来说,这里要探讨的问题就是给出很多个数据元素,也即是很多个ElemType,那么把它们存到一个单链表里,应该怎么处理。其实很简单,第一步肯定是要从无到有先创建一个单链表,也即是先初始化一个单链表。接下来,每一次去一个数据元素,然后把这个数据元素插到表尾的位置,或者每一次都插到表头的位置,所以这两种方法就分别对应所谓的尾插法和头插法,这里我们探讨的是带头结点的单链表。
  • 首先我们要探讨的是尾插法:
typedef struct LNode{		//定义单链表结点类型
	ElemType data;		//每个结点存放一个数据元素
	struct LNode *next;		//指针指向下一个结点
}LNOde, *LinkList;

//初始化一个单链表(带头结点)
bool InitList(LinkList &L){
	L = (LNode *)malloc(sizeof(LNode));		//分配一个头结点
	if(L==NULL)		//内存不足,分配失败
		return false;
	L->next = NULL;		//头结点之后暂时还没有结点
	return true;
}

void test(){
	LinkList L;		//声明一个指向单链表的指针
	//初始化一个空表
	InitList(L);
	//...后续代码...
}
  • 第一步要先初始化一个单链表,接下来我们每一次取一个数据元素插到这个单链表的尾部,这个操作我们可以按之前已经实现的按位序插入这个基本操作来实现。由于我们每一次都是要把数据源插入到这个单链表的表尾,所以我们可以设置一个变量叫length,用这个变量来记录单链表的当前长度。然后再写一个while循环,每次取出一个数据元素e,然后调用按位插入这个基本操作,每次都把这个数据元素e插入到length+1个位置。而每一次插入一个新的元素之后,都会导致单链表的长度length+1,这样的方式就可以实现用尾插法建立一个单链表。
//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ElemType e){
	if(i<1)
		return false;
	LNode *p;		//指针p指向当前扫描到的结点
	int j = 0;		//当前p指向的是第几个结点
	p = L;		//L指向头结点,头结点是第0个结点(不存数据)
	while(p!=NULL && j<i-1){		//循环找到第i-1个结点
		p = p->next;
		j++
	}
	if(p==NULL)		//i值不合法
		return false;
	LNode *s = (LNode *)malloc(sizeof(LNode));
	s->data = e;
	s->next = p->next;
	p->next = s;		//将结点s连到p之后
	return true;		//插入成功
}
  • 不过如果用这种方式实现的话,那么当每次要在表尾的位置插入一个元素的时候,它都会用循环从表头的位置开始依次往后遍历,直到找到最后一个结点。按照这个逻辑,当我们要插入第1个元素的时候,也即是只有一个头结点的时候,while循环可以直接跳过,也即是循环次数是0次。而当我们要出入第2个元素的时候,while循环需要循环1次。要插入第3个元素的时候,需要循环2次,以此类推。所以如果我们要插入n个元素的话,总共需要循环n-1次。因此循环的次数总共就应该是0+1+2+…+(n-1),算出来应该是O(n^2)这样的一个时间复杂度。这个时间复杂度还是很高的,那其实我们根本没有必要每一次都从头开始往后寻找。其实我们可以设置一个指针,让这个指针指向表尾的最后一个数据结点,然后当我们要在尾部插入一个新的数据元素的时候,只需要对结点做一个后插操作就可以了。
//后插操作:在p结点之后插入元素e
bool InsertNextNode(LNode *p, ElemType e){
	if(p==NULL)
		return false;
	LNode *s = (LNode *)malloc(sizeof(LNode));
	if(s==NULL)		//内存分配失败
		return false;
	s->data = e;		//用结点s保存数据元素e
	s->next = p->next;
	p->next = s;		//将结点s连到p之后
	return true;
}
  • 接下来我们看一下头插法,顾名思义,也就是说我们每一次取一个新的数据元素时候,都把它插入到单链表的表位置。实现这个算法的核心和刚才一样,也是一个对指定结点的后插操作,每插入一个数据元素,其实就是对这个头结点执行一次后插操作,所以用头插法建立单链表也很简单,即是先初始化一个单链表,然后循环里面每次取一个数据元素,然后每取得一个数据元素之后,都调用一次后插操作,每次都是对头结点进行后插操作,插入新的数据元素e。
LinkList_HeadInsert(LinkList &L){		//逆向建立单链表
	LNode *s;
	int x;
	L = (LinkList)mallloc(sizeof(LNode));		//创建头结点
	L->next = NULL;		//初始为空链表
	scanf("%d", &x);		//输入结点的值
	while(x!=9999){		//输入9999表示结束
		s=(LNode *)malloc(sizeof(LNode));		//创建新结点
		s->data = x;
		s->next = L->next;
		L->next = s;		//将新结点插入表中,L为头指针
		scanf("%d", &x);
	}
	return L;
}
  • 以上代码中每次取的数据元素是用scanf,也即是让用户用键盘输入一个整数,作为此次要插入的新的数据元素。然后while循环里面的代码其实就是实现了一个后插操作,只不过每一次执行后插操作的指定结点都是指定了头结点。再往后执行,申请一个新的结点s,这个s结点的data域把它设为x,再往后的话,s结点的next指针指向了L结点的next指针,然后头结点再指向新的结点。如果还有别的新的结点陆续插入的话,情况也是类似的。最后一个结点的next指针,它肯定都会指向我们都不知道是什么地方的地方,所以我们必须初始化。

双链表

在这里插入图片描述

  • 在单链表中,由于每一个结点只包含指向它的后继结点的指针,所以给定一个结点p的话,那么想要找到它的前驱结点是很麻烦的,双链表就是在单链表的基础上再添加一个指针域。
typedef struct DNode{		//定义双链表结点类型
	ElemType data;		//数据域
	struct DNode *prior, *next;		//前驱和后继指针
}DNode, *DLinkList;
  • 指针prior是指向结点的前驱结点,来看一下怎么从无到有创建一个双链表,这里我们讨论的是带头结点的情况。
typedef struct DNode{
	ElemType data;
	struct DNode *prior, *next;
}DNode, *DLinkList;

//初始化双链表
bool InitDLinkList(DLinkList &L){
	L = (DNode *)malloc(sizeof(DNode));		//分配一个头结点
	if(L==NULL)		//内存不足,分配失败
		return false;
	L->prior = NULL;		//头结点的prior永远指向NULL
	L->next = NULL;		//头结点之后暂时还没有结点
	return true;
}

void testDLinkList(){
	//初始化双链表
	DLinkList L;
	InitDLinkList(L);
	//...后续代码...
}
  • 首先,声明一个指向头结点的指针L,然后调用双链表的初始化函数,会申请一片空间用来存放头结点,并且让指针L指向头结点,然后需要把头结点的前向指针和后向指针都设为NULL。这个头结点之前肯定不会再有其它的结点了,所以头结点的prior指针域肯定是永远指向NULL的。对于带头结点的双链表来说,如果要判断它是否为空的话,只需要判断这个头结点的next指针是否等于NULL就可以了。如果等于NULL的话,说明这个表此时暂时还没有存入任何数据元素,接下来看一下双链表的插入怎么实现。
//在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s){
	s->next = p->next;		//将结点*s插入到结点*p之后
	p->next->prior = s;
	s->prior = p;
	p->next = s;
}
  • 第一步会把s结点的next指针指向p结点的下一个结点,第二步会把p结点的后继结点,它的前向指针指向此次新插入的s结点,第三步,把s结点的前向指针指向p结点,第四步,再把p结点的后向指针指向s结点,这就完成了p结点的后插操作,在它的后面插入s结点。如果p结点,它刚好是双链表的最后一个结点的话,那么第二句执行就会出现问题,因为p的next为NULL,所以肯定会有一个空指针的错误。因此,这段代码我们可以把它写得更严谨一点。
//在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s){
	if(p==NULL || s==NULL)		//非法参数
		return false;
	s->next = p->next;
	if(p->next!=NULL)		//如果p结点有后继结点
		p->next->prior = s;
	s->prior = p;
	p->next = s;
	return true;
}
  • 其中加一个if语句来处理p结点没有后继结点的情况,假设现在p结点是最后一个结点,第一句第一句代码会让s结点的next指针指向p结点的next指针相同的位置,也即是同样指针NULL。然后先判断p结点此时还有没有后继结点,如果它没有后继结点的话,那当然就不需要再修改它后继结点的前向指针,因此,在这种情况下,if条件不满足会跳到之后一句执行,让s结点的前向指针指向p结点。第四句,再把p结点的后向指针指向新插入的s结点。如果想要按位序插入一个新的结点的话,我们只需要从头结点开始,找到某一个位序的前驱结点,然后对这个前驱结点执行后插操作就可以了。而如果我们想要在某一个结点前面进行一个前插操作的话,由于双链表的特性,我们可以很方便地找到给定结点的前驱结点,然后再对它的前驱结点执行后插操作,这样的话我们就要可以实现所谓的前插操作,也即是其它的插入操作其实最终都可以转换为用这个后插操作来实现,接下来看怎么实现双链表的删除。
//删除p结点的后继结点q
p->next = q->next;
q->next->prior = p;
free(q);
  • 假设我们此次要删除的是指定结点p的后继结点q结点,第一句代码会让p结点的next指针指向q结点的next指针相同的位置,也即是指向q的后继结点,第二句代码会把q结点的后继结点的前向指针指向p结点,第三句代码,再释放q结点,这样的话就完成了对q结点的删除。不过和之前一样,这个代码也是有一点点问题的。如果此时要删除的结点q刚好是双链表的最后一个结点的话,那么第二句代码同样是会出现一个空指针的错误,所以我们可以增加一些条件判断,用来提升这个代码的健壮性。
//删除p结点的后继结点
bool DeleteNextDNode(DNode *p){
	if(p==NULL)
		return false;
	DNode *q = p->next;		//找到p的后继结点q
	if(q=NULL)		//p没有后继
		return false;
	p->next = q->next;
	if(q->next!=NULL)		//q结点不是最后一个结点
		q->next->prior = p;
	free(q);		//释放结点空间
	return true;
}
  • 假设此次我们要删除的是给定结点p的后继结点的话,首先会声明一个q指针,让q指针指向p的后继结点。如果q==NULL的话,那说明p结点是没有后继结点的,这种情况下返回false。接下来是让p结点的next指针指向q结点的next指针相同的位置,再往后,我们需要先判断一下q结点还有没有后继结点,如果q结点有后继结点的话,那么我们才会尝试修改它后继结点的前向指针。实现了这个删除操作之后,如果我们想要销毁一个双链表的话,那我们是不是可以用一个while循环,每一次都删除头结点的后继结点,一次把这些结点占用的空间给释放掉,直到头结点之后再无其它结点,也即是说这个表变空了,最后再把这个头结点占的空间也给释放掉,然后让头结点指向NULL,这样的话就可以销毁一个双链表。
  • 最后来看一下双链表的遍历怎么实现,其实也很简单,就是一个while循环,每次循环让p指针指向下一个结点。
  • 后向遍历:
while(p!=NULL){
	//对结点p做相应处理,如打印
	p = p->next;
;
  • 在这个循环的内部,可以对此次p指针指向的结点做相应的处理,不如说要打印出这个结点的数值之类的。
  • 前向遍历:
while(p!=NULL){
	//对结点p做相应处理
	p = p->prior;
;
  • 前向遍历也是一样的,给一个指定的结点p,然后每次让这个p指针往前移一位,在while循环里对结点p做相应的处理就可以了。如果在这个循环里面只想处理那些数据结点,并不想处理头结点的话,那只需要把while循环的条件给改一下就行了。如果说p结点的前向指针已经等于NULL的话,那么说明此时p结点指向的就已经是头结点了,这样的话,while循环条件不满足,也即是不会对当前p结点指向的头结点进行处理:
while(p->prior!=NULL){
	//对结点p做相应处理
	p = p->prior;
;
  • 只要知道怎么遍历一个双链表,那本质上按位查找,按值查找这些操作,核心代码就是一个遍历。如果要实现按位查找的话,那么在这个循环里面,只需要累加一个计数器,用于记录此时指向的是哪个位序的元素就可以了。如果要按值查找的话,在循环里面只需要对当前指向的结点进行一个值的对比就可以了。
  • 由于双链表并没有随机存取的特性,所以这种查找操作时间复杂度就是O(n)这个数量级,因为只能用这种循环的方式,一个一个对比,依次往后找。
    在这里插入图片描述

循环链表

在这里插入图片描述

  • 在单链表中,最后一个结点的next指针是指向NULL,但是循环单链表当中最后一个结点的next指针是指回了头结点。
typedef struct LNode{		//定义单链表结点类型
	ElemType data;		//每个结点存放一个数据元素
	struct LNode *next;		//指针指向下一个结点
}LNode, *LinkList;

//初始化一个循环单链表
bool InitList(LinkList &L){
	L = (LNode *)malloc(sizeof(LNode));		//分配一个头结点
	if (L==NULL)		//内存不足,分配失败
		return false;
	L->next = L;		//头结点next指向头结点
	return true;
}
  • 需要注意的是,在初始化一个循环单链表的时候,我们需要把头结点的next指针指向头结点它自己。相应的,如果要判断它是否为空的话,只需要检查它的头结点的next指针是否是指向它自己就可以了。
//判断循环单链表是否为空
bool Empty(LinkList L){
	if(L->next==L)
		return true;
	else
		return false;
}
  • 相比之下,之前普通的单链表,当它为空的时候,头结点的next指针是指向空的,也即是指向NULL。如果循环单链表不为空的话,就需要把最后一个结点的next指针指向头结点。相应的,如果要判断某一个结点p,它是否是循环单链表的表尾结点的话,只需要看一下这个p结点的下一个结点是否为头结点。
//判断结点p是否为循环单链表的表尾结点
bool isTail(LinkList L, LNode *p){
	if(p->next==L)
		return true;
	else
		return false;
}
  • 对于一个普通的单链表,如果给一个结点p,那么其实只知道这个结点p后续的结点,对于它前面的那些结点是什么情况,是不可知的,除非能获得头结点的指针。但是对于循环单链表来说,只要给一个结点p,那肯定可以找到整个循环单链表当中的任意一个结点。这种特性还是有一些作用的,不如说如果要实现一个功能,删除结点p,删除这个结点之后,肯定需要修改它的前驱结点的next指针,但是对于普通的单链表,只知道结点p的指针,肯定找不到它的前驱结点。而对于循环单链表来说,可以顺着链,依次往后找,然后直到找到此次要删除的结点p的前驱结点,然后修改它前驱结点的next指针,这样的话就可以完成删除结点p的工作。所以可以循环遍历各个结点,这个特效还是有一些作用的。
  • 之前我们对链表的操作很多时候都是在链表的头部或者链表的尾部,比如之前提到的用头插法建立链表或者用尾插法建立链表。如果这个单链表,它是普通的单链表,也即是最后一个结点,它的next指针是指向NULL的话,此时,如果我们只知道这个链表的头结点,那么要从头结点开始,找到最后一个表尾的结点,我们只能写一个循环,依次往后扫描,直到找到最后一个结点,所以找到最后一个结点的时间复杂度是O(n)这个数量级。而对于循环单链表来说,如果我们让单链表的指针L不是指向头结点,而是指向尾部的结点,那么从这个尾结点出发,找到头结点只需要O(1)的时间复杂度,因为只要往后找一个结点就可以了。而由于L这个指针是指向尾部的,所以当我们需要对链表的尾部进行操作的时候,也可以在O(1)的时间复杂度内就直接找到我们要操作的那个位置,而不需要像之前那样从头往后依次循环遍历。
  • 接下来看一下循环双链表,表尾结点的next指针,它会指向头结点,然后头结点的prior指针,它又会指向尾结点,也即是所有的next指针,它其实形成了一个闭环,一个循环,而所有的prior指针,它也形成了另外一个方向的闭环,循环,这便是循环双链表。
typedef struct DNode{
	ElemType data;
	struct DNode *prior, *next;
}DNode, *DLinkList;

//初始化空的循环双链表
bool InitDLinkList(DLinkList &L){
	L = (DNode *)malloc(sizeof(DNode));		//分配一个头结点
	if(L==NULL)		//内存不足,分配失败
		return false;
	L->prior = L;		//头结点的prior指向头结点
	L->next = L;		//头结点的next指向头结点
	return true;
}

void testDLinkList(){
	//初始化循环双链表
	DLinkList L;
	InitDLinkList(L);
	//...后续代码...
}
  • 当我们在初始化一个空的循环双链表的时候,我们需要让头结点的前指针和后指针都指向头结点自己,而普通的双链表它们都是指向NULL。所以,循环双链表的判空也有一点点区别,就是判断此刻这个头结点的next指针是否指向了它自身。如果满足了条件的话,那么说明此时这个循环双链表,它是一个空表,就return一个true。
//判断循环双链表是否为空
bool Empty(DLinkList){
	if(L->next==L)
		return true;
	else
		return false;
}
  • 而对于循环双链表,它的最后一个结点的next指针是指向了头结点,所以当我们判断一个结点p,它是不是循环双链表表尾结点的时候,判断的条件应该是这个结点的next指针是否指向了头结点,如果满足的话,那么说明这个结点,它就是尾部的结点。按照这个逻辑,当循环双链表为空表的时候,这个头结点,它的next指针也是指向了头结点本身。所以这个头结点,它既是第一个结点,也是最后一个结点。
//判断结点p是否为循环双链表的表尾结点
bool isTail(DLinkList L, DNode *P){
	if(p->next==L)
		return true;
	else
		return false;
}
  • 接下来看一下循环双链表和普通的双链表,它们在实现基本操作的时候有哪些区别,看之前提到过的这段代码:
//在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s){
	s->next = p->next;		//将结点*s插入到结点*p之后
	p->next->prior = s;
	s->prior = p;
	p->next = s;
}
  • 这段代码实现了在结点p之后插入一个结点s,如果用这段代码处理普通的双链表的话,当p结点刚好是表尾结点的时候,第二句代码的执行会出现错误,因为p结点它没有后继结点,所以我们就无法修改所谓的它的后继结点的前向指针。但如果我们用的是循环双链表的话,那这个逻辑其实就是正确的。因为即便p结点它是表尾的最后一个结点,但是它的next指针依然是非空的。
  • 对于双链表的删除也是一样的,来看之前的这一段代码:
//删除p结点的后继结点q
p->next = q->next;
q->next->prior = p;
free(q);
  • 第二句其实是有一点点问题的,如果说我们此次要删除的结点q,它刚好是最后一个结点的话,那么和之前一样,q结点没有它的后继结点,所以执行第二句代码的时候,就会出现一个空指针的错误。而如果使用的是循环双链表的话,第一句代码首先会把p结点的next指针指向q结点的next,也即是指向头结点的位置,接下来第二句,它会把q结点的next,也即是头结点,它的prior指针指向p结点,最后第三句,再把q结点给释放掉。所以如果采用循环双链表的话,之前的代码逻辑就是没有问题的。

静态链表

在这里插入图片描述

  • 之前的单链表中,它的各个结点是离散的,分布在内存中的各个角落,每个结点会存放一个数据元素,还有指向下一个结点的指针,也即是下一个结点在内存当中的存放地址。而静态链表是要分配一整片连续的内存空间,各个数据元素存放在这一整片空间的其中某些位置。静态链表中的每一个结点包含了数据元素,还有下一个结点的数组下标。在静态链表中,数组下标为0的结点,它充当了头结点的角色,也即是说这个结点当中是不存放实际的数据元素的。所以静态链表中的数组下标或者游标,它充当的角色其实和单链表当中的指针是差不多的。只不过指针是指明了具体的内存地址,而游标只是指明了下一个元素,它的数组下标。单链表的表尾元素,它的next指针是指向NULL的,在静态链表当中,如果要表示这个结点,它是最后一个结点的话,那么它的游标的值可以设为-1,这就表示,在这个结点之后已经没有其它结点了。由于静态链表当中存放各个结点的这些空间是连续的,所以如果说一个静态链表,它的数据元素占四个字节,游标也占四个字节,也即是说一整个的结点,它需要占八个字节的话。结点的存放地址就应该是结点的存放地址加上每一个结点的大小乘以接下来要寻找的这个结点的数组下标。用这样的方式就可以把静态链表当中的游标,或者说数组下标,把它映射成某一个数组下标所对应结点的实际存放地址,这既是静态链表的一个基本原理。
#define MaxSize 10		//静态链表的最大长度
struct Node{		//静态链表结构类型的定义
	ElemType data;		//存储数据元素
	int next;		//下一个元素的数组下标
};

void testSLinkList(){
	struct Node a[MaxSize];
	//...后续代码...
}
  • 首先声明了它之后,肯定需要对它进行初始化,我们在单链表当中初始化一个单链表的时候,需要把头结点的next指针指向NULL,所以对应到静态链表里面的话,我们在初始化的时候,肯定需要把头结点,也即是a[0]这个结点的next,把它设为-1,因为-1其实等价于NULL,即是它没有指向任何一个元素。
  • 在静态链表当中,如果我们要查找某一个位序的结点的话,那我们肯定只能从头结点出发,通过游标记录的线索依次地往后寻找后一个结点,然后直到找到我们想要的那个结点为止。所以在这种静态链表当中,如果要找到某一个位序的结点的话,那么时间复杂度应该是O(n)这个数量级。值得注意的是,我们这说的是某一个位序的结点,而不是某一个数据下标的结点。位序指的是各个结点在逻辑上的顺序,而数组下标其实只是反映了各个结点在物理上的一个顺序。
  • 接下来看一下,如果要在位序为i的地方插入一个结点的话,不难想到,第一步肯定是要找到一片空闲的空间用来存放这个新的结点。所以可以按照某一种算法,比如说从前往后扫描,或者从后往前扫描之类的,即是找到一个此时空闲的结点,用于存放此次要存入的这个新的数据元素。第二步,既然要插入位序为i的结点,那么我们肯定需要把位序为i-1的这个结点,它的后向指针或者说它的游标给改了,这个其实和单链表很类似。
  • 静态链表其实就是用数组的这种方式实现的一个链表,虽然说静态链表的存储空间是一整片的连续存储空间,但是在这一片空间内,各个逻辑上相邻的数据元素也可以在物理上不相邻,各个元素之间的先后关系,这种逻辑关系是用游标或者说数组下标来表示的。在静态链表中,如果要增加或者删除一个数据元素的话,并不需要像顺序表那样大量地移动元素,只需要修改相关结点的游标就可以了。静态链表和单链表一样,它也不能支持随机存取,每次只能从头结点依次往后开始查找。另外,还有一个缺点,静态链表的容量是固定不变的,只要声明了一个静态链表,那么它所能存放的最大容量就已经被固定了,不可以拓展,所以静态链表现在用的相对来说少一些,就早期的一些不支持指针的低级语言,会用静态链表这样的方式实现和单链表同样的功能。另外,如果在应用场景当中,数据元素的数量几乎是固定不变的,在这种情况下用静态链表还是比较合适的,像操作系统的文件管理中,文件分配表FAT,其实本质上就是一个静态链表。

顺序表和链表的比较

  • 之前提到,当我们谈论起一个数据结构的时候,我们应该关注数据结构的三要素,也即是逻辑结构,物理结构还有数据的运算。
  • 首先来看一下数据结构的第一个要素,逻辑结构。其实,不管是顺序表还是链表,它们在逻辑上看其实都是线性结构的,也即是说,它们都属于线性表,各个数据元素之间有着一对一的关系。
  • 再来看第二个方面,存储结构。顺序表是采用了顺序存储的方式实现了线性表,由于采用了顺序存储,并且各个数据元素的大小是相等的,因此,我们只需要知道这个顺序表的起始地址,那么我们就可以立即找到第i个元素存放的位置,也即是说,顺序表拥有随机存取的特性。另一个方面,顺序表当中的各个结点只需要存储数据元素本身,不需要存储其它的冗余信息,因此,顺序表的存储密度也会更高。另一方面,顺序存储的这种存储结构要求系统给它分配一整片的连续的存储空间,所以在给顺序表分配空间的时候会比较不方便,并且如果我们想要改变顺序表的容量也会很不方便。而如果我们采用链表,也即是链式存储的方式来实现这种线性结构的话,那么,由于各个结点可以离散地存放在不同的空间当中,所以我们每次要添加一个结点的时候,只需要用malloc函数动态地申请一小片的空间就可以了。同时,由于各个结点的存储空间不要求连续,因此改变容量也会方便一些。链式存储带来的问题是,当我们要找到第i个结点的时候,我们只能从第一个结点,也即是表头的这个结点开始依次往后寻找,所以链式存储是不可随机存取的。另外,由于各个结点当中除了存储数据元素之外,还需要花费一定的空间来存储指针,所以它的存储密度也会更低一些。
  • 接下来看数据结构的第三个要素,也即是基本操作或者说基本运算。对于任何一个数据结构的基本操作,最重要的无非就是创销增删改查。首先得知道如何初始化,也即是如何创建一个数据结构,还需要知道如何销毁一个数据结构。首先我们来看一下,当我们创建,也即是当我们初始化一个顺序表,或者初始化一个链表的时候,需要做的事情有什么不同。由于顺序表要求给它分配的是一整片的连续空间,所以当我们在初始化一个顺序表的时候,我们就需要给这个顺序表预分配大片的连续空间。如果一开始给它分配的空间太小,那我们之后想要拓展这个顺序表的长度会很不容易。而如果我们刚开始给它分配的空间过大的话,那么又会有大量的空间是长时间处于空闲的状态,也即是会导致内存资源的利用率不高,浪费内存这样的一个现象。而对于链表来说,当我们在初始化,也即是当我们在创建一个链表的时候,其实只需要声明一个头指针,并且分配一个头结点所需要的空间就可以了。当然,我们也可以让这个链表没有头结点。那无论是有头结点还是没有头结点,对于一个链表来说,当它之后想要拓展这个链表容量的时候,其实是很方便的,每次需要拓展的时候,只需要用malloc函数再申请一小片新的空间,然后再用指针的方式把它连到这个链表里面就可以了。所以对于存储容量的弹性,或者说灵活性,肯定是链表会更胜一筹。如果我们的顺序表是采用静态分配的方式实现的话,那么我们顺序表的这个容量就是不可更改的。而即便顺序表采用动态分配的方式来实现,虽然它的容量可以更改,但是更改它的容量也需要移动大量的元素,所以时间代价也会很高。因此,从顺序表和链表的创建这个基本操作出发,我们可以联想到的是关于存储空间的灵活性方面的问题,在这个方面,显然链表是更胜一筹的。接下来是销毁操作,先来看链表,如果想要销毁一个链表的话,那无非就是把链表当中的各个结点都依次删除。所以对链表的销毁操作,它的核心其实就是一个free函数。可以写个循环,然后依次扫描各个结点,把各个结点都给free掉,这样的话,就可以把链表占用的结点空间都依次回收。那对于顺序表来说,如果觉得它之后没有了需要销毁,那首先需要把它的length改为0,也即是表示这个顺序表当前已经变成了一个空表。这一步操作只是在逻辑上把这个顺序表标记为了一个空表,但是顺序表它所占用的这片存储空间应该怎么回收呢?分两种情况,如果顺序表是用静态分配的方式实现的话,那么也就意味着,顺序表所占用的这片存储空间是通过声明一个静态数组的方式来请求系统分配的,在这种情况下,这片存储空间的回收是由系统自动进行的,当定义的这个静态数组,它的生命周期结束之后,系统会自动地把这片空间给回收。也即是说如果采用的是静态分配的方式,那么对于空间回收的问题,是不需要考虑的,只需要把length的值改为0就可以了。如果采用的是动态分配的方式,也即是这个动态数组是用malloc函数申请的一片空间,那在这种情况下,就需要手动地把这片空间给free掉。由malloc函数申请的内存空间是属于内存当中的堆区,在堆区的内存空间不会由系统自动地回收,也即是说,程序里的malloc和free,这两个函数肯定是成对出现的。对于链表也是一样,任何一个结点,我们都是用malloc函数来申请的,所以当我们销毁这个链表的时候,也相应地需要对每一个结点执行free操作。
  • 接下来我们看一下增删改查,首先来看增加或者说插入一个数据元素和删除一个数据元素。对于顺序表,由于顺序存储这样的存储结构要求各个数据元素在内存里面是相邻的,并且是有序的,所以当我们在插入和删除一个数据元素的时候,都需要把我们此次插入的这个位置之后的那些元素都给后移或者前移。如果插入一个元素的话,那么就需要后移,如果删除一个元素的话,就需要前移。而相比之下,对于链表的插入和删除就会更加简单一些,我们只需要修改相应的指针就可以了,不需要像顺序表那样大量地移动元素的存储位置,随遇顺序表来说,插入和删除这两个操作的最坏时间复杂度和平均时间复杂度都是O(n)这个数量级,这个时间开销主要是来自移动元素所需要的时间开销。链表的插入和删除,它的时间复杂度也是O(n),不过链表的这个时间开销主要是来自于查找目标元素,也即是需要从第一个元素开始,依次往后寻找,直到找到想要插入的那个位置,或者找到想要删除的那个数据结点。从这个角度来看,虽然说顺序表和链表的插入删除,它的时间复杂度都是O(n)这个数量级,但是考虑到有的时候,我们的这个数据元素它可能很大,比如说,一个数据元素,它就占一兆个字节,那么也许移动这么多的数据,就需要用十毫秒左右的时间,那如果要移动n个数据元素的话,那所花的这个时间代价其实还是很高的,而对于链表来说,通过一个结点找到下一个结点,这样的时间开销很显然要比这种移动大量的数据所带来的时间开销要更短很多。比如说,假设每往后找一个结点只需要话一微妙的时间,那么即便往后找n个结点所需要花费的时间,很显然也远小于移动元素所带来的时间开销。所以虽然说从大O表示法这样的角度来看,顺序表和链表的插入删除,它们的时间复杂度都是O(n)这样的一个数量级,但是当我们结合考虑一些现实因素的时候,不难发现,其实对于插入一个数据元素或者删除一个数据元素这样的基本操作来说,链表的效率肯定要比顺序表高很多。
  • 接下来看一下查找操作,之前我们探讨过按位查找和按值查找。对于顺序表来说,想要找到某一个位序的元素所存放的位置,只需要O(1)的时间复杂度,也即是说它具有随机存取的特性,而链表只能从第一个元素开始依次往后查找,所以它的按位查找时间复杂度是O(n)这个数量级。对于按值查找这个操作来说,如果顺序表当中各个数据元素的排列本来就是无序的,那么我们就只能从第一个元素开始,依次往后对比,所以时间复杂度是O(n)这个数量级,而如果说这个顺序表中的元素,它是有序的,那我们就可以用一些查找算法,比如说像折半查找这样的算法,可以在O(log2n)这样的时间复杂度内就可以找到目标元素。那对于链表来说,无论它里面的这些数据元素是有序还是无序,当我们在进行按值查找的时候,都只能从第一个元素开始依次往后遍历,所以无论数据元素,它有序还是无序,在链表当中,按值查找肯定都是O(n)这样的时间复杂度。所以对于查找相关的操作,肯定顺序表的效率会高很多。
  • 最后根据顺序表和链表各自不同的特性,我们就可以知道什么时候应该使用顺序表,什么时候应该使用链表。如果在应用场景当中,线性表的表长难以估计,并且经常会使用插入和删除这样的基本操作的话,那很显然,使用链表会更好一些。相反,如果应用场景当中,线性表的表长是可预估的,比较稳定的,并且查询操作会比较多,那用顺序表的方式来实现,效率肯定会更高。
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值