【C】初阶数据结构4 -- 双向循环链表

news2025/2/20 4:45:13

  之前学习的单链表相比于顺序表来说,就是其头插和头删的时间复杂度很低,仅为O(1) 且无需扩容;但是对于尾插和尾删来说,由于其需要从首节点开始遍历找到尾节点,所以其复杂度为O(n)。那么有没有一种结构是能使得头插和头删的时间复杂度都为 O(1) 的呢?那么这个结构就是双向循环链表。


目录

1  链表的分类

2  双向循环带头链表的结构

1) 逻辑结构

2) 物理结构 

 3  双向循环带头链表操作的实现

1) 初始化和销毁

(1) 初始化

(2) 销毁  

2) 头插与头删

(1) 头插

(2) 头删

 3) 尾插和尾删

(1) 尾插

(2) 尾删

4) 在pos位置之前插入和删除pos节点

(1) 在pos位置之前插入

(2) 删除pos节点

5) 判空和查找

4  代码 

5  顺序表与链表的对比


1  链表的分类

  链表分类总共根据以下3个因素来分类:

1) 带头或者不带头

2) 循环或者不循环

3) 单向或者双向

其中带头的意思是:链表中有一个头节点,也叫哨兵位,这个头节点仅仅用来当做链表的开始节点,并不存储任何有效数据。有头节点的好处是插入数据时不必再判断链表为空的情况。所以带头节点的链表从物理结构上来说是不会为空链表的。

其中循环的意思是:链表最后一个节点并不指向NULL,而是指向开始节点,以此来达到循环的效果。所以找到循环链表的尾节点,并不是判断尾节点的next为不为NULL,而是看尾节点的next指针是否指向开始节点。

其中单或双向意思是:是否能够通过节点内部指针找到下一个节点或者前一个节点。只能找到下一个节点的链表就是单向链表;能够找到前一个和后一个节点的链表就是双向链表

  为了更好的理解不同链表的结构,在这里可以画一下双向不循环带头链表的结构:
   按照以上因素,链表共分为 8 种,分别是:

链表的分类
单向不循环不带头链表双向不循环不带头链表
单向循环不带头链表双向循环不带头链表
单向不循环带头链表双向不循环带头链表
单项循环带头链表双向循环带头链

所以之前单链表的全称应该是单向不循环不带头链表。


重点一  双向循环带头链表节点结构

2  双向循环带头链表的结构

1) 逻辑结构

  有了之前分类的铺垫,双向循环带头链表(以下简称为双向链表)的逻辑结构就很好画出来了:

2) 物理结构 

  链表是由节点组成的,所以定义链表的物理结构也就是定义链表节点的结构,通过对逻辑结构的分析,可以发现双向链表的结构共有3个域组成,一个是存储数据的数据域(data),还有一个存储下一个节点地址的指针域(next),还有一个存储前一个节点地址的指针域(prev)

//定义数据类型
typedef int LTDataType;
//定义双向链表结点的结构
typedef struct ListNode
{
  LTDataType data;//结点里的数据
  struct ListNode* prev;//前驱指针,指向前一个结点
  struct ListNode* next;//后继指针,指向后一个结点
}LTNode;

 同样的,双向链表并不一定只存储整型,所以要用 LTDataType 来 typedef 一下整型,以后如果要存储其他类型的数据,只需要改这一个地方就可以了。


重点二  双向循环带头链表的实现

 3  双向循环带头链表操作的实现

  对于双向链表,其常用的操作主要有初始化、销毁、头插、头删、尾插、尾删、在pos位置之后插入、删除pos节点、判空以及查找

  虽然双向链表比单链表的结构复杂,但是由于其结构的天然优势,其操作实现实现起来会比单链表简单很多,且不需要考虑链表为空的情况。

1) 初始化和销毁

(1) 初始化

  对于双向链表的初始化,由于其结构的特殊性,必须保证是带头且循环,所以在初始化里面就要开辟一个节点,使这个节点成为链表的头节点,让其 next 指针和 prev 指针都指向自己,所以一个逻辑上为空的双向链表物理结构为:

 这样才能保证其循环和带头的特性。


(2) 销毁  

  销毁的话也不是像单链表一样从头节点开始销毁,而是从头节点后面的节点开始销毁,因为最后一个节点的 next 指针是指向头节点的,由于其循环的特性,判断除头节点之外的节点是否都销毁了就是看遍历链表的指针是否指向头节点

   所以这里定义一个 pcur 指针指向头节点的下一个节点,然后循环判断 pcur 是否等于 phead(头节点的地址),在循环里面先保存 pcur 下一个节点的地址 next,然后释放当前节点,再让 pcur 指向下一个节点,最后释放头节点。


