链表(单向/双向/循环链表)
链式存储结构的原理与Python实现
一、数据结构概述
链表(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
curr = next_temp
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:
for _ in range(m - 2):
curr = 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
print(josephus(7, 3))
约瑟夫环的数学解:
约瑟夫环问题除了可以用循环链表模拟求解(时间复杂度 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 = {}
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)
p1 = Polynomial()
p1.add_term(3, 5)
p1.add_term(2, 3)
p1.add_term(1, 1)
p1.add_term(7, 0)
print(p1)
7.3 其他经典应用
- 操作系统进程调度: 使用循环链表实现时间片轮转调度算法(Round-Robin)
- 内存管理: 空闲链表(Free List)管理动态内存分配和回收
- 图的邻接表表示: 每个顶点的邻接点用链表存储
- 哈希表中的链地址法: 用链表解决哈希冲突
- 浏览器的前进后退: 双向链表管理访问历史,支持前/后向导航
- 音乐播放器播放列表: 循环链表实现循环播放
八、快慢指针技巧
快慢指针(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
fast = fast.next.next
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
for _ in range(n):
fast = fast.next
while fast.next:
fast = fast.next
slow = slow.next
slow.next = slow.next.next
return dummy.next
快慢指针总结:
- 检测环: 快指针走 2 步,慢指针走 1 步,相遇即有环
- 寻找环入口: 相遇后慢指针重置到头,同速再相遇即入口
- 找中间节点: 快指针到末尾时,慢指针即中点
- 找倒数第 N 个节点: 快指针先走 N 步,然后同速移动
- 判断链表是否相交: 分别遍历两个链表到末尾,若末尾相同则相交
九、核心要点总结
- 链表本质: 链式存储结构,通过指针将零散的内存块串联成逻辑上的线性序列,节点可动态分配和释放
- 单向链表: 每个节点持有 next 指针,只能单向遍历。插入和删除的关键是找到前驱节点
- 双向链表: 每个节点持有 prev 和 next 两个指针,支持双向遍历,已知节点删除可达 O(1),空间开销比单向多约 50%
- 循环链表: 尾节点指向头节点形成闭环,适合处理周期性数据(如约瑟夫环、时间片轮转)
- 时间复杂度核心: 链表的查找为 O(n),一旦找到位置,插入/删除为 O(1)。双向链表利用尾指针可使尾部操作降为 O(1)
- 链表 vs 数组: 数组随机访问 O(1),缓存友好;链表动态插入/删除 O(1),内存零散可用。无绝对优劣,取决于使用场景
- 虚拟头节点技巧: 在链表操作中使用 dummy 节点可以统一处理边界条件,是必须掌握的编码技巧
- 快慢指针: 解决环检测、环入口、中点、倒数第 N 个节点等问题的最优方案,时间复杂度 O(n),空间复杂度 O(1)
- LRU 缓存: 双向链表 + 哈希表是实现 O(1) 级缓存操作的标准范式,面试高频考点
- 反转链表: 三指针迭代法是基础,递归法更简洁但空间开销大,需要做到手写无误
十、进一步思考
链表作为最基础的动态数据结构,其设计思想贯穿于计算机科学的各个领域。深入理解链表的本质,对于学习更复杂的数据结构(如树、图、哈希表)有着重要的奠基作用。
进阶学习方向:
- 跳表(Skip List): 在链表基础上增加多层索引,将查找时间复杂度优化到 O(log n),是 Redis 有序集合的核心实现
- 块状链表(Unrolled Linked List): 每个节点存储一个数组块,结合数组和链表的优势,改善缓存局部性
- 十字链表(Orthogonal Linked List): 用于稀疏矩阵存储,每个节点同时属于行链表和列链表
- 自组织链表(Self-organizing List): 根据访问频率动态调整节点位置(前移法、换位法、计数法)
- 多级反馈队列(MLFQ): 操作系统中使用多级链表队列实现进程调度
经典练习题(LeetCode):
- 206. 反转链表 —— 迭代和递归两种方法都要掌握
- 141. 环形链表 —— 快慢指针基础题
- 142. 环形链表 II —— 找到环的入口
- 21. 合并两个有序链表 —— 虚拟头节点的经典应用
- 19. 删除链表的倒数第 N 个节点 —— 快慢指针应用
- 876. 链表的中间结点 —— 快慢指针应用
- 146. LRU 缓存 —— 面试高频题,手写实现
- 23. 合并 K 个升序链表 —— 分治 + 合并
- 25. K 个一组翻转链表 —— 困难题,综合考察链表操作
- 160. 相交链表 —— 双指针技巧的另一种应用
学习链表的关键在于理解指针的运作方式,并在纸上画图模拟操作过程。建议初学者在编写代码前,先用纸笔画出链表的初始状态、目标状态以及指针的变化步骤,这样可以有效减少 bug 的产生。对于 Python 学习者来说,虽然 Python 中的变量本质上是引用(类似于指针),但理解底层的内存模型对于写出正确的链表代码至关重要。