双向链表

前言

先赞后看好习惯 打字不容易,这都是很用心做的,希望得到支持你 大家的点赞和支持对于我来说是一种非常重要的动力 看完之后别忘记关注我哦!️️️

作者: @小小Programmer
这是我的主页:@小小Programmer
在食用这篇博客之前,博主在这里介绍一下其它高质量的编程学习栏目:
数据结构专栏:数据结构 这里包含了博主很多的数据结构学习上的总结,每一篇都是超级用心编写的,有兴趣的伙伴们都支持一下吧!
算法专栏:算法 这里可以说是博主的刷题历程,里面总结了一些经典的力扣上的题目,和算法实现的总结,对考试和竞赛都是很有帮助的!
力扣刷题专栏Leetcode想要冲击ACM、蓝桥杯或者大学生程序设计竞赛的伙伴,这里面都是博主的刷题记录,希望对你们有帮助!

干货满满~ 强烈建议本篇收藏后再食用~
看完本篇,相信你会对双向链表有一个比较深入的了解,而且会深深地体会到这一种链表相比于之前的单链表结构上的优越性使用更为方便的特点。

双向链表的基本介绍

一些链表的分类

在链表这一数据结构的模块里,我们可以通过它的结构,做出以下分类。

单向 双向
循环 非循环
带头 不带头
tips:带头即:带有哨兵位的头结点的链表,哨兵位头结点不存储数据。

最常用的两种链表即:
1.无头单向非循环链表
这一种单链表,若单独使用,缺陷较多,不常用
1)因此,单链表经常在OJ题里面出现。
2)另外,它是很多复杂数据结构的子结构,如图、哈希表等
2.带头双向循环链表
1)常用。
2)STL里面的list就是这种链表

因此,这两种链表都是我们必须掌握的知识点
如果对无头单向非循环链表不太了解的伙伴,可以翻看我之前的博客,先做了解,再食用本篇【数据结构】单链表的介绍和基本操作(C语言实现)【保姆级别详细教学】

带头双向循环链表的基本结构

带头双向循环链表在以下简称为双向链表,无头单向非循环链表简称单链表。

以下就是双向链表的基本结构

这种链表的结点里面,相比于单链表,多了一个prev指针,指向前一个结点。
head头结点-不存储数据,作为哨兵位使用
head头结点的prev指向链表尾
链表尾的next指向head
特殊的:链表为空的时候,并不是一个结点都没有,而是只有头结点。此时head的next和prev均指向它自己。

有了这些铺垫,我们就可以开始实现我们的双向链表了。

双向链表的实现

同样,实现这个链表需要3个源文件,这样可以使我们的程序可读性更高,更为清晰。对此不明白的伙伴可以翻看博主之前关于单链表或者扫雷游戏的作品,里面有讲解~

test.c:用于测试
List.c:用于实现接口
List.h:存放接口的声明

结点的定义、头指针的创建

学习过单链表的伙伴应该已经对这一步骤很熟悉了。
.h文件里创建结点

typedef int LTDataType;
typedef struct ListNode {
//指针域
struct ListNode* next;
struct ListNode* prev;
//数据域
LTDataType data;
}ListNode;

然后在.cmain()里创建头指针

void TestList1() {
ListNode* phead = NULL;
}
int main() {
TestList1();
return 0;
}

这个时候,我们还需要初始化一些我们的头结点,记住只有头结点链表为空。头结点初始化完之后,在后续操作中,头结点是不动的

这里非常关键,我们知道,实现没有头的链表的时候,做头插,头删这些操作的时候,因为头结点会有可能改变,因此每次我们从main()里传参,都要传头指针的地址,也就是一个二级指针,但是
**带头的就不同!**头指针永不变,这表明,除了初始化头指针这个接口之外,别的接口,都不需要传二级指针!

因此,我们现在需要创建一个头指针了!
此时需要一个开辟结点的接口,我们先写这个开辟结点的接口。

开辟结点接口

