链表入门指北

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

相关文章

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;} 例题:

Java 创建图形用户界面(GUI)入门指南(Swing库 JFrame 类)概述

概述 基本概念 Java Swing 的架构 Java Swing 是一个为 Java 设计的 GUI 工具包,是 JAVA 基础类的一部分,基于 Java AWT 构建,提供了一系列轻量级、可定制的图形用户界面(GUI)组件。 与 AWT 相比,Swing 提供了许多比 AWT 更好的屏幕显示元素,更加灵活和可定制,具有更好的跨平台性能。 组件和容器 Java Swing 提供了许多

【IPV6从入门到起飞】5-1 IPV6+Home Assistant(搭建基本环境)

【IPV6从入门到起飞】5-1 IPV6+Home Assistant #搭建基本环境 1 背景2 docker下载 hass3 创建容器4 浏览器访问 hass5 手机APP远程访问hass6 更多玩法 1 背景 既然电脑可以IPV6入站,手机流量可以访问IPV6网络的服务,为什么不在电脑搭建Home Assistant(hass),来控制你的设备呢?@智能家居 @万物互联

poj 2104 and hdu 2665 划分树模板入门题

题意: 给一个数组n(1e5)个数,给一个范围(fr, to, k),求这个范围中第k大的数。 解析: 划分树入门。 bing神的模板。 坑爹的地方是把-l 看成了-1........ 一直re。 代码: poj 2104: #include <iostream>#include <cstdio>#include <cstdlib>#include <al

MySQL-CRUD入门1

文章目录 认识配置文件client节点mysql节点mysqld节点 数据的添加(Create)添加一行数据添加多行数据两种添加数据的效率对比 数据的查询(Retrieve)全列查询指定列查询查询中带有表达式关于字面量关于as重命名 临时表引入distinct去重order by 排序关于NULL 认识配置文件 在我们的MySQL服务安装好了之后, 会有一个配置文件, 也就

深入手撕链表

链表 分类概念单链表增尾插头插插入 删尾删头删删除 查完整实现带头不带头 双向链表初始化增尾插头插插入 删查完整代码 数组 分类 #mermaid-svg-qKD178fTiiaYeKjl {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-

建立升序链表

题目1181:遍历链表 时间限制:1 秒 内存限制:32 兆 特殊判题:否 提交:2744 解决:1186 题目描述: 建立一个升序链表并遍历输出。 输入: 输入的每个案例中第一行包括1个整数:n(1<=n<=1000),接下来的一行包括n个整数。 输出: 可能有多组测试数据,对于每组数据, 将n个整数建立升序链表,之后遍历链表并输出。 样例输

【每日一题】LeetCode 2181.合并零之间的节点(链表、模拟)

【每日一题】LeetCode 2181.合并零之间的节点(链表、模拟) 题目描述 给定一个链表,链表中的每个节点代表一个整数。链表中的整数由 0 分隔开,表示不同的区间。链表的开始和结束节点的值都为 0。任务是将每两个相邻的 0 之间的所有节点合并成一个节点,新节点的值为原区间内所有节点值的和。合并后,需要移除所有的 0,并返回修改后的链表头节点。 思路分析 初始化:创建一个虚拟头节点