目标:理解链表的内存模型,掌握指针操作技巧,熟练运用虚拟头节点、快慢指针、反转等核心模式解决复杂问题。
一、为什么需要链表?
1.1 数组的结构性缺陷
数组要求内存连续,导致插入和删除的代价与数据规模成正比:
python
arr = [1, 2, 3, 4, 5]
# 头部操作:O(n) ------ 所有元素移动
arr.insert(0, 0) # [0, 1, 2, 3, 4, 5]
arr.pop(0) # [1, 2, 3, 4, 5]
# 中间操作:O(n) ------ 后半部分元素移动
arr.insert(2, 99) # [1, 2, 99, 3, 4, 5]
核心矛盾:数组的物理连续性带来了随机访问的优势(O(1) 索引),但也导致了动态修改的劣势。
1.2 链表的解决思路
链表通过指针连接离散节点,将物理连续性要求转化为逻辑连续性:
数组(连续内存) 链表(离散内存)
┌───┬───┬───┬───┐ ┌─────┐ ┌─────┐ ┌─────┐
│ 1 │ 2 │ 3 │ 4 │ │ 1 │●│────→│ 2 │●│────→│ 3 │●│────→ None
└───┴───┴───┴───┘ └──┬──┘ └──┬──┘ └──┬──┘
插入需批量移动 插入只需修改指针
| 维度 | 数组 | 链表 |
|---|---|---|
| 内存布局 | 连续 | 离散 |
| 随机访问 | O(1) | O(n) |
| 头部插入/删除 | O(n) | O(1) |
| 中间插入/删除(已知前驱) | O(n) | O(1) |
| 缓存友好性 | 高(局部性) | 低(指针跳转) |
选择策略:
- 查询频繁、修改稀少 → 数组
- 修改频繁、无需随机访问 → 链表
- 需要兼顾 → 动态数组 (Python list)或 跳表
二、单向链表:从零实现
2.1 节点定义
链表的基本单元是节点,包含数据域和指针域:
python
class ListNode:
"""链表节点"""
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def __repr__(self):
return str(self.val)
2.2 核心操作
链表操作的本质是指针的重新指向。以下是最小可用实现:
python
class LinkedList:
"""单向链表(最小完整实现)"""
def __init__(self):
self.head = None
self._size = 0
def add_at_head(self, val) -> None:
"""头插:O(1)"""
self.head = ListNode(val, self.head)
self._size += 1
def add_at_tail(self, val) -> None:
"""尾插: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 delete_at_index(self, index: int) -> None:
"""删除第 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 to_list(self) -> list:
"""转换为 Python 列表"""
result = []
curr = self.head
while curr:
result.append(curr.val)
curr = curr.next
return result
# 验证
ll = LinkedList()
ll.add_at_tail(1)
ll.add_at_tail(2)
ll.add_at_tail(3)
ll.add_at_head(0)
print(ll.to_list()) # [0, 1, 2, 3]
ll.delete_at_index(1)
print(ll.to_list()) # [0, 2, 3]
三、链表算法的六大核心技巧
3.1 技巧一:虚拟头节点(Dummy Head)
问题:头节点没有前驱,插入/删除时需要特殊处理。
解决:引入虚拟头节点,统一所有位置的操作逻辑。
python
def remove_elements(head: ListNode, val: int) -> ListNode:
"""
删除链表中所有值为 val 的节点
使用虚拟头节点避免对头节点的特殊判断
"""
dummy = ListNode(0, head) # 虚拟头节点
prev = dummy
curr = head
while curr:
if curr.val == val:
prev.next = curr.next # 删除 curr
else:
prev = curr # prev 后移
curr = curr.next
return dummy.next # 返回真正的头节点
价值:将"头节点可能是目标"的边界情况转化为普通情况,代码更简洁,bug 更少。
3.2 技巧二:反转链表(三指针法)
迭代法:用三个指针依次后移,逐个反转指向。
python
def reverse_list(head: ListNode) -> ListNode:
"""
反转单向链表
时间:O(n),空间:O(1)
"""
prev = None
curr = head
while curr:
next_temp = curr.next # 暂存后继,防止断链
curr.next = prev # 反转指向
prev = curr # prev 前移
curr = next_temp # curr 前移
return prev # 新的头节点
执行过程 (1 → 2 → 3 → None):
| 步骤 | prev | curr | next_temp | 链表状态 |
|---|---|---|---|---|
| 初始 | None | 1 | 2 | 1 → 2 → 3 |
| 第1轮 | 1 | 2 | 3 | None ← 1 2 → 3 |
| 第2轮 | 2 | 3 | None | None ← 1 ← 2 3 |
| 第3轮 | 3 | None | --- | None ← 1 ← 2 ← 3 |
递归法(理解即可,面试推荐迭代法):
python
def reverse_list_recursive(head: ListNode) -> ListNode:
"""递归反转"""
if not head or not head.next:
return head
new_head = reverse_list_recursive(head.next)
head.next.next = head # 下一个节点指向自己
head.next = None # 断开原指向,防止环
return new_head
3.3 技巧三:快慢指针
检测环(Floyd 判圈算法)
python
def has_cycle(head: ListNode) -> bool:
"""
判断链表是否有环
时间:O(n),空间:O(1)
"""
if not head or not head.next:
return False
slow = head
fast = head.next # 快指针先走一步,避免初始相遇
while fast and fast.next:
if slow == fast:
return True
slow = slow.next # 慢:1步
fast = fast.next.next # 快:2步
return False
原理:有环时,快指针相对慢指针的速度为 1 步/轮,必然追上;无环时,快指针先到尾。
找环入口
python
def detect_cycle(head: ListNode) -> ListNode:
"""找到环的入口节点,无环返回 None"""
if not head or not head.next:
return None
# 阶段1:找相遇点
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
break
else:
return None # 无环
# 阶段2:找入口
# 数学证明:头到入口距离 = 相遇点到入口距离
ptr1 = head
ptr2 = slow
while ptr1 != ptr2:
ptr1 = ptr1.next
ptr2 = ptr2.next
return ptr1
找中点
python
def find_middle(head: ListNode) -> ListNode:
"""
找到链表中间节点
偶数长度时返回第二个中间节点
"""
slow = fast = head
while fast and fast.next:
slow = slow.next # 1步
fast = fast.next.next # 2步
return slow # fast 到尾时,slow 在中点
应用:归并排序的链表版、重排链表、判断回文链表。
3.4 技巧四:删除倒数第 N 个节点
双指针(一次遍历):
python
def remove_nth_from_end(head: ListNode, n: int) -> ListNode:
"""
删除倒数第 n 个节点
时间:O(n),空间:O(1)
"""
dummy = ListNode(0, head)
fast = slow = dummy
# fast 先走 n+1 步,保持 slow 在目标前驱
for _ in range(n + 1):
fast = fast.next
while fast:
fast = fast.next
slow = slow.next
slow.next = slow.next.next # 删除
return dummy.next
原理 :fast 与 slow 的距离恒为 n+1,当 fast 到尾(None)时,slow 恰好在倒数第 n+1 个节点(目标的前驱)。
3.5 技巧五:合并有序链表
python
def merge_two_lists(l1: ListNode, l2: ListNode) -> ListNode:
"""
合并两个升序链表
时间:O(n+m),空间:O(1)
"""
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
3.6 技巧六:重排链表(综合应用)
LeetCode 143 :将链表重新排列为 L0 → Ln → L1 → Ln-1 → L2 → Ln-2 → ...
python
def reorder_list(head: ListNode) -> None:
"""
重排链表
1. 找中点(快慢指针)
2. 反转后半部分
3. 交替合并
"""
if not head or not head.next:
return
# 步骤1:找中点
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# 步骤2:反转后半部分
prev = None
curr = slow.next
slow.next = None # 断开前后两部分
while curr:
next_temp = curr.next
curr.next = prev
prev = curr
curr = next_temp
# 步骤3:交替合并
first, second = head, prev
while second:
tmp1 = first.next
tmp2 = second.next
first.next = second
second.next = tmp1
first = tmp1
second = tmp2
四、双向链表与 LRU 缓存
4.1 双向链表实现
双向链表每个节点有前驱和后继指针,支持 O(1) 的任意位置删除:
python
class DListNode:
"""双向链表节点"""
def __init__(self, key=0, val=0):
self.key = key
self.val = val
self.prev = None
self.next = None
class DoublyLinkedList:
"""带头尾哨兵的双向链表"""
def __init__(self):
self.head = DListNode() # 头哨兵
self.tail = DListNode() # 尾哨兵
self.head.next = self.tail
self.tail.prev = self.head
self.size = 0
def add_to_head(self, node: DListNode) -> None:
"""头部添加:O(1)"""
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
self.size += 1
def remove(self, node: DListNode) -> None:
"""删除指定节点:O(1)(已知节点地址)"""
node.prev.next = node.next
node.next.prev = node.prev
self.size -= 1
def move_to_head(self, node: DListNode) -> None:
"""移到头部:O(1)"""
self.remove(node)
self.add_to_head(node)
def remove_tail(self) -> DListNode:
"""删除尾部:O(1)"""
node = self.tail.prev
self.remove(node)
return node
4.2 LRU 缓存(LeetCode 146)
设计目标 :get 和 put 操作均为 O(1)。
数据结构组合:
- 哈希表 :
key → node,O(1) 查找 - 双向链表:维护访问顺序,头部最近使用,尾部最久未使用
python
class LRUCache:
"""
LRU 缓存
get/put 时间复杂度:O(1)
"""
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = {} # key → node
self.dll = DoublyLinkedList()
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
self.dll.move_to_head(node) # 更新为最近使用
return node.val
def put(self, key: int, value: int) -> None:
if key in self.cache:
node = self.cache[key]
node.val = value
self.dll.move_to_head(node)
else:
if self.dll.size >= self.capacity:
tail = self.dll.remove_tail()
del self.cache[tail.key] # 淘汰最久未使用
new_node = DListNode(key, value)
self.cache[key] = new_node
self.dll.add_to_head(new_node)
# 验证
cache = LRUCache(2)
cache.put(1, 1)
cache.put(2, 2)
print(cache.get(1)) # 1(1变为最近使用)
cache.put(3, 3) # 淘汰 key=2
print(cache.get(2)) # -1(已被淘汰)
cache.put(4, 4) # 淘汰 key=1
print(cache.get(1)) # -1
print(cache.get(3)) # 3
print(cache.get(4)) # 4
五、链表 vs 其他数据结构
| 操作 | 单向链表 | 双向链表(带尾指针) | 动态数组 |
|---|---|---|---|
| 头部插入 | O(1) | O(1) | O(n) |
| 尾部插入 | O(n) | O(1) | O(1) 均摊 |
| 中间插入(已知前驱) | O(1) | O(1) | O(n) |
| 头部删除 | O(1) | O(1) | O(n) |
| 尾部删除 | O(n) | O(1) | O(1) |
| 随机访问 | O(n) | O(n) | O(1) |
| 内存开销 | 高(指针) | 更高(双指针) | 低(连续) |
六、链表问题决策树
链表问题类型判断:
├── 需要修改头节点? → 使用虚拟头节点
├── 需要找位置/中点/环? → 快慢指针
├── 需要反转部分/全部? → 三指针迭代反转
├── 需要删除倒数第N个? → 双指针(间距n+1)
├── 需要合并多个有序? → 虚拟头节点 + 多路归并
└── 需要O(1)的get/put且按访问排序? → 哈希表 + 双向链表
七、核心心法
链表问题的难点不在于逻辑,而在于指针操作的精确性:
1. 画图辅助
遇到复杂操作(如反转、合并),先在纸上画出指针变化,再写代码。
2. 边界检查清单
- 头节点为空?
- 单节点链表?
- 操作后是否断链?
- 返回值是否正确(dummy.next vs head)?
3. 虚拟头节点的条件反射
只要操作可能涉及头节点修改,立即使用 dummy head,省去 90% 的边界判断。
4. 快慢指针的条件反射
看到"找中点"、"判环"、"倒数第 K"等关键词,立即考虑快慢指针。
判断标准:当你能在看到链表题目时,10 秒内确定使用哪种技巧模式,并能在纸上准确画出指针变化------链表才真正成为你的本能。