avatar
【LeetCode系列】链表讲解
🌏

【LeetCode系列】链表讲解

published_date
最新编辑 2024年12月11日
slug
链表是一种由节点组成的数据结构,分为单向链表、双向链表和循环链表。其优点包括便于插入和删除、动态长度和有效利用内存,但随机访问效率低且需要额外空间。链表在前端开发中应用于实现DOM结构、异步队列、LRU缓存等。处理链表时需注意指针丢失、越界和内存泄露等问题,使用哨兵节点可以简化操作。经典链表题目包括反转链表、合并有序链表等。
tags
LeetCode

概念

链表是一种常用的数据结构它是由一系列节点组成的,每个节点包含两部分:数据域和指针域。数据域存储节点的数据,指针域存储下一个节点的地址。

种类

链表可以分为单向链表、双向链表和循环链表三种。
  1. 单向链表:每节点只包含一个指针域,指向下一节点。
  1. 双向链表:每节点包含两个指针域,分别指向前一节点和后一节点。
  1. 循环链表:最后一节点的指针域指向第一个节点,形成一个环。

优点

  1. 插入和删除效率高:由于链表节点包含指针域,因此在链表中插入或删除元素只需要修改指针域的指向即可,不需要移动其他元素,因此效率很高。
  1. 动态维护:由于链表的内存空间是动态分配的,因此链表长度可以随时增加或减少,不受内存限制。
  1. 有效利用内存:由于链表节点是分散存储在内存中的,因此可以有效利用内存空间,不会造成内存碎片问题。

缺点

  1. 随机访问效率低:无法通过下标直接访问,需顺序遍历。
  1. 增加较多内存占用:每个节点需要额外的指针域,占用更多资源。

前端应用

在前端开发中,链表并不是最常用的数据结构之一,但是在一些特定的场景中,链表还是会被用到。下面列举一些常见的应用场景:
  1. DOM结构的实现:DOM树的结构就可以看做是一种树形的数据结构,而树的实现可以借助链表的方式来完成。
  1. 实现异步队列:JavaScript中的异步任务通常使用事件循环机制来处理,而事件循环队列就可以用链表来实现。
  1. 实现LRU缓存:LRU缓存算法中需要删除最近最少使用的数据,而这个可以通过链表的方式来实现,即将最近访问的元素插入到链表的头部,当需要删除元素时,删除链表尾部的元素即可。
  1. 实现动画效果:JavaScript中的动画效果可以通过设置定时器或者使用requestAnimationFrame来实现,而动画帧通常是通过链表的方式来实现。

难点

1. 理解链表的指向

就拿基础的单链表来说,每个节点有一个指向域(指针),指向下一个节点。
这个指向的作用是连接链表中的节点,使得节点之间可以通过指针相互访问和操作。
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针(指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量)
p→next = p→next→next ⇒ 指的是p结点的next 指针存储了p结点的下下结点的内存地址
这句话有点绕,可以这样理解这个过程:
  • 假设当前链表为 A->B->C,p节点是指向B的指针,p→next 指向B的下一个节点C,p→next→next 指向C的下一个节点(即下下个节点)。
  • 执行上述代码后,相当于将 p 的 next 指针直接指向了下下个节点,即跳过了 B 这个节点,此时链表变为 A->C,B 节点被删除了。

2. 注意指针丢失、指针越界、内存泄露

在链表中,每个节点的指针都是存储在内存中的,因此在使用指针时,需要注意空指针和野指针等安全问题
  • 指针丢失:一个指针指向了一个动态分配的内存块,但在后续的程序执行过程中,该指针的值被改变,无法再访问原本指向的内存块
  • 指针越界:访问链表中不存在的节点时,比如,访问链表中的第n个节点,但链表只有n-1个节点,这时程序就会试图访问不存在的节点,导致指针越界错误。这种错误通常会导致程序崩溃或异常结束。
  • 内存泄露:使用动态内存分配时,没有正确地释放已经分配的内存,导致这部分内存无法再被程序访问到,造成内存浪费和程序的不稳定性
假设我们要在p和b节点中插入x,常见的错误如下: p->next = x; // 将p的next指针指向x结点; x->next = p->next; // 将x的结点的next指针指向b结点; 第一步操作后p→next指针已经不再指向结点b,而是指向结点x 第二步代码相当于把x赋值给x→next,也就是自己指向自己,指针丢失了
  • 插入结点时要注意操作的顺序,先将结点x的next指针指向结点p,再把结点p的next指针指向x
  • 在链表中删除节点时,需要更新前一个节点的next指针,以使其指向被删除节点的下一个节点。如果没有更新指针,就会导致链表出现错误的指针引用,甚至出现内存泄漏
  • 在使用指针前将其初始化为空指针或有效指针
  • 在访问链表节点之前,应该先检查节点是否存在,是否已被释放

3. 利用哨兵

