文章目录



一、线性表的定义和基本操作

我们在学习数据结构时,需要关注数据结构的三个方面:逻辑结构、物理结构和数据的运算。该小节中,我们首先要介绍线性表的定义和基本操作,其实就是要探讨这种数据结构它的逻辑结构是怎样的,我们要对这种数据结构实现哪些基本运算,也就是所谓的基本操作。

在之后的小节中,我们还会探讨如何使用不同的存储结构来实现线性表。

ps:当采用不同的存储结构时,对于数据的运算的具体实现也会不同,这点我们后面会用具体的代码帮助大家进行理解。

1.1线性表的定义

什么是线性表?讲大白话就是各个数据元素,它们之间的逻辑关系、逻辑结构是一条线的结构,就是被穿到一起,数据元素之间有前后关系。如下图:
在这里插入图片描述
官方定义:线性表是具有相同数据类型的n(n>=0)个数据元素有限序列,其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则其一般表示为:
在这里插入图片描述
ps:这里用L描述线性表时,脚标是从1开始的,a1表示线性表的第1个元素,ai表示线性表第i个元素
所谓的第几个,我们用一个专业术语描述,叫作数据元素在线性表中的位序
(位序从1开始,我们实际实现时下标从0开始)
线性表的第一个元素a1称为表头元素,最后一个元素an称为表尾元素
除了第一个元素外,其他所有元素有且仅有一个直接前驱;除了最后一个元素外,每个元素都有一个直接后继

在这个定义中我们需要注意如下几点:
1 .线性表中的各个元素,它们的数据类型是相同的,比如其中一个元素是int型,其他的也肯定是int型

2 .所有数据元素的数据类型都相同,也就意味着各个数据元素它们的存储空间是一样的。该特性可以帮助计算机快速找到某个具体的数据元素

3 .线性表是一个序列,所谓的序就是有次序,各个数据元素之间有先后次序。

4 .线性表中的数据元素数量是有限的

ps:如果题目中问所有的整数按递增次序排列,是线性表吗?
答案:不是,虽然这样的数据结构满足了各个数据元素类型相同、且有序,但是这里提到的是所有的整数,整数是无限的没有满足有限,所以不能算是一个线性表。

1.2线性表的基本操作

下面的基本操作我们只是给一个粗略的介绍,具体的代码(伪代码)会在后面顺序表与链表进行详细介绍

1.2.1线性表初始化

InitList(&L):初始化表。构造一个空的线性表L,分配内存空间

1.2.2线性表销毁

DestroyList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间。

1.2.3线性表插入

ListInsert(&L,i,e):插入操作。在表L中第i个位置插入指定元素e

1.2.4线性表删除

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

1.2.5线性表其他常用操作

Length(L):求表长。返回线性表L的长度,即L中数据元素的个数。

PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值。

Empty(L):判空操作。若L为空表返回true,否则返回false

ps:
1.对数据的操作一般都是创建销毁,增删改查
2.C语言中函数定义都会有参数类型,我们这里没有定义参数类型是因为不同线性表的参数类型也不一样,具体到题目可以加上具体参数类型
3.实际开发中,可以根据需要定义其他操作
4.函数名和参数的形式、命名都可以改变(规矩不是死的,只要你能让人读得懂就行)
5.什么时候要传入参数的引用“&”:当你对参数的修改需要“带回来”,也就是形参要影响到实参

1.3小结

在这里插入图片描述

二、顺序表的定义

2.1顺序表的定义

顺序表——用顺序存储的方式实现线性表顺序存储。把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。

举个例子:下图是一个线性表L,它的逻辑结构如图所示
在这里插入图片描述

它在内存中存储就是下面这个样子
在这里插入图片描述
而我们在前面也提到过,线性表当中的各个数据元素,它们的数据类型都是相同的,也就是每个数据元素所占的内存空间是一样大的。

假设我们顺序表中第1个数据元素,它的存放地址是LOC(L),
那么第2个元素地址就是LOC(L)+1个数据元素大小
同理,第n个数据元素地址就是LOC(L)+(n-1)个数据元素大小

在这里插入图片描述
ps:C语言中我们可以通过sizeof(元素类型)来知道数据元素的大小

2.2顺序表的实现

在这里插入图片描述
下面的代码实现基本都是伪代码,具体C语言的代码可以看博主的这篇文章:C语言实现顺序表

2.2.1静态分配

所谓静态分配,就是用大家最熟悉的数组,这种定义方式实现一个顺序表。当然了,这个数组是静态数组:就是说这个数组的长度、大小一旦确定之后,就不可以再改变了,这也是静态数组的特点。

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

如果从内存的角度来看,当你声明了一个data数组的时候,其实就是在内存中开辟了一整片的连续空间,这片连续的空间最多可以放MaxSize个数据元素,空间大小为MaxSize*sizeof(ElemType)
在这里插入图片描述

我们来看一段简单的实现代码

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

2.2.2动态分配

我们的静态分配有一个问题就是如果我们声明的数组长度不够了,也就是存满了咋办?遇到这种问题静态分配没有解决办法,放弃治疗吧/doge

因为静态数组的长度,只要你刚开始声明了,那它的容量之后就不能再改变了。也就是说给这个顺序表分配的存储空间是不可变的,是静态的。

有同学可能会说,那你一开始就申请大点的空间呗。但是那样又会产生另一个问题——一开始我们用不了这么多空间,你申请太多太浪费内存了。

所以,如果想让顺序表的大小可变的话,那我们就可以采用动态分配这种实现方式
#define InitSize 10//顺序表的初始长度
typedef struct{
   ElemType* data;//指向动态分配数组的指针(指向顺序表第一个元素)
   int MaxSize;//顺序表的最大容量
   int length;//顺序表的当前长度
}SeqList//顺序表的类型定义(动态分配方式)

C语言中提供了mallocfree这两个函数
来分别实现动态的申请一片内存空间和释放一片内存空间。

malloc详解:

L.data=(ElemType*)malloc(sizeof(ElemType)*InitSize);

malloc这个函数会申请一整片的连续的内存空间,而这一整片的内存空间,它肯定有一个起始的内存地址,malloc函数执行结束后会return返回一个指向这一整片存储空间开始地址的指针
在这里插入图片描述
而由于申请的空间是用于存放我们一个个数据元素的,所以我们需要把malloc返回的指针强制转换成你所定义这个元素的指针(不然你没法找后面的元素)。

