链表(单向/双向/循环链表)

链式存储结构的原理与Python实现

核心主题: 链表数据结构(Linked List)的三种主要形式及其算法实现

主要内容: 单向链表的节点定义与基本操作(增删改查、反转、合并、检测环)、双向链表的prev指针机制、循环链表与约瑟夫环、时间复杂度分析、与数组的全面对比、经典应用场景(LRU缓存、多项式表示)、快慢指针技巧

关键词: 链表, 单向链表, 双向链表, 循环链表, 快慢指针, 反转链表, 合并链表, 约瑟夫环, LRU缓存

一、数据结构概述

链表(Linked List)是一种链式存储结构,它采用一组地址任意的存储单元来存储数据元素,通过指针将各个节点串联起来。与数组的连续内存分配不同,链表的每个节点除了存储数据本身外,还需要额外存储指向下一个(和上一个)节点的指针信息。

核心概念:

  • 节点(Node): 链表的基本组成单元,包含数据域和指针域
  • 头指针(Head): 指向链表第一个节点的指针,是访问整个链表的入口
  • 尾节点(Tail): 最后一个节点,其指针域为 None(或指向头节点形成循环)
  • 链式存储: 节点之间的逻辑顺序通过指针链接实现,不要求物理存储连续

链表的核心优势在于插入和删除操作的高效性——在已知位置进行插入或删除只需 O(1) 时间,而不需要像数组那样移动大量元素。但代价是随机访问性能较差,查找某个元素需要从头遍历,时间复杂度为 O(n)。

链表的三种基本形态

  • 单向链表(Singly Linked List): 每个节点只包含指向下一个节点的指针,只能单向遍历
  • 双向链表(Doubly Linked List): 每个节点包含指向前一个和后一个节点的两个指针,可双向遍历
  • 循环链表(Circular Linked List): 尾节点指向头节点形成环形结构,分为单向循环和双向循环
单向链表结构示意图:
data | next --> data | next --> data | next --> None
双向链表结构示意图:
None <--> prev | data | next <--> prev | data | next <--> None

二、单向链表(Singly Linked List)

2.1 节点定义

单向链表的每个节点包含两个部分:存储数据的数据域和存储下一个节点地址的指针域(通常命名为 next)。

class ListNode: """单向链表节点""" def __init__(self, val=0, next=None): self.val = val # 数据域 self.next = next # 指针域,指向下一个节点 class LinkedList: """单向链表类(含头节点)""" def __init__(self): self.head = None # 头指针,初始为空 self.size = 0 # 链表长度

2.2 基本操作——增删改查

(1)遍历与查找

链表的遍历从 head 开始,沿着 next 指针依次访问每个节点,直到遇到 None 为止。

def traverse(self): """遍历链表,返回所有节点值组成的列表""" values = [] curr = self.head while curr: values.append(curr.val) curr = curr.next return values def get(self, index): """获取指定索引处的节点值(0-based)""" if index < 0 or index >= self.size: raise IndexError("Index out of range") curr = self.head for _ in range(index): curr = curr.next return curr.val def find(self, target): """查找第一个值为 target 的节点索引,未找到返回 -1""" curr = self.head index = 0 while curr: if curr.val == target: return index curr = curr.next index += 1 return -1

(2)插入操作

插入操作有三种常见场景:头插法(在链表头部插入)、尾插法(在链表尾部插入)和指定位置插入。核心原理是修改相邻节点的指针指向。

def insert_at_head(self, val): """在链表头部插入新节点 O(1)""" new_node = ListNode(val) new_node.next = self.head self.head = new_node self.size += 1 def insert_at_tail(self, val): """在链表尾部插入新节点 O(n)""" new_node = ListNode(val) if not self.head: self.head = new_node else: curr = self.head while curr.next: curr = curr.next curr.next = new_node self.size += 1 def insert_at_index(self, index, val): """在指定索引处插入节点 O(n)""" if index < 0 or index > self.size: raise IndexError("Index out of range") if index == 0: self.insert_at_head(val) return new_node = ListNode(val) prev = self.head for _ in range(index - 1): prev = prev.next new_node.next = prev.next prev.next = new_node self.size += 1