ListNode* BuyListNode(LTDataType x);//.h的声明
ListNode* BuyListNode(LTDataType x) {
ListNode* node = (ListNode*)malloc(sizeof(ListNode));
node->next = NULL;//前后的指针先置空,到了操作接口里面,我们再操作这些指针
node->prev = NULL;
node->data = x;//数据置为传入的x
return node;
}

有了这个开辟结点的接口,我们可以初始化头结点了

初始化头结点接口

void ListInit(ListNode** pphead);//刚才解释过了,要传入二级指针。
void ListInit(ListNode** pphead) {//初始化的时候要定义头结点,所以要二级指针
*pphead = BuyListNode(0);
(*pphead)->next = *pphead;
(*pphead)->prev = *pphead;
//->优先级和*相同,所以括号一下
}

需要注意的点:
1.因为我们的头结点不存储数据,所以传0给开辟结点接口
2.只有头结点的时候链表为空,head的两个指针都要指向自己

接下来,我们可以先把打印接口写一写

打印接口

//打印接口
void ListPrint(ListNode* phead);
void ListPrint(ListNode* phead) {
//这里的打印和单链表就不一样了
//1.不能直接遍历
//2.哨兵位的头结点不要打印
assert(phead);//头结点肯定不能为空
ListNode* cur = phead->next;//跳过头结点
while (cur != phead) {
printf("%d ", cur->data);
cur = cur->next;
}
printf("\n");
}

注意:
1.和单链表的遍历不一样,这里的遍历是到cur回到phead为结束标志,因为它是一个循环接口,这个很好理解
2.另外,与单链表不同,这里所有的接口都要assert(),因为头结点永远都在。
3.打印的时候要跳过头结点。

尾插接口

尾插:即在链表尾部插入一个新结点

从现在开始,我们将会深深地体会到这一种链表结构上的优越性,它们的代码实现实在是比单链表简单太多了,以致于有些地方根本不需要多解释,伙伴们都能够明白

尾插
首先,在单链表中,我们的第一步是遍历找尾,在这里我们需要这样吗?
phead的prev就是尾,根本就不用找。
因此,我们所需要做的,就是定义一个tail,让tail,phead,newnode之间的连接关系搞好,就大功告成了。
其次,在单链表中,我们重新调整结点之间连接关系的时候,常常需要临时指针储存我们的结点,为什么:怕丢,我们调整一个指针的时候,可能就会丢掉原来那个,为什么这么容易丢:因为每个结点只有一个指针指着。
而在这里,我们需要这样做吗?很明显不需要!我们每个结点都有多个指针指着,我们美美地调整连接关系就可以了。
其三:在单链表中,我们常常要在操作的时候分情况,链表为空吗,链表只有一个结点还是多个?在这里统统不需要,因为我们有带哨兵位的头结点。不明白的伙伴画个图就明白了。
我们直接上代码:

void ListPushBack(ListNode* phead, LTDataType x) {
//这种链表的尾插非常简单
//不用找尾
//头结点的prev就是尾
//而且不用判断链表是否为空,因为这是带头结点的链表
assert(phead);
ListNode* tail = phead->prev;//找到尾了
ListNode* newnode = BuyListNode(x);
//phead ...tail..newnode //处理tail和newnode的关系
tail->next = newnode;
newnode->prev = tail;
//处理head和newnode的关系
newnode->next = phead;
phead->prev = newnode;
}

我们可以测试一下

test.c

void TestList1() {
ListNode* phead = NULL;
//初始化
ListInit(&phead);//不用二级指针,用返回的方式得到栈上开辟的新指针也是可以的
//尾插
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPrint(phead);
}
int main() {
TestList1();
return 0;
}

其实看到这里,伙伴们应该都具有独立写出后面那些接口的能力了,可以先尝试自己写,其实真的比单链表简单很多,写完在继续食用下面的接口

尾删接口

尾删:在链表末尾删除一个结点

void ListPopBack(ListNode* phead) {
assert(phead);
//这里要稍微注意一下,链表不能为空,空了就把头结点删了,删完还要删就崩了
assert(phead->next != phead); ListNode* tail = phead->prev;
phead->prev = tail->prev;
phead->prev->next = phead;//画个图就能明白
//这一句看不明白的可以画图,或者定义一个tailPrev也是可以的,这样更清晰
//以后尽量少写
//解决方法:定义一个tailPrev即可
free(tail);
tail = NULL;//别忘了这一句,养成好习惯
}

