【数据结构】认清带头双向循环链表的庐山真面目

news2025/1/12 18:49:07

目录

    • 前言
    • 一、带头双向循环链表的介绍
    • 二、带头双向循环链表的类型重定义
        • 1.对数据类型进行重定义
        • 2.链表结点结构
        • 3.结点类型重定义
    • 三、常见函数操作的实现
        • 1.声明
        • 2.定义
      • 1. 申请新节点
      • 2. 初始化
      • 3. 销毁链表
      • 4. 打印链表
      • 5. 尾插数据
      • 6. 尾删数据
      • 7. 头插结点
      • 8.头删结点
      • 9. 在指定的位置前插入数据
      • 10. 在指定位置删除数据
      • 11. 链表中的结点个数
      • 12. 根据指定值找结点
    • 四、测试链表逻辑
      • 1.尾插数据
      • 2.尾删数据
      • 3. 测试头插数据
      • 4. 测试头删数据
      • 5.测试在pos位置之前插入新节点
      • 6.测试删除pos位置的结点
    • 六、顺序表VS链表(面试常考)
    • 七、顺序表的优点之CPU高速缓存命中率高

前言

前面我们学习的是单链表,我们主要学习的是不带头结点的单链表,这个单链表是单向的,只支持从前往后遍历,也就是说它只能从上一个结点找到下一个结点,不能从下一个结点找到上一个结点,因此在解决一些问题的时候就会比较麻烦。今天我们将学习一个比较优秀的链表:带头双向循环链表

一、带头双向循环链表的介绍

  1. 基本样式
    在这里插入图片描述
  2. 结构分析
  • 带头:结构中具有头结点,就是在插入的时候不需要对插入第一个结点进行特殊处理,这是相比于不带头结点的优势
  • 双向:支持从上一个结点找到下一个结点,也支持从下一个结点找到上一个结点,支持双向遍历
  • 循环:尾结点指向的是头结点,头结点的前一个指向的是尾结点,因此在找尾的时候就会比较方便

二、带头双向循环链表的类型重定义

1.对数据类型进行重定义

与前面学习的数据结构一样,数据结构都是用来存储数据的,存储的数据类型是未知的,因此,我们最好对数据类型进行重定义,以便后面随时可以修改存储的数据类型,这里先假设存储的是int类型,如果我们想要将存储的数据类型修改成double,那么我们只需要修改这里的int为double即可。

// 对结构中存储的数据类型进行重定义
typedef int ListDataType;

2.链表结点结构

我们知道带头双向循环链表中是支持双向遍历的,也就是支持从上一个结点找到下一个结点,支持从下一个结点找到上一个结点,这些都是由链表中的结点的结构来决定的。链表中的结点的结构中除了需要包含存储的数据之外,还需要一个指向前一个结点的指针和一个指向后一个结点的指针,这样才能满足上面的需求。大致实现如下:

// 链表中的结点类型
struct ListNode
{
	ListDataType val;// 数据域
	struct ListNode* prev;// 指向前一个结点的指针
	struct ListNode* next;// 指向下一个结点的指针
};

3.结点类型重定义

通常为了方便表示结点的类型,也就是为了每次使用结点类型的时候不加struct这个关键字或者更加简短,通常会对结点的类型进行重定义成一个更简短的名字。比如:

// 链表中的结点类型
typedef struct ListNode
{
	ListDataType val;// 数据域
	struct ListNode* prev;// 指向前一个结点的指针
	struct ListNode* next;// 指向下一个结点的指针
}ListNode;

三、常见函数操作的实现

1.声明

// 实现带头双向循环链表中的常见的操作
// 申请新节点
ListNode* BuyListNode(ListDataType val);
// 初始化
ListNode* ListInit();

// 销毁链表
void ListDestroy(ListNode* head);

// 尾插数据
void ListPushBack(ListNode* head, ListDataType val);

// 尾删数据
void ListPopBack(ListNode* head);

// 头插数据
void ListPushFront(ListNode* head, ListDataType val);