(3)删除操作

删除操作需要找到待删除节点的前驱节点,将其 next 指针指向待删除节点的下一个节点。这是删除操作的核心要点——必须通过前驱节点来完成。

def delete_at_index(self, index): """删除指定索引处的节点 O(n)""" if index < 0 or index >= self.size: raise IndexError("Index out of range") if index == 0: self.head = self.head.next else: prev = self.head for _ in range(index - 1): prev = prev.next prev.next = prev.next.next self.size -= 1 def delete_by_value(self, val): """删除第一个值为 val 的节点""" if not self.head: return if self.head.val == val: self.head = self.head.next self.size -= 1 return curr = self.head while curr.next: if curr.next.val == val: curr.next = curr.next.next self.size -= 1 return curr = curr.next

2.3 反转链表

反转链表是面试中最经典的链表题目之一。核心思路是使用三个指针(prev、curr、next_temp),逐个改变节点的指向方向。迭代法的空间复杂度为 O(1),递归法则为 O(n)。

方法一:迭代法(三指针)

def reverse_iterative(self): """迭代反转链表 O(n) 空间 O(1)""" prev = None curr = self.head while curr: next_temp = curr.next # 保存下一个节点 curr.next = prev # 当前节点指向前一个节点 prev = curr # prev 前移 curr = next_temp # curr 后移 self.head = prev return self.head
反转过程演示(三个指针的移动):
初始: prev=None, curr->1, next_temp->2  ==>  1.next=None  ==>  prev->1, curr->2
第2步: curr->2, 2.next=1  ==>  prev->2, curr->3
第3步: curr->3, 3.next=2  ==>  prev->3, curr->None
结果: None<-1<-2<-3

方法二:递归法

def reverse_recursive(self, head): """递归反转链表 O(n) 空间 O(n)""" if not head or not head.next: return head new_head = self.reverse_recursive(head.next) head.next.next = head # 让下一个节点指回当前节点 head.next = None # 断开当前节点的原指向 return new_head

2.4 合并两个有序链表

将两个升序链表合并为一个新的升序链表,同样可以采用迭代法和递归法两种方式实现。

def merge_two_lists_iterative(l1, l2): """迭代合并两个有序链表(使用虚拟头节点)""" dummy = ListNode(0) # 虚拟头节点,简化边界处理 curr = dummy while l1 and l2: if l1.val <= l2.val: curr.next = l1 l1 = l1.next else: curr.next = l2 l2 = l2.next curr = curr.next # 将剩余部分直接链接 curr.next = l1 if l1 else l2 return dummy.next def merge_two_lists_recursive(l1, l2): """递归合并两个有序链表""" if not l1: return l2 if not l2: return l1 if l1.val <= l2.val: l1.next = merge_two_lists_recursive(l1.next, l2) return l1 else: l2.next = merge_two_lists_recursive(l1, l2.next) return l2

合并两个有序链表的时间复杂度为 O(m+n),其中 m 和 n 分别是两个链表的长度。迭代法的空间复杂度为 O(1),递归法为 O(m+n)。

虚拟头节点技巧(Dummy Node):

在链表操作中,创建一个虚拟头节点(dummy node)可以大大简化边界条件的处理。例如在合并链表时,使用 dummy 节点可以避免单独处理第一个节点的特殊情况;在删除节点时,使用 dummy 节点可以统一处理头节点和非头节点的删除逻辑。

三、双向链表(Doubly Linked List)

3.1 节点定义

双向链表的每个节点包含三个部分:数据域前驱指针 prev后继指针 next。这一结构赋予了链表双向遍历的能力。