强转完之后,把这个指针赋给顺序表中data这个指针变量,也就是说data这个指针也是指向了这申请的一整瓶存储空间的起始地址。

第二个需要注意的点就是既然malloc函数是申请一整片的连续存储空间,那么到底要申请多大的空间呢?这个就是由malloc函数的参数来表明,比如你要申请10个int型的空间,那么你传参就是sizeof(int)*10,其他的以此类推

free详解:
free就很好理解了,你申请完后的空间起始指针传参到free,free会自动确认大小然后释放

接下来我们来看一段简单的动态分配实现顺序表的代码:

#include<stdlib.h>//malloc和free的头文件
#define InitSize 10//顺序表的初始长度
typedef struct {
	int* data;//指向动态分配数组的指针(指向顺序表第一个元素)
	int MaxSize;//顺序表的最大容量
	int length;//顺序表的当前长度
}SeqList;//顺序表的类型定义(动态分配方式)

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

//增加动态数组的长度
void IncreaseSize(SeqList &L,int len)
{
	int *p = L.data;
	L.data = (int*)malloc(sizeof(int)*(L.MaxSize + len));
	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;
}

IncreaseSize函数逻辑如下:
假设我们现在有一个10个int长度的顺序表L,如下图,我们现在然后要扩容5个int长度
在这里插入图片描述
我们定义一个指针p,把顺序表的data指针赋给p,也就是说p指针和data指向的是一个位置,如下图
在这里插入图片描述
接下来要调用malloc函数,malloc函数申请了一整片内存空间,大小为sizeof(int)*(L.MaxSize + len),本例中也就是15个int型(原先10个,再扩容5个)。

malloc申请完空间,返回申请空间起始地址给data指针,因为新申请的空间没有数据,我们就用一个for循环把原先的数据复制进来

在这里插入图片描述
数据都复制完了,因为已经扩容了嘛,然后把MaxSize再修改一下,最后free掉原先的顺序表

在这里插入图片描述
最后可能有同学问,那这个p没free掉啊。
p是局部变量啊哥么,最后跳出这个IncreaseSize函数,p会被系统自动回收
在这里插入图片描述

最后总结一下数据结构选择题常考的内容:
在这里插入图片描述

2.3小结

在这里插入图片描述

三、顺序表的插入与删除

3.1顺序表的插入

ListInsert(&L,i,e):插入操作。在表L中第i个位置插入指定元素e

现在假如我们用静态分配的方式实现一个顺序表

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

这个顺序表总共可以存10个元素,现在假设已经存了abdef这五个元素,如下图:
在这里插入图片描述
在这里插入图片描述
假如现在要在第3个位置插入c这个元素,那么从逻辑结构上看,进行插入操作后c就变成了b的后继结点,d的前驱结点
在这里插入图片描述
由于我们的线性表是用顺序表的方式来实现的,所以需要用存储位置上的相邻关系来体现这种数据元素之间的逻辑关系,因此,如果要在第三个位置插入元素c的话,那么需要把后面这三个元素依次往后移动。插入完成后length+1
在这里插入图片描述
接下来我们来看怎么用代码实现(这里都是基于静态分配):

#include<stdio.h>
#define MaxSize 10//定义最大长度
typedef struct {
	int data[MaxSize];//用静态的数据存放数据元素
	int length;//顺序表当前长度
}Sqlist;//顺序表的类型定义

void ListInsert(Sqlist &L, int i, int e)
{
	for (int j = L.length;j >= i;j--)
	{
		L.data[j] = L.data[j - 1];
	}
	L.data[i - 1] = e;
	L.length++;
}

int main()
{
	Sqlist L;//声明一个顺序表
	InitList(L);//初始化顺序表
	//此处省略一些代码:先插几个元素
	ListInsert(L, 3, 3);//在顺序表第3个位置插入3
	return 0;
}

我们来解释一下这段代码:
在main函数中,我们会声明一个顺序表,并且对它进行初始化的操作,然后我们省略了一部分代码,假设省略的代码会在顺序表中存入一些元素,比如12456,此时顺序表长度为5,如下图:
在这里插入图片描述
接下来会调用ListInsert函数来实现插入操作,我们这里是
往第三个位置插入3。

要插入,首先要把第三个位置及后面的元素分别往后移动一位,我们这里用一个for循环来实现。
for循环中,定义一个变量j,刚开始j变量的值=顺序表长度=5,然后把下标4的元素移动到下标5,j–,再把下标3的元素移动到下标4…以此类推。直到j=3,也就是把下标2的元素移动到下标3。注意,下标2的元素就是第三个位置,至此就全部移动完了,这个时候你再把需要插入的元素放到下标3就行。
在这里插入图片描述
至此简单的按位插入就写完了,但是该段代码不够健壮。
为什么?还是以上面的例子为例,我们现在有顺序表L:123456,length=6
如果有人用我们这个函数,但是他想插入第9个位置,也就是下标8的位置,如下图
在这里插入图片描述
但是如果这样插入,中间会出现断层,这就违反了顺序表定义了。
另外如果顺序表满了,还想插入数据也是不对的。

所以,为了避免这样的错误,我们需要在插入函数中增加if判断,来检测要插入的位置是否合法。

这里为了让使用者用的更爽,我们还可以把返回值改成bool类型,告诉使用者是否成功插入,代码如下:

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

最后,我们来分析一下顺序表插入操作的时间复杂度
数据结构:第一章 绪论中我们讲过,要分析一段代码的时间复杂度,就应该关注这段代码最深层循环的语句执行的次数与问题规模n的关系
在这里插入图片描述

3.2顺序表的删除

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

比如我们有如下的顺序表,现在要删除c这个元素
在这里插入图片描述
在这里插入图片描述

要删一个元素,我们需要把这个元素后面的元素都依次前移,最后length-1

在这里插入图片描述
代码如下:

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

我们来看一下这个代码的具体执行流程:
首先还是声明一个顺序表,然后初始化,再插入几个元素
假设插入了123456这6个元素。

假设现在要删除第三个元素,我们首先定义一个和顺序表中元素一样的元素e,给它设初值-1,后面要用这个元素e告诉使用者删除的元素是什么。至于为啥e初值是-1,这个无所谓,你想设什么初值就设什么初值,后面反正都是要变成被删元素的。

