链表入门指北

2024-04-09 20:48
文章标签 链表 入门 指北

本文主要是介绍链表入门指北,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

数组的短板

先来回忆下数组的几个特点:

  • 可以存储若干份类型一样的元素
  • 每个元素占用的内存大小相同
  • 所有元素都连续的存储于一段内存中

因为所有元素存储在一段连续的内存中,使得某些操作变得很费时:

  • 修改数组的长度:需要先申请一段新的内存,然后进行数据拷贝。
    数组扩容
  • 删除指定位置插入元素:先删除指定位置的数据,然后移动后面的数据。
    数组删除元素
  • 向指定位置插入数据:先将指定位置及其后的元素向后移动,再插入元素。考虑到长度问题,可能需要先扩容再插入。

不难发现,本来只想操作其中一个元素,但是为了保证「元素在内存中的位置连续」,不得不移动非常多的元素。

总结一下就是,数组的优点在于随机读写,缺点在于增、删、扩容的效率很低。接下来看看链表如何解决上述问题,以及链表又有哪些短板呢?

链表——碎裂的数组

链表,由若干个结点组成,每个结点包含数据域和指针域。在实现上,结点的类型一般由一个类描述,比如:

//定义一个结点模板
template<typename T>
struct Node {T data; // 数据域Node *next; // 指针域Node() : next(nullptr) {}Node(const T &d) : data(d), next(nullptr) {}
};

按上述定义,一个结点存储于一段连续的内存中。在用途上,这段内存被分为数据域和指针域:

  • 数据域,顾名思义,用来存放数据的区域。
  • 指针域,存储(在逻辑上相邻的)结点的内存地址。

在链表中,逻辑上相邻的两个结点,也无需保证在内存中相邻。只需保证每一个结点的指针域存储了相邻结点的地址即可。

一般来讲,链表中有一个结点的指针域为空,该结点为尾结点,其他结点的指针域都会存储一个结点的内存地址。

链表中也会有一个结点的内存地址,没有存储在其他结点的指针域中,该结点称为头结点

如下图所示,一条以"赵二"为头结点的,长度为四的链表。为了直观,我们用箭头表示指针域中的值,表示其中存储了箭头指向节点的地址。另外约定 ‘N’ 代表空指针。

因此,只要拿到头节点的地址,就可顺着指针域依次找到所有节点了。

因为无需保证结点在内存中的位置关系,因此插入或者删除结点时无需移动其他结点。比如要在结点 p 之后,增加结点 q,整个过程总共分三步:

  1. 申请一段内存用以存储 q。
  2. 将 p 的指针域数据复制到 q 的指针域。
  3. 更新 p 的指针域为 q 的地址。

比如要在 “张三” 之后插入 “钱六”,过程如下:

删除结点 p 之后的结点 q 总共分两步:

  1. 将 q 的指针域复制到 p 的指针域。
  2. 释放 q 结点的内存,即将内存归还操作系统。

比如删除"赵二"之后的"张三":

而且链表根本没有长度的概念,只要内存足够就可增加新节点。

链表的短板

链表松散的存储方式,使其可以快速增删指定节点。但这使得链表无法通过下标快速访问指定节点。

回忆一下,数组中所有元素存储在一段连续内存中,且每个元素所占字节数相同。因此,数组操作指定下标元素的只需三步:

  • 计算偏移量:下标 × 单个元素所占字节数
  • 计算内存地址:首地址 + 偏移量
  • 操作内存地址的数据

不难发现,仅需一次乘法和一次加法即可找到目标元素在内存中的位置。

但是,链表中节点的内存地址没有规律可循,无法通过算术运算获得指定下标的位置。因此,如果想操作指定次序(比如第100个)的元素,只能从头结点开始依次寻找(从头结点指针向后跳99次),非常笨重。

另外,因为多了指针域,内存的开销也比数组要多一些。比如在 64 位的系统上,存储一个 char,数组仅需一个字节,而链表需要九个字节。

孰优孰劣

说了这么多,那么链表与数组孰优孰劣呢?其实两者没有绝对的优劣,只是适应场景不同,毕竟存在即合理嘛。下面从以下几个角度分别比较下。

  • 插入元素
    链表优于数组。数组要移动若干个元素,给待插入元素腾出位置,而链表只需修改两个指针。
  • 删除元素
    链表优于数组。数组在删除元素后,需要移动若干个元素,以填补删除元素的位置,而链表只需修改一个指针。
  • 修改元素
    链表和数组的性能相同。
  • 查找元素
    数组和链表性能相当,但考虑到内存局部性原理,数组可能稍优于链表。
  • 长度限制
    数组存在长度限制,插入元素时可能需要重新分配内存。但链表没有这个限制,只要内存够用,可以一直插入新元素。