// 头删数据
void ListPopFront(ListNode* head);

// 在指定的位置插入数据
void ListInsert(ListNode* head, ListNode* pos, ListDataType val);

// 在指定位置删除数据
void ListErase(ListNode* head, ListNode* pos);

// 链表中的结点个数
int ListSize(ListNode* head);

// 打印链表
void ListPrint(ListNode* head);

// 根据指定值找结点
ListNode* ListFind(ListNode* head, ListDataType val);

2.定义

1. 申请新节点

// 申请新节点
ListNode* BuyListNode(ListDataType val)
{
	ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
	if (newnode == NULL)
	{
		printf("malloc fail\n");
		return;
	}
	// malloc success
	newnode->val = val;
	newnode->next = NULL;
	return newnode;
}

由于在链表中的各种插入中需要频繁使用申请新节点这个功能,为了防止程序中代码冗余,我们可以将申请新节点这个功能封装成一个新的函数,这个函数中需要注意一些点,就是指针问题,当一个结点成功创建之后一定要注意马上对其中的指针进行处理,防止后续出现野指针

2. 初始化

// 初始化
ListNode* ListInit()
{
	ListNode* head = BuyListNode(0);
	head->next = head;
	head->prev = head;
	return head;
}

初始化函数主要是为了完成头结点中指针的初始化,防止出现野指针,当带头双向循环链表为空时,头结点中的prev指针和next指针都是指向本身,结点中的数据域可初始化可不初始化,不初始化时就默认是随机值,不影响。这个初始化函数是通过在初始化函数中初始化链表的头结点之后返回对应的头指针来实现的,而不是将头指针交给初始化函数进行初始化,这个细节是需要注意的

3. 销毁链表

// 销毁链表
void ListDestroy(ListNode* head)
{
	assert(head);
	// 从第一个结点(非头结点)开始进行释放结点
	ListNode* cur = head->next;
	while (cur != head)
	{
		ListNode* next = cur->next;
		free(cur);
		cur = next;
	}
	head->next = head->prev = head;
}

带头双向循环链表的销毁和单链表的销毁思路基本一致,需要一个遍历指针从第一个有效结点开始,不断释放结点,直到再次遍历到头结点时停止。释放有效结点之后需要将head结点的prev指针和next指针指向其本身。

4. 打印链表

// 打印链表
void ListPrint(ListNode* head)
{
	assert(head);
	ListNode* cur = head->next;
	while (cur != head)
	{
		printf("%d ", cur->val);
		cur = cur->next;
	}
}

思路和单链表类似

5. 尾插数据

// 尾插数据
void ListPushBack(ListNode* head, ListDataType val)
{
	assert(head);
	ListNode* newnode = BuyListNode(val);
	// 找尾
	ListNode* tail = head->prev;

	// 连接
	tail->next = newnode;
	newnode->prev = tail;
	newnode->next = head;
	head->prev = newnode;
	
}

尾插数据的时候不需要像单链表一样去从头遍历找尾,尾结点就是头结点的前一个,找到尾结点之后,再将新节点连接到尾结点和头结点之间即可。

6. 尾删数据

// 尾删数据
void ListPopBack(ListNode* head)
{
	assert(head);
	assert(head->next);
	// 找尾
	ListNode* tail = head->prev;
	// 找尾的前一个
	ListNode* Tailprev = tail->prev;
	// 释放尾结点
	free(tail);
	// 连接尾结点的前后结点
	Tailprev->next = head;
	head->prev = Tailprev;
}

找到尾,尾的前一个,删除尾结点,连接尾结点的前后结点,删除的算法一定要注意,删除的结构是否为空,如果结构中就没有数据,那么是不能删除的。

7. 头插结点

// 头插数据
void ListPushFront(ListNode* head, ListDataType val)
{
	assert(head);
	// 申请新节点
	ListNode* newnode = BuyListNode(val);
	// 找到头结点的下一个结点
	ListNode* next = head->next;

	// 头插数据
	head->next = newnode;
	newnode->prev = head;
	newnode->next = next;
	next->prev = newnode;

}