接下来调用删除操作,我们这里删顺序表的第三个元素,也就是ListDelete(L,3,e)。
进入删除函数,先对要删除位置进行合法判断,如果i非法我们就可以直接return false告诉使用者删除失败了,如果i合法,先把要被删的元素赋值给e

在这里插入图片描述
再往后就是把数据依次前移,比如这里我们是要删除第3个位置元素,i=3,那么for循环开始:
先将3号下标的元素值赋给2号下标,j++;将4号下标的元素值赋给3号下标…
直到把length-1号下标的元素赋值给length-2号下标循环结束。
在这里插入图片描述
最后还是老规矩,来分析一下删除操作的时间复杂度:
在这里插入图片描述

3.3小结

在这里插入图片描述

四、顺序表的查找

4.1按位查找

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

4.1.1静态分配方式

#define MaxSize 10//定义最大长度
typedef struct {
	int data[MaxSize];//用静态的数据存放数据元素
	int length;//顺序表当前长度
}SqList;//顺序表的类型定义

ElemType GetElem(SqList L, int i){
    return L.data[i-1];//和访问普通数组的方法一样
}

4.1.2动态分配方式

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

ElemType GetElem(SqList L, int i){
    return L.data[i-1];//和访问普通数组的方法一样
}

看到这里,可能会有同学疑惑,你这静态动态两个函数怎么都一样,这里我来做一个解释:

虽然我们动态分配方式data是一个指针,但同样可以用这种数组下标的方式访问相应的元素,
因为arr[i]=*(arr+i)

综上,因为按位查找都只需要一个return语句,所以,我们的时间复杂度是O(1)

由于顺序表的各个数据元素在内存中连续存放,因此可以根据起始地址和数据元素大小立即找到第i个元素,也就是随机存取

4.2按值查找

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

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

//在顺序表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;//查找失败
}

老规矩,来看一下这个函数的时间复杂度:算时间复杂度,我们需要关注的是最深层循环这个语句执行的次数,也就是循环了几次?这个循环次数和问题规模n的关系是什么?

我们这里的问题规模n值的是线性表的表长。

最好情况:要找的值刚好在表头,也就是O(1)
最坏情况:要找的值刚好在表尾,也就是O(n)
平均情况:假设目标元素出现在任何一个位置的概率相同,都是1/n
在这里插入图片描述

ps:关于结构体类型的比较,我们不能简单的用==
在这里插入图片描述
虽然我们写代码结构体不能直接比较,但是我们考研初试中是可以直接使用==的,无论比较的数据是基本数据类型还是结构体类型

4.3小结

在这里插入图片描述

五、单链表的定义

什么是单链表?如下图:单链表中某一结点,它需要存放数据元素,同时还需要包含一个指向下一结点的指针,由于每个节点只包含一个指针,所以叫单链表
在这里插入图片描述
单链表中各个结点在物理上是可以离散存放的,所以当我们要扩展单链表长度的时候,只需要在内存中随便找一个地方,把它作为存放新结点的区域即可。

因此采用这种链式存储的方式改变容量会很方便,但我们采用这种方式需要找到某个位序的结点,我们只能从第一个结点开始,利用指针的信息依次往后找,直到找到我们所需结点,也就是说单链表不支持随机存取

5.1用代码定义一个单链表

在这里插入图片描述

不难看出,我们的单链表是由一个个的结点组成的,而一个结点中需要有一片空间存放数据元素,还需要有另一片空间存放指向下一结点的指针,所以我们可以定义一个struct类型的结构体:

struct LNode {//定义单链表结点类型
	ElemType data;//每个节点存放一个数据元素
	//data变量又称为数据域
	struct LNode* next;//指针指向下一结点
	//next变量又称为指针域
};

如果我们想增加一个新的结点,我们需要在内存中申请一个结点所需空间,并用指针p指向这个结点

struct LNode* p=(struct LNode*)malloc(sizeof(struct LNode));

可能会有同学嫌弃每次都写struct太麻烦,我们就可以使用typedef这个关键字了,具体用法如下

typedef <数据类型>  <别名>
//比如 typedef int zhengshu
//你下次用int就可以直接用zhengshu这个别名,效果和int一样
//对于指针类型也是一样的
//比如 typedef int* zhengshuzhizhen

typedef具体应用到我们的单链表,有两种方法,如下

法一

typedef struct LNode{//定义单链表的结点类型
    ElemType data;//每个结点存放一个数据元素
    struct LNode *next;//指针指向下一结点
}LNode,*LinkList;

法二

struct LNode{//定义单链表的结点类型
    ElemType data;//每个结点存放一个数据元素
    struct LNode *next;//指针指向下一结点
}
typedef struct LNode LNode;
typedef struct LNode* LinkList;

当我们要表示一个单链表的时候,我们只需要声明一个所谓的头指针L,该头指针指向了单链表的第一个结点,而由于各个结点由next指针把它们一个一个连起来,所以我们只要找到第一个结点,就可以找到整个单链表。

由于L这个指针是指向某一结点,所以我们定义L的时候有如下两种方式

LNode* L;//声明一个指向单链表第一个结点的指针
LinkList L;//声明一个指向单链表第一个结点的指针
//两种声明方式从效果上看是一样的,只不过后面这种方式代码可读性更强
//LNode*更强调是一个结点
//LinkList更强调是一个单链表

5.2不带头结点

struct LNode {//定义单链表结点类型
	ElemType data;//每个节点存放一个数据元素
	struct LNode* next;//指针指向下一结点
}LNode, *LinkList;

//初始化一个空的单链表
bool InitList(LinkList &L) {
	L = NULL;//空表
	return true;
}

void test() {
	LinkList L;//声明一个指向单链表的指针
	//初始化一个空表
	InitList(L);
	//后续代码...
}

我们来看一下上述代码的运行逻辑:
在test函数中,我们首先声明一个指向单链表的指针,注意,此处并没有创建一个结点
在这里插入图片描述
然后初始化一个空表,也就是进入InitList函数,把L置空防止之前有遗留的脏数据
在这里插入图片描述
ps:InitList函数我们传参是传的L的引用&L,如果没有这个引用符号&,那么在InitList函数里我们修改的L其实是我们test函数中创建的L的复制品

判断单链表是否为空也很简单,只要看L是否等于NULL即可