class DListNode: """双向链表节点""" def __init__(self, val=0, prev=None, next=None): self.val = val self.prev = prev # 前驱指针 self.next = next # 后继指针 class DoublyLinkedList: def __init__(self): self.head = None self.tail = None # 尾指针,便于尾部操作 self.size = 0

3.2 双向链表的插入与删除

双向链表由于持有前驱指针,插入和删除操作比单向链表更加灵活,不需要像单向链表那样额外维护前驱节点。

def insert_at_head(self, val): """头部插入 O(1)""" new_node = DListNode(val) if not self.head: self.head = self.tail = new_node else: new_node.next = self.head self.head.prev = new_node self.head = new_node self.size += 1 def insert_at_tail(self, val): """尾部插入 O(1)(利用 tail 指针)""" new_node = DListNode(val) if not self.tail: self.head = self.tail = new_node else: new_node.prev = self.tail self.tail.next = new_node self.tail = new_node self.size += 1 def delete_node(self, node): """删除指定节点 O(1)(已知节点引用时)""" if node.prev: node.prev.next = node.next else: self.head = node.next if node.next: node.next.prev = node.prev else: self.tail = node.prev self.size -= 1 def traverse_forward(self): """正向遍历""" values = [] curr = self.head while curr: values.append(curr.val) curr = curr.next return values def traverse_backward(self): """反向遍历(双向链表的独特优势)""" values = [] curr = self.tail while curr: values.append(curr.val) curr = curr.prev return values

双向链表 vs 单向链表:

  • 正向遍历:单向和双向均可 O(n)
  • 反向遍历:仅双向可 O(n),单向需先反转(O(n))或使用额外存储
  • 已知节点删除:双向 O(1),单向仍需 O(n) 找前驱
  • 尾部插入:双向 O(1)(有 tail 指针),单向 O(n)
  • 空间开销:双向每个节点多一个指针,约多出 50% 的指针存储

四、循环链表(Circular Linked List)

4.1 基本概念

循环链表是一种特殊形式的链表,其尾节点的 next 指针指向头节点,从而形成一个环状结构。循环链表可以是单向的,也可以是双向的。循环链表没有严格的"头"和"尾"之分,但从任意节点出发都能遍历整个链表。

单向循环链表结构:
1 --> 2 --> 3 --> 4 --> ↑____________________________________|
class CircularLinkedList: def __init__(self): self.head = None self.size = 0 def insert(self, val): """在循环链表尾部插入新节点""" new_node = ListNode(val) if not self.head: self.head = new_node new_node.next = self.head # 指向自身,形成循环 else: curr = self.head while curr.next != self.head: curr = curr.next curr.next = new_node new_node.next = self.head self.size += 1 def traverse(self, max_steps=None): """遍历循环链表(可指定最大步数以防止无限循环)""" values = [] if not self.head: return values curr = self.head steps = 0 while True: values.append(curr.val) steps += 1 if max_steps and steps >= max_steps: break curr = curr.next if curr == self.head: break return values

4.2 约瑟夫环问题(Josephus Problem)

约瑟夫环是循环链表最经典的应用问题之一。问题描述:N 个人围成一圈,从第一个人开始报数,数到 M 的人出列,然后下一个人重新从 1 开始报数,直到所有人都出列。求最后剩下的人的原始编号。

def josephus(n, m): """ 约瑟夫环问题求解 n: 总人数 m: 报数到 m 的人出列 返回: 出列顺序列表 """ # 构建循环链表 head = ListNode(1) prev = head for i in range(2, n + 1): prev.next = ListNode(i) prev = prev.next prev.next = head # 形成循环 # 开始游戏 result = [] curr = head while curr.next != curr: # 只剩一个节点时停止 # 报数到 m-1,停在待删除节点的前驱 for _ in range(m - 2): curr = curr.next # curr.next 是要删除的节点 to_remove = curr.next result.append(to_remove.val) curr.next = to_remove.next # 删除节点 curr = curr.next # 从下一个节点继续报数 result.append(curr.val) # 最后一人 return result # 测试:n=7, m=3 print(josephus(7, 3)) # 输出: [3, 6, 2, 7, 5, 1, 4] # 最后幸存者编号: 4