2) 头插与头删

(1) 头插

  头插有一个需要注意的点:头插是在头节点的后面,头节点后面的第一个节点的前面插入,而不是在头节点的前面插入(在头节点前面插入相当于尾插)

  头插过程如图:

 

 

  在头插改变指针指向的时候,有一个需要注意的点:一定要最后在 newnode 节点改变next 和 prev 指针指向之后,再改变 phead->next 节点的指向,因为如果先改变 phead->next 节点的指向,那么原来的 phead->next 就找不到了,newnode->next 指针也就没法指向下一个节点了。当然,如果事先保存了 phead->next 节点的地址,先改变谁的指向也就无所谓了。

  我们再来看一下特殊情况,那就是只有一个头节点的情况(可以结合下面的代码):当只有一个头节点时,此时phead->next 与 phead->prev 都指向 phead,头插的时候只会改变指针的指向,也不会出现对NULL指针的解引用的特殊情况,所以对于只有一个头节点的双向链表也是可以的。


(2) 头删

  既然是删除节点,就先要判断链表为不为空(链表中只剩一个头节点)。双向链表的头删如下图所示:

 


 3) 尾插和尾删

(1) 尾插

  在尾插这一接口的实现过程中,可以真正看到双向链表与单链表结构的区别带来的优势,在单链表里面,尾插的时间复杂度仍未O(n),而在双向链表里面,时间复杂度仅为O(1),只需改变指针的指向即可,尾插的过程如图所示:

 

  同样的,尾插对于只有一个头节点的双向链表来说,也不需要特殊处理,因为仅仅改变指针的指向,也不存在对NULL指针的解引用,且逻辑也是可以的。


(2) 尾删

  尾删需要先判断链表是否为空(即是否仅有一个头节点)。尾删的过程如图:


4) 在pos位置之前插入和删除pos节点

(1) 在pos位置之前插入

  在pos位置之后插入数据,首先先要保证pos位置是有效的位置(即pos不为NULL)。在pos位置之后插入数据和头插、尾插一样,只需要改变指针的指向即可:先开辟一个新的节点 newnode,让 newnode->prev 指向 pos,newnode->next 指向 pos->next指向的节点,再让 newnode->next->prev 指向 newnode,pos->next 指向 newnode(这里就不画图了,逻辑比较简单,依照前两个插入画图即可)。

(2) 删除pos节点

  既然是删除节点,那就需要判断链表为不为空。然后删除pos节点也只需改变指针的指向,然后释放pos节点即可。先让 pos->next->prev 指向 pos->prev,再让 pos->prev->next 指向 pos->next,然后释放 pos 即可。

  但是删除完 pos 节点之后要把原来传给 LTErase函数(删除pos节点的函数)pos 形参的实参给置为NULL,因为pos节点释放之后,原来的形参就变为了野指针,使用会发生越界访问。


5) 判空和查找

  这两个接口比较简单,判空只需要判断 phead->next(phead为指向头节点的指针)是否等于phead ;而查找的话只需要遍历链表,对比每个节点内的值是否是需要查找的值,如果是,返回该节点的地址;如果不是,那就继续判断下一个节点,直到遍历完整个链表,返回NULL。

  查找时只需要有一点需要注意,就是判断是否遍历完链表的条件为 pcur (遍历链表时指向每个节点的指针变量)是否等于phead(指向头节点的指针),而不是 pcur 是否等于 NULL。


4  代码 

1) List.h文件:

#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>

//定义数据类型
typedef int LTDataType;
//定义双向链表结点的结构
typedef struct ListNode
{
  LTDataType data;//结点里的数据
  struct ListNode* prev;//前驱指针,指向前一个结点
  struct ListNode* next;//后继指针,指向后一个结点
}LTNode;

//初始化
LTNode* LTInit();
//销毁
void LTDestroy(LTNode* phead);
//打印
void LTPrint(LTNode* phead);
//判空
bool LTEmpty(LTNode* phead);
//尾插
void LTPushBack(LTNode* phead, LTDataType x);
//尾删
void LTPopBack(LTNode* phead);
//头插
void LTPushFront(LTNode* phead, LTDataType x);
//头删
void LTPopFront(LTNode* phead);
//在pos位置之后插入数据
void LTInsert(LTNode* pos, LTDataType x);
//删除pos位置数据
void LTErase(LTNode* pos);
//查找数据
LTNode* LTFind(LTNode* phead, LTDataType x);

2) List.c文件:

#include"List.h"

//开辟新结点
LTNode* buyNode(LTDataType x)
{
  LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
  if (newnode == NULL)
  {
    perror("malloc fail!\n");
    exit(1);
  }
  newnode->data = x;
  newnode->prev = newnode->next = newnode;
  return newnode;
}

//初始化
LTNode* LTInit()
{
  //创建一个头节点,不存储任何有效数据
  LTNode* phead = (LTNode*)malloc(sizeof(LTNode));
  //要让头节点的前驱指针与后继指针都指向自己,才能循环
  phead->prev = phead->next = phead;
  return phead;
}
//销毁
void LTDestroy(LTNode* phead)
{
  LTNode* pcur = phead->next;
  while (pcur != phead)
  {
    LTNode* next = pcur->next;
    free(pcur);
    pcur = next;
  }
  //最后释放头结点
  free(phead);
  phead = NULL;
}
//打印
void LTPrint(LTNode* phead)
{
  LTNode* pcur = phead->next;
  while (pcur != phead)
  {
    printf("%d ", pcur->data);
    pcur = pcur->next;
  }
  printf("\n");
}
//判空
bool LTEmpty(LTNode* phead)
{
  //只需判断头结点的下一个结点是不是头节点
  return phead->next == phead;
}

//尾插
void LTPushBack(LTNode* phead, LTDataType x)
{
  //由于有头节点,所以不需判断头节点是否为空
  LTNode* newnode = buyNode(x);
  //phead  phead->prev  newnode 改变这三个结点指针的指向
  newnode->next = phead;
  newnode->prev = phead->prev;
  phead->prev->next = newnode;
  phead->prev = newnode;
}

//尾删
void LTPopBack(LTNode* phead)
{
  //删除先判空
  assert(!LTEmpty(phead));
  //把要删除的结点设为del结点
  LTNode* del = phead->prev;
  //del->prev phead 改变这两个结点的指向
  del->prev->next = phead;
  phead->prev = del->prev;
  free(del);
  del = NULL;
}

//头插
void LTPushFront(LTNode* phead, LTDataType x)
{
  //不是插入头节点,而是在头节点后面插入一个结点
  LTNode* newnode = buyNode(x);
  //phead  newnode  phead->next 改变这三个结点之间的指向
  newnode->prev = phead;
  newnode->next = phead->next;
  phead->next->prev = newnode;
  phead->next = newnode;
}

//头删
void LTPopFront(LTNode* phead)
{
  //先判断是否为空
  assert(!LTEmpty(phead));
  LTNode* del = phead->next;
  phead->next = del->next;
  del->next->prev = phead;
  //释放del节点
  free(del);
  del = NULL;
}

//在pos位置之后插入数据
void LTInsert(LTNode* pos, LTDataType x)
{
  assert(pos);
  LTNode* newnode = buyNode(x);
  //pos  newnode  pos->next
  newnode->prev = pos;
  newnode->next = pos->next;
  pos->next->prev = newnode;
  pos->next = newnode;
}

//删除pos位置数据
//删除完pos位置数据之后,要把原来实参位置置为NULL,否则原指针会变成野指针
void LTErase(LTNode* pos)
{
  assert(pos);
  //pos->prev pos pos->next
  pos->prev->next = pos->next;
  pos->next->prev = pos->prev;
  free(pos);
  pos = NULL;
}

//查找数据
LTNode* LTFind(LTNode* phead, LTDataType x)
{
  LTNode* pcur = phead->next;
  //这里必须判断pcur是否等于phead,不能判断pcur是否为NULL
  while (pcur != phead)
  {
    if (pcur->data == x)
      return pcur;
    pcur = pcur->next;
  }
  return NULL;
}    

3) test.c文件:

#include "List.h"

void Test3()
{
    LTNode* phead = LTInit();
    //测试头插
   /* LTPushFront(phead, 1);
    LTPushFront(phead, 2);
    LTPushFront(phead, 3);
    LTPushFront(phead, 4);
    LTPrint(phead);*/
    //测试头删
    /*LTPopFront(phead);
    LTPopFront(phead);
    LTPopFront(phead);
    LTPopFront(phead);*/
    //LTPopFront(phead);
    //测试尾插
    LTPushBack(phead, 1);
    LTPushBack(phead, 2);
    LTPushBack(phead, 3);
    LTPushBack(phead, 4);
    LTPrint(phead);
    测试尾插
    //LTPopBack(phead);
    //LTPopBack(phead);
    //LTPopBack(phead);
    //LTPopBack(phead);
    //LTPopBack(phead);
    //LTPrint(phead);
    //测试查找
    LTNode* ret = LTFind(phead, 4);
    /*if (ret == NULL)
    {
        printf("没找到!\n");
    }
    else
        printf("找到了!\n");*/
    //测试在pos位置之前插入数据
    //LTInsert(ret, 6);
    LTErase(ret);
    LTPrint(phead);
    LTDestroy(phead);

}
int main()
{
    Test3();
    return 0;
}

