链表入门指北

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

相关文章

Linux链表操作方式

《Linux链表操作方式》:本文主要介绍Linux链表操作方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、链表基础概念与内核链表优势二、内核链表结构与宏解析三、内核链表的优点四、用户态链表示例五、双向循环链表在内核中的实现优势六、典型应用场景七、调试技巧与

Python中OpenCV与Matplotlib的图像操作入门指南

《Python中OpenCV与Matplotlib的图像操作入门指南》:本文主要介绍Python中OpenCV与Matplotlib的图像操作指南,本文通过实例代码给大家介绍的非常详细,对大家的学... 目录一、环境准备二、图像的基本操作1. 图像读取、显示与保存 使用OpenCV操作2. 像素级操作3.

POI从入门到实战轻松完成EasyExcel使用及Excel导入导出功能

《POI从入门到实战轻松完成EasyExcel使用及Excel导入导出功能》ApachePOI是一个流行的Java库,用于处理MicrosoftOffice格式文件,提供丰富API来创建、读取和修改O... 目录前言:Apache POIEasyPoiEasyExcel一、EasyExcel1.1、核心特性

Python中模块graphviz使用入门

《Python中模块graphviz使用入门》graphviz是一个用于创建和操作图形的Python库,本文主要介绍了Python中模块graphviz使用入门,具有一定的参考价值,感兴趣的可以了解一... 目录1.安装2. 基本用法2.1 输出图像格式2.2 图像style设置2.3 属性2.4 子图和聚

Spring Boot + MyBatis Plus 高效开发实战从入门到进阶优化(推荐)

《SpringBoot+MyBatisPlus高效开发实战从入门到进阶优化(推荐)》本文将详细介绍SpringBoot+MyBatisPlus的完整开发流程,并深入剖析分页查询、批量操作、动... 目录Spring Boot + MyBATis Plus 高效开发实战:从入门到进阶优化1. MyBatis

Python FastAPI入门安装使用

《PythonFastAPI入门安装使用》FastAPI是一个现代、快速的PythonWeb框架,用于构建API,它基于Python3.6+的类型提示特性,使得代码更加简洁且易于绶护,这篇文章主要介... 目录第一节:FastAPI入门一、FastAPI框架介绍什么是ASGI服务(WSGI)二、FastAP

使用C++实现链表元素的反转

《使用C++实现链表元素的反转》反转链表是链表操作中一个经典的问题,也是面试中常见的考题,本文将从思路到实现一步步地讲解如何实现链表的反转,帮助初学者理解这一操作,我们将使用C++代码演示具体实现,同... 目录问题定义思路分析代码实现带头节点的链表代码讲解其他实现方式时间和空间复杂度分析总结问题定义给定

Spring Security 从入门到进阶系列教程

Spring Security 入门系列 《保护 Web 应用的安全》 《Spring-Security-入门(一):登录与退出》 《Spring-Security-入门(二):基于数据库验证》 《Spring-Security-入门(三):密码加密》 《Spring-Security-入门(四):自定义-Filter》 《Spring-Security-入门(五):在 Sprin

csu1329(双向链表)

题意:给n个盒子,编号为1到n,四个操作:1、将x盒子移到y的左边;2、将x盒子移到y的右边;3、交换x和y盒子的位置;4、将所有的盒子反过来放。 思路分析:用双向链表解决。每个操作的时间复杂度为O(1),用数组来模拟链表,下面的代码是参考刘老师的标程写的。 代码如下: #include<iostream>#include<algorithm>#include<stdio.h>#

数论入门整理(updating)

一、gcd lcm 基础中的基础,一般用来处理计算第一步什么的,分数化简之类。 LL gcd(LL a, LL b) { return b ? gcd(b, a % b) : a; } <pre name="code" class="cpp">LL lcm(LL a, LL b){LL c = gcd(a, b);return a / c * b;} 例题: