数据结构之双链表(C语言)


1 链表的分类

链表根据是否带头(哨兵位)、是否双向(单、双)、是否循环三种特性而分为8种不同的链表。上一节我们讲述了最常见的两种链表之一的单链表(不带头单向不循环链表),本节我们来介绍另一种类型:双链表(带头双向循环链表)。

2 双向链表的结构

双向链表也是由每个节点构成的,只不过相较于单链表,双向列表的每个节点由三部分组成。第一部分是该节点所指向的上一节点的指针(地址),第二部分是该节点所存储的数据,第三部分是该节点所指向的下一节点的指针(地址)。
在这里插入图片描述

代码定义如下:

typedef int LTDatatype;//对涉及链表内的int类型进行重命名

typedef struct ListNode
{
    
    
	LTDatatype data;//该节点所存储的数据
	struct ListNode* prev;//指向前一个节点的指针
	struct ListNode* next;//指向后一个节点的指针
}LTNode;

3 双向链表的节点创建与初始化

3.1 节点创建函数

在单链表中,若单链表为空,我们便直接将其phead置为NULL。而在双向链表为空时,链表中只剩下一个头节点(哨兵位),在对其初始化时能否将prevnext指针都置为NULL

解决这一问题需要我们从双向链表的定义入手,双向链表是带头双向循环链表。在上一个问题中prevnext指针都为空,并不是一个循环。故此时的头节点并不能构成一个空的双向链表。正确操作是将头节点的prev指针与next指针都指向头节点自身,形成自循环。如此才是一个空的双向链表。在节点创建的其他方面与单链表的节点创建函数无异。
代码如下:

LTNode* LTBuyNode(LTDatatype x)
{
    
    
	LTNode* Newnode = (LTNode* )malloc(sizeof(LTNode));
	//申请一个节点,用一级指针Newnode存储该节点地址
	if (Newnode == NULL)//判断是否申请成功
	{
    
    
		perror("malloc fail");
		exit(1);
	}
	//申请成功
	Newnode->data = x;//将节点数据复制给data
	Newnode->prev = Newnode->next = Newnode;//将prev与next指针都指向本节点地址,即Newnode所存储的地址
	return Newnode;
}

3.2 初始化函数

对一个空的双向链表进行初始化即对其头节点进行初始化。我们不妨将头节点命名为phead,内部数据data一般情况下均赋值为**-1**。在3.1中,我们之所以将节点创建函数的返回值类型设置为LTNode*,目的就是为了在初始化函数中直接调用它,再将它的返回值赋值给phead完成初始化。
代码如下:

void Init(LTNode** pphead)//注意这里使用的是二级指针来接受形参
{
    
    
	*pphead = LTBuyNode(-1);//对头节点进行初始化
}

在上面函数的形参中我们使用的是二级指针来接受传参,这种传参方式取决于测试文件test.c中的代码编写。具体见下:
test.c:

LTNode* plist = NULL;//定义一个空指针
Init(&plist);//初始化这个指针,使其成为双向空链表的头节点

我们若想避免在初始化函数中使用二级指针来接受传参,可以使用如下编写方式:
test.c:

LTNode* plist = Init();//将Init的返回值直接赋值给plist,在Init内即完成头节点phead的创建

List.c

LTNode* Init()
{
    
    
	LTNode* phead = LTBuyNode(-1);//创建头节点并完成初始化
	return phead;
}

4 双向链表插入节点与删除节点的前序分析