5  顺序表与链表的对比

  最后,我们再来对比一下顺序表与链表。顺序表与链表作为数据结构中最基础的线性表,他们的差异还是挺大的,具体体现在以下几个方面:
 

顺序表与链表的对比
不同点顺序表双向链表
存储空间数组,物理结构上是连续的一个一个节点,物理结构上不连续
能否随机访问可以,时间复杂度为O(1)不支持,需要从头遍历
插入数据或者删除数据效率对比需要频繁挪动数据,复杂度为O(n)只需改变指针指向,复杂度为O(1)
插入时开辟空间对比满了需要扩容,且有时会造成空间的浪费没有容量,按需申请。不会浪费空间
应用场景元素高效存储和频繁访问频繁插入和删除数据

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2299475.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

小爱音箱控制手机和电视听歌的尝试

最近买了小爱音箱pro&#xff0c;老婆让我扔了&#xff0c;吃灰多年的旧音箱。当然舍不得&#xff0c;比小爱还贵&#xff0c;刚好还有一台红米手机&#xff0c;能插音箱&#xff0c;为了让音箱更加灵活&#xff0c;买了个2元的蓝牙接收模块Type-c供电3.5接口。这就是本次尝试起…

Kotlin Lambda

Kotlin Lambda 在探索Kotlin Lambda之前&#xff0c;我们先回顾下Java中的Lambda表达式&#xff0c;Java 的 Lambda 表达式是 Java 8 引入的一项强大的功能&#xff0c;它使得函数式编程风格的代码更加简洁和易于理解。Lambda 表达式允许你以一种更简洁的方式表示实现接口&…

Java 设计模式之备忘录模式

文章目录 Java 设计模式之备忘录模式概述UML代码实现 Java 设计模式之备忘录模式 概述 备忘录(Memento)&#xff1a;在不破坏封装性的前提下&#xff0c;捕获一个对象的内部状态&#xff0c;并在该对象之外保存这个状态。方便对该对象恢复到原先保存的状态。 UML Originnato…

vue3搭建实战项目笔记二

vue3搭建实战项目笔记二 2.1.git管理项目2.2.隐藏tabBar栏2.2.1 方案一&#xff1a;在路由元信息中设置一个参数是否显示tabBar2.2.2 方案二&#xff1a;通过全局设置相对定位样式 2.3.项目里封装axios2.3.1 发送网络请求的两种做法2.3.2 封装axios并发送网络请求2.3.2.1 对axi…

【原创】解决vue-element-plus-admin无法实现下拉框动态控制表单功能,动态显隐输入框

前言 目前使用vue-element-plus-admin想要做一个系统定时任务功能&#xff0c;可以选择不同的定时任务类型&#xff0c;比如使用cron表达式、周期执行、指定时间执行等。每种类型对应不同的输入框&#xff0c;需要动态显隐输入框才行&#xff0c;但是这个vue-element-plus-adm…

大疆无人机需要的kml文件如何制作kml导出(大疆KML文件)

大疆无人机需要的轨迹kml文件&#xff0c;是一种专门的格式&#xff0c;这个kml里面只有轨迹点&#xff0c;其它的属性信息都不需要。 BigemapPro提供了专门的大疆格式输出&#xff0c; 软件这里下载 www.bigemap.com 安装后&#xff0c;kml导入如下图&#xff1a; 然后选择…

免费deepseek的API获取教程及将API接入word或WPS中

免费deepseek的API获取教程: 1 https://cloud.siliconflow.cn/中注册时填写邀请码&#xff1a;GAejkK6X即可获取2000 万 Tokens; 2 按照图中步骤进行操作 将API接入word或WPS中 1 打开一个word&#xff0c;文件-选项-自定义功能区-勾选开发工具-左侧的信任中心-信任中心设置…

(三)Axure制作转动的唱片

效果图 属性&#xff1a; 图标库&#xff1a;iconfont-阿里巴巴矢量图标库 方形图片转为圆角图片&#xff0c;裁剪&#xff0c;然后加圆角&#xff0c; 唱片和底图是两个图片&#xff0c;点击播放&#xff0c;唱片在旋转。 主要是播放按钮和停止按钮&#xff0c;两个动态面板…