//法一
bool Empty(LinkList L){
    if(L==NULL)
       return true;
    else
       return false;
}
//法二
bool Empty(LinkList L){
    return (L==NULL);
}

5.3带头结点

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);
	//后续代码...
}

一起来看一下上述代码的运行逻辑:
test函数中,我们先声明一个指向单链表的指针L
在这里插入图片描述
然后进入InitList函数,在InitList函数中,我们用malloc申请一片空间,可以存放得下这样一个结点。然后把malloc返回的地址赋给L,也就是说头指针L指向了这个结点
在这里插入图片描述
再接下来需要把L这个指针指向的结点中的next指针域设为NULL
在这里插入图片描述
注意,头结点是不存储数据的,它是为了方便我们后续实现一些基本操作

对于带头结点的单链表,要判定它是否为空,我们要判定的就是头结点的next指针是否为空,代码如下:

bool Empty(LinkList L){
    if(L->next ==NULL)
       return true;
    else
       return false;
}

ps:带头结点和不带头结点的单链表有什么区别呢?
简单来说带头结点的后面写基本操作更简单,不带的麻烦一些

5.4小结

在这里插入图片描述

六、单链表的插入与删除

6.1插入

6.1.1按位序插入

6.1.1.1带头结点

ListInsert(&L,i,e):插入操作。在表L中第i个位置插入指定元素e

因为是链表,所以我们需要先找到第i-1个结点,因为我们肯定是要修改第i-1个结点的next指针的。

举个例子,假如我们现在要在第2个位置插入一个新结点,也就是i=2
在这里插入图片描述
那么我们要先找到第一个结点,然后用malloc申请一个新的结点,往这个结点里面存入数据元素e
在这里插入图片描述
再接下来对指针进行修改即可
在这里插入图片描述

如果想在第1个位置,也就是i=1的时候插入一个新结点,我们就可以体会到带头结点的好处了,我们可以把头结点看成第0个节点,然后用上面的逻辑一样可以实现,具体代码如下:

struct LNode {//定义单链表结点类型
	ElemType data;//每个节点存放一个数据元素
	struct LNode* next;//指针指向下一结点
}LNode, *LinkList;
//在第i个位置插入元素e(带头结点)

bool ListInsert(LinkList &L,int i,ElemType e){
     if(i<1)//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;//插入成功
}

ListInsert这个函数,我们在传参时,传入了一个带头结点的单链表L,指定了此次要插入的位置的位序,也给出了新结点中要存放的数据元素e

我们来用这段代码来分析一下刚才说的几种情况:
在这里插入图片描述

情况分析1:如果i=1(插在表头)
进入ListInsert函数,i=1不符合i<1,所以不会返回
声明一个指针p,指针p指向了和L相同的位置,也就是指向了这个头结点
在这里插入图片描述
随后我们定义一个变量j,j表示当前p指向的是第几个结点,而我们现在p是指向头结点(第0个结点),所以给j赋值为0

接下来执行while循环,p!=NULL满足,但是j<i-1是不满足的(j=0,i-1=0),所以while循环不会执行,直接进行下面malloc申请一个新结点的空间,用指针s指向这片空间,把参数e存到新结点里面

在这里插入图片描述
再接下来,会让s指向的结点的next指针指向p结点next指向的位置,也就是a1
在这里插入图片描述
最后把p的next指向s即可
在这里插入图片描述
由于i=1,while循环被跳过了,所以该情况的时间复杂度为O(1)

情况分析2:如果i=3(插在表中)
i=3时,ListInsert前面的操作会让p指向头结点,j=0,
在这里插入图片描述

然后看while循环,此时p!=NULL,同时j<i-1=3-1=2,所以进入while里面执行p=p->next,p指向了下一结点;j++,j变成了1
在这里插入图片描述
此时j=1<2,所以还是要循环,执行p=p->next,p指向了下一结点;j++,j变成了2
在这里插入图片描述
j=2已经不满足j<i-1了,所以不循环了,往下执行代码,malloc申请一个新结点的空间,然后依次修改指针
在这里插入图片描述
在这里插入图片描述

情况分析3:如果i=5(插在表尾)
和前面一样,循环找到第i-1个结点,也就是第4个结点
在这里插入图片描述
往下执行代码,malloc申请一个新结点的空间,然后依次修改指针
在这里插入图片描述
在这里插入图片描述
由于i=5,要执行整个while循环,所以该情况的时间复杂度为O(n),这里问题规模n指的是表的长度

情况分析4:如果i=6
此时我们的表长度只有4,你最多插到第5个位置,插第6个位置明显是错的
在这里插入图片描述

综上,平均时间复杂度为O(n)

6.1.1.2不带头结点

ListInsert(&L,i,e):插入操作。在表L中第i个位置插入指定元素e

基本思路和带头结点是一样的,都是先找到第i-1个结点,然后把新结点放第i-1个结点后。

但是由于我们是不带头结点的,所以不存在第0个结点,因此如果要在第一个位置插入这个元素需要特殊处理

struct LNode {//定义单链表结点类型
	ElemType data;//每个节点存放一个数据元素
	struct LNode* next;//指针指向下一结点
}LNode, *LinkList;
//在第i个位置插入元素e

bool ListInsert(LinkList &L,int i,ElemType e){
     if(i<1)//i表示的是位序,位序最小是1
        return false;
     if(i==1){//如果插第一个位置需要特殊处理
        LNode* s=(LNode*)malloc(sizeof(LNode));
        s->data=e;
        s->next=L;
        L=s;//头指针指向新结点
        return true;
     }
     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;//插入成功
}

不带头结点的我们这里只分析i=1的特殊情况,其他情况与带头结点的相同:

i=1时,也是首先malloc申请一个新结点的空间,然后把数据e写到里面

在这里插入图片描述
随后,让新结点的next指针指向L所指向的结点
在这里插入图片描述
最后修改头指针L,然后return true插入成功
在这里插入图片描述
综上,我们可以知道,如果一个单链表是不带头结点的,我们插入或删除第一个结点时,我们是要修改头指针指向的(需要特殊处理),但是带头结点,头指针肯定是永远指向头结点的(不需要特殊处理)
我们后续的讲解也是默认带头结点来实现代码,考试中两种情况都有可能,大家注意审题

6.1.2指定结点的后插操作

给定一个节点p,在p之后插入一个结点