在单链表中我们是使用二级指针来来作为形参接收传参,这是因为单链表在为空链表是其头节点地址为NULL,传参后形参开辟空间所临时拷贝的值也是NULL,在栈区空间为NULL基础上进行插入与删除对原链表plist不会有影响,原链表plist仍然保持初始状态。
但是,在双向链表中由于空链表的情况下依然有一个头节点(哨兵位),其一级指针plist所存储的就不再是NULL,而是头节点(哨兵位)的地址。并且头节点(哨兵位)的地址不能发生改变,也就意味着后续的插入与删除都不能改变头节点(哨兵位)的位置。故我们对以下两种插入与删除函数形参部分的设置进行分析:
一、与单链表的插入保持一致,依然使用二级指针接收传参。
首先这种设置是肯定行得通的,但在删除节点时会存在将头节点(哨兵位)也删除的情况。若使用这种形参设置就要做好对头节点(哨兵位)的保护。
二、使用一级指针接收传参
之所以双向链表能使用一级指针接收传参,而单链表不能如此是因为双向链表为空时其头节点(哨兵位)有实际的地址,而单链表却为NULL。在头节点(哨兵位)有实际地址的情况下,将地址作为实参传给对应的插入与删除函数的形参(一级指针),之后无论是插入还是删除都是在这个实际地址之后进行操作故同样会对原链表头节点(哨兵位)之后的节点位置造成改变。相比之下由于单链表的头节点地址为NULL,便不会有上面的变化。此外,使用一级指针来作为形参的好处在于无论是插入还是删除都不会影响头节点(哨兵位)的位置。这是因为头节点(哨兵位)地址作为实参传到形参后,是由形参phead存储。此时一级指针plistphead均同时指向头节点(哨兵位)地址,不仅在phead存储的地址后进行插入与删除可以影响plist存储的实参后的内容,而且即使误将phead所存储的头节点(哨兵位)删除,还有plist指向头节点地址(虽然此时这块地址存储的内容已经为空),保证了头节点(哨兵位)位置的恒定,所以并没有违反双向空链表的规则。
综上所述,二者相较之下建议使用设置二作为形参设置。(后续的插入与删除操作以设置二为例)

5 双向链表尾插法与头插法

5.1 尾插函数

执行尾插操作时关键在于链接建立的顺序。首先我们要让新节点newnodeprevnext指针同原链表连接,然后改变原链表尾节点的位置,让尾节点为新插入的节点newnode
具体步骤一:
在双向链表中,找尾十分容易,头节点(哨兵位)的prev指针指向即为尾节点。所以让newnode->prev = phead->prev接完成了新节点与尾节点的连接,再让newnode->next = phead接完成了新节点与头节点phead的连接。循环设置的一半完成。
具体步骤二:
原本尾节点的next指针指向的是pheadnewnode尾插进来后要使其指向newnode让newnode成为新的尾节点,即phead->prev->next = newnode。然后再将头节点(哨兵位)的prev指针指向新的尾节点newnode,即phead->prev = newnode注意:步骤二中两步的先后顺序不能发生改变。
​如图所示:
在这里插入图片描述
代码如下:

void LTPushBack(LTNode* phead, LTDatatype x)
{
    
    
	//判断传参不为空
	assert(phead);
	LTNode* newnode = LTBuyNode(x);
	//步骤一
	newnode->prev = phead->prev;//newnode->prev指向尾节点
	newnode->next = phead;//newnode->next指向头节点
	//步骤二
	phead->prev->next = newnode;//原本尾节点指向新节点newnode
	phead->prev = newnode;//将新节点newnode变为新的尾节点
	//注意步骤二中两步的先后顺序不能发生改变
}

5.2 头插函数

5.1知,将新节点插入到头节点的前面即尾插(因为是循环链表)。故将新节点插入到头节点的后面即头插。头插法的关键与尾插一致,均为链接建立的顺序。首先我们要让新节点newnode与原链表的第二个节点phead->next建立联系,然后再将新节点newnode与头节点phead连接。
具体步骤一:
首先我们要找到第二个节点phead->next,然后让新节点newnodenext指针指向第二个节点phead->next,即newnode->next = phead->next再将第二个节点phead->nextprev指针指向新节点newnode,即phead->next->prev = newnode
具体步骤二:
原本pheadnext指针是指向第二个节点phead->next,新节点newnode插入进来后要让pheadnext指针指向newnode,即phead->next = newnode。然后再让newnodeprev指针指向phead,即newnode->prev = phead
代码如下:

void LTPushFront(LTNode* phead, LTDatatype x)
{
    
    
	//判断传参不为空
	assert(phead);
	LTNode* newnode = LTBuyNode(x);
	//步骤一
	newnode->next = phead->next;
	phead->next->prev = newnode;
	//步骤二
	newnode->prev = phead;
	phead->next = newnode;
}

6 双向链表的尾删法与头删法

6.1尾删函数

既然要删除链表中的节点,我们就要保证链表中不能只有一个头节点,即链表不能为空。此外,在删除的过程中,我们要确保双向链表的结构不受到破坏,也就是我们不能直接删除掉对应节点,否则该节点前面的节点与后面的节点就无法连接到一起,故应该先把要删除的节点保存下来,完成上述连接操作后再删除该节点。
代码如下:

void LTPopBack(LTNode* phead)
{
    
    
	//链表必须有效且不能为空(只有一个哨兵位)
	assert(phead && phead->next != phead);
	//将尾节点先保存下来
	LTNode* del = phead->prev;
	//步骤一:重新设置尾节点
	del->prev->next = phead;
	phead->prev = del->prev;
	//步骤二:删除原本尾节点(del)
	free(del);
	del = NULL;
}

6.2 头删函数

6.1中尾删函数步骤相同,先把要删除的节点保存下来,将头节点(哨兵位)与第三个节点连接起来,在将先前所保存的第二个节点删除。
代码如下:

void LTPopFront(LTNode* phead)
{
    
    
	//链表必须有效且不能为空(只有一个哨兵位)
	assert(phead && phead->next != phead);
	//将第二个节点先保存下来
	LTNode* del = phead->next;
	//步骤一:重新设置第二个节点
	del->next->prev = phead;
	phead->next = del->next;
	//步骤二:删除原本第二个节点(del)
	free(del);
	del = NULL;
}

7 双向链表在指定位置插入或删除数据

7.1 查找指定节点函数

要想在指定位置插入或删除数据就要先找到指定位置的节点。在双向链表中查找指定节点需要对链表进行遍历。代码如下:

LTNode* Find(LTNode* phead, LTDatatype x)
{
    
    
	//判断传参不为空,并且链表不为空(只有一个哨兵位)
	assert(phead && phead->next != phead);
	//从第一个有效节点开始查找,该节点用pcur存储
	LTNode* pcur = phead->next;
	while (pcur != phead)//当查找到头节点(哨兵位)时表示没找到,退出循环
	{
    
    
		if (pcur->data == x)//注意这里是判断语句,要用“==”而不是赋值的“=”
		{
    
    
			return pcur;//找到指定节点
		}
		pcur = pcur->next;//没找到指定节点,继续向后遍历
	}
	return NULL;//在该链表中不存在指定节点
}

7.2 在指定位置插入数据

与尾插和头插思路一致,先完成新节点newnode与插入位置之后的节点的连接,再完成newnode与插入位置之前的节点的连接。我们以在指定的pos节点之后插入数据为例:
代码如下:

void Insert(LTNode* pos, LTDatatype x)
{
    
    
	//判断传参不为空
	assert(pos);
	//创建要插入的节点
	LTNode* newnode = LTBuyNode(x);
	//步骤一:完成与插入位置后的节点的连接
	newnode->next = pos->next;
	pos->next->prev = newnode;
	//步骤二:完成与插入位置前的节点的连接
	newnode->prev = pos;
	pos->next = newnode;
}

7.3 在指定位置删除数据

首先使用查找函数找到pos节点,然后将pos节点的前后节点连接,再将pos节点删除即可。
代码如下:

void LTErase(LTNode* phead, LTNode* pos)
{
    
    
	//判断传参不为空,并且pos节点不是头节点(哨兵位)
	assert(pos && pos != phead);
	//步骤一:将pos节点前后的节点连接
	pos->prev->next = pos->next;
	pos->next->prev = pos->prev;
	//步骤二:删除pos节点
	free(pos);
	pos = NULL;
}

8 销毁双向链表与打印函数

8.1 销毁函数

