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

news2024/11/13 17:54:57

前言

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

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

  • 顺序存储结构

    • 使用此结构的线性表也叫 顺序表
    • 物理存储上是连续的,因此可以随机访问,时间复杂度为 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.coloradmin.cn/o/1923098.html

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

相关文章

Redis 中Sorted Set 类型命令(命令语法、操作演示、命令返回值、时间复杂度、注意事项)

Sorted Set 类型 文章目录 Sorted Set 类型zadd 命令zrange 命令zcard 命令zcount 命令zrevrange 命令zrangebyscore 命令zpopmax 命令bzpopmax 命令zpopmin 命令bzpopmin 命令zrank 命令zscore 命令zrem 命令zremrangebyrank 命令zremrangebyscore 命令zincrby 命令zinterstor…

秋招Java后端开发冲刺——Mybatis使用总结

一、基本知识 1. 介绍 MyBatis 是 Apache 的一个开源项目&#xff0c;它封装了 JDBC&#xff0c;使开发者只需要关注 SQL 语句本身&#xff0c;而不需要再进行繁琐的 JDBC 编码。MyBatis 可以使用简单的 XML 或注解来配置和映射原生类型、接口和 Java POJO&#xff08;Plain …

服务器数据恢复—2块硬盘离线且热备盘未完全激活的raid5数据恢复案例

服务器存储数据恢复环境&#xff1a; 北京某企业一台EMC FCAX-4存储上搭建一组由12块成员盘的raid5磁盘阵列&#xff0c;其中包括2块热备盘。 服务器存储故障&#xff1a; raid5阵列中两块硬盘离线&#xff0c;热备盘只有一块成功激活&#xff0c;raid瘫痪&#xff0c;上层LUN…

Sentinel限流算法:滑动时间窗算法、漏桶算法、令牌桶算法。拦截器定义资源实现原理

文章目录 滑动时间窗算法基本知识源码算法分析 漏桶算法令牌桶算法拦截器处理web请求 滑动时间窗算法 基本知识 限流算法最简单的实现就是使用一个计数器法。比如对于A接口来说&#xff0c;我要求一分钟之内访问量不能超过100&#xff0c;那么我们就可以这样来实现&#xff1…

学习C++,应该循序渐进的看哪些书?

学习C是一个循序渐进的过程&#xff0c;需要根据自己的基础和目标来选择合适的书籍。以下是一个推荐的学习路径&#xff0c;包含了从入门到进阶的书籍&#xff1a; 1. 入门阶段 《C Primer Plus 第6版 中文版》 推荐理由&#xff1a;这本书同样适合C零基础的学习者&#xff0…

几何建模-Parasolid中GO功能使用

1.背景介绍 1.1 Parasolid和它的接口间关系 1.2 什么是GO GO全称是Graphical Output.你的程序需要在屏幕或者打印设备上显示模型数据时。在需要使用PK中的某个渲染函数时创建图形显示数据时&#xff0c;Parasolid会调用GO相关的函数。GO函数会输出绘图指令给你的应用程序提供…

映美精黑白相机IFrameQueueBuffer转halcon的HObject

映美精黑白相机&#xff0c;用wpfhalcon开发取图 1.到官网下载&#xff0c;开发包 1sdk 2c开发例子 3c#开发例子 引入TIS.Imaging.ICImagingControl35.dll 3.ICImagingControl使用这个类控制相机 /// <summary> /// 相机控制 /// </summary> public ICImagingC…

《昇思25天学习打卡营第16天|基于MindNLP+MusicGen生成自己的个性化音乐》

MindNLP 原理 MindNLP 是一个自然语言处理&#xff08;NLP&#xff09;框架&#xff0c;用于处理和分析文本数据。 文本预处理&#xff1a;包括去除噪声、分词、词性标注、命名实体识别等步骤&#xff0c;使文本数据格式化并准备好进行进一步分析。 特征提取&#xff1a;将文…

LightRAG:高效构建和优化大型语言模型应用的 PyTorch 框架

一、前言 随着大语言模型 (LLM) 的蓬勃发展&#xff0c;检索增强生成 (RAG) 技术作为一种将 LLM 与外部知识库结合的有效途径&#xff0c;受到了越来越多的关注。 然而&#xff0c;构建 LLM 应用的真正挑战在于开发者需要根据具体需求进行高度定制化&#xff0c;而现有的 RAG …

《向量数据库指南》2024年中国向量数据库名录大全