ASP.NET Core SixLabors.ImageSharp 位图图像创建和下载

从 MVC 控制器内部创建位图图像并将其发送到浏览器&#xff1b;用 C# 编写并与 Linux 和 Windows 服务器兼容。 使用从 ASP.NET MVC 中的控制器下载任何文件类型File。 此示例创建一个位图 (jpeg) 并将其发送到浏览器。它需要 NuGet 包SixLabors.ImageSharp v1.0.4。 另请参…

机器学习所需要的数学知识【01】

总览 导数 行列式 偏导数 概理论 凸优化-梯度下降 kkt条件

【D2】神经网络初步学习

总结&#xff1a;学习了 PyTorch 中的基本概念和常用功能&#xff0c;张量&#xff08;Tensor&#xff09;的操作、自动微分&#xff08;Autograd&#xff09;、正向传播、反向传播。通过了解认识LeNet 模型&#xff0c;定义神经网络类&#xff0c;熟悉卷积神经网络的基本结构和…

变相提高大模型上下文长度-RAG文档压缩-3.优化map-reduce(reranker过滤+社区聚类)

我遇到的业务问题实际上是RAG需要处理很多同一对象的日常报告&#xff0c;不像常识类问题&#xff0c;它的相关Document更多而且更分散&#xff0c;日常报告代表数据库里有很多它的内容&#xff0c;而且对象可能只在段落中的几句话提及到。top-k数量受限于大模型长度&#xff0…

电解电容的参数指标

容量 这个值通常是室温25℃&#xff0c;在一定频率和幅度的交流信号下测得的容量。容量会随着温度、直流电压、交流电压值的变化而改变。 额定电压 施加在电容上的最大直流电压&#xff0c;通常要求降额使用。 例如额定电压是4V&#xff0c;降额到70%使用&#xff0c;最高施…

计时器任务实现(保存视频和图像)

下面是一个简单的计时器任务实现&#xff0c;可持续地每秒保存一幅图像&#xff0c;也可持续地每60秒保存一个视频&#xff0c;图像和视频均以当前时间命名&#xff1a; TimerTask类的实现如下&#xff1a; class TimerTask { public:TimerTask(const std::string& path):…

Django 美化使用ModelForm的输入框

在初次使用ModelForm时&#xff0c;我的html文件代码如下&#xff0c;主要内容是显示一个卡片式表单&#xff0c;通过循环遍历 form 对象动态生成表单字段 {% extends layout.html %}{% block content %} <div class"container"><div class"c1"&g…

应用层优秀的共享民宿物联网框架该怎么选?

有一说一&#xff0c;应用层优秀的物联网框架通常能帮助提升用户体验、提高运营效率、节能减排等等优势&#xff0c;很多老板也很注重这个层面的设计和打磨&#xff0c;那么对于选择应用层优秀的共享民宿物联网框架时&#xff0c;大家可以从哪几个关键因素进行考量呢&#xff1…

【kafka系列】生产者

目录 发送流程 1. 流程逻辑分析 阶段一&#xff1a;主线程处理 阶段二&#xff1a;Sender 线程异步发送 核心设计思想 2. 流程 关键点总结 重要参数 一、核心必填参数 二、可靠性相关参数 三、性能优化参数 四、高级配置 五、安全性配置&#xff08;可选&#xff0…

Unity 获取独立显卡数量

获取独立显卡数量 导入插件包打开Demo 运行看控制台日志 public class GetGraphicCountDemo : MonoBehaviour{public int count;// Start is called before the first frame updatevoid Start(){count this.GetIndependentGraphicsDeviceCount();}}

Deepseek R1模型本地化部署+API接口调用详细教程:释放AI生产力

文章目录 前言一、deepseek R1模型与chatGPT o1系列模型对比二、本地部署步骤1.安装ollama2部署DeepSeek R1模型删除已存在模型&#xff0c;以7b模型为例 三、DeepSeek API接口调用Cline配置 前言 随着最近人工智能 DeepSeek 的爆火&#xff0c;越来越多的技术大佬们开始关注如…

Mac ARM 架构的命令行(终端)中,删除整行的快捷键是:Ctrl + U

在 Mac ARM 架构的命令行&#xff08;终端&#xff09;中&#xff0c;删除整行的快捷键是&#xff1a; Ctrl U这个快捷键会删除光标所在位置到行首之间的所有内容。如果你想删除光标后面的所有内容&#xff0c;可以使用&#xff1a; Ctrl K这两个快捷键可以帮助你快速清除当…