哨兵(Sentinel)是一个额外的节点,用于简化链表的操作。哨兵节点不存储数据,只起到占位符的作用,通常在链表头或尾部设置。
  • 在链表头部插入节点时,如果没有哨兵节点,需要考虑链表为空的情况,而哨兵节点可以避免这种情况的处理,因为哨兵节点永远存在,链表头指针指向哨兵节点的后继节点即可。
  • 在删除链表中的某个节点时,如果节点是第一个节点,需要特殊处理。而有了哨兵节点,就可以把第一个节点当做普通节点来处理,只需要把哨兵节点的后继节点删除即可。
💡
在使用哨兵节点时,需要保证哨兵节点的指针正确指向链表中的节点,否则会导致程序错误。同时,在遍历链表时,也需要注意跳过哨兵节点。

4. 边界问题

  • 空链表:即链表中没有任何结点。在对链表进行操作时,需要特别处理空链表的情况,否则可能会导致程序崩溃。
  • 只有一个结点的链表:如果链表中只有一个结点,需要特别注意处理这种情况,否则可能会导致出现指针丢失或者内存泄露等问题。
  • 头结点和尾结点的处理:在处理链表时,需要注意处理头结点和尾结点的情况。例如,在删除链表中的某个结点时,如果要删除的结点是头结点或者尾结点,需要特别注意处理,否则可能会出现指针丢失或者内存泄露等问题。
  • 特殊位置的处理:有些链表可能会存在一些特殊位置,例如倒数第 k 个结点,需要特别注意处理这些特殊位置的情况,否则可能会导致程序出错。

解题技巧

1. 明确链表题的本质

链表是一个节点相连的数据结构,每个节点包含两个部分:
  • 值(value)
  • 指针(next):指向下一个节点。
💡
理解链表操作的关键是:每次操作的目标是控制“指针”改变方向或继续遍历。
关键问题:
  • 需要操作节点的“值”还是操作节点的“指针”?
  • 需要修改链表结构还是只是遍历?

2.常见的链表赋值和头节点处理

每次写链表题目时,通常需要明确以下几个操作:
(1)头节点的处理
  • 如果链表给的是一个已有的头节点 head,直接用它进行操作即可
  • 如果链表需要你手动初始化,比如合并链表或构建链表:
    • 通常会创建一个虚拟头节点(dummy node),便于操作。例如:
    • let dummy = new ListNode(-1); // 创建虚拟头节点 let curr = dummy; // 初始化当前节点指针为 dummy
    • dummy.next 最终会指向结果链表的头节点
(2)遍历链表的基础模板
链表的遍历非常重要,通常写成以下形式:
let curr = head; // curr 指向当前节点,初始为 head while (curr !== null) { console.log(curr.val); // 处理当前节点值 curr = curr.next; // 移动到下一个节点 }
(3)改变链表结构
当需要插入、删除、反转节点时,操作的本质是修改节点的 next 指针。最常见的模板是:
let prev = null; // 反转链表时需要前一个节点 let curr = head; // 当前节点 while (curr !== null) { let nextTemp = curr.next; // 保存下一个节点 curr.next = prev; // 改变当前节点的指向 prev = curr; // 前进一步,更新 prev curr = nextTemp; // 前进一步,更新 curr }

3. 写链表题时的思考顺序

(1)题目目标是什么?
  • 是遍历链表?
  • 修改链表结构(如反转、合并、删除)?
  • 检测某些性质(如是否有环)?
(2)从哪里开始?
  • 如果需要操作头节点,明确是否需要处理特殊情况(例如链表为空)
  • 如果需要返回一个新链表,通常会创建一个虚拟头节点(dummy)
(3)需要什么指针?
  • 一个遍历指针(如 curr)负责移动
  • 需要辅助指针(如 prev 或 nextTemp)时,明确它的作用
(4)特殊边界情况?
  • 链表为空
  • 链表只有一个节点
  • 链表是偶数节点还是奇数节点(如找中点)

4. 总结:怎么找到下手点

  • 记住通用模板
    • 遍历链表
    • 改变节点指针的方向
  • 善用辅助节点:如虚拟头节点(dummy)
  • 对链表题多实践
    • 从简单的反转链表、合并链表开始。
    • 再挑战环形链表、LRU 缓存等复杂问题。
  • 分析题目:多画图
    • 动手画链表节点的变化,帮助理解操作。

leetcode链表经典题目

简单题

  • Reverse Linked List(反转链表)
  • Merge Two Sorted Lists(合并两个有序链表)
  • Remove Duplicates from Sorted List(删除排序链表中的重复元素)
  • Linked List Cycle(判断链表是否有环)

中高级题

  • Swap Nodes in Pairs(两两交换链表中的节点)
  • Reverse Nodes in k-Group(k 个一组翻转链表)
  • Reverse Linked List II(反转链表 II)
  • Reorder List(重排链表)

链表变形

  • Rotate List(旋转链表)
  • Partition List(分隔链表)
  • Odd Even Linked List(奇偶链表)

例题讲解

206题:反转链表