先找到第一个结点,申请一个新节点,将新节点连接在头结点和原来第一个结点之间即可

8.头删结点

// 头删数据
void ListPopFront(ListNode* head)
{
	assert(head);
	assert(head->next);
	// 找到第一个有效结点
	ListNode* cur = head->next;
	// 找到第一个有效结点的下一个结点
	ListNode* next = cur->next;
	// 删除第一个有效结点
	free(cur);
	// 连接第一个有效结点的前后结点
	head->next = next;
	next->prev = head;
}

先找到第一个结点(要删除的结点)的下一个位置(可能为空),删除第一个结点,再让头结点指向刚刚找到的第一个结点的下一个位置。

9. 在指定的位置前插入数据

// 在指定的位置插入数据
void ListInsert(ListNode* head, ListNode* pos, ListDataType val)
{
	assert(head);
	// 申请新节点
	ListNode* newnode = BuyListNode(val);
	// 在pos位置前插入数据
	// 找到pos位置结点的前一个结点
	ListNode* prev = pos->prev;
	prev->next = newnode;
	newnode->prev = prev;

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

}

上面这个代码实现的是在指定位置前插入数据,当然也可以实现一个在指定位置后插入数据,这个可以根据需求来实现,基本逻辑,找到指定位置的前一个结点,申请一个新节点,将指定位置的前一个结点的下一个连接为新节点,将新节点的下一个结点连接为指定的那个结点即可。

10. 在指定位置删除数据

// 在指定位置删除数据
void ListErase(ListNode* head, ListNode* pos)
{
	assert(head);
	assert(head->next);

	// 找到pos位置的前一个结点
	ListNode* prev = pos->prev;

	// 找到pos位置的下一个结点
	ListNode* next = pos->next;

	// 删除pos位置的结点
	free(pos);
	prev->next = next;
	next->prev = prev;
}

给定了某个结点的地址,在双向循环链表中我们可以通过这个结点的指针找到这个结点的前一个结点和后一个结点,那么现在需要删除这个结点,我们只需要删除这个结点之后,将这个结点的前后指针连接起来即可。

11. 链表中的结点个数

// 链表中的结点个数
int ListSize(ListNode* head)
{
	int count = 0;
	ListNode* cur = head->next;
	while (cur != head)
	{
		cur = cur->next;
		count++;
	}
	return count;
}

12. 根据指定值找结点

// 根据指定值找结点
ListNode* ListFind(ListNode* head, ListDataType val)
{
	assert(head);
	ListNode* cur = head->next;
	while (cur != head)
	{
		if (cur->val == val)
		{
			return cur;
		}
		cur = cur->next;
	}
	return NULL;
}

四、测试链表逻辑

1.尾插数据

  • 测试代码
void test_list1()
{
	// 初始化
	ListNode* plist = ListInit();
	
	// 尾插数据
	ListPushBack(plist, 1);
	ListPushBack(plist, 2);
	ListPushBack(plist, 3);

	// 打印链表
	ListPrint(plist);
}
  • 测试结果:
    在这里插入图片描述

2.尾删数据

  • 测试代码
void test_list2()
{
	// 初始化
	ListNode* plist = ListInit();

	// 尾插数据
	ListPushBack(plist, 1);
	ListPushBack(plist, 2);
	ListPushBack(plist, 3);

	// 打印链表
	ListPrint(plist);

	// 尾删数据
	ListPopBack(plist);
	// ListPopBack(plist);

	// 打印链表
	ListPrint(plist);
}

  • 测试结果
    在这里插入图片描述

3. 测试头插数据

  • 测试代码
void test_list3()
{
	// 初始化
	ListNode* plist = ListInit();

	// 头插数据
	ListPushFront(plist, 1);
	ListPushFront(plist, 2);
	ListPushFront(plist, 3);

	// 打印链表
	ListPrint(plist);
}

  • 测试结果
    在这里插入图片描述

4. 测试头删数据

  • 测试代码
void test_list4()
{
	// 初始化
	ListNode* plist = ListInit();

	// 头插数据
	ListPushFront(plist, 1);
	ListPushFront(plist, 2);
	ListPushFront(plist, 3);

	// 打印链表
	ListPrint(plist);

	// 头删数据
	ListPopFront(plist);
	ListPrint(plist);

	ListPopFront(plist);
	ListPrint(plist);

	ListPopFront(plist);
	ListPrint(plist);

	ListPopFront(plist);
	ListPrint(plist);

}
  • 测试结果
    在这里插入图片描述

5.测试在pos位置之前插入新节点

  • 测试代码
void test_list5()
{
	// 初始化
	ListNode* plist = ListInit();

	// 尾插数据
	ListPushBack(plist, 1);
	ListPushBack(plist, 2);
	ListPushBack(plist, 3);

	// 打印链表
	ListPrint(plist);

	// 在1前面插入一个结点,首先需要找到1这个结点,再调用实现的函数即可
	ListNode* ret = ListFind(plist, 1);
	if (ret)
	{
		// 能够找到对应的结点
		ListInsert(plist, ret, 6);
	}
	else
	{
		// 找不到
		return;
	}

	// 打印链表
	ListPrint(plist);

}
  • 测试结果
    在这里插入图片描述
  • 测试逻辑
    先利用查找函数查找到对应的结点,如果查找不到,则啥事都不需要做,如果查找到,记录该节点,使用对应的函数进行插入数据即可

6.测试删除pos位置的结点

  • 测试代码
void test_list6()
{
	// 调用erase函数删除3这个结点
	// 初始化
	ListNode* plist = ListInit();

	// 头插数据
	ListPushFront(plist, 1);
	ListPushFront(plist, 2);
	ListPushFront(plist, 3);

	// 打印链表
	ListPrint(plist);

	// 先找到3这个结点
	ListNode* ret = ListFind(plist,3);
	if (ret)
	{
		// 找到了
		ListErase(plist, ret);
	}
	else
	{
		// 找不到
		return;
	}

	// 打印链表
	ListPrint(plist);
}
  • 测试结果
    在这里插入图片描述

  • 测试逻辑
    调用ListErase函数来删除某个指定的结点,通常的方法就是使用查找函数找到对应值的结点,然后将这个结点的指针传给ListErase函数进行删除即可。


通过上面的代码的测试已经实现,我们会发现,上面这个结构相比于之前学习的单链表拥有很大的优势,并且上面学习的这个结构相对是比较优秀的,那么上面这个结构有没有什么缺点呢??
缺点通常都是相对而言的,正如:没有对比就没有伤害,相比于前面学习的顺序表,这个结构还是存在一定的缺点的,那么下面我们就来分析顺序表和带头双向循环链表的优缺点。

六、顺序表VS链表(面试常考)

  1. 顺序表
    (1)优点:
  • 物理空间连续,支持通过数组下标对数据进行随机访问
  • CPU高速缓存命中率高
    (2)缺点
  • 由于物理空间连续,当空间不够时就需要进行扩容,而扩容本身是具有一定的成本的,并且扩容也会存在一定的空间浪费,因为不可能刚好扩的空间是满足需求的
  • 在顺序表的头部进行插入和删除的时候需要挪动大量的数据,效率比较低,成本比较高
  1. 链表
    (1)优点
  • 在任意的位置插入和删除数据的效率都比较高
  • 可以按需申请对应的结点空间,并不会存在内存浪费
    (2)缺点
  • 不支持通过下标进行随机访问
  • 有些算法:二分查找,排序等不适合用在链表上

重点:如何理解顺序表的优点之CPU高速缓存命中率高?

七、顺序表的优点之CPU高速缓存命中率高