约瑟夫环的数学解:

约瑟夫环问题除了可以用循环链表模拟求解(时间复杂度 O(n*m)),还存在数学递推公式:f(1) = 0;f(i) = (f(i-1) + m) % i,其中 f(i) 表示 i 个人时幸存者的索引(0-based)。最终幸存者编号为 f(n) + 1。数学解法的时间复杂度为 O(n)。

五、链表常见操作与时间复杂度

操作 单向链表 双向链表 说明
访问头节点 O(1) O(1) 通过 head 指针直接访问
访问尾节点 O(n) O(1) 单向需全部遍历;双向有 tail 指针
随机访问(索引) O(n) O(n) 必须从头(或结尾)遍历
头部插入 O(1) O(1) 修改头指针即可
尾部插入 O(n) O(1) 单向需遍历到尾;双向有 tail 指针
中间插入 O(n) O(n) 主要耗时在查找插入位置
已知节点删除 O(n) O(1) 单向需找前驱;双向直接有 prev 指针
按值删除 O(n) O(n) 主要耗时在查找节点
遍历 O(n) O(n) 沿 next 指针单向或双向移动
反转 O(n) O(n) 交换指针方向

时间复杂度分析要点:

  • 链表的操作复杂度主要受限于查找——一旦找到目标位置,插入和删除本身是 O(1) 的
  • 双向链表尾部操作的 O(1) 依赖于 tail 指针的维护,需要保证增删操作同时更新 tail
  • 空间复杂度: 单向链表每个节点额外存储一个指针(约多 8 字节),双向链表多两个指针(约多 16 字节)
  • 链表的空间局部性较差,节点分散存储可能导致 CPU 缓存命中率低于数组

六、链表与数组的全面对比

对比维度 数组(Array) 链表(Linked List)
存储方式 连续内存空间 非连续,通过指针链接
随机访问 O(1) O(n)
头部插入/删除 O(n) O(1)
尾部插入/删除 O(1)(均摊) O(1)(双向)/O(n)(单向)
中间插入/删除 O(n) O(n)
空间开销 仅数据,无额外指针 额外指针存储(单向 1 指针/节点,双向 2 指针/节点)
内存碎片 需要连续大块内存 可利用零散内存
缓存局部性 优秀(连续内存,CPU 缓存友好) 较差(节点分散,缓存不友好)
动态扩容 需复制迁移(均摊 O(1)) 天然动态,无需整体迁移
空间预分配 需预先分配容量(可能浪费或不足) 按需分配,无浪费

选择原则

何时使用数组:

  • 需要频繁随机访问(通过索引读取元素)
  • 已知数据规模,且不会频繁在中间位置插入/删除
  • 对 CPU 缓存性能敏感的高性能场景
  • 数据规模较小,且操作简单

何时使用链表:

  • 需要频繁在头部或中间位置插入/删除元素
  • 数据规模动态变化,难以预估最大容量
  • 内存碎片化严重,难以分配大块连续内存
  • 无需随机访问,始终顺序遍历即可

七、链表的经典应用

7.1 LRU 缓存淘汰算法

LRU(Least Recently Used,最近最少使用)缓存的核心思想是:当缓存容量满时,淘汰最久未使用的数据。使用双向链表 + 哈希表可以高效实现 LRU 缓存——哈希表提供 O(1) 的查找,双向链表提供 O(1) 的移动和删除操作。

