苏菲的算法笔记——01.链表(上)

576 阅读11分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 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 <= 105
  • pos-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 解题思路

image.png 慢指针走过a+b的距离,快指针走过a+n(b+c)+b的距离。由于快指针是慢指针的2倍,所以:2(a+b) = a+n(b+c)+b,而我们实际上并不用关心n是多少,有可能是10,也有可能是1,因此上述公式可以简化为:a = c

  1. 先使用双指针判断该链表是否有环,并获得相遇点
  2. 将其中一个指针指向头结点,两个指针每次走一步,再次相遇点则为环起点。

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. 使用链表的思维(唯一指向的思维)来解题。
  2. 这个问题可以转换为链表是否有环的问题,如果遍历到某个节点为1(把1看做链表中的null),说明没环,就是快乐数;如果遍历到重复的节点值,说明有环,就不是快乐数。
  3. 既然是链表是否有环的问题,我们就可以使用快慢指针来解决。

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;
    }
};