数据结构:链表详解 (c++实现)

前言

对于数据结构的线性表,其元素在逻辑结构上都是序列关系,即数据元素之间有前驱和后继关系。
在这里插入图片描述

但在物理结构上有两种存储方式:

  • 顺序存储结构

    • 使用此结构的线性表也叫 顺序表
    • 物理存储上是连续的,因此可以随机访问,时间复杂度为 O(1)
      在这里插入图片描述
  • 链式存储结构

    • 使用此结构的线性表也叫 链表
    • 物理存储上不连续,因此不支持随机访问
      在这里插入图片描述

接下来要介绍的就是 链表
链表分为 单向链表(单链表)与 双向链表(双链表),理解了单链表,双链表自然也明白了。


1. 什么是单链表

1.1 定义

链表由一系列的节点组成(链表中的每个元素都可称为节点),对于单链表而言,它的节点包含两部分:

  • 数据域:存储当前节点的数据
  • 指针域:存储当前节点的下一个节点(后继节点)的地址

在这里插入图片描述

那么现在定义单链表 SingleList 的节点 Node:

1.2 创建 Node

struct Node {int val;		// 数据域Node* next;		// 指针域:指向的是 Node,所以类型为 Node*
};

这么定义的 Node 类只能接收数据类型为 int 的数据,对于其他类型的数据当前的类不能处理,因此为了代码的通用性,将 Node 定义为模版类:

template <typename T>
struct Node
{T val;		// 数据域Node* next;		// 指针域Node(T v, Node* n = nullptr) :val{ v }, next{ n } { }
};

为了方便初始化,Node 还增加了构造函数:一个节点肯定必须有数据域,但指针域可以为空(表示没有后继节点了)

那么我们可以如下创建节点:

Node<int> node2(2);		// 数据域为:2 (int),指针域为空
Node<int> node1(1, &node2);		// 数据域为:1,指针域为 node2 的地址

在这里插入图片描述

1.3 创建 SingleList

从单链表的定义可以看出,单链表都会有:

  • 头结点(head):第一个节点
  • 尾结点(rear):最后一个节点
    在这里插入图片描述

对于 SingleList 来说,我们显然需要能够访问链表中的所有节点。
对于一个节点来说,我们能得到两部分信息:

  • 当前节点自身的值(数据域)
  • 当前节点的下一个节点(指针域)

也就是说,我们只需要通过头结点,就可以访问该链表上的所有节点,并且不会越界

当某一节点的后继节点为空时,说明当前节点是尾结点,不能在继续访问下一节点。

因此你可以在 SingleList 类中保存头结点,但是这会有一个问题:
如果当前单链表没有节点怎么办?

之前已经说明节点不能为空:一个节点肯定必须有数据域,但指针域可以为空。

显然保存头结点不是一个好的方法,那么我们可以保存 头指针

头指针:指向头结点的指针

此时就可以

  • 通过 头指针 访问 头结点,进而访问所有节点。
  • 头指针nullptr 时,说明当前单链表没有节点。
    在这里插入图片描述

由于 Node 为模版类,因此 SingleList 也为模板类:

template <typename T>
class SingleList {
public:SingleList() = default;	   // 默认构造空单链表private:Node<T>* head = nullptr;
};

下面来实现一些单链表经常会用到的操作:


2 SingleList 的操作

接下来的操作会涉及到指针的相关操作,使用不当很容易导致 bug

补充 SingleList 类:

template <typename T>
class SingleList {
public:SingleList() = default;// 成员变量为指针,析构时需要释放内存~SingleList();	  // 返回节点数int size() const;// 返回 i 位置的节点值int get(int i) const;// 头插法void push_front(T t);// 尾插法void push_back(T t);// 删除头结点void pop_front();// 删除尾结点void pop_back();// 在 i 位置插入void insert(int i, T t);// 删除 i 位置的节点void erase(int i);private:Node<T>* head = nullptr;
};

2.1 size()

作用:求链表的节点个数

在此之前先来看如何遍历链表:

head 为 类指针('Node<...>*'),
可以通过 '->' 去访问类的成员
'head->next' <==> '(*head).next' 

对于下面的链表有:
在这里插入图片描述
换言之可以通过 head 访问所有节点,那么用一个临时变量 node 来拷贝一份 head,用 node 来遍历链表:

auto node = head;
while (node != nullptr) {	// node == nullptr 说明 node 为尾结点的下一个节点(空)cout << node->val << endl;node = node->next;		// 将 node 后移一个节点
}

用上面的例子来分析此程序:

  • 首先拷贝 head

    auto node = head;
    

    在这里插入图片描述

  • 因为 node != nullptr,故进入 while 循环

  • 此时 node 指向第一个节点,node->val = 0,此时有
    在这里插入图片描述

  • node = node->next
    在这里插入图片描述

  • 由于 node != nullptr,进入下一次循环

  • 此时 node 指向第二个节点,node->val = 1,此时有
    在这里插入图片描述

  • 执行 node = node->next
    在这里插入图片描述

  • 此时 node == nullptr,退出循环

因此可以 size() 函数实现如下:

下面的所有成员函数都是在类内部实现的

int size() const 
{auto node = head;int res = 0;while (node != nullptr) {res++;node = node->next;}return res;
}

【注】为什么不直接用 head 进行遍历,而用一个临时指针?

如果用 head 进行遍历:

while (head != nullptr) {cout << node->val << endl;head = head->next;		
}

根据前面的分析,如果链表中有节点,采用此方法会造成最后 head 指向链表的尾结点的下一个节点(nullptr),那么之后 head 就无法用来遍历此链表了,即此链表 “丢失” 了。


在链表的插入与删除操作,需要特别注意先后顺序。

2.2 push_front(T t)

作用:头插法,在链表的头部插入一个节点

设待插入节点为 node
在这里插入图片描述

  • node->next = head
    在这里插入图片描述

  • head = node

    新加入的节点现在成为头结点了

    在这里插入图片描述

void push_front(T t)
{Node<T>* node = new Node<T>(t);   // 创建的是指针,需要 new 一块内存 node->next = head;head = node;
}

为了用户更易于理解此单链表,从用户视角来看:他关心的仅仅是数据域;指针域用户不需要关心,由类的设计者来管理。因此函数 push_front 的参数应该是节点的数据域( push_front(T t) ),而不应该是节点 ( push_front(Node n) ),后面的几个函数也是如此。

【易错】 node->next = headhead = node 的顺序不能颠倒。

如果颠倒了,那么:
初始状态:
在这里插入图片描述

  • head = node
    在这里插入图片描述

  • node->next = head

显然结果不对

不需要死记硬背,自己画图分析即可


2.3 push_back(T t)

作用:尾插法,在尾结点后面插入一个节点

只需要:将尾结点的 next 指向待插入节点即可
在这里插入图片描述