class LRUCache: """LRU 缓存:双向链表 + 哈希表实现 O(1) 操作""" def __init__(self, capacity): self.capacity = capacity self.cache = {} # key -> node 映射 self.head = DListNode(0) # 虚拟头节点 self.tail = DListNode(0) # 虚拟尾节点 self.head.next = self.tail self.tail.prev = self.head def _remove_node(self, node): """从链表中移除节点""" node.prev.next = node.next node.next.prev = node.prev def _add_to_head(self, node): """将节点插入到头部(最近使用)""" node.next = self.head.next node.prev = self.head self.head.next.prev = node self.head.next = node def get(self, key): """获取值,并将该节点移到头部 O(1)""" if key not in self.cache: return -1 node = self.cache[key] self._remove_node(node) self._add_to_head(node) return node.val def put(self, key, value): """插入/更新值 O(1)""" if key in self.cache: node = self.cache[key] node.val = value self._remove_node(node) self._add_to_head(node) else: if len(self.cache) >= self.capacity: # 淘汰尾节点(最久未使用) lru = self.tail.prev self._remove_node(lru) del self.cache[lru.key] new_node = DListNode(value) new_node.key = key self.cache[key] = new_node self._add_to_head(new_node)

7.2 多项式表示与运算

链表非常适合表示稀疏多项式。多项式的每一项可表示为一个节点(系数, 指数),指数非零的项才存储,节省空间。

class PolyTerm: """多项式项节点""" def __init__(self, coef, exp): self.coef = coef # 系数 self.exp = exp # 指数 self.next = None class Polynomial: """多项式:链表表示(按指数降序排列)""" def __init__(self): self.head = None def add_term(self, coef, exp): """添加一项,合并同指数项""" if coef == 0: return # 处理插入或合并 if not self.head or exp > self.head.exp: new_node = PolyTerm(coef, exp) new_node.next = self.head self.head = new_node return curr = self.head while curr.next and curr.next.exp > exp: curr = curr.next if curr.exp == exp: curr.coef += coef else: new_node = PolyTerm(coef, exp) new_node.next = curr.next curr.next = new_node def add(self, other): """多项式加法""" result = Polynomial() p, q = self.head, other.head while p and q: if p.exp > q.exp: result.add_term(p.coef, p.exp) p = p.next elif p.exp < q.exp: result.add_term(q.coef, q.exp) q = q.next else: result.add_term(p.coef + q.coef, p.exp) p, q = p.next, q.next while p: result.add_term(p.coef, p.exp) p = p.next while q: result.add_term(q.coef, q.exp) q = q.next return result def __str__(self): terms = [] curr = self.head while curr: terms.append(f"{curr.coef}x^{curr.exp}") curr = curr.next return " + ".join(terms) # 示例:3x^5 + 2x^3 + x + 7 p1 = Polynomial() p1.add_term(3, 5) p1.add_term(2, 3) p1.add_term(1, 1) p1.add_term(7, 0) print(p1) # 输出: 3x^5 + 2x^3 + 1x^1 + 7x^0

7.3 其他经典应用

八、快慢指针技巧

快慢指针(Fast & Slow Pointers)是链表问题中最重要、最常用的技巧之一。其核心思想是使用两个速度不同的指针遍历链表,利用速度差来解决问题。快指针每次移动两步,慢指针每次移动一步。

8.1 检测链表中是否有环