2024年中国向量数据库名录大全 序号 百科ID 数据库名称 slogan 厂商 来源 开源 类型 1 1228 TensorDB 爱可生向量数据库 上海爱可生信息技术股份有限公司 自研 商业 向量 2 972 Milvus 开源向量数据库 上海赜睿信息科技…

centos安装minio文件系统服务器(踩坑版)

centos安装minio文件系统服务器&#xff08;踩坑版&#xff09; 引安装1. 下载2. 启动3. 创建access keys4. 创建buckets 坑 引 本来安装挺简单的&#xff0c;网上的教程一大堆&#xff0c;有些写的也挺详细的。不过自己还是踩到坑了&#xff0c;耽误了个把小时&#xff0c;特…

【源码+文档+调试讲解】全国消费水平展示平台

摘 要 随着科学技术的飞速发展&#xff0c;各行各业都在努力与现代先进技术接轨&#xff0c;通过科技手段提高自身的优势&#xff1b;对于全国消费水平展示平台当然也不能排除在外&#xff0c;随着网络技术的不断成熟&#xff0c;带动了全国消费水平展示平台&#xff0c;它彻底…

如何找回误删的文件?4个常用文件恢复方法!

对于许多用户来说&#xff0c;误删文件是一种常见而令人懊恼的情况。恢复误删文件的重要性在于&#xff0c;它可以帮助用户找回宝贵的数据&#xff0c;避免因数据丢失带来的各种不便和损失。 如何找回不小心删除的文件&#xff1f; 误删数据不知道怎么恢复&#xff0c;会给我…

对进阶指针的追忆

目录 思维导图 指针前言 一&#xff1a;字符指针 二&#xff1a;指针数组 三&#xff1a;数组指针 四&#xff1a;数组参数 && 指针参数 五&#xff1a;函数指针 六&#xff1a;函数指针数组 七&#xff1a;函数指针数组的指针 八&#xff1a;回调函数 思维导…

MySql 数据库 (基础) - 下载安装

MySQL数据库 简单介绍 数据库 数据存储的仓库数据库管理系统 操作和管理数据库的大型软件SQL 操作关系型数据库的变成语言&#xff0c;是一套标准 版本 MySQL官方提供了两种不同的版本&#xff1a; 社区版 免费&#xff0c;MySQL不提供任何的技术支持商业版 收费&#xff0c…

暑期备考美国数学竞赛AMC8和AMC10:吃透1850道真题和知识点

距离接下来的AMC8、AMC10美国数学竞赛还有几个月的时间&#xff0c;实践证明&#xff0c;做真题&#xff0c;吃透真题和背后的知识点是备考AMC8、AMC10有效的方法之一。 通过做真题&#xff0c;可以帮助孩子找到真实竞赛的感觉&#xff0c;而且更加贴近比赛的内容&#xff0c;…

谷粒商城实战笔记-29~34-前端基础 - ES6

文章目录 零&#xff0c;安装Live Server插件一&#xff0c;创建前端工程1&#xff0c;创建工程2&#xff0c;在工程ES6中创建一个html文件 二&#xff0c;ES6 简介1&#xff0c;ES6 的历史 三&#xff0c;前端基础ES61&#xff0c;let 和 const1.1&#xff0c;let1.1.1 严格的…

JavaScript青少年简明教程:开发工具与运行环境

JavaScript青少年简明教程&#xff1a;开发工具与运行环境 JavaScript是一种基于对象和事件驱动且具有安全性能的脚本语言。使用它和HTML结合可以开发出交互式的Web页面。 脚本语言是为了缩短传统的编写-编译-链接-运行过程而创建的计算机编程语言。脚本通常是解释执行而非编…

text prompt如何超过77个词

【深度学习】sdwebui的token_counter,update_token_counter,如何超出77个token的限制?对提示词加权的底层实现_prompt中token权重-CSDN博客文章浏览阅读1.6k次,点赞26次,收藏36次。文章探讨了如何在StableDiffusionProcessing中处理超过77个token的提示,涉及token_counte…

Is Temperature the Creativity Parameter of Large Language Models?阅读笔记

最近有小伙伴来问LLM的参数该如何设计&#xff0c;废话不多说来看看paper吧。首先&#xff0c;常见的可以进行调参的几个值有temperature&#xff0c;top-p和top-k。今天这篇文章是关于temperature的。 原文链接&#xff1a;https://arxiv.org/abs/2405.00492 temperature如果…