做题技巧

无法根据下标访问元素,是链表的劣势。然而面试的时候经常碰见诸如获取倒数第k个元素获取中间位置的元素判断链表是否存在环判断环的长度等和长度与位置有关的问题。这些问题都可以通过灵活运用双指针来解决。

倒数第k个元素的问题

设有两个指针 p 和 q,初始时均指向头结点。首先,先让 p 沿着 next 移动 k 次。此时,p 指向第 k+1个结点,q 指向头节点,两个指针的距离为 k 。然后,同时移动 p 和 q,直到 p 指向空,此时 q 即指向倒数第 k 个结点。可以参考下图来理解:
移动过程中保持距离为 k

class Solution {
public:ListNode* getKthFromEnd(ListNode* head, int k) {ListNode *p = head, *q = head; //初始化while(k--) {   //将 p指针移动 k 次p = p->next;}while(p != nullptr) {//同时移动,直到 p == nullptrp = p->next;q = q->next;}return q;}
};

获取中间元素

设有两个指针 fast 和 slow,初始时指向头节点。每次移动时,fast 向后走两次,slow 向后走一次,直到 fast 无法向后走两次。这使得在每轮移动之后。fast 和 slow 的距离就会增加一

设链表有 n 个元素,那么最多移动 n 2 \frac{n}{2} 2n 轮。当 n 为奇数时,slow 恰好指向中间结点,当 n 为 偶数时,slow 恰好指向中间两个结点的靠前一个
快慢指针

class Solution {
public:ListNode* middleNode(ListNode* head) {if (head == nullptr) {return nullptr;}ListNode *p = head, *q = head;while(q->next != nullptr && q->next->next != nullptr) {p = p->next;q = q->next->next;}return p;} 
};

是否存在环

将尾结点的 next 指针指向任意一个结点,链表就存在了一个环。
一个有环的链表
当一个链表有环时,快慢指针必然会进入到环中。想象一下在操场跑步的场景,只要一直跑下去,快的总会追上慢的(也就是套了一圈)。

当两个指针都进入环后,每轮移动使得慢指针到快指针的距离增加一,同时快指针到慢指针的距离也减少一,只要一直移动下去,快指针总会追上慢指针。
快慢指针在环上追及
根据上述表述得出,如果一个链表存在环,那么快慢指针必然会相遇。实现代码如下:

class Solution {
public:bool hasCycle(ListNode *head) {ListNode *slow = head;ListNode *fast = head;while(fast != nullptr) {fast = fast->next;if(fast != nullptr) {fast = fast->next;}if(fast == slow) {return true;}slow = slow->next;}return nullptr;}
};

还有一个问题:如果存在环,如何判断环的长度呢?方法是,快慢指针在第一次相遇后继续移动,直到第二次相遇。两次相遇间的移动次数即为环的长度。

仅用一个指针判环及环的长度

这里介绍一种比较 hack 的做法,仅在 Linux 下用 C++ 验证过,不确定能否在其他操作系统及编程语言下实现


上图描述了 32/64 位系统对内存地址的划分,不难发现,用户空间地址的最高位全部为 0。我们可利用这一点表示某个节点是否被访问过:

  • 节点指针域的最高位为 0,表示该节点未被访问过。
  • 节点指针域的最高位为 1,表示该节点已经被访问过了。

利用上述标记方法,可以用一个指针判断是否有环。下述代码可在 64 位系统上正确运行。

class Solution {
public:bool hasCycle(ListNode *pHead) {const uint64_t mask = 0x8000000000000000;while (pHead != nullptr && pHead->next != nullptr) {uint64_t &adr = *(uint64_t*)(&(pHead->next));if (adr & mask) {return true;}pHead = pHead->next;adr |= mask;}return false;}
};

链表的主要代码

#include <bits/stdc++.h>using namespace std;//定义一个结点模板
template<typename T>
struct Node {T data;Node *next;Node() : next(nullptr) {}Node(const T &d) : data(d), next(nullptr) {}
};//删除 p 结点后面的元素
template<typename T>
void Remove(Node<T> *p) {if (p == nullptr || p->next == nullptr) {return;}auto tmp = p->next->next;delete p->next;p->next = tmp;
}//在 p 结点后面插入元素
template<typename T>
void Insert(Node<T> *p, const T &data) {auto tmp = new Node<T>(data);tmp->next = p->next;p->next = tmp;
}//遍历链表
template<typename T, typename V>
void Walk(Node<T> *p, const V &vistor) {while(p != nullptr) {vistor(p);p = p->next;}
}int main() {auto p = new Node<int>(1);Insert(p, 2);int sum = 0;Walk(p, [&sum](const Node<int> *p) -> void { sum += p->data; });cout << sum << endl;Remove(p);sum = 0;Walk(p, [&sum](const Node<int> *p) -> void { sum += p->data; });cout << sum << endl;return 0;
}

最后

上文中的链表只有一个指针,我们称之为单链表。在此基础上,衍生出了双链表,十字链表,跳表,舞蹈链等数据结构。这些后面有机会再和大家一起探讨。

好了朋友们,链表就先讲到这里啦,希望对大家有帮助。有不足或者错误的地方,欢迎大家指出。

这篇关于链表入门指北的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/889175

相关文章

Java Lettuce 客户端入门到生产的实现步骤

《JavaLettuce客户端入门到生产的实现步骤》本文主要介绍了JavaLettuce客户端入门到生产的实现步骤,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要... 目录1 安装依赖MavenGradle2 最小化连接示例3 核心特性速览4 生产环境配置建议5 常见问题

从入门到精通详解Python虚拟环境完全指南

《从入门到精通详解Python虚拟环境完全指南》Python虚拟环境是一个独立的Python运行环境,它允许你为不同的项目创建隔离的Python环境,下面小编就来和大家详细介绍一下吧... 目录什么是python虚拟环境一、使用venv创建和管理虚拟环境1.1 创建虚拟环境1.2 激活虚拟环境1.3 验证虚

Java List 使用举例(从入门到精通)

《JavaList使用举例(从入门到精通)》本文系统讲解JavaList,涵盖基础概念、核心特性、常用实现(如ArrayList、LinkedList)及性能对比,介绍创建、操作、遍历方法,结合实... 目录一、List 基础概念1.1 什么是 List?1.2 List 的核心特性1.3 List 家族成

Java集合中的链表与结构详解

《Java集合中的链表与结构详解》链表是一种物理存储结构上非连续的存储结构,数据元素的逻辑顺序的通过链表中的引用链接次序实现,文章对比ArrayList与LinkedList的结构差异,详细讲解了链表... 目录一、链表概念与结构二、当向单链表的实现2.1 准备工作2.2 初始化链表2.3 打印数据、链表长

c++日志库log4cplus快速入门小结

《c++日志库log4cplus快速入门小结》文章浏览阅读1.1w次,点赞9次,收藏44次。本文介绍Log4cplus,一种适用于C++的线程安全日志记录API,提供灵活的日志管理和配置控制。文章涵盖... 目录简介日志等级配置文件使用关于初始化使用示例总结参考资料简介log4j 用于Java,log4c

史上最全MybatisPlus从入门到精通

《史上最全MybatisPlus从入门到精通》MyBatis-Plus是MyBatis增强工具,简化开发并提升效率,支持自动映射表名/字段与实体类,提供条件构造器、多种查询方式(等值/范围/模糊/分页... 目录1.简介2.基础篇2.1.通用mapper接口操作2.2.通用service接口操作3.进阶篇3

Python自定义异常的全面指南(入门到实践)

《Python自定义异常的全面指南(入门到实践)》想象你正在开发一个银行系统,用户转账时余额不足,如果直接抛出ValueError,调用方很难区分是金额格式错误还是余额不足,这正是Python自定义异... 目录引言:为什么需要自定义异常一、异常基础:先搞懂python的异常体系1.1 异常是什么?1.2

Python实现Word转PDF全攻略(从入门到实战)

《Python实现Word转PDF全攻略(从入门到实战)》在数字化办公场景中,Word文档的跨平台兼容性始终是个难题,而PDF格式凭借所见即所得的特性,已成为文档分发和归档的标准格式,下面小编就来和大... 目录一、为什么需要python处理Word转PDF?二、主流转换方案对比三、五套实战方案详解方案1:

Spring WebClient从入门到精通

《SpringWebClient从入门到精通》本文详解SpringWebClient非阻塞响应式特性及优势,涵盖核心API、实战应用与性能优化,对比RestTemplate,为微服务通信提供高效解决... 目录一、WebClient 概述1.1 为什么选择 WebClient?1.2 WebClient 与

Spring Boot 与微服务入门实战详细总结

《SpringBoot与微服务入门实战详细总结》本文讲解SpringBoot框架的核心特性如快速构建、自动配置、零XML与微服务架构的定义、演进及优缺点,涵盖开发环境准备和HelloWorld实战... 目录一、Spring Boot 核心概述二、微服务架构详解1. 微服务的定义与演进2. 微服务的优缺点三