注意:删除结点的接口要多一个细节:判断链表是否为空,因此加多一句assert()即可。assert(phead->next != phead);

头插接口

头插:在链表头插入一个新结点

头插其实就是在头结点和第一个结点之间插入一个新结点
很简单:定义一个first结点表示第一个结点,然后调整newnode,phead和first三者关系即可。

void ListPushFront(ListNode* phead,LTDataType x) {
//比较简单,但是要判断一下链表为空的情况
ListNode* first = phead->next;
ListNode* newnode = BuyListNode(x);
//调整关系
phead->next = newnode;
newnode->prev = phead;
newnode->next = first;
first->prev = newnode;
}

写链表要细心,对特殊情况要敏感一些,考虑链表为空的情况。
我们用同样的逻辑套一下那个空链表的特殊情况,发现上面那段代码是符合的,first就是phead自己,完全没问题
这就是双向链表的优势

头删接口

头删:删除第一个结点

定义一个first指向第一个结点,second指向第二个结点,删除first,重新调整phead和second的关系即可。

void ListPopFront(ListNode* phead) {
assert(phead);
assert(phead->next != phead);
//同样,非常简单,phead-first-second 把first free掉就可以了
ListNode* first = phead->next;
ListNode* second = first->next;
phead->next = second;
second->prev = phead;
free(first);
first = NULL;
//写到这里真的是感受到了双向带头链表的优越性,毫无死角,操作非常简单
}

同样:删除的接口要有assert(phead->next != phead);

查找接口

查找接口通常和在任意位置插入结点,在任意位置删除结点,修改结点,这些功能结合在一起,因为我们找到以后,想改可以改,想插入可以插入,想删除可以删除了。

//查找
ListNode* ListFind(ListNode* phead, LTDataType x) {
assert(phead);
ListNode* cur = phead->next;
while (cur != phead) {
if (cur->data == x) {
return cur;
}
cur = cur->next;
}
return NULL;//找了一圈都没有找到,返回空
}

插入接口

在pos位置前插入一个结点
关键还是调整结点之间的链接关系
思路非常简单,不赘述了。不明白的小伙伴可以私信留言

//插入
void ListInsert(ListNode* pos, LTDataType x) {
//在pos前面插入x
assert(pos);
ListNode* posPrev = pos->prev;
ListNode* newnode = BuyListNode(x);
//posPrev newnode pos 的链接关系
posPrev->next = newnode;
newnode->prev = posPrev;
newnode->next = pos;
pos->prev = newnode;
}

删除接口

删除pos位置的结点

//删除
void ListErase(ListNode* pos) {
assert(pos);
//注意:pos不能是phead
//assert(pos != phead); ListNode* posPrev = pos->prev;
ListNode* posNext = pos->next;
free(pos);
pos = NULL;
posPrev->next = posNext;
posNext->prev = posPrev;
}

我们可以测试一下最后这三个接口

void TestList2() {
ListNode* phead = NULL;
ListInit(&phead);
//先尾插一些数据进去先
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPrint(phead);
//想要在3前面插入30
ListNode* pos = ListFind(phead, 3);
ListInsert(pos, 30);
ListPrint(phead);
//删除3
pos = ListFind(phead,3);
ListErase(pos);
ListPrint(phead);
//30改300
pos = ListFind(phead, 30);
pos->data = 300;
ListPrint(phead);
}
int main() {
//TestList1();
TestList2();
return 0;
}

在本篇中博主并没有展示所有接口的测试,但是我们自己写的时候,我们每写完一个都要测试,这是一个编程的好习惯,而且测试成功也会给我们自己更多的自信。

测试代码和头文件代码的完整展示

test.c