void push_back(T t)
{Node<T>* node = new Node<T>(t);auto rear = head;while (rear->next != nullptr) {		// 遍历找到尾结点rear = rear->next;	}rear->next = node;	// 将尾结点的 next 指向待插入节点
}

2.4 pop_front()

作用:删除头结点

在这里插入图片描述
你可能直接如下实现:

 void pop_front(){head = head->next;}

但是这存在 内存泄露 问题:被删除的指针所指的内存没有被释放

 void pop_front(){auto node = head;head = head->next;delete node;	// 释放旧头指针}

【注】 注意 delete node 的时机

来看下面代码:

 void pop_front(){auto node = head;delete node;	head = head->next;}

如果这样做,那么相当于

 void pop_front(){delete head;	head = head->next;}

delete head,那么此时 head 所指的内存已经被释放了,此时 head 的值就是一个随机值,之后再使用 head 就是没有意义的,会导致未定义行为,产生逻辑错误甚至程序直接崩溃。

后面涉及到 delete 的函数也需要考虑此问题


2.5 pop_back()

作用:删除尾结点

在这里插入图片描述

void pop_back()
{auto rsecond = head;while (rsecond->next->next != nullptr) {   // 得到尾结点的前一个节点 rsecond = rsecond->next;}delete rsecond->next;	// 释放尾结点rsecond->next = nullptr;
}

需要注意释放完尾结点后,需要将现在的尾结点的 next 指向 nullptr,否则它将指向一块未定义的内存(随机值)。


insert 与 erase 函数涉及到中间节点的插入与删除,因此下面只讲解方法,所有的代码在文章最后

2.6 中间节点的插入

【例】在位置 1 插入节点 node

  • node1 代指图中值为 1 的节点,以此类推… …
  • 默认头结点的下标为 0,那么插入前的位置 1 就是下面的 node2,node1 为待插入节点

在这里插入图片描述

  • node1->next = node2

在这里插入图片描述

  • node0->next = node1
    在这里插入图片描述

2.7 中间节点的删除

【例】删除位置 1 的节点
初始状态:

在这里插入图片描述
你可以直接 node0->next = node2,逻辑上没有问题,但是代码上存在 内存泄漏
在这里插入图片描述
因此在执行 node0->next = node2 前,需要保存被删除的节点,在后续以释放内存。


3. 双向链表

3.1 什么是双链表

在单链表中,你会发现一个问题:单链表只能朝一个方向上(从头到尾)进行遍历,此外由于只存储了头指针,因此在尾结点的插入与删除的时间复杂度都是 O(n)。
为了解决这些问题,双链表就此诞生:
双链表在单链表的基础上增加了尾指针,节点增加了一个指针域(pre)用于指向当前节点的前驱节点。

  • 尾指针:指向尾结点的指针
  • 前驱节点:某节点的前一个节点

在这里插入图片描述

因此你会发现:

  • 头节点的前驱节点为空
  • 尾节点的后继节点为空
  • 其余节点的前驱、后继节点都不为空

由于增加了尾指针,因此在尾结点的插入与删除时间复杂度变为 O(1),因为此时可以通过尾指针直接在尾结点进行操作。
同时由于加入了 pre 指针,因此可以对链表进行双向遍历。


你理解了单链表的操作,双链表的操作也很容易理解,下面讲解较难的中间节点的插入与删除

3.2 中间节点的插入

【例】在位置 1 插入节点
在这里插入图片描述
看起来比较复杂,其实只需要从目标反推即可。
我们的目标是:
在这里插入图片描述
在之前的单链表中,你可以发现 对于节点的插入

一般是 先给 待插入节点 的指针域进行赋值,否则可能会丢失某些节点。

比如如果我们先执行 node0->next = node1,会导致 node2 丢失
在这里插入图片描述
因此需要先对待插入节点的指针域进行赋值

当然针对上面的操作,你可以在执行 node0->next = node1 之前,将 node2 进行保存,就不会丢失 node2。
这也是可以的,但是比较浪费空间。

  • node1->pre = node0
  • node1->next = node2

在这里插入图片描述

  • node0->next = node1
  • node2->pre = node1
    在这里插入图片描述

上面的代码有的可以交换位置,有的不可以,所以还是那句话:没必要死记硬背,自己画图分析即可。(在这里重点分析是否是丢失对某节点的指针)


3.3 中间节点的删除

【例】删除位置 1 上的节点
在这里插入图片描述
同理从目标反推:
在这里插入图片描述
因此我们需要

  • node0->next = node2
  • node2->pre = node0

两者可以交换顺序。但这只是就逻辑层面上可以,在代码层面上还需要考虑 内存泄漏,node1 需要被释放。


4. 循环链表

首尾相连 的链表。分为:

  • 单循环链表

    • rear->next = head
      在这里插入图片描述
  • 双循环链表

    • head->pre = rear
    • rear->next = head

在这里插入图片描述


5. 线性表 与 链表 的比较

优点缺点使用场景
顺序表(1)程序设计简单;(2)能随机访问,时间复杂度为O(1);(3)存储空间利用率高(1)需事先知道表长;(2)插入元素需移动元素;(3)多次插入元素后可能会造成溢出(1)事先确定表长;(2)很少在非尾部位置进行插入和删除;
链表(1)存储空间动态分配,不需事先确定表长;(2)插入与删除只引起指针的变化;(1)程序设计较为复杂;(2)不能随机访问,读取的时间复杂度为O(n);(3)存在结构性开销;(1)事先不确定表长;(2)需要经常进行插入与删除

【解释】

  • 访问元素的时间复杂度
    • 线性表:由于它的物理存储空间是连续的,所以元素的下标与实际的内存地址存在线性关系,可以直接计算得出,即可以随机访问,因此时间复杂度为 O(1)
    • 链表:物理存储空间一般不连续,故不能随机访问,时间复杂度为 O(n)
  • 不管是线性表还是链表,核心目的都是存储数据
    • 线性表:它的元素就是所需要存储的数据,所以存储空间利用率高;

    • 链表:它的元素除了所需要存储的数据,还存在指针域以保存额外的信息,但是这部分信息在用户层面上是没必要的。

      尽管底层设计需要维护指针,但是使用它的人只关心链表所存储的数据

      故存在结构性开销,存储空间利用率较低。


最后

单链表实现代码见:SingleList

源码仅一个头文件,将其包含即可进行使用以及测试,如代码有 bug,敬请指正。

本文参考教科书以及网上资料,并加入自己的一些理解。
如有错误或者不足,欢迎指出。

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

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

相关文章

电压反馈型运算放大器的增益和带宽

简介 本教程旨在考察标定运算放大器的增益和带宽的常用方法。需要指出的是&#xff0c;本讨论适用于电压反馈(VFB)型运算放大器。 开环增益 与理想的运算放大器不同&#xff0c;实际的运算放大器增益是有限的。开环直流增益(通常表示为AVOL)指放大器在反馈环路未闭合时的增益…

借人工智能之手,编织美妙歌词篇章

在音乐的领域中&#xff0c;歌词宛如璀璨的明珠&#xff0c;为旋律增添了无尽的魅力和情感深度。然而&#xff0c;对于许多创作者来说&#xff0c;编织出美妙动人的歌词并非易事。但如今&#xff0c;随着科技的飞速发展&#xff0c;人工智能为我们带来了全新的创作可能。 “妙…

Cornerstone3D导致浏览器崩溃的踩坑记录

WebGL: CONTEXT_LOST_WEBGL: loseContext: context lost ⛳️ 问题描述 在使用vue3vite重构Cornerstone相关项目后&#xff0c;在Mac本地运行良好&#xff0c;但是部署测试环境后&#xff0c;在window系统的Chrome浏览器中切换页面会导致页面崩溃。查看Chrome的任务管理器&am…

浅析Kafka Streams消息流式处理流程及原理

以下结合案例&#xff1a;统计消息中单词出现次数&#xff0c;来测试并说明kafka消息流式处理的执行流程 Maven依赖 <dependencies><dependency><groupId>org.apache.kafka</groupId><artifactId>kafka-streams</artifactId><exclusio…

【三维AIGC】扩散模型LDM辅助3D Gaussian重建三维场景

标题&#xff1a;《Sampling 3D Gaussian Scenes in Seconds with Latent Diffusion Models》 来源&#xff1a;Glasgow大学&#xff1b;爱丁堡大学 连接&#xff1a;https://arxiv.org/abs/2406.13099 提示&#xff1a;写完文章后&#xff0c;目录可以自动生成&#xff0c;如何…

Spring Security学习笔记(一)Spring Security架构原理

前言&#xff1a;本系列博客基于Spring Boot 2.6.x依赖的Spring Security5.6.x版本 Spring Security中文文档&#xff1a;https://springdoc.cn/spring-security/index.html 一、什么是Spring Security Spring Security是一个安全控制相关的java框架&#xff0c;它提供了一套全…

海外ASO:iOS与谷歌优化的相同点和区别

海外ASO是针对iOS的App Store和谷歌的Google Play这两个主要海外应用商店进行的优化过程&#xff0c;两个不同的平台需要采取不同的优化策略&#xff0c;以下是对iOS优化和谷歌优化的详细解析&#xff1a; 一、iOS优化&#xff08;App Store&#xff09; 1、关键词覆盖 选择关…

用node.js写一个简单的图书管理界面——功能:添加,删除,修改数据

涉及到的模块&#xff1a; var fs require(‘fs’)——内置模块 var ejs require(‘ejs’)——第三方模块 var mysql require(‘mysql’)——第三方模块 var express require(‘express’)——第三方模块 var bodyParser require(‘body-parser’)——第三方中间件 需要…

打造你的智能家居指挥中心:基于STM32的多协议(zigbee、http)网关(附代码示例)

1. 项目概述 随着物联网技术的蓬勃发展&#xff0c;智能家居正逐步融入人们的日常生活。然而&#xff0c;市面上琳琅满目的智能家居设备通常采用不同的通信协议&#xff0c;导致不同品牌设备之间难以实现互联互通。为了解决这一难题&#xff0c;本文设计了一种基于STM32的多协…

ant design form动态增减表单项Form.List如何进行动态校验规则

项目需求&#xff1a; 在使用ant design form动态增减表单项Form.List时&#xff0c;Form.List中有多组表单项&#xff0c;一组中的最后一个表单项的校验规则是动态的&#xff0c;该组为最后一组时&#xff0c;最后一个表单项是非必填项&#xff0c;其他时候为必填项。假设动态…

docker inspect 如何提取容器的ip和端口 网络信息?

目录 通过原生Linux命令过滤找到IP 通过jq工具找到IP 使用docker -f 的过滤&#xff08;模板&#xff09; 查找端口映射信息 查看容器内部细节 docker inspect 容器ID或容器名 通过原生Linux命令过滤找到IP 通过jq工具找到IP jq 是一个轻量级且灵活的命令行工具&#xf…

(视频演示)基于OpenCV的实时视频跟踪火焰识别软件V1.0源码及exe下载

本文介绍了基于OpenCV的实时视频跟踪火焰识别软件&#xff0c;该软件通过先进的图像处理技术实现对实时视频中火焰的检测与跟踪&#xff0c;同时支持导入图片进行火焰识别。主要功能包括相机选择、实时跟踪和图片模式。软件适用于多种场合&#xff0c;用于保障人民生命财产安全…

OpenGL笔记二之glad加载opengl函数以及opengl-API(函数)初体验

OpenGL笔记二之glad加载opengl函数以及opengl-API(函数)初体验 总结自bilibili赵新政老师的教程 code review! 文章目录 OpenGL笔记二之glad加载opengl函数以及opengl-API(函数)初体验1.运行2.重点3.目录结构4.main.cpp5.CMakeList.txt 1.运行 2.重点 3.目录结构 01_GLFW_WI…

Python-PLAXIS自动化建模技术与典型岩土工程

有限单元法在岩土工程问题中应用非常广泛&#xff0c;很多软件都采用有限单元解法。在使用各大软件进行数值模拟建模的过程中&#xff0c;您是否发现GUI界面中重复性的点击输入工作太繁琐&#xff1f;从而拖慢了设计或方案必选进程&#xff1f; 搭建自己的Plaxis模型&#xff…

设计模式的七大原则

1.单一职责原则 单一职责原则(Single responsibility principle)&#xff0c;即一个类应该只负责一项职责。如类A负责两个不同职责&#xff1a;职责1&#xff0c;职责2。当职责1需求变更而改变A时&#xff0c;可能造成职责2执行错误&#xff0c;所以需要将类A的粒度分解为A1、…

安卓14中Zygote初始化流程及源码分析

文章目录 日志抓取结合日志与源码分析systemServer zygote创建时序图一般应用 zygote 创建时序图向 zygote socket 发送数据时序图 本文首发地址 https://h89.cn/archives/298.html 最新更新地址 https://gitee.com/chenjim/chenjimblog 本文主要结合日志和代码看安卓 14 中 Zy…

C/C++ 进阶(7)模拟实现map/set

个人主页&#xff1a;仍有未知等待探索-CSDN博客 专题分栏&#xff1a;C 一、简介 map和set都是关联性容器&#xff0c;底层都是用红黑树写的。 特点&#xff1a;存的Key值都是唯一的&#xff0c;不重复。 map存的是键值对&#xff08;Key—Value&#xff09;。 set存的是键…

Git的命令使用与IDEA内置git图形化的使用

Git 简介 Git 是分布式版本控制系统&#xff0c;它可以帮助开发人员跟踪和管理代码的更改。Git 可以记录代码的历史记录&#xff0c;并允许您在不同版本之间切换。 通过历史记录可以查看&#xff1a; 进行了哪些更改&#xff1f;谁进行了更改&#xff1f;何时进行了更改&#…

网络安全高级工具软件100套

1、 Nessus&#xff1a;最好的UNIX漏洞扫描工具 Nessus 是最好的免费网络漏洞扫描器&#xff0c;它可以运行于几乎所有的UNIX平台之上。它不止永久升级&#xff0c;还免费提供多达11000种插件&#xff08;但需要注册并接受EULA-acceptance–终端用户授权协议&#xff09;。 它…

在生产环境中部署Elasticsearch:最佳实践和故障排除技巧——聚合与搜索(三)

#在生产环境中部署Elasticsearch&#xff1a;最佳实践和故障排除技巧——聚合与搜索&#xff08;三&#xff09; 前言 文章目录 前言- 聚合和分析- 执行聚合操作- 1. 使用Java API执行聚合操作- 2. 使用CURL命令执行聚合操作- 1. 使用Java API执行度量操作- 2. 使用CURL命令执…