算法与数据结构

链表与变体:单链、双链与哨兵

02-线性数据结构核心预计 20 分钟链表指针快慢指针
学习状态:

一句话定义

链表(Linked List)用「节点 + 指针」把元素串成链式存储:每个节点存数据与指向后继(双向链表还指向前驱)的引用,以放弃随机访问为代价,换取在已知位置上的 O(1) 插入与删除。

为什么重要

链表是与数组互补的另一极:所有「已知节点位置、要求 O(1) 摘除/嫁接」的场景(LRU 淘汰、任务队列、内存空闲链、邻接表)都靠它。同时它是指针操作的试金石——反转、找环、合并这些操作训练出的不变量思维,会直接迁移到树与图的操作里。

前置知识

kp-006(作为对照面);指针/引用的基本语义。

核心概念

  • 单链表:节点 = {值, next};只能单向前进;找前驱需从头 O(n)。
  • 双向链表:节点 = {prev, 值, next};已知节点可 O(1) 自我摘除,是 LRU 的标配。
  • 循环链表:尾指回头,适合环形调度(约瑟夫问题)。
  • 哨兵节点(Sentinel/Dummy):增设永不删除的头(尾)占位节点,统一「空表/头插/头删」的边界代码,消灭大部分特判。
  • 快慢指针:同速/倍速双指针一次遍历完成找中点、判环(Floyd 判圈)等任务。

直观类比

数组是连排座位,链表是寻宝游戏:每张卡片(节点)写着下一张卡片藏在哪。找到位置后插入新卡片只需改两张卡片的「下一站」,但想直接「跳到第 1000 张」只能一张张翻。

原理与机制

反转单链表的三指针迭代法(核心训练题):

prev ← null; cur ← head
当 cur ≠ null:
    nxt ← cur.next      // 先保住后继
    cur.next ← prev     // 反转当前指针
    prev ← cur; cur ← nxt
返回 prev                // prev 即新表头
不变量:prev 已反转完成;cur 待反转;nxt 未触碰。
时间 Θ(n),空间 O(1)。

操作代价表:

操作单链表双向链表
头插/头删O(1)O(1)
尾插(无尾指针)O(n)O(n),有尾指针 O(1)
已知节点摘除O(n)(需前驱)O(1)
按下标访问O(n)O(n)

图示

单链表:  head ─▶ [A|•]──▶ [B|•]──▶ [C|✕]
双向链表: ⇄ [A] ⇄ [B] ⇄ [C] ⇄(哨兵 D 双向环)
删除已知节点 X(双向): X.prev.next ← X.next ; X.next.prev ← X.prev

实例或案例

  • LRU 缓存:哈希表(kp-009)键→双向链表节点定位,链表按访问时间排序,命中/淘汰均 O(1)——两大结构互补的经典组合。
  • 操作系统的空闲内存块链、内存池的 free list 都是链表。
  • 多项式加法:按指数降序的链表合并,是「归并」思想的最小练习。
  • 约瑟夫问题:循环链表模拟报数出圈,或直接用数学递推 O(n)。

常见误区

  • 头插法建表后忘记新表头,或遍历中丢失 next 导致断链(必须先存后继再改指针)。
  • 认为「链表省内存」:每节点多一个指针(8 字节级)外加内存碎片与缓存劣势,小元素时总占用反而更高。
  • 用链表做按下标随机访问:O(n) 的访问让整体复杂度崩坏,该场景必须用数组。
  • 实现栈/队列时链表版忽视哨兵,边界特判写漏导致空表崩溃。

自测题

  1. 用快慢指针描述「找中间节点」的做法,并说明奇偶长度时的结果。

答案要点:慢走 1 快走 2,快到尾时慢在中点;偶数长度时慢落在后一半的第一个(约定可自定,说明清楚即可)。

  1. 只给你指向单链表中间某节点的指针 p,能否 O(1) 删除该节点(不含表尾)?

答案要点:把后继的值复制进 p,再摘除后继节点;表尾节点不可行(前驱无法 O(1) 获得)。

  1. 为什么 LRU 必须用双向链表而不是单链表?

答案要点:需在 O(1) 内删除任意已知节点(含尾部淘汰),单链表摘除需前驱,只能 O(n)。

与其他知识点的关系

kp-008 的栈队列可用链表实现;kp-009 哈希冲突的链地址法本质是链表;kp-016 图的邻接表同样以链式存储为骨架;树(kp-011)可以看作「每个节点可以有两个 next 的链表」。

延伸阅读

《数据结构与算法分析:C 语言描述》第 3 章;LeetCode 206(反转链表)、141(环形链表)题解对照本节三指针不变量。