#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include"List.h"
#if 1
void TestList1() {
ListNode* phead = NULL;
ListInit(&phead);//不用二级指针,用返回的方式得到栈上开辟的新指针也是可以的
//测试尾插
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPrint(phead);
//测试尾删
ListPopBack(phead);
ListPopBack(phead);
ListPopBack(phead);
ListPrint(phead);
//测试头插
ListPushFront(phead, 0);
ListPushFront(phead, -1);
ListPushFront(phead, -2);
ListPushFront(phead, -3);
ListPrint(phead);
//测试头删
ListPopFront(phead);
ListPopFront(phead);
ListPopFront(phead);
ListPopFront(phead);
ListPrint(phead); }
void TestList2() {
ListNode* phead = NULL;
ListInit(&phead);
ListPushBack(phead, 1);
ListPushBack(phead, 2);
ListPushBack(phead, 3);
ListPushBack(phead, 4);
ListPrint(phead);
//想要在3前面插入30
ListNode* pos = ListFind(phead, 3);
ListInsert(pos, 30);
ListPrint(phead);
//删除3
pos = ListFind(phead,3);
ListErase(pos);
ListPrint(phead);
//30改300
pos = ListFind(phead, 30);
pos->data = 300;
ListPrint(phead);
}
int main() {
TestList1();
TestList2();
return 0;
}

List.h

#pragma once
#include<stdio.h>
#include<assert.h>
typedef int LTDataType;
typedef struct ListNode {
struct ListNode* next;
struct ListNode* prev;
LTDataType data;
}ListNode; //初始化
void ListInit(ListNode** pphead);
//开辟新结点接口
ListNode* BuyListNode(LTDataType x);
//打印接口
void ListPrint(ListNode* phead);
//尾插
void ListPushBack(ListNode* phead, LTDataType x);
//尾删
void ListPopBack(ListNode* phead);
//头插
void ListPushFront(ListNode* phead, LTDataType x);
//头删
void ListPopFront(ListNode* phead);
//查找(修改)
ListNode* ListFind(ListNode* phead,LTDataType x);
//插入
void ListInsert(ListNode* pos, LTDataType x);
//删除
void ListErase(ListNode* pos);

尾声

看到这里,相信伙伴们已经对带头双向循环链表已经有了比较深入的了解,掌握了基本的操作接口实现方法。相信我们已经深深感受到了这种结构的厉害之处。
如果看到这里的你感觉这篇博客对你有帮助,不要忘了收藏,点赞,转发,关注哦。

【链表】双向链表的介绍和基本操作(C语言实现)【保姆级别详细教学】的更多相关文章

  1. R语言-Knitr包的详细使用说明

    R语言-Knitr包的详细使用说明 by 扬眉剑 来自数盟[总舵] 群:321311420 1.相关资料 1:自动化报告-谢益辉 https://github.com/yihui/r-ninja/bl ...

  2. 「C语言」单链表/双向链表的建立/遍历/插入/删除

    最近临近期末的C语言课程设计比平时练习作业一下难了不止一个档次,第一次接触到了C语言的框架开发,了解了View(界面层).Service(业务逻辑层).Persistence(持久化层)的分离和耦合, ...

  3. 详解双向链表的基本操作(C语言)

    @ 目录 1.双向链表的定义 2.双向链表的创建 3.双向链表的插入 4.双向链表的删除 5.双向链表更改节点数据 6.双向链表的查找 7.双向链表的打印 8.测试函数及结果 1.双向链表的定义 上一 ...

  4. [数据结构]单向链表及其基本操作(C语言)

    单向链表 什么是单向链表 链表是一种物理储存单元上非连续.非顺序的储存结构.它由一系列结点(链表中每一个元素称为结点)组成,结点可动态生成.每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存 ...

  5. MySQL数据库 介绍,安装,基本操作

    - 数据库介绍: 1.随意存放在一个文件中的数据,数据的格式千差万别 tank|123 jason:123 sean~123 2.软件开发目录规范 - Project: - conf - bin - ...

  6. mariadb_1 数据库介绍及基本操作

    数据库介绍 1.什么是数据库? 简单的说,数据库就是一个存放数据的仓库,这个仓库是按照一定的数据结构(数据结构是指数据的组织形式或数据之间的联系)来组织,存储的,我们可以通过数据库提供的多种方法来管理 ...

  7. 二叉树的基本操作(C语言版)

    今天走进数据结构之二叉树 二叉树的基本操作(C 语言版) 1 二叉树的定义 二叉树的图长这样: 二叉树是每个结点最多有两个子树的树结构,常被用于实现二叉查找树和二叉堆.二叉树是链式存储结构,用的是二叉 ...

  8. cassandra简单介绍与基本操作

    项目中用到了cassandra,用来存储海量数据,且要有高效的查询:本博客就进行简单的介绍和进行一些基本的操作 一.使用场景: 是一款分布式的结构化数据存储方案(NoSql数据库),存储结构比Key- ...

  9. hibernate框架学习第一天:hibernate介绍及基本操作

    框架辅助开发者进行开发,半成品软件,开发者与框架进行合作开发 Hibernate3Hibernate是一种基于Java的轻量级的ORM框架 基于Java:底层实现是Java语言,可以脱离WEB,在纯J ...

  10. 顺序栈的基本操作(C语言)

    由于现在只学了C语言所以就写这个C语言版的栈的基本操作 这里说一下 :网上和书上都有这种写法 int InitStack(SqStack &p) &p是取地址  但是这种用法好像C并不 ...