struct LNode {//定义单链表结点类型
	ElemType data;//每个节点存放一个数据元素
	struct LNode* next;//指针指向下一结点
}LNode, *LinkList;
//在第i个位置插入元素e
//后插操作:在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;
}

如果给定一个节点p,p之后的结点我们都可以通过循环的操作把它们找出来,但是p结点之前的就没法知道了。

上面这个代码的逻辑和我们前面说的很像,前面讲的要找到第i-1个结点,我们这里相当于是直接把第i-1个结点告诉你了,我们malloc一个结点,然后把数据e放进去,然后修改指针。
在这里插入图片描述
在这里插入图片描述
这段代码并没有循环之类的东西,所以时间复杂度为O(1)

6.1.3指定结点的前插操作

给定一个节点p,在p之前插入一个结点

这里就出现问题了,给一个p,我们可以找到p后面的结点,前面的咋找啊?

法1,你再传一个头指针过来,从前往后找到p前面一个结点

bool InsertPriorNode(LinkList L,LNode* p,ElemType e)

该方法时间复杂度O(n)

法2:偷天换日

bool InsertNextNode(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;
}

首先mallo申请一个新的结点
在这里插入图片描述
然后把新结点连成p结点的后继结点
在这里插入图片描述
我们知道结点是不能换的,但结点中的数据可换啊,
把p结点中的数据放到s结点
在这里插入图片描述
再把数据e放到p结点中
在这里插入图片描述
这种方法的时间复杂度为O(1)

6.2删除

6.2.1按位序删除

ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值
在这里插入图片描述

思路:找到第i-1个结点,将其指针指向第i+1个结点,释放第i个结点

代码实现如下:

struct LNode {//定义单链表结点类型
	ElemType data;//每个节点存放一个数据元素
	struct LNode* next;//指针指向下一结点
}LNode, *LinkList;

bool ListDelete(LinkList &L,int i,ElemType &e){
     if(i<1)//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来分析一下这段代码
i=4,我们循环找到i-1个结点,也就是第3个结点
在这里插入图片描述
接下来会定义一个指针q,q指针指向了p结点的next,也就是第i个结点
在这里插入图片描述
再接下来会把q结点的元素复制到变量e中,这个变量e我们用需要把此处删除的值带回到这个函数调用者那里,所以e这个参数是引用类型
在这里插入图片描述
再往后,p的next指向q的next,也就是指向NULL
在这里插入图片描述
最后free掉q结点即可
在这里插入图片描述
由于需要以此循环找到第i-1个结点,
所以该算法最坏、平均时间复杂度为O(n),最好时间复杂度为O(1)

6.2.2指定结点的删除

按照之前思路,如果要删除结点p,还需要修改它的前驱结点的next指针,同样的问题发生了,没法找到p结点的前驱结点啊,这里还是两种方法:1.传头指针,2.偷天换日

我们这里主要讲“偷天换日”的方法

我们现在要删除结点p
在这里插入图片描述
先声明一个指针q指向p的后继结点
在这里插入图片描述
然后把p的后继结点这个数据元素复制到p中
在这里插入图片描述
然后让p结点next指针指向q结点next
在这里插入图片描述
最后释放掉q结点即可
在这里插入图片描述
代码实现如下:

//删除指定结点p
bool DeleteNode(LNode* p){
     if(p==NULL)
        return false;
     LNode* q=p->next;//q指向*p的后继结点
     p->data=p->next->data;//后继节点数据域覆盖p节点数据域
     p->next=q->next;//将*q结点从链中“断开”
     free(q);//释放后继节点的存储空间
     return true;
}

ps:如果p是最后一个结点,我们在执行p->data=p->next->data;是有问题的,所以如果真是找最后一个结点,你只能用法1,也就是从头开始找
在这里插入图片描述

6.3小结

在这里插入图片描述

七、单链表的查找

ps:本节只探讨带头结点的情况

7.1按位查找

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

其实在前面单链表的插入与删除就已经实现了按位查找的逻辑,只不过当时我们查找的是第i-1个结点罢了
在这里插入图片描述
仿照这个逻辑,我们实现找到第i-1个结点就很简单了

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

接下来我们来探讨上述代码几种可能出现的情况
情况分析1:如果i=0
i=0经过一系列操作,指针p会指向头结点,j=0
进入循环判断:p!=NULL成立,j<i不成立,所以跳过循环
返回p,也就是头结点
在这里插入图片描述

情况分析2:如果i=8
i=8这种情况就是传入的参数大于链表长度了。
i=8经过一系列操作,指针p会指向头结点,j=0
进入循环p会一直往后,而j会一直++,直到p指向NULL
在这里插入图片描述
p=NULL循环就跳出来了,然后返回p,也就是返回一个NULL
ps:i<0和i>链表长度时返回值都是NULL,
下次调用这个函数,只需要判断返回值是不是NULL就可以知道此次按位查找操作是否成功了。

情况分析3:如果i=3
i=3经过一系列操作,指针p会指向头结点,j=0
p!=NULL,j=0<i=3,进入循环,p=p->next,j++
在这里插入图片描述
在这里插入图片描述

p!=NULL,j=1<i=3,再次循环,p=p->next,j++
在这里插入图片描述

p!=NULL,j=2<i=3,再次循环,p=p->next,j++
在这里插入图片描述

p!=NULL,j=3=i=3,退出循环
返回p指针指向的结点,也就是a3

按位查找操作平均时间复杂度为O(n)

在实现了按位查找的函数之后,我们就可以对之前写的插入和删除函数进行封装了
在这里插入图片描述

7.2按值查找

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

大白话就是给你一个数据元素e,让你找到单链表中哪个结点的数据是e

LNode* LocateElem(LinkList L,ElemType e){
    LNode* p=L->next;
    while(p!=NULL&&p->data!=e)
    {
       p=p->next;
    }
    return p;//找到后返回该结点的指针,否则返回NULL
}

老规矩,来看几个情况的分析,我们假设本例中ElemType是int型,链表如下:
在这里插入图片描述

情况分析1:e的值可以找到,比如e=8
进入函数,首先让p指向头结点的下一结点,也就是5这个结点,
p!=NULL&&p->data!=8,进入循环,p=p->next
在这里插入图片描述

p!=NULL但是p->data=8,退出循环
返回p执行结点,也就是8这个结点

情况分析2:e的值可以找到,比如e=6
和刚才一样,while不断执行,p不断后移,直到p=NULL
在这里插入图片描述
退出循环,返回p,也就是返回NULL

按值查找操作平均时间复杂度为O(n)

7.3求链表长度

这个和前面两个查找逻辑非常像,只不过求链表长度是要一直遍历到链表尾部

//求链表长度
int length(LinkList L)
{
   int len=0;
   LNode* p=L;
   while(p->next!=NULL){
       p=p->next;
       len++;
   }
   return len;
}

求链表长度这个操作也需要用到while循环来让p指针从头到尾依次扫描,因此平均时间复杂度为O(n)

7.4小结

在这里插入图片描述

八、单链表的创建

如果现在给你多个数据元素,让你把它们存到单链表里面,该怎么存呢?

方法也很简单,我们首先肯定要先创建一个单链表,即先初始化一个单链表,接下来,每次取一个数据元素,把该数据元素插到表尾或者插到表头
(本节还是探讨带头结点的情况)

8.1尾插

初始化一个带头结点的单链表代码如下:

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);
    //后续代码
}