想要弄懂这个问题,首先需要知道一些背景知识:一个源代码在写好之后是
会通过编译链接形成一个可执行程序,这个时候这个可执行程序是存放在磁盘上的,当这个程序要被运行起来的时候,CPU会将这个程序从磁盘加载到内存,当CPU要去执行这个程序的时候,CPU是不会直接从内存拿数据的,因为CPU的执行速率比较快,而内存的运行速度相比于CPU而言还是比较慢的,所以CPU并不会直接从内存读数据,而是会将内存中需要运行的数据从内存中加载到高速缓存
,所以CPU每次执行程序的时候都会去看看高速缓存中是否已经存在对应的数据和代码,如果存在,则称为CPU命中,若不存在,则称为不命中。当CPU第一次访问高速缓存的时候,显然这个时候不存在对应的数据,这个时候是不命中的,所以此时会将内存中对应的数据从内存加载到高速缓存,由局部性原理可知:一次加载并不仅仅是将要运行的数据加载进去,而是会将要运行的数据及其周围的数据一起加载到高速缓存。由于顺序表中数据是连续存放的,链表中的数据不是连续存放的,因此,在每次CPU进行命中的时候,显然使用顺序表时CPU的高速缓存命中率会比较高。

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

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

相关文章

嵌入式开发的程序架构

前言 在嵌入式软件开发,包括单片机开发中,软件架构对于开发人员是一个必须认真考虑的问题。 软件架构对于系统整体的稳定性和可靠性是非常重要的,一个合适的软件架构不仅结构清晰,并且便于开发。 我相信在嵌入式或单片机软件开发…

聚焦云原生安全|安全狗亮相云原生产业联盟年会

1月9日,云原生产业联盟年会成功举办。 作为国内云原生安全领导厂商,安全狗也受邀参与此次大会。 安全狗高级副总裁陈荣有发表寄语 在此次线上会议中,安全狗凭借突出的云原生安全整体实力,通过层层筛选与审核,入选成为…

OpenCV从3D-2D 点对应中查找对象姿势solvePnP

1.概述:在使用相机拍照片时,大多数人会考虑拍的好不好看,关注相机中物体坐标的并不多,但是对于地信学科来说,如果能从照片中获取物体的真实位置,对地理信息获取大有帮助,在这里面,十…

深入分析Linux PCI驱动框架(三)

说明: Kernel版本:4.14ARM64处理器使用工具:Source Insight 3.5, Visio 1. 概述 先回顾一下PCIe的架构图: 本文将讲PCIe Host的驱动,对应为Root Complex部分,相当于PCI的Host Bridge部分&…

Vue 总结四 (ref, mixin, 插件, 插槽, VueX)

目录 ref 混入 mixin 插件 插槽 使用插槽的情景 使用方法 VueX 使用场景 使用 state 存放共享数据 actions 操作共享数据的API mutations 操作共享数据的API 生命周期图 ref 和id的区别 对于传统标签来说没有区别 都拿到的是 html内容 对于自定义的vue 的标签…

Spring事务源码分析

1. 前言 Spring支持两种事务管理的方式:声明式事务和编程式事务。编程式事务的优点是可以在代码里控制事务的粒度,实现细粒度的事务控制,缺点是对业务代码存在侵入性,代码复杂度较高,一般很少使用。声明式事务的优点是…

Linux下的动静态库

目录 认识动静态库 如何制作动静态库? 静态库 动态库 使用库 使用静态库 使用动态库 为什么动态链接是如此呢? 认识动静态库 我们在使用标准库的时候,需要有系统的头文件和系统的库文件,这个库文件是什么呢? …

Databend 借助对象存储帮你实现降本增效

本篇文章围绕着: 什么是对象存储当 Databend 遇到对象存储2022 年 Databend 利用对象存储降本的案例国内优秀的对象存储产品基于对象存储创业的产品 什么是对象存储 对象存储是一种可以非结构化存储和管理数据的技术。 可以简单理解为 NoSQL 接口方式存储和访问数…

linux系统中使用QT实现多媒体的功能方法