弗洛伊德判圈算法(Floyd's Cycle Detection):如果链表有环,快指针最终会追上慢指针(在环中相遇)。

def has_cycle(head): """ 检测链表是否有环 使用快慢指针,如果相遇则有环 时间复杂度 O(n),空间复杂度 O(1) """ if not head or not head.next: return False slow = fast = head while fast and fast.next: slow = slow.next # 慢指针走 1 步 fast = fast.next.next # 快指针走 2 步 if slow == fast: # 相遇即存在环 return True return False

8.2 寻找环的入口

在检测到环的基础上,当快慢指针第一次相遇后,将其中一个指针重置到 head,然后两者以相同速度移动(每次一步),再次相遇的位置就是环的入口。

def detect_cycle_entry(head): """ 寻找链表中环的入口节点 1. 快慢指针相遇确定有环 2. 慢指针重置到 head 3. 两指针同步前进,相遇处即环入口 """ if not head or not head.next: return None slow = fast = head has_cycle_flag = False while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: has_cycle_flag = True break if not has_cycle_flag: return None # 慢指针重置到头节点 slow = head while slow != fast: slow = slow.next fast = fast.next return slow # 环的入口节点

数学原理:

设链表中非环部分长度为 a,环长度为 b。当快慢指针相遇时,慢指针走了 s 步,快指针走了 2s 步,且满足 2s - s = kb(快指针比慢指针多走了 k 圈),即 s = kb。此时将慢指针重置到 head,两者同时以相同速度前进,慢指针走 a 步到达环入口,快指针从相遇点也走 a 步,位置为 kb + a = a + kb,正好也是环入口。两者相遇在环入口。

8.3 寻找链表的中间节点

def find_middle(head): """ 寻找链表的中间节点 快指针到末尾时,慢指针恰好在中点 如果节点数为偶数,返回第二个中间节点 """ if not head: return None slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next return slow

8.4 删除链表的倒数第 N 个节点

使用快慢指针,快指针先走 N 步,然后慢指针和快指针同步移动,当快指针到达末尾时,慢指针恰好在倒数第 N 个节点的前驱位置。

def remove_nth_from_end(head, n): """删除链表的倒数第 n 个节点""" dummy = ListNode(0) dummy.next = head fast = slow = dummy # 快指针先走 n 步 for _ in range(n): fast = fast.next # 同步移动,直到快指针到达末尾 while fast.next: fast = fast.next slow = slow.next # slow 指向待删除节点的前驱 slow.next = slow.next.next return dummy.next

快慢指针总结:

  • 检测环: 快指针走 2 步,慢指针走 1 步,相遇即有环
  • 寻找环入口: 相遇后慢指针重置到头,同速再相遇即入口
  • 找中间节点: 快指针到末尾时,慢指针即中点
  • 找倒数第 N 个节点: 快指针先走 N 步,然后同速移动
  • 判断链表是否相交: 分别遍历两个链表到末尾,若末尾相同则相交

九、核心要点总结

十、进一步思考

链表作为最基础的动态数据结构,其设计思想贯穿于计算机科学的各个领域。深入理解链表的本质,对于学习更复杂的数据结构(如树、图、哈希表)有着重要的奠基作用。

进阶学习方向:

  • 跳表(Skip List): 在链表基础上增加多层索引,将查找时间复杂度优化到 O(log n),是 Redis 有序集合的核心实现
  • 块状链表(Unrolled Linked List): 每个节点存储一个数组块,结合数组和链表的优势,改善缓存局部性
  • 十字链表(Orthogonal Linked List): 用于稀疏矩阵存储,每个节点同时属于行链表和列链表
  • 自组织链表(Self-organizing List): 根据访问频率动态调整节点位置(前移法、换位法、计数法)
  • 多级反馈队列(MLFQ): 操作系统中使用多级链表队列实现进程调度

经典练习题(LeetCode):

  1. 206. 反转链表 —— 迭代和递归两种方法都要掌握
  2. 141. 环形链表 —— 快慢指针基础题
  3. 142. 环形链表 II —— 找到环的入口
  4. 21. 合并两个有序链表 —— 虚拟头节点的经典应用
  5. 19. 删除链表的倒数第 N 个节点 —— 快慢指针应用
  6. 876. 链表的中间结点 —— 快慢指针应用
  7. 146. LRU 缓存 —— 面试高频题,手写实现
  8. 23. 合并 K 个升序链表 —— 分治 + 合并
  9. 25. K 个一组翻转链表 —— 困难题,综合考察链表操作
  10. 160. 相交链表 —— 双指针技巧的另一种应用

学习链表的关键在于理解指针的运作方式,并在纸上画图模拟操作过程。建议初学者在编写代码前,先用纸笔画出链表的初始状态、目标状态以及指针的变化步骤,这样可以有效减少 bug 的产生。对于 Python 学习者来说,虽然 Python 中的变量本质上是引用(类似于指针),但理解底层的内存模型对于写出正确的链表代码至关重要。