双向链表详解

目录

[1 · 双向链表的结构](#1 · 双向链表的结构)

[2 · 双向链表的实现](#2 · 双向链表的实现)

[2 - 1 · 接口总览与结构定义](#2 - 1 · 接口总览与结构定义)

[2 - 2 · 初始化,销毁,打印](#2 - 2 · 初始化,销毁,打印)

[2 - 2 - 1 · 初始化](#2 - 2 - 1 · 初始化)

[2 - 2 - 2 · 销毁](#2 - 2 - 2 · 销毁)

[2 - 2 - 3 · 打印](#2 - 2 - 3 · 打印)

[2 - 2 - 4 · 测试](#2 - 2 - 4 · 测试)

[2 - 3 · 头插,头删](#2 - 3 · 头插,头删)

[2 - 3 - 1 · 头插](#2 - 3 - 1 · 头插)

[2 - 3 - 2 · 头删](#2 - 3 - 2 · 头删)

[2 - 3 - 3 · 测试](#2 - 3 - 3 · 测试)

[2 - 4 · 尾插,尾删](#2 - 4 · 尾插,尾删)

[2 - 4 - 1 · 尾插](#2 - 4 - 1 · 尾插)

[2 - 4 - 2 · 尾删](#2 - 4 - 2 · 尾删)

[2 - 4 - 3 · 测试](#2 - 4 - 3 · 测试)

[2 - 5 · 查找,指定位置之前插入,指定位置删除](#2 - 5 · 查找,指定位置之前插入,指定位置删除)

[2 - 5 - 1 · 查找](#2 - 5 - 1 · 查找)

[2 - 5 - 2 · 指定位置之前插入](#2 - 5 - 2 · 指定位置之前插入)

[2 - 5 - 3 · 指定位置删除](#2 - 5 - 3 · 指定位置删除)

[2 - 5 - 4 · 测试](#2 - 5 - 4 · 测试)

[3 · 顺序表和链表的区别](#3 · 顺序表和链表的区别)

[3 - 1 · 缓存利用率](#3 - 1 · 缓存利用率)

总结


1 · 双向链表的结构

双向链表,全称带头 双向 循环链表,下面这张图便是:

带头链表的头结点,也就是上面的 head 结点,也称为哨兵位,哨兵位不存放任何有效元素,只是在这里放哨的。

借助哨兵位,我们可以实现接口一致,并且可以作为我们循环链表遍历的结束条件。


2 · 双向链表的实现

2 - 1 · 接口总览与结构定义

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int LTDatatype;

typedef struct ListNode
{
	int val;
	struct ListNode* prev;
	struct ListNode* next;

}ListNode;

//初始化,创建哨兵位
ListNode* ListInit();
//销毁
void ListDestroy(ListNode* phead);
//打印
void ListPrint(ListNode* phead);
//头插
void ListPushFront(ListNode* phead, LTDatatype x);
//头删
void ListPopFront(ListNode* phead);
//尾插
void ListPushBack(ListNode* phead, LTDatatype x);
//尾删
void ListPopBack(ListNode* phead);
//查找
ListNode* ListFind(ListNode* phead, LTDatatype x);
//指定位置的前一个位置插入
void ListInsert(ListNode* pos, LTDatatype x);
//指定位置删除
void ListErase(ListNode* pos);

这里用到了 typedef ,最上面的是方便进行存储类型的修改,在代码实现中用 LTDataType,到时候如果想要修改存储的类型,只需要改这里一处即可。

下面在结构体 这里的 typedef 是方便后续使用,可以少写 struct。

与单链表的结构定义同理,结构体中的成员变量的 struct 不能省略。

定义了两个结构体指针,一个指向前一个结点,一个指向后一个结点。


2 - 2 · 初始化,销毁,打印

2 - 2 - 1 · 初始化

代码如下:

复制代码
#include "DoublyLinkedList.h"

ListNode* BuyNode(LTDatatype x)
{
	ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
	if (newnode == NULL)
	{
		perror("malloc");
		exit(1);
	}
	
	newnode->val = x;
	newnode->next = newnode->prev = newnode;
	return newnode;
}

ListNode* ListInit()
{
	return BuyNode(-1);
}

双向链表的初始化就是创建一个哨兵位。

我们写了一个 BuyNode ,方便初始化以及后续申请结点。

由于是双向循环链表,那么新的结点的 prev 与 next 指针是不能够置空的,因此在创建新结点的时候,将这两个指针都指向自己即可。

那么在初始化这个接口中,只需要调用BuyNode 即可,由于哨兵位不存放任何有效元素,我们在链表遍历访问时也不会访问哨兵位存放的元素,这里我们给哨兵位的元素存了 -1。


2 - 2 - 2 · 销毁

代码如下:

复制代码
void ListDestroy(ListNode* phead)
{
	assert(phead);

	//遍历,销毁
	ListNode* pcur = phead->next;
	while (pcur != phead)
	{
		ListNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	free(phead);
	phead = NULL;
}

进行遍历,然后一个个销毁。

定义一个 pcur 指向哨兵位的 next ,pcur 一步步往后走,当pcur走到了表尾,下一步就会循环回到哨兵位,而此时便说明遍历已完成。

在销毁时,如果直接free(pcur),此时会找不到下一个结点,因此要先将下一个结点的地址保存。

当遍历并销毁之后,最后将哨兵位销毁。

注意:为了保证接口一致,这里的形参phead是使用一级指针接收的,当初始化时,由于我们只能拿到哨兵位的地址,所以传参时传的也是一级指针,那么此时对形参的修改 是影响不到实参的。因此在调用完 ListDestroy 后,我们要手动将实参置空。


2 - 2 - 3 · 打印

代码如下:

复制代码
void ListPrint(ListNode* phead)
{
	ListNode* pcur = phead->next;

	while (pcur != phead)
	{
		printf("%d -> ", pcur->val);
		pcur = pcur->next;
	}
	printf("NULL\n");
}

简单的遍历,打印。

为了更加直观,所以在打印的时候加上了箭头(->)。

哨兵位不存放任何有效元素,因此无需打印哨兵位的元素。


2 - 2 - 4 · 测试

我们对上面的功能测试一下:

复制代码
#include "DoublyLinkedList.h"


ListNode* BuyNode(LTDatatype x);
void Test1()
{
	ListNode* head = ListInit();

	ListNode* node1 = BuyNode(1);
	ListNode* node2 = BuyNode(2);

	head->next = node1;
	node1->next = node2;
	node2->next = head;

	head->prev = node2;
	node2->prev = node1;
	node1->prev = head;

	ListPrint(head);

	ListDestroy(head);
	//需要手动置空
	head = NULL;
}

int main()
{
	Test1();
	//Test2();
	//Test3();
	//Test4();

	return 0;
}

这里手动连接了这个双向链表。

运行一下:

监视窗口看看销毁:

销毁前:

销毁后:


2 - 3 · 头插,头删

2 - 3 - 1 · 头插

头插就是在表头进行插入,有哨兵位的存在,表头结点是哨兵位的下一个结点。

代码如下:

复制代码
void ListPushFront(ListNode* phead, LTDatatype x)
{
	assert(phead);

	ListNode* newnode = BuyNode(x);

	newnode->next = phead->next;
	newnode->prev = phead;

	phead->next->prev = newnode;
	phead->next = newnode;
}

方便理解过程,我们画个图:

其中 红色的部分是我们应进行的操作

一共有四步需要操作,那么这其中就会有顺序问题。

为了确保不发生问题,我们先完成对将要被插入的 newnode 的指针域的修改,newnode 还未插入表中,先对 newnode 的指针域进行修改不会影响到原链表的顺序。

随后的两步其实可以随意,就算先改动了哨兵位的next指针 ,也可以通过newnode 的 next指针找到原来的哨兵位的next指向的结点。

对于空表的情况,此时哨兵位的 next指针和 prev指针都指向自身,我们写的代码也是能够应付这种情况的。


2 - 3 - 2 · 头删

头删就是对表头结点进行删除,有哨兵位的存在,表头结点是哨兵位的下一个结点。

代码如下:

复制代码
void ListPopFront(ListNode* phead)
{
	assert(phead);
	//空表不能删
	assert(phead->next != phead);

	ListNode* delnode = phead->next;
	//先改指针域
	phead->next = delnode->next;
	delnode->next->prev = phead;

	free(delnode);
	delnode = NULL;
}

方便理解过程,我们画张图:

红色是我们需要进行的操作。

如果直接进行free,会导致很难找到被删除结点的下一个结点,所以最好先对指针域进行修改。

对于链表中只有一个结点的情况,我们写的代码也是可以应付的。


2 - 3 - 3 · 测试

我们对上面写的功能测试一下:

复制代码
void Test2()
{
	ListNode* head = ListInit();

	ListPushFront(head, 1);
	ListPrint(head);

	ListPushFront(head, 2);
	ListPrint(head);

	ListPopFront(head);
	ListPrint(head);

	ListPopFront(head);
	ListPrint(head);

	/*ListPopFront(head);
	ListPrint(head);*/

	ListDestroy(head);
	head = NULL;
}

运行一下:

此时再进行一次头删便会触发assert断言。


2 - 4 · 尾插,尾删

2 - 4 - 1 · 尾插

尾插就是在表尾进行插入。

代码如下:

复制代码
void ListPushBack(ListNode* phead, LTDatatype x)
{
	assert(phead);

	ListNode* newnode = BuyNode(x);
	newnode->next = phead;
	newnode->prev = phead->prev;

	phead->prev->next = newnode;
	phead->prev = newnode;
}

方便理解过程,我们画张图:

其中红色是我们要进行的操作。

与头插的操作类似,先改newnode 的指针域,随后入表,连接。

对于空表的情况,我们写的代码也是能够应付的。


2 - 4 - 2 · 尾删

尾删就是对表尾进行删除。

代码如下:

复制代码
void ListPopBack(ListNode* phead)
{
	assert(phead);
	//空表不能删
	assert(phead->next != phead);

	ListNode* delnode = phead->prev;
	phead->prev = delnode->prev;
	delnode->prev->next = phead;

	free(delnode);
	delnode = NULL;
}

方便理解过程,我们画个图:

与头删操作类似,先修改指针域,再进行删除。

对于链表中只有一个结点的情况,我们写的代码也是可以应付的。


2 - 4 - 3 · 测试

测试一下:

复制代码
void Test3()
{
	ListNode* head = ListInit();

	ListPushBack(head, 1);
	ListPrint(head);

	ListPushBack(head, 2);
	ListPrint(head);

	ListPopBack(head);
	ListPrint(head);

	ListPopBack(head);
	ListPrint(head);

	//ListPopBack(head);
	//ListPrint(head);

	ListDestroy(head);
	head = NULL;
}

运行一下:

此时再进行一次尾删就会触发assert断言。


2 - 5 · 查找,指定位置之前插入,指定位置删除

2 - 5 - 1 · 查找

代码如下:

复制代码
ListNode* ListFind(ListNode* phead, LTDatatype x)
{
	assert(phead);

	ListNode* pcur = phead->next;
	//遍历
	while (pcur != phead)
	{
		if (pcur->val == x)
		{
			return pcur;
		}
		pcur = pcur->next;
	}
	return NULL;
}

简单来说,就是遍历,查找,如果没找到就返回空指针。


2 - 5 - 2 · 指定位置之前插入

我们在单链表那篇中提到了单链表很难实现这个接口,而双向链表由于有了指向前驱结点的指针,实现这个接口就很方便了。

代码如下:

复制代码
void ListInsert(ListNode* pos, LTDatatype x)
{
	assert(pos);

	ListNode* newnode = BuyNode(x);
	newnode->next = pos;
	newnode->prev = pos->prev;

	pos->prev->next = newnode;
	pos->prev = newnode;
}

代码的实现与头插是十分类似的。


2 - 5 - 3 · 指定位置删除

同样的,我们提到过单链表很难实现这个接口,而双向链表有指向前驱结点的指针,实现这个接口就很方便了。

代码如下:

复制代码
void ListErase(ListNode* pos)
{
	assert(pos);

	pos->prev->next = pos->next;
	pos->next->prev = pos->prev;

	free(pos);
	pos = NULL;
}

与前面实现删除的思路是一致的。


2 - 5 - 4 · 测试

测试一下:

复制代码
void Test4()
{
	ListNode* head = ListInit();

	ListPushBack(head, 1);
	ListPushBack(head, 2);
	ListPushBack(head, 3);
	ListPrint(head);

	ListNode* find = ListFind(head, 2);
	ListInsert(find, 4);
	ListPrint(head);

	find = ListFind(head, 1);
	ListInsert(find, 5);
	ListPrint(head);

	find = ListFind(head, 1);
	ListErase(find);
	ListPrint(head);

	find = ListFind(head, 3);
	ListErase(find);
	ListPrint(head);

	//find = ListFind(head, 3);
	//ListErase(find);
	//ListPrint(head);

	ListDestroy(head);
	head = NULL;
}

运行一下:


3 · 顺序表和链表的区别

如下:

|--------------|--------------------------------|-----------------------------------|
| 不同点 | 顺序表 | 链表(带头双向循环) |
| 存储空间上 | 物理结构上一定连续 | 物理结构上不一定连续 |
| 随机访问 (用下标) | 支持,时间复杂度O(1) | 不支持,时间复杂度O(N) |
| 任意位置 插入或删除元素 | 可能需要挪动元素, 效率低:O(N) | 只需要修改指针 指定位置取决于find 头/尾 插/删 为O(1) |
| 容量 | 动态,不够了就扩容 (扩容本身是有消耗的, 原地扩和异地扩) | 没有容量的概念 按需申请 |
| 应用场景 | 元素高效存储,需要频繁访问 | 需要任意位置频繁的插入或删除 |
| 缓存利用率 | 高 | 低 |

异地扩容的消耗是很大的,开空间,拷贝数据,释放旧空间。

总的来说,顺序表与链表各有优劣,这两个数据结构是互补的关系。


3 - 1 · 缓存利用率

我们先来看看存储器的结构:

CPU的速度是很快的,而内存的速度是很慢的,所以CPU不会去内存中找数据,而是去缓存中找。

那什么是缓存呢?缓存是介于内存与CPU 之间的存储器,容量虽然小,但是速度比内存更快,用于缓解CPU的运算速度与内存条读写速度不匹配的矛盾。

缓存的工作原理是:如果CPU需要读取一个数据,首先会从缓存中找,如果找到,会立刻读取并发送给CPU进行处理,这样大大减少了CPU访问内存的时间。如果没找到,就需要从较慢速度的内存中读取并发送给CPU,同时也会将这个数据调入高速缓存中,以便CPU再次读取。

而缓存拿数据也不是一个一个拿的,而是从一个地址开始,一次拿多个。

因此,对于连续存放数据的顺序表,缓存可以一次拿到多个需要的数据,方便CPU从缓存中找到(命中)。

而对于不一定连续的链表,缓存利用率是更低的。

总结

以上简单介绍了双向链表有关内容,关于数据结构其余内容,请期待后续更新。


以上内容如有错误或不准确之处,欢迎指出,或者你有更好的想法,也欢迎交流。

相关推荐
薇茗1 小时前
【初阶数据结构】 升沉有序的平仄 排序 2
c语言·数据结构·算法·排序算法·快排精讲
安生生申1 小时前
uni-app 连接 JDY-31 蓝牙串口模块实践
c语言·前端·javascript·stm32·单片机·嵌入式硬件·uni-app
番茄灭世神1 小时前
Vscode开发/调试ARM单片机最新教程
c语言·arm开发·vscode·stm32·嵌入式·gd32
Controller-Inversion1 小时前
105. 从前序与中序遍历序列构造二叉树
数据结构·算法
故事和你911 小时前
洛谷-【图论2-4】连通性问题2
开发语言·数据结构·c++·算法·动态规划·图论
为何创造硅基生物10 小时前
C语言 结构体内存对齐规则(通俗易懂版)
c语言·开发语言
仰泳之鹅10 小时前
【C语言】自定义数据类型2——联合体与枚举
c语言·开发语言·算法
jolimark11 小时前
C语言自学攻略:小白入门三步走
c语言·编程入门·学习路线·实践项目·自学攻略
cen__y12 小时前
Linux12(Git01)
linux·运维·服务器·c语言·开发语言·git