大家好,今天主要和大家聊一聊,如何使用QT中的多媒体的功能。 目录 第一:多媒体基本简介 第二:应用实例实现 第三:程序运行效果 第一:多媒体基本简介 QT的多媒体模块提供了音频,视频&#xff…

分布式系统-CAP 理论

在前一篇分布式系统–拜占庭将军问题(The Byzantine Generals Problem) 我们理解了共识问题的背景,这一节主要讨论如何解决或者理解自己系统中的共识问题,通过什么来分辨自己的系统需要哪一种共识。 这个理论就是 CAP 理论,先想下面几个问题…

linux 线程详解

前言 程序运行在内存空间中叫进程,进程中包含有若干线程,线程是系统调度和执行的基本单位。线程才是程序运行的实体,通常程序里的main()函数就相当于主线程,把进程理解成一个容器,里面可以包含有若干线程和若干资源&am…

6)Mybatis启动流程

1. 首先Mybatis会加载配置文件mybatis-config.xml, 主要实现在Mybatis的builder模块,包路径org.apache.ibatis.builder,解析入口XMLConfigBuilder private void settingsElement(Properties props) {configuration.setAutoMappingBehavior(Au…

指针进阶篇(2)

进阶指针 🤔前言🤔 一、😊函数指针😊 二、😜函数指针数组😜 三 、😝指向函数指针数组的指针😝 四、🌝回调函数🌝 🍀小结🍀 &…

摩丝-题解

看到题目,怀疑是莫尔斯电码,打开发现果然是莫尔斯电码的点和划.. .-.. --- ...- . -.-- --- ..-简单说一下电报的原理最简单的电报模型就是一个电源,一个开关和一个电磁铁当需要长距离使用时候,需要用到继电器按下开关&#xff0c…

【BP靶场portswigger-服务端10】XML外部实体注入(XXE注入)-9个实验(全)

前言: 介绍: 博主:网络安全领域狂热爱好者(承诺在CSDN永久无偿分享文章)。 殊荣:CSDN网络安全领域优质创作者,2022年双十一业务安全保卫战-某厂第一名,某厂特邀数字业务安全研究员&…

C#【必备技能篇】使用NPOI实现对excel的读取和写入

文章目录1、Winform界面布局2、引用NPOI的dll3、源码4、运行效果5、NPOI的dll下载地址6、补充【以上步骤只能打开.xls文件(97-2003版本),打不开.xlsx文件(2007版本)】1、Winform界面布局 2、引用NPOI的dll 3、源码 us…

(十二)devops持续集成开发——jenkins的全局工具配置之sonar qube环境安装及配置

前言 本节内容我们主要介绍一下在jenkins中如何集成sonar qube代码质量检查工具,sonar qube可以在流水化项目集成部署前对我们的代码质量检查。开始本节内容前我们需要先搭建好sonar qube服务,关于sonar qube服务的搭建可参考作者往期博客内容&#xff…

P4391 [BOI2009]Radio Transmission 无线传输

题目描述 给你一个字符串 s_1s1​,它是由某个字符串 s_2s2​ 不断自我连接形成的。但是字符串 s_2s2​ 是不确定的,现在只想知道它的最短长度是多少。 输入格式 第一行一个整数 LL,表示给出字符串的长度。 第二行给出字符串 s_1s1​ 的一个子…

【linux入门】基础知识学习笔记

文章目录【第一章-宏观知识】1.硬件和软件的关系2.操作系统 是什么、作用是什么3.常见的操作系统4.Linux的诞生5.Linux内核 是什么6.Linux发行版 是什么7.WSL是什么8.虚拟机快照9.FinalShell(Xshell替代品)【第二章-Linux基础命令】1.Linux目录结构2.什么…

Linux---权限

目录 1.文件访问者的分类(人/用户) 2.文件类型和访问权限(事物属性) 3.文件权限值的表示方法 a)字符表示方法 b)8进制数值表示方法 4.文件访问权限的相关设置方法 4.1 改属性 4.2 改人(改拥有者/所属组)…