该段代码在单链表的定义已经讲的很清楚,不再过多赘述

而尾插法就是在单链表尾部插入一个元素,这个逻辑就和我们之前的按位插入很像了。我们按位插入是给一个位置,然后插入元素,尾插就是确定了那个位置是链表尾部,然后插入一个元素。

但是问题也随之而来,如果每次都用按位插入,需要找到第i-1个结点,插到最后一个位置。
那如果要插入n个结点,循环的次数为:0,1,2,3,4,…n-1

平均时间复杂度为1+2+3+…n=(1+n)*n/2,也就是O(n^2)

这个时间复杂度其实是比较大的,那我们可以改一下这个代码。
因为每次都确认是在最后一个位置插入,那我们用一个尾指针r指向链表尾部,每次要插入就可以直接通过尾指针r找到链表尾部了,然后实现一下后插操作即可。
在这里插入图片描述
后插操作代码如下:

//后插操作,在结点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;
}

尾插法建立单链表代码如下:

LinkList List_TailInsert(LinkList &L){//正向建立单链表
   int x;//设ElemType为整形
   L=(LinkList)malloc(sizeof(LNode));//建立头结点
   L->next=NULL;
   LNode *s,*r=L;//r为表尾指针
   scanf("%d",&x);
   while(x!=9999){//9999就是一个特殊数字,只是想使用者入这个数字时跳出循环,你也可以用别的数字
     s=(LNode*)malloc(sizeof(LNode));
     s->data=x;
     r->next=s;
     r=s;//r指向新的表尾结点
     scanf("%d",&x);
   }
   r->next=NULL;//尾结点指针置空
   return L;
}

来规矩,上实例来分析这段代码:
在该代码中,首先声明一个局部变量x,再用malloc申请一个头结点,这里没有把头结点置为NULL,是因为头结点这个指针后面会被修改
在这里插入图片描述
再往后,声明两个指针s和r,s和r都指向头结点
在这里插入图片描述
调用scanf,比如我们这里输入一个10,10就是我们此次要插入的元素。

进入循环判断,10!=9999,执行循环语句
malloc申请一个新的结点,让s指向新结点,把10放到新节点里面
在这里插入图片描述
再接下来,把r结点的next指针指向s
在这里插入图片描述
最后把r指针指向s,因为r是标记尾结点位置嘛
在这里插入图片描述
如果还想插入元素,比如我们这里scanf再接收一个16
在这里插入图片描述

再次进入循环判断,16!=9999,执行循环语句
malloc申请一个新的结点,让s指向新结点,把16放到新节点里面
在这里插入图片描述
再接下来,把r结点的next指针指向s
在这里插入图片描述
最后r指向s
在这里插入图片描述
剩下如果还想插入元素就和前面分析的一样了,不再赘述
如果不想插入元素了,输入9999,循环结束,r-next指向NULL
在这里插入图片描述
显然,如果要差n个结点,该循环的次数也是n次,所以该算法时间复杂度为O(n)

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

头插法建立单链表代码如下:

LinkList List_HeadInsert(LinkList &L){//正向建立单链表
   LNode *s;
   int x;//设ElemType为整形
   L=(LinkList)malloc(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;
}

如果我们依次输入10,16,27,9999形成的链表如下

在这里插入图片描述
可以看到,按照头插法的规则:形成的链表数据是我们输入数据的一个逆序
而在很多题目中,如果要求我们对一个链表逆置,
法1:我们用一个指针顺序扫描该链表,然后把扫描到的结点按照头插法插入到一个新链表中

法2:我们用一个指针顺序扫描该链表,然后把扫描到的结点取出来,头插到链表头结点后,这样就不需要建立另外一个链表,而是把链表原地逆置

8.3小结

在这里插入图片描述

九、双链表

在单链表中,由于每个结点只包含指向它的后继结点的指针,所以,如果给定一个结点p,想要找的它的前驱结点是很麻烦的。
双链表就是在单链表的基础上再增加一个指针域,这个指针prior是指向这个结点的前驱结点,代码如下:

typedef struct DNode{//定义双链表结点类型
    ElemType data;//数据域
    struct DNode *prior,*next;//前驱和后继指针
}DNode,*DLinklist

9.1双链表的初始化(带头结点)

//初始化双链表
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);
   //后续代码
}

我们来看一下InitDLinkList这个函数的运行逻辑:
首先malloc一个结点,然后头指针指向该结点,接下来把头结点的前向指针和后向指针都设为NULL
在这里插入图片描述
对于带头结点的双链表来说,如果要判断它是否为空,只需要判断头结点的next是否等于NULL即可,代码如下:

bool Empty(DLinklist L){
   if(L->next==NULL)
       return true;
   else
       return false;
}

9.2双链表的插入

//在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结点
在这里插入图片描述

第一步,会把s结点的next指针指向p的next
在这里插入图片描述
第二步,把p结点的后继结点的前向指针指向s
在这里插入图片描述
第三步,把s指针的前向指针指向p结点
在这里插入图片描述
最后一步,把p结点的next指向s
在这里插入图片描述
但是如果p是双链表的最后一个节点,你在执行第二步p->next->prior=s是有问题的,如下图,NULL是没有前向指针的
在这里插入图片描述
所以我们把代码修改一下,让它更加严谨