题目描述:
单链表的反转,是指将单链表中的每个节点的指针指向它的前一个节点。
最终的结果就是原来的尾节点变成了头节点,原来的头节点变成了尾节点
输入:head = [1,2,3,4,5] 输出:[5,4,3,2,1]
notion image
解题思路:
  • 初始化三个指针 prevcurtemp,其中 prev temp 都是 null,而 cur 是头节点
  • 遍历链表。每次将 cur 的指针指向 prev,然后将三个指针都向后移动一个节点
💡
具体来说,对于每个节点,我们先将它的 temp 指针指向它的前一个节点(先存起来),然后将三个指针都往后移动一个节点
  • 最后,prev 就是反转后的头节点,而 cur 是 null,即反转后的尾节点,输出prev
 
执行过程
假设我们有一个单链表:1 -> 2 -> 3 -> 4 -> 5,现在要对它进行反转
第一步
当前prev=null, cur = headhead→1,也就是cur→1,也就是cur.next=1
因为我们要进行反转,也就是1指向nul才对,这时候prev=nul
所以是cur.next=prev,也就是1→null
💡
在难点中说了我们要注意指针丢失,大家可以思考下,上面的步骤有什么不对吗?
没错,就是1→2的这段指针丢了,为什么呢?
1原本是指向2的,然后现在变成指向null了,那谁指向2呢?
所以,我们要将1→2这个指针先存起来,也就是
temp=cur.next.next // 1.next=2, 1=cur.next, 所以cur.next.next=2
第二步
将1指向null,也就是cur.next=prev
第三步
prev本来是指向null的,现在要将它往前一步,所以要指向head
看一下第一步,谁是head?是cur!!!也就是prev=cur
第四步
大家都往前走了一步,cur也要往前一步,本来cur.next是1,现在应该是2了
而2刚才在第一步的最后被我们存起来了,也就是temp,所以cur=temp
总结
所以,遍历的步骤就是
temp=cur.next.next
cur.next=prev
prev=cur
cur=temp
 
结束过程
一直走到最后,cur=null后就结束,也就是while(cur != null)
💡
注意点
这题如果要提升性能还要考虑边界问题,链表可能是空链表或者只有一个节点的链表
所以,在while的最前面,要做个判断
if (head == null || head.next == null) return head;
 
解法2
上面是迭代法,还可以用递归法,递归法的思路相对简单。
我们先将当前节点的下一个节点作为参数传递进去递归,然后再将当前节点的 next 指针指向前一个节点。
递归终止条件是当前节点为空或者当前节点的下一个节点为nul
💡
多画图,每一步往下走,配合图更容易理解
单链表的反转,是指将单链表中的每个节点的指针指向它的前一个节点。最终的结果就是原来的尾节点变成了头节点,原来的头节点变成了尾节点。
例如,假设我们有一个单链表:1 -> 2 -> 3 -> 4 -> 5,现在要对它进行反转。我们可以按照如下的方式进行:
  1. 初始化三个指针 prevcurnext,其中 prevnext 都是 null,而 cur 是头节点。
  1. 遍历链表,每次将 cur 的指针指向 prev,然后将三个指针都向后移动一个节点。具体来说,对于每个节点,我们先将它的 next 指针指向它的前一个节点,然后将三个指针都往后移动一个节点。这个过程可以用如下的伪代码来表示:
while (cur != null) { next = cur.next cur.next = prev prev = cur cur = next }
  1. 最后,prev 就是反转后的头节点,而 cur 是 null,即反转后的尾节点。
例如,对于链表 1 -> 2 -> 3 -> 4 -> 5,上述算法的执行过程如下所示:
prev cur next null 1 -> 2 -> 3 -> 4 -> 5 -> null | | | | | +---------+ | | | | | | | +-----+ | | | | | +----+ | | | +----+ prev cur next 1 <- 2 3 -> 4 -> 5 -> null | | | | | +---------+ | | | | | | | +-----+ | | | | | +----+ | prev cur next 1 <- 2 <- 3 4 -> 5 -> null | | | | | +---------+ | | | | | | | +-----+ | | | | | +----+ | prev cur next 1 <- 2 <- 3 <- 4 5 -> null | | | | +---------+ | | | | | +-----+ | | | +---------+ prev cur next 1 <- 2 <- 3 <- 4 <- 5 null | | | +---------+ | | | +----------+
/** * Definition for singly-linked list. * function ListNode(val, next) { * this.val = (val===undefined ? 0 : val) * this.next = (next===undefined ? null : next) * } */ /** * @param {ListNode} head * @return {ListNode} */ var reverseList = function(head) { if(head == null || head.next == null) return head let prev = null let curr = head while(curr !==null){ let temp = curr.next curr.next = prev prev = curr curr = temp } return prev };
 

关于我

My desire to practice my skills and share my acquired knowledge fuels my endeavors.

联系 : znjessie858@gmail.com

订阅

订阅我的文章,收到我的最新发布通知,可随时取消订阅!