一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。
前言
😬 大家好,我是苏菲,一个来自福建的前端程序媛。
这是我第二次参加更文挑战,但是上次由于各种原因没有卷下去,希望这次能坚持(juan)得久一点。先给自己定个小目标,我要更它个28天!
由于本文是学习笔记,因此文中会有许多C++的代码,但是!请不需要太在意语言!!重要的是思路!!!当然,在大部分地方我也加上了js/ts代码,方便前端小伙伴理解。若遇到疑问,也可在评论区提出,一起学习讨论,大家一起卷起来~~~
由于本文篇幅略长,所以我将它分为了两个部分,下一部分将在清明节期间更新,敬请期待。
(再插句话,本文中有些时候用的“节点”,有些时候用的“结点”,完全是因为打字过程中不注意造成的,绝对不是因为我不知道该用哪个jie造成的,所以请问各位大佬,用哪个jie比较好😂我下次一定改!)
一、链表的基础知识
1.1 链表的概念
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成。结点可以在运行时动态生成。每个结点包括2个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
1.2 链表的抽象概念
链表代表了一种唯一指向思想。
1.3. 链表的适用场景
链表适用于存储一些经常增加、删除的数据;不适合经常需要遍历查询操作的数据。
1.4 链表的特点
- 链表中的每个节点至少包含两个部分:
数据域与指针域; - 链表中的每个节点,通过指针域的值,形成一个线性结构;
- 查找节点O(n),插入节点O(1),删除节点O(1);
- 不适合快速地定位数据,适合动态地插入和删除数据的应用场景。
1.5 链表的图示
如下图所示,head仅存储了下一节点的地址,它并不是一个链表节点,内部存储框内的三个才是链表节点。
二、几种经典的链表实现方法
2.1 使用结构体实现链表
结构体类似于JavaScript中的对象。
#include <cstdlib>
#include <queue>
#include <stack>
#include <algorithm>
#include <string>
#include <map>
#include <set>
#include <vector>
using namespace std;
struct Node {
Node(int data) : data(data), next(NULL) {} // 构造函数
int data;
Node *next;
};
int main(){
Node *head = NULL;
head = new Node(1);
head->next = new Node(2);
head->next->next = new Node(3);
head->next->next->next = new Node(4);
Node *p = head;
while(p != NULL){
printf("%d->", p->data);
p = p->next;
}
printf("\n");
return 0;
}
输出结果:
2.2 从思维逻辑层面实现链表
#include <iostream>
int data[10]; // 数据域
int next[10]; // 指针域
/**
* 添加节点的函数
* 在ind节点后面添加节点p,节点p里存储的值是val。
*/
void add(int ind, int p, int val){
next[p] = next[ind]; // 增1,加上这行后,在中间插入就不会把后边的链表丢了
next[ind] = p;
data[p] = val;
return;
}
int main(){
// 构造链表
int head = 3;
data[3] = 0;
add(3,5,1);
add(5,2,2);
add(2,7,3);
add(7,9,100);
add(5,6,123); // 增2,在中间插入
// 访问链表
int p = head;
while(p != 0){
printf("%d->", data[p]);
p = next[p];
}
printf("\n");
return 0;
}
不含新增代码的输出结果:
新增代码后的输出结果:
若删除 增1 的输出结果:
三、对链表的操作
3.1 增
有一个链表:1→2→3→4,我们想在3与4之间加入一个结点3.5,应该如何操作呢?
3.1.1 操作示意图
3.1.2 操作步骤
① 先将3.5结点指向4结点;
② 断开3结点与4结点之间的链接;
③ 将3结点指向3.5结点。
3.1.3 操作代码(示例)
3.5.next = 4;
3.next = 3.5;
3.2 删
3.2.1 删除最后一个节点
3.2.1.1 操作示意图
3.2.1.2 操作步骤
找到待删除节点的前一个节点并使其指向null即可。
3.2.1.3 操作代码(示例)
3.next = null;
3.2.2 删除其他节点
3.2.2.1 操作示意图
3.2.2.2 操作步骤
- 使待删除节点的前一个节点指向待删除节点的下一个结点即可。
- 🤔如何找到待删除节点的上一个节点呢?
- 比如这里我们要删除3这个节点,我们可以定义一个指针cur,遍历这个链表,判断cur指针指向的节点的next节点值是否为3,如果不是,继续遍历,如果是,则说明找到了待删除节点的上一个节点了,即可进行删除操作。
3.2.2.3 操作代码(示例)
2.next = 2.next.next;
3.3 改
3.3.1 操作示意图
3.3.2 操作步骤
- 我们这里需要将3节点替换为5节点,应该如何操作呢?
- ①先让5节点指向3节点的下一个节点
- ②让3节点的上一个节点指向5节点
3.3.3 操作代码(示例)
5.next = 3.next;
2.next = 5;
四、链表的典型应用场景
- 操作系统内的动态内存分配
- LRU缓存淘汰算法:LRU = Least Recently Used(近期最少使用)
五、链表的访问
5.1 环形链表
5.1.1 【LeetCode#141.环形链表】
5.1.1.1 问题
- 给定一个链表,判断链表中是否有环。
- 如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
- 如果链表中存在环,则返回 true 。 否则,返回 false 。
- 进阶:你能用O(1)(即常量)内存解决此问题吗?
示例 1:
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
示例 2:
输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。
提示:
- 链表中节点的数目范围是
[0, 104] -105 <= Node.val <= 105pos为-1或者链表中的一个 有效索引 。
5.1.1.2 解题思路
- 思路一: 哈希表。我们只需要依次遍历整个链表,并创建一个哈希表来存储遍历过的节点:在存储之前,先判断哈希表之后是否已经存在该节点,如果没有,则存入哈希表;当要存入的节点,已经存在于哈希表中,说明链表有环,遍历结束。如下图所示:
-
- 缺点:需要额外存储区。
- 总结:我们只需要遍历这个链表,在遍历的过程中记录我们遍历过的节点,如果遇到next节点为null的节点,说明没有环;如果遇到我们以前遍历过的节点,说明有环。
-
思路二: 快慢指针。定义两个指针,一个快指针,一个慢指针,快指针每次向前移动两步,慢指针每次向前移动一步,遍历链表。当快指针的next节点为null或者快指针本身节点为null时,说明该链表没有环,遍历结束;如果链表有环,那么快慢指针一定会相遇,指向同一个节点,当指向同一个节点时,遍历结束。
5.1.1.3 代码
- 快慢指针·C++解法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
bool hasCycle(ListNode *head) {
if(head == nullptr) return false;
ListNode *p = head, *q = head->next; // p - 慢指针, q - 快指针
while (p!= q && q && q->next){
p = p->next;
q = q->next->next;
}
return q && q->next;
}
};
- 快慢指针·JS解法
/**
* Definition for singly-linked list.
* function ListNode(val) {
* this.val = val;
* this.next = null;
* }
*/
/**
* @param {ListNode} head
* @return {boolean}
*/
// 后面写了三种解法,其实都可以,看大家哪种理解起来比较容易懂吧~
// 解法一
var hasCycle = function(head){
if (!head) return false;
let slow = head, fast = head;
while(fast && fast.next){
slow = slow.next;
fast = fast.next.next;
if(slow == fast) return true;
}
return false;
}
// 解法二
var hasCycle = function(head) {
if (!head) return false;
let slow = head, fast = head.next;
if(slow == fast) return true;
while (slow != fast && fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
if(slow == fast) return true;
}
return false;
};
// 解法三
var hasCycle = function(head) {
if (!head) return false;
let p = head, q = head.next;
while(q && p != q){
p = p.next;
q = q.next;
if(!q) return false;
q = q.next;
}
return q != null;
}
5.1.2 【LeetCode#142.环形链表Ⅱ】
5.1.2.1 问题
- 给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
- 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意,pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。
- 说明:不允许修改给定的链表。
- 进阶:你是否可以使用
O(1)空间解决此题?
示例1:
输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解释:链表中有一个环,其尾部连接到第二个节点。
5.1.2.2 解题思路
慢指针走过
a+b的距离,快指针走过a+n(b+c)+b的距离。由于快指针是慢指针的2倍,所以:2(a+b) = a+n(b+c)+b,而我们实际上并不用关心n是多少,有可能是10,也有可能是1,因此上述公式可以简化为:a = c。
- 先使用双指针判断该链表是否有环,并获得相遇点
- 将其中一个指针指向头结点,两个指针每次走一步,再次相遇点则为环起点。
5.1.2.3 代码
- C++解法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
if(head == nullptr) return nullptr;
ListNode *p = head, *q = head;
bool hasCycle = false;
while(q->next && q->next->next){
p = p->next;
q = q->next->next;
if(p == q){
hasCycle = true;
break;
}
}
if(hasCycle){
p = head;
while (p != q){
p = p->next;
q = q->next;
}
return q;
}
return nullptr;
}
};
- JS解法
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
* }
*/
function detectCycle(head: ListNode | null): ListNode | null {
let p1 = head;
let p2 = head;
let hasCycle = false;
while(p2 && p2.next && p2.next.next){
p1 = p1.next;
p2 = p2.next.next;
if(p1 == p2){
hasCycle = true;
break;
}
}
if(hasCycle){
p1 = head;
while(p1 && p1.next){
if(p1 == p2) return p1;
p1 = p1.next;
p2 = p2.next;
}
}
return null;
};
5.2 快乐数
5.2.1 【LeetCode#202.快乐数】
5.2.1.1 问题
- 编写一个算法来判断一个数 n 是不是快乐数。
- 「快乐数」 定义为:
-
- 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
- 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
- 如果这个过程 结果为 1,那么这个数就是快乐数。
- 如果 n 是 快乐数 就返回 true ;不是,则返回 false 。
示例 1:
输入:n = 19
输出:true
解释:
12 + 92 = 82
82 + 22 = 68
62 + 82 = 100
12 + 02 + 02 = 1
示例 2:
输入:n = 2
输出:false
提示:
1 <= n <= 231 - 1
5.2.1.2 解题思路
- 使用链表的思维(唯一指向的思维)来解题。
- 这个问题可以转换为链表是否有环的问题,如果遍历到某个节点为1(把1看做链表中的null),说明没环,就是快乐数;如果遍历到重复的节点值,说明有环,就不是快乐数。
- 既然是链表是否有环的问题,我们就可以使用快慢指针来解决。
5.2.1.3 代码
- C++ 解法
/*
* @lc app=leetcode.cn id=202 lang=cpp
*
* [202] 快乐数
*/
// @lc code=start
class Solution {
public:
int getNext(int x){
int result = 0;
while(x>0){
int a = x % 10;
result += a * a;
x = x / 10;
}
return result;
}
bool isHappy(int n) {
int slow = n, fast = n;
do{
slow = getNext(slow);
fast = getNext(getNext(fast));
}while(slow != fast && fast != 1);
return fast == 1;
}
};
- TS解法
/*
* @lc app=leetcode.cn id=202 lang=typescript
*
* [202] 快乐数
*/
// @lc code=start
function isHappy(n: number): boolean {
let slow = n, fast = getNext(n);
while (slow != fast && fast != 1){
slow = getNext(slow);
fast = getNext(getNext(fast));
}
return fast == 1;
};
function getNext(n: number): number
{
let result = 0;
while (n) {
let a = n % 10;
result += a * a;
n = Math.floor(n / 10); // Math.floor() 很重要!!!
}
return result;
}
5.2.1.4 课后思考
🤔 会不会出现这个链表太长,有上个几千、几万、几十万的链表单元,影响我们找不到结果呢?
六、链表的反转
6.1 反转链表
6.1.1 【LeetCode#206.反转链表】
6.1.1.1 问题
- 给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
示例 1:
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]
示例 2:
输入:head = [1,2]
输出:[2,1]
示例 3:
输入:head = []
输出:[]
提示:
- 链表中节点的数目范围是 [0, 5000]
- -5000 <= Node.val <= 5000
进阶:
- 链表可以选用迭代或递归方式完成反转。你能否用两种方法解决这道题?
6.1.1.2 解题思路
- 思路一
-
- 指针初始化:
-
-
- 定义指针pre(反转之后链表的头结点),pre指向空null;
- 定义指针cur(未反转链表的头结点),cur指向头结点head;
- 定义指针next(未反转链表头结点的下一位),next指向cur所指向节点的下一个节点;
-
-
- 开始操作:
-
-
- 先将cur指针所指向的节点指向pre指针所指向的节点;
- 然后移动指针pre到cur所在的位置,移动cur到next所在的位置;
- 将next指针指向cur指针所指向节点的下一节点;
- 当cur指针指向null的时候,就完成了整个链表的反转。
-
-
思路二 递归实现(基于递归的回溯过程)
6.1.1.3 代码
- 思路一·C++解法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if(head == nullptr) return nullptr;
ListNode *pre = nullptr, *cur = head, *p = head->next;
while(cur != nullptr){
cur->next = pre;
pre = cur;
(cur = p) && (p = p->next);
}
return pre;
}
};
- 思路一·TS解法
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
* }
*/
function reverseList(head: ListNode | null): ListNode | null {
if (!head) return null;
let pre = null, cur = head, p = head.next;
while (cur) {
cur.next = pre;
pre = cur;
(cur = p) && (p = p.next);
}
return pre;
};
- 思路二·C++解法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if(head == nullptr || head->next == nullptr) return head; // 链表为空或只有一个节点,不需要反转
ListNode *tail = head->next; // 先记录下一个节点的地址
ListNode *p = reverseList(head->next); // 对后部分链表进行反转,*p记录反转后头结点链表的地址
head->next = tail->next; // 就是让头节点先指向null
tail->next = head; // 把头结点放在反转后链表的尾部
return p;
}
};
6.1.1.4 扩展
- 反转链表的头n个结点:
#include <cstdlib>
#include <queue>
#include <stack>
#include <algorithm>
#include <string>
#include <map>
#include <set>
#include <vector>
using namespace std;
struct Node {
Node(int data) : data(data), next(NULL) {}
int data;
Node *next;
};
Node* reverseList(Node *head, int n){
if(n == 1) return head;
Node *tail = head->next;
Node *p = reverseList(head->next, n - 1);
head->next = tail->next;
tail->next = head;
return p;
}
int main(){
Node *head = NULL;
head = new Node(1);
head->next = new Node(2);
head->next->next = new Node(3);
head->next->next->next = new Node(4);
head = reverseList(head, 2);
Node *p = head;
while(p != NULL){
printf("%d->", p->data);
p = p->next;
}
printf("\n");
return 0;
}
输出结果:
6.1.2 【LeetCode#92.反转链表II】
6.1.2.1 问题
- 给你单链表的头指针 head 和两个整数 left 和 right ,其中 left <= right 。请你反转从位置 left 到位置 right 的链表节点,返回 反转后的链表 。
示例 1:
输入:head = [1,2,3,4,5], left = 2, right = 4
输出:[1,4,3,2,5]
示例 2:
输入:head = [5], left = 1, right = 1
输出:[5]
提示:
- 链表中节点数目为 n
- 1 <= n <= 500
- -500 <= Node.val <= 500
- 1 <= left <= right <= n
进阶:
- 你可以使用一趟扫描完成反转吗?
6.1.2.2 解题思路
我们在6.1.1.4中扩展了一个反转链表头N个结点的方法,那么这题我们就可以利用这个方法,首先找到待反转区域的起点,再反转待反转区域的前N个结点即可N = right - left + 1。
这边需要用到虚拟头结点,它是一个真实的链表结点,而不是一个指针。我们反转的操作需要站在待反转区域的前一位进行,比如:如果待反转区域包含链表的头结点,那么我们就需要站在虚拟头结点对链表进行操作。
6.1.2.3 代码
- C++解法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseN(ListNode *head, int n){
if(n == 1) return head;
ListNode *tail = head->next;
ListNode *p = reverseN(head->next, n - 1);
head->next = tail->next;
tail->next = head;
return p;
}
ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode ret(0, head), *p = &ret; // ret为虚拟头结点,指针p指向虚拟头结点
int cnt = right - left + 1;
while(--left) { // 让p指向待反转区域的前一个结点,即p需要走left - 1步,如果待反转区域的第一个结点为头结点,那么p不需要移动。
p = p->next;
}
p->next = reverseN(p->next, cnt); // 从p->next结点开始反转cnt个结点
return ret.next;
}
};