与前文中删除节点类似,只不过销毁时需要借助循环来完成,并且在销毁过程中我们需要保存本次循环删除的节点的下一个节点,并且在后续循环中不断更新。
代码如下:

void LTDestroy(LTNode* phead)
{
    
    
	assert(phead);
	LTNode* pcur = phead->next;
	while (pcur != phead)
	{
    
    
		LTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	//此时pcur也指向phead,phead还没有被删除
	free(phead);
	phead = NULL;
}

其实无论是LTErase还是LTDestroy理论上都要传二级指针,因为我们需要让形参影响实参,但是我们为了接口的一致性都是用一级指针来接收传参。用一级来接收传参的弊端在于当我们在函数内将phead(或find)指向的地址的空间(存储内容)释放掉后,在函数内将形参phead(或find)置为NULL却并不能将函数外的实参plist(或Pos)也置为NULL。因此解决办法是:在调用完函数后手动将实参置为NULL

8.2 打印函数

总体与单链表的打印相同,只不过双向链表的打印需要我们跳过头节点(哨兵位)。

void LTPrint(LTNode* phead)
{
    
    
	assert(phead && phead->next != phead);
	LTNode* pcur = phead->next;
	while (pcur != phead)
	{
    
    
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
	printf("\n");
}

9 双向链表全程序及演示结果

9.1 List.h文件

#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>

typedef int LTDatatype;

typedef struct ListNode
{
    
    
	LTDatatype data;
	struct ListNode* prev;
	struct ListNode* next;
}LTNode;

//节点创建函数
LTNode* LTBuyNode(LTDatatype x);

//初始化函数01
//void Init(LTNode** pphead)

//初始化函数02
LTNode* Init();

//尾插函数
void LTPushBack(LTNode* phead, LTDatatype x);

//头插函数
void LTPushFront(LTNode* phead, LTDatatype x);

//尾删函数
void LTPopBack(LTNode* phead);

//头删函数
void LTPopFront(LTNode* phead);

//查找函数
LTNode* LTFind(LTNode* phead, LTDatatype x);

//在pos位置之后插入
void LTInsert(LTNode* pos, LTDatatype x);

//删除POS节点
void LTErase(LTNode* phead, LTNode* pos);

//销毁链表
void LTDestroy(LTNode* phead);

//打印函数
void LTPrint(LTNode* phead);

9.2 List.c文件

#include"List.h"

//节点创建函数
LTNode* LTBuyNode(LTDatatype x)
{
    
    
	LTNode* Newnode = (LTNode* )malloc(sizeof(LTNode));
	//申请一个节点,用一级指针Newnode存储该节点地址
	if (Newnode == NULL)//判断是否申请成功
	{
    
    
		perror("malloc fail");
		exit(1);
	}
	//申请成功
	Newnode->data = x;//将节点数据复制给data
	Newnode->prev = Newnode->next = Newnode;//将prev与next指针都指向本节点地址,即Newnode所存储的地址
}

//初始化函数01
//void Init(LTNode** pphead)//注意这里使用的是二级指针来接受形参
//{
    
    
//	*pphead = LTBuyNode(-1);//对头节点进行初始化
//}

//初始化函数02
LTNode* Init()
{
    
    
	LTNode* phead = LTBuyNode(-1);
	return phead;
}

//尾插函数
void LTPushBack(LTNode* phead, LTDatatype x)
{
    
    
	//判断传参不为空
	assert(phead);
	LTNode* newnode = LTBuyNode(x);
	//步骤一
	newnode->prev = phead->prev;//newnode->prev指向尾节点
	newnode->next = phead;//newnode->next指向头节点
	//步骤二
	phead->prev->next = newnode;//原本尾节点指向新节点newnode
	phead->prev = newnode;//将新节点newnode变为新的尾节点
	//注意步骤二中两步的先后顺序不能发生改变
}

//头插函数
void LTPushFront(LTNode* phead, LTDatatype x)
{
    
    
	//判断传参不为空
	assert(phead);
	LTNode* newnode = LTBuyNode(x);
	//步骤一
	newnode->next = phead->next;
	phead->next->prev = newnode;
	//步骤二
	newnode->prev = phead;
	phead->next = newnode;
}

//尾删函数
void LTPopBack(LTNode* phead)
{
    
    
	//链表必须有效且不能为空(只有一个哨兵位)
	assert(phead && phead->next != phead);
	//将尾节点先保存下来
	LTNode* del = phead->prev;
	//步骤一:重新设置尾节点
	del->prev->next = phead;
	phead->prev = del->prev;
	//步骤二:删除原本尾节点(del)
	free(del);
	del = NULL;
}

//头删函数
void LTPopFront(LTNode* phead)
{
    
    
	//链表必须有效且不能为空(只有一个哨兵位)
	assert(phead && phead->next != phead);
	//将第二个节点先保存下来
	LTNode* del = phead->next;
	//步骤一:重新设置第二个节点
	del->next->prev = phead;
	phead->next = del->next;
	//步骤二:删除原本第二个节点(del)
	free(del);
	del = NULL;
}

//查找函数
LTNode* LTFind(LTNode* phead, LTDatatype x)
{
    
    
	//判断传参不为空,并且链表不为空(只有一个哨兵位)
	assert(phead && phead->next != phead);
	//从第一个有效节点开始查找,该节点用pcur存储
	LTNode* pcur = phead->next;
	while (pcur != phead)//当查找到头节点(哨兵位)时表示没找到,退出循环
	{
    
    
		if (pcur->data == x)//注意这里是判断语句,要用“==”而不是赋值的“=”
		{
    
    
			return pcur;//找到指定节点
		}
		pcur = pcur->next;//没找到指定节点,继续向后遍历
	}
	return NULL;//在该链表中不存在指定节点
}

//在pos位置之后插入
void LTInsert(LTNode* pos, LTDatatype x)
{
    
    
	//判断传参不为空
	assert(pos);
	//创建要插入的节点
	LTNode* newnode = LTBuyNode(x);
	//步骤一:完成与插入位置后的节点的连接
	newnode->next = pos->next;
	pos->next->prev = newnode;
	//步骤二:完成与插入位置前的节点的连接
	newnode->prev = pos;
	pos->next = newnode;
}

//删除POS节点
void LTErase(LTNode* phead, LTNode* pos)
{
    
    
	//判断传参不为空,并且pos节点不是头节点(哨兵位)
	assert(pos && pos != phead);
	//步骤一:将pos节点前后的节点连接
	pos->prev->next = pos->next;
	pos->next->prev = pos->prev;
	//步骤二:删除pos节点
	free(pos);
	pos = NULL;
}

//销毁链表
void LTDestroy(LTNode* phead)
{
    
    
	assert(phead);
	LTNode* pcur = phead->next;
	while (pcur != phead)
	{
    
    
		LTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	//此时pcur也指向phead,phead还没有被删除
	free(phead);
	phead = NULL;
}

//打印函数
void LTPrint(LTNode* phead)
{
    
    
	assert(phead && phead->next != phead);
	LTNode* pcur = phead->next;
	while (pcur != phead)
	{
    
    
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
	printf("\n");
}

9.3 test.c文件

运行结果图:
在这里插入图片描述

#include"List.h"
void Test01()
{
    
    
	/*LTNode* plist = NULL;
	Init(&plist);*/
	//若想让初始化函数Init内不用二级指针来接受传参
	//可按如下方式编写
	LTNode* plist = Init();

	LTPushBack(plist, 1);
	LTPushBack(plist, 2);
	LTPushBack(plist, 3);
	LTPrint(plist);

	LTNode* find = LTFind(plist, 3);
	//LTInsert(find, 66);
	LTErase(plist,find);
	find = NULL;

	LTPrint(plist);
	LTDestroy(plist);
}

int main()
{
    
    
	Test01();
}

全文至此结束!!!
写作不易,不知各位老板能否给个一键三连或是一个免费的赞呢(▽)(▽),这将是对我最大的肯定与支持!!!谢谢!!!(▽)(▽)