目录
链表的分类
如上,我们可以看到,链表一共有 8 种(2*2*2)
1. 是否带头就是指有无哨兵位(哨兵位就是头节点前面还有一个节点,该节点不能被进行任何操作)
2. 单向与双向的区别就在于:单向仅存有下一个节点的地址,而双向存有下一个与上一个节点的地址
也就是说,单向链表无法通过某个节点找到其上一个节点,但是双向链表可以
3. 循环与否就是看有无形成一个闭环
上一篇博客我们已经实现过了单链表的各种功能,那个单链表全称为:不带头、单向、不循环链表,简称单链表(听名字也能听得出来是最简单的一种)
本期为各位带来的是:带头、双向、循环链表,简称双链表。只要我们将最简单的和最难的给理解透了,剩下的 6 种链表类型自然也就会了
如果对单链表有困惑,或者想更深入了解的话,可以看一看这篇博客:
总代码
如果有需要看双链表总代码的,可以点击下方链接,代码放在 gitee 中,点击即食
双链表的语法结构
typedef int LTDataType;
为了防止未来需要修改数据类型,比如将链表内里的数据全部从 int 改为 char,我们在开始写之前就先将要放进链表里的数据类型用 typedef 定义好,如上
而我们的双链表除了自身存有的数据之外,不仅要存有上一个的地址,还需要有下一个的地址
所以其结构如下:
typedef int LTDataType;
typedef struct ListNode {
LTDataType data;
struct ListNode* prev;//指向上一个节点
struct ListNode* next;//指向下一个节点
}LTNode;
会重复开辟动态空间,故封装一个函数 LTBuyNode
开辟空间的函数就只干两件事:
1. 开辟空间并检查
2. 对开辟的空间进行初始化
对于动态开辟空间,我们可以使用 malloc ,而初始化我们则需要传数据过来,将其中的非指针部分初始化成传递过来的数据
接着,是我们两个指针的初始化:由于这是一个循环的双向链表,所以我们在初始化时,都需要将指针指向自己:
代码如下:
LTNode* LTBuyNode(LTDataType x)
{
//开辟空间
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
//判断是否开辟成功
if (newnode == NULL)
{
perror("malloc fail!");
exit(1);
}
//初始化
newnode->data = x;
newnode->next = newnode->prev = newnode;
return newnode;
}
双链表的初始化 LTInit
我们今天要实现的是带头、双向、循环链表,带头就是有哨兵位,而哨兵位是在头节点之前的一块空间,并且我们不能对哨兵位进行任何操作
所以,我们在初始化部分就不像单链表那样,只是将里面的指针置空就可以了,我们需要创建空间,那创建完我们随便赋个值进去即可(因为即使是遍历也是到哨兵位就停止,所以哨兵位内部存放什么值并无影响)
而我们在上文已经实现了一个开辟空间函数 —— LTBuyNode,我们直接引用该函数即可
代码如下:
LTNode* LTInit()
{
//创建哨兵位
LTNode* phead = LTBuyNode(-1);//随便传个值
return phead;
}
链表中的 一级指针 & 二级指针问题
我们上一篇单链表博客中,头插尾插用的都是二级指针,但为什么呢?
我们有一个指针一直指向头节点,这里我们假设他名为 phead
我们每次头插,头节点都会发生改变,而我们指向头节点的指针自然要发生变化
我们一级指针指向的是链表内的结构体,如果单纯改变链表内部的内容的话,我们就用一级指针
而我们的二级指针,改变的是一级指针的指向,比如我要让一级指针从指向 A 改为指向 B,这时我们要使用二级指针
而我们头插有涉及到 phead 的指向改变,所以我们需要使用二级指针
而单链表的尾插有这么一种情况,当链表里面没有节点的时候,phead 指向 NULL,此时头插和尾插是一个效果,所以为了放置这种情况,我们需要使用二级指针
如果各位有兴趣的话,可以试试将尾插代码设置为用一级指针接收,头插用二级指针,在 main 函数内部先头插一个数据,在使用尾插,最后打印,会发现是可以将数据打印出来的
现在,我们实现的是带有哨兵位的双向链表!!!
头节点会一直指向哨兵位,并不会改变,也不能改变
所以,今天的双链表专题中,传的都是一级指针,因为不用改变指向哨兵位的指针的指向
双链表的尾插 LTPushBack
由于这是带头双向循环链表,所以我们尾插不需要像之前实现单链表一样,遍历链表找尾节点,我们头节点的前一个节点就是尾节点了(循环)
尾插核心步骤如下:(假设指向前一个节点的指针为 prev,指向下一个节点的指针为 next )
现在我们有一个 4 要插入链表中(下方的 1 节点是哨兵位 )
先让 4 的 prev 指向原尾节点,next 指向哨兵位
先将要插入节点的 prev 和 next 令其分别指向哨兵位和原尾节点
接着,先让哨兵位的 prev 指向要插入的节点
最后,让原尾节点的 next 指向要插入的节点
到这里再说明一个点,尾插涉及到了哨兵位,原尾节点,要插入的节点
这里面,哨兵位有指针指着,会传到尾插函数中来,是已知条件
原尾节点可以通过 哨兵位的 prev 找到(因为是循环,所以是哨兵位的上一个节点)
要插入节点我们在使用完上文实现过的 LTBuyNode 函数之后会返回
尾插总代码如下:
//尾插
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = LTBuyNode(x);
//为要插入节点开辟动态空间
newnode->next = phead;
newnode->prev = phead->prev;
phead->prev->next = newnode;
phead->prev = newnode;
}
双链表的头插 LTPushFront
头插的核心思想和尾插是差不多的,归根结底就是改变指针的指向
注:下图中的 -1 节点是哨兵位,我们的 4 节点是插入在 2 节点与哨兵位之间的
头插代码如下:
//头插
void LTPushFront(LTNode* phead, LTDataType x) {
assert(phead);
LTNode* newnode = LTBuyNode(x);
newnode->next = phead->next;
newnode->prev = phead;
phead->next->prev = newnode;
phead->next = newnode;
}
双链表的打印 LTPrint
我们在尝试打印双链表时需要考虑到哨兵位,因为哨兵位的下一个节点才是我们的头节点,所以我们打印应该从哨兵位的下一个节点开始打印起
但是这是个循环链表,我们打印了一圈之后又回来了,所以我们需要一个判断条件来终止循环
我们可以先定义一个指针令其指向哨兵位的下一个节点,假设这个指针的名字为 pcur,我们用 pcur 来遍历链表,当 pucr -> next 为哨兵位时,循环结束
而我们每找到一个节点,就打印对应节点的数据
//打印
void LTPrint(LTNode* phead)
{
assert(phead);//phead不能为空
LTNode* pcur = phead->next;
while (pcur != phead)
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("\n");
}
双链表的头删 LTPopFront
对于带头的链表,所谓的头删就是删除哨兵位的后一个节点(不能对哨兵位进行任何操作)
核心步骤如下:(假设下图中 -1 节点为哨兵位)
注意!!!我们的 2 节点和 3 节点要有指针提前保存好,不然改变了指向之后就找不到了
3 节点要保存是因为不是所有的双链表都只有两个节点
头删代码如下:
//头删
void LTPopFront(LTNode* phead) {
assert(phead);
assert(phead->next != phead);
LTNode* del = phead->next;
LTNode* next = del->next;
next->prev = phead;
phead->next = next;
free(del);
del = NULL;
}
双链表的尾删 LTPopBack
尾删和头删的原理相同,涉及到的节点有:哨兵位,尾节点,尾节点的上一个节点
同样的为了防止改变指针指向之后找不到尾节点和尾节点的上一个节点,我们需要提前将其存起来
原理与头删相同,各位如果理解了头删,那么就会觉得尾删跟头删差别不大
总代码如下:
//尾删
void LTPopBack(LTNode* phead) {
assert(phead);
//链表为空:只有一个哨兵位节点
assert(phead->next != phead);
LTNode* del = phead->prev;
LTNode* prev = del->prev;
prev->next = phead;
phead->prev = prev;
free(del);
del = NULL;
}
双链表的查找 LTFind
查找相对简单,本质就是遍历一遍链表
由于是带头的链表,所以我们在遍历的时候需要从哨兵位的下一个节点开始遍历
每到一个节点,我们就比对一下节点内的数据,相等就 return 下标,不相等就继续向下遍历,直到下一个节点为哨兵位时,终止循环
如果跳出循环了,就意味着链表里并没有这个数,我们 return 一个空指针即可
代码如下:
//查找
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead)
{
if (pcur->data == x)
{
return pcur;
}
pcur = pcur->next;
}
return NULL;
}
在指定位置之后插入数据 LTInsert
假设我们的指定位置名为 pos,即 pos 节点
如下,假如现在我们要在 2 节点之后插入数据:
此时,涉及到的节点有 pos 节点,pos 节点的下一个节点,要插入的节点
pos 节点和要插入的节点是已知条件,pos 节点的下一个节点我们也可以通过 pos -> next 来找到
但是,我们如果先将 pos 节点的指向改变了的话,我们就找不到 pos 节点的下一个节点了
所以,我们需要先改变 pos -> next 节点的指向,最后再来改变 pos 节点的指向
代码如下:
//在pos位置之后插入数据
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = LTBuyNode(x);
newnode->next = pos->next;
newnode->prev = pos;
pos->next->prev = newnode;
pos->next = newnode;
}
删除指定位置数据 LTErase
假设指定位置有一个名为 pos 的指针指着,即 pos 节点
如下,假如我们现在要删除的是节点 3 :
此处先改变哪个节点都无所谓
总得来说,删除 pos 节点就是改变指针的指向,先是让 pos 节点的后一个节点的 prev 指向 pos 节点的前一个结点,再让 pos 节点的前一个节点的 next 指向 pos 节点的后一个节点
代码如下:
//删除pos位置的数据
void LTErase(LTNode* pos)
{
assert(pos);
pos->next->prev = pos->prev;
pos->prev->next = pos->next;
free(pos);
pos = NULL;
}
删除双链表 LTDesTroy
对于删除双链表,这里可以用二级指针也可以用一级指针
因为涉及到指向哨兵位指针的变化,所以使用二级指针,如果使用一级指针的话,那么我们就需要在函数结束之后,在 main 函数内部再将指向哨兵位的指针置为 NULL,多了一步
但是,但是,但是,这里推荐使用一级指针来接收!!!
仔细看我们的代码,头删尾删、头插尾插......都是拿一级指针来接收的,而此时如果我们将二级指针放进销毁函数里的话,就会增加用户的使用成本
当然你强行要用也没有问题,这里只是推荐使用一级指针
如下将会以一级指针为例:
我们要销毁双向链表,自然少不了遍历链表,但是我将一个节点 free 了之后我就找不到下一个节点了,这可如何是好?
我们会发现,我们需要两个指针,一个指向要删除的节点(pcur),一个指向要删除节点的下一个节点(next),当删除完之后,我们就让 pcur = next,同时 pcur 往后走一步
函数结束之后,我们需要在 main 函数中将指向哨兵位的指针手动置空
代码如下:
//test.c
LTNode* plist = LTInit();
LTDesTroy(plist);
plist = NULL;
//List.c
void LTDesTroy(LTNode* phead)
{
//哨兵位不能为空
assert(phead);
LTNode* pcur = phead->next;
while (pcur != phead)
{
LTNode* next = pcur->next;
free(pcur);
pcur = next;
}
//链表中只有一个哨兵位
free(phead);
phead = NULL;
}
结语
至此,我们对双链表相关内容的讲解就结束啦!
如果对你有帮助,希望可以多多支持!!!