//在p结点之后插入s结点
bool InsertNextDNode(DNode*p,DNode*s){
   if(p==NULL||s==NULL)//非法参数
      return false;
   s->next=p->next;//将结点*s插入到结点*p之后
   if(p->next!=NULL)//p节点有后继结点
      p->next->prior=s;
   s->prior=p;
   p->next=s;
   return true;
}

9.3双链表的删除

bool DeleteNextDNode(DNode* p){
   if(p==NULL)
     return false;
   DNode* q=p->next;//找到p的后继结点q
   if(q==NULL)
     return false;//p没有后继
   p->next=q->next;
   if(q->next!=NULL)//q结点不是最后一个结点
      q->next->prior=p;
   free(q);//释放结点空间
   return true;
}

看一下上述代码的逻辑
假设我们现在要删的是给定结点p的后继结点,
如果p指向NULL那肯定没有后继啊,返回false。
如果p不指向NULL,我们会声明一个q指针,让q指向p的后继

这里会出现两种情况:
情况1:q==NULL,说明p没有后继结点,这时返回false

情况2:q!=NULL,说明p是有后继结点的,我们就开始执行删除操作

然而确定了p有后继结点,还是有两种情况需要讨论
在这里插入图片描述

首先我们让p的next指向q的next
在这里插入图片描述
再往后,我们要判断一下q结点是否还有后继结点,也就是上图的第二种情况
如果q的next是NULL,那我们直接free掉q就完成删除操作了。
在这里插入图片描述

但如果q是有后继结点的,我们需要把q的后继结点的前驱改成p,然后再释放q结点
在这里插入图片描述
到这里我们就明白了双链表怎么删除给定结点的后继结点,那如果我们要销毁一个单链表(删除所有结点),只需要用一个循环,然后循环里面删结点即可,代码如下:

void DestoryList(DLinklist &L){
    //循环释放各个数据结点
    while(L->next!=NULL)
        DeleteNextDNode(L);
    free(L);
    L=NULL;//头指针指向NULL
}

9.4双链表的遍历

双链表的遍历其实很简单,
如果是后向遍历,每次循环让p指针指向下一结点,然后做相应处理,比如打印结点数据
如果是前向变量,每次循环让p指针指向前一结点

双链表不可随机存取。按位查找、按值查找操作都只能用遍历方式实现,时间复杂度为O(n)

9.5小结

在这里插入图片描述

十、循环链表

循环链表其实也就是在单链表/双链表的基础上进行一个小小的改进,然后就得到了所谓的循环单链表/双链表

10.1循环单链表

如下图:我们之前学习的单链表,最后一个结点的next是指向NULL的,但是循环单链表的next是指向头结点
在这里插入图片描述

10.1.1循环单链表初始化

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

//判断循环单链表是否为空
bool Empty(LinkList L){
   if(L->next==L)
      return true;
   else
      return false;
}

在初始化一个循环单链表时,我们需要把头结点的next指针指向头结点它自己,相应的,如果要判断循环单链表是否为空,只需要判断头结点的next指针是否指向它自己即可
在这里插入图片描述

10.1.2循环单链表判断尾结点

上面也提到了,如果这个循环单链表不为空,就需要把最后一个结点的next指针指向头结点,那么如果我们给定一个结点p,要判断p是否为循环单链表的表尾结点,只需要判断p的next是否是指向头结点即可
在这里插入图片描述

//判断结点p是否为循环单链表的表尾结点
bool isTail(LinkList L,LNode* p){
   if(p->next==L)
      return true;
   else
      return false;
}

10.1.3循环单链表和普通单链表的一些区别

我们知道,普通单链表给定一个结点p,只能知道p和p之后的结点。
而我们循环单链表最后一个结点指向的是NULL,
所以我们给定一个p结点可以找到这个单链表中所有结点。

另外,很多时候对链表的操作都是在头部或者尾部,我们普通单链表如果要找最后一个结点,但是只给了头结点,我们只能一个一个往后遍历,时间复杂度为O(n)
然而对于循环单链表来说,如果我们让这个单链表的指针L不是指向头结点,而是指向尾部这个结点,从尾部找到头部只需要往后找一个结点即可,时间复杂度为O(1),而找尾部的话,因为本身指针就是指向尾部,时间复杂度也是O(1)

在这里插入图片描述
所以如果在你的应用场景中,需要经常对表头或者表尾进行操作,那么当你使用循环单链表时,你可以让你这个单链表的指针L指向表尾元素,当然,如果你要这么做的话,当你在表尾插入或者删除一个元素时,就需要修改指针L的指向了

10.2循环双链表

和循环单链表类型,循环双链表也是重在让一个链表形成闭环,我们让表尾结点next指向头结点,头结点的prior指向表尾结点。
在这里插入图片描述

10.2.1循环双链表初始化

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);
    //后续代码
}

在初始化循环双链表时,我们需要让这个头结点的prior和next都指向头结点自己,如下图
在这里插入图片描述
循环双链表的判空也很简单,就是判断头结点的next指针是否指向它自身即可

bool Empty(DLinklist L){
    if(L->next=L)
       return true;
    else
       return false;
}

10.2.2循环双链表判断尾结点

我们知道,循环双链表的最后一个结点的next指向头结点,所以我们在判断一个结点p是否是循环双链表表尾结点,只需要判断该结点的next是否指向头结点

bool isTail(DLinklist L,DNode* p){
   if(p->next==L)
      return true;
   else
      return false;
}

10.2.3双链表的插入

//在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->next->prior=s这句代码是肯定会报错的。
但如果我们使用的是循环双链表,这段代码就没有任何问题了。因为即使p结点是表尾的最后一个结点,但是它的next指针依旧就非空的,也就是说p->next!=NULL,那么p->next->prior就没有问题了。

10.2.4双链表的删除

//删除p的后继结点q
p->next=q->next;
q->next->prior=p;
free(q);

在这里插入图片描述上述代码中,对于普通双链表来说在执行q->next->prior=p;时,如果p恰好是最后一个结点,q的next是NULL啊,哪里来的prior呢?

然而如果是循环双链表,第一句p的next指向q的next
在这里插入图片描述

接下来第二句q->next->prior=p;会把q的next的prior,也就是头结点的prior指向p
在这里插入图片描述
该段代码逻辑就没问题了