随机推荐

  1. 【每日一题】11.黑白树 (树上DFS)

    补题链接:Here 题目描述 一棵 \(n\) 个点的有根树,\(1\) 号点为根,相邻的两个节点之间的距离为 \(1\) .树上每个节点 \(i\)对应一个值\(k[i]\).每个点都有一个颜色,初 ...

  2. 【驱动】以太网扫盲(四)phy驱动link up流程分析

    1. 简介 在调试网口驱动的过程中发现phy芯片的驱动框架结构还有点复杂,不仔细研究的话还不好搞懂,另外百度到的资料也不够全面,这篇就总结梳理一下这方面的知识. 我们知道一个 phy 驱动的原理是非常 ...

  3. vue后台管理系统,接口环境配置

    https://coding.imooc.com/lesson/397.html#mid=31487

  4. Skywalking 的使用

    本文为博主原创,未经允许不得转载: 官网:http://skywalking.apache.org/下载:http://skywalking.apache.org/downloads/Github:h ...

  5. 【日常踩坑】Debug 从入门到入土

    写代码难免遇到 bug,调试解决 bug 的快慢很影响开发的效率.本文主要是梳理并记录下个人经常用的调试方法(主要以 C/C++ 的 segment fault 为例) 分类 根据调试时机与 bug ...

  6. AMBA总线介绍-01

    AMBA总线介绍 AMBA总线概述 AHB APB 不同IP之间的互连 1.系统总线简介 系统芯片中各个模块之间需要有接口连接,使用总线作为子系统之间共享的通信链路 优点:成本低,方便易用(通用协议, ...

  7. ORA-01017: 用户名/密码无效;登录被拒绝

    总结 出现此错误的原因有多种: 您的用户名或密码实际上不正确 数据库配置不正确(tnanames.ora. $ORACLE_SID 参数) 现在,我们来看看这个错误的解决方案. ORA-01017 解 ...

  8. 【OpenVINO】基于 OpenVINO C# API 部署 RT-DETR 模型

      RT-DETR是在DETR模型基础上进行改进的,一种基于 DETR 架构的实时端到端检测器,它通过使用一系列新的技术和算法,实现了更高效的训练和推理,在前文我们发表了<基于 OpenVINO ...

  9. Redis和Springboot在Windows上面设置开机启动的方法

    Redis和Springboot在Windows上面设置开机启动的方法 背景 同事遇到一个问题 Windows 晚上自动更新服务 然后第二天 Springboot开发的程序没有启动起来. 所以基于此想 ...

  10. [转帖]LSM-Tree:从入门到放弃——入门:基本概念、操作和Trade-Off分析

    https://zhuanlan.zhihu.com/p/428267241 LSM-Tree,全程为日志结构合并树,有趣的是,这个数据结构实际上重点在于日志结构合并,和 tree 本身的关系并不是特 ...