10.3小结

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

十一、静态链表

因为静态链表在实际考察中很少考到,而且考到也基本不会问代码实现,我们这里主要介绍静态链表的一些实现思路,也给同学们减轻负担

11.1什么是静态链表

我们知道,单链表中各个结点是离散的,分部在内存中各个角落。每个结点都会存放数据元素及指向下一结点的指针(下一个结点在内存中存放的地址)
在这里插入图片描述

静态链表则是需要分配一块连续的内存空间,各个数据元素存放在这一整片空间中的某些位置。静态链表中的每一个结点包含了数据元素还有下一结点的数组下标
在这里插入图片描述
在静态链表中,数组下标为0的结点,充当了头结点的角色,也就是说该结点不存放实际数据元素,而头结点的下一结点,它是存放数组下标为2的位置
在这里插入图片描述
然后以此类推,再下一个结点在数组下标为1的位置…

所以静态链表中这个数组下标(也有的地方把它称为游标)其实和单链表中指针的作用是一样的,只不过指针指明了具体的内存地址,而游标只是指明了下一元素的数组下标

单链表的表尾元素,它的next指针是指向NULL的,在静态链表中,如果要表示这个结点是最后一个结点,那么它的游标值可以设为-1,也就是下图中的e4
在这里插入图片描述
由于静态链表中存放各个结点的空间是连续的,那么如果静态链表的数据元素占4B,游标也占4B,那么一个结点是8B

假设起始地址为addr
那么数组下标为n的结点,
它的存放地址就应该是0号结点的存放地址加上1个结点的大小 * 所许结点的数组下标
比如数组下标为2的结点e1的存放地址=addr+8*2

用这样的方式可以把静态链表中的游标或者说数组下标,把它映射成某一个数组下标对应结点实际存放地址

11.2如何定义一个静态链表

如何定义一个静态链表呢?我们可以定义一个结构体Node,每一个Node,也就是每一个结点里面,它包含一个数据元素data和下一结点的游标或者说数组下标。
而我们需要的是多个连续存放的结点,那我们可以用数组的方式来定义它。

#define MaxSize 10 //静态链表的最大长度
struct Node{//静态链表结构类型的定义
    ElemType data;//存储数据元素
    int next;//下一个元素的数组下标
}

void testSLinkList(){
    struct Node a[MaxSize];//数组a作为静态链表
}

上述代码中数组a有MaxSize,也就是10个数组元素,每个数组元素的类型都是struct Node,也就是我们上面定义的这样一个结点。所以用这样的方式声明一个数组,这个数组a其实是占用了内存中这样一整片连续的存储空间
在这里插入图片描述

11.3简述基本操作的实现

初始化
首先声明静态链表之后,肯定要对它进行初始化,那么我们在初始化单链表时,是把头指针的next指向NULL。所以对应到静态链表时,我们在初始化是把头结点,也就是a[0]这个结点的next设置为-1

查找
在静态链表中,如果我们要查找某一个位序的结点的话,我们肯定只能从这个头结点出发,通过游标记录的这些线索依次的往后寻找,直到找到所需结点。
所以在静态链表中,如果要找到某个位序的结点,那么时间复杂度应该是O(n)
ps:注意,我们这里说的是某一个位序的结点,而不是某一个数组下标的结点。
位序指的是各个结点在逻辑上的顺序,而这里的数组下标则是反应各个结点在物理上的一个顺序

位序为i的地方插入结点
在位序为i的地方插入结点,因为是静态链表,第一步肯定是要找一块空闲的空间来存放这个新结点,如下图中数组下标为4、5、7、8、9都是空闲的空间
在这里插入图片描述
而要找到这些空闲空间,我们可以采用从上到下/从下到上的扫描方法

第二步,既然要插入位序为i的结点,那么我们肯定需要把位序为i-1的这个结点的后向指针(游标)给修改了,改成指向我们新插入结点的数组下标

第三步,修改我们新结点的next
ps:我们上面说找到空结点,我们这里是给了图,肉眼就可以知道哪里没有存数据,但是对于计算机来说,内存基本每个位置都有数据,只不过有些数据暂时用不到(也就是所谓的脏数据),所以,为了让计算机识别哪些结点暂时没有存放数据,我们可以把结点的next值设为一些特殊值,比如-2,后面你判断是否为空结点,直接判断next是否==-2即可

在这里插入图片描述

十二、顺序表与链表的比较

12.1逻辑结构

不管是顺序表还是链表,它们在逻辑上看都是线性结构,也就是说它们都属于线性表,各个数据元素之间有一对一的关系
在这里插入图片描述

12.2物理结构/存储结构

存储结构中,顺序表采用了顺序存储方式实现线性表,由于是采用了顺序存储,并且每个数据元素的大小是相等的,因此,我们只需要知道顺序表的起始地址,我们就可以立即找到第i个元素存放的位置,也就是顺序表拥有随机存取的特性。另一方面,顺序表中各个结点只需要存储数据元素本身,并不需要存储其它冗余信息,因此顺序表的存储密度也更高。然而,因为顺序存储这种存储结构要求系统给它分配一整片的连续存储空间,所以在给顺序表分配空间时会比较不方便,并且我们想要改变顺序表容量也会不方便

如果我们采用链表,也就是链式存储的方式来实现这种线性结构,那么由于各个结点可以离散的存放在不同空间中,所以我们每次要添加一个结点时,只需要用malloc函数动态申请一小片的空间即可,同时由于各个结点存储空间不要求连续,所以改变容量也会更方便一些。而链式存储带来的问题就是,当我们要找到第i个结点时,我们只能从第一个结点表头这个结点开始依次往后找,所以链式存储是不可随机存取的,另外,由于各个结点中除了存储数据元素外,还需要花费一点空间来存储指针,所以它的存储密度更低
在这里插入图片描述

12.3数据的运算/基本操作

基本操作大家太熟悉了:创建销毁、增删改查

12.3.1创建(初始化)

在这里插入图片描述

12.3.2销毁

在这里插入图片描述

12.3.3增加/删除

在这里插入图片描述

12.3.4查找

在这里插入图片描述

12.4小结

在这里插入图片描述

Logo

汇聚原天河团队并行计算工程师、中科院计算所专家以及头部AI名企HPC专家,助力解决“卡脖子”问题

更多推荐