第 4 章

链表:指针的艺术

第四章:链表 — 指针的艺术

你有一排学生站队。数组的方式是:所有人站在一块连续的空地上,按编号排好。如果要在中间插一个人,后面所有人都得往后挪一步。链表的方式是:每个人手里拿着一张纸条,上面写着下一个人站在哪儿。要插人?只需要改两张纸条就行了。

这个比喻揭示了链表最本质的特征:逻辑顺序和物理存储位置完全解耦。节点可以散落在内存的任何角落,仅靠指针(引用)维系彼此的关系。这赋予了链表极大的灵活性,但也付出了代价——你再也不能"算出"第 k 个元素在哪儿,只能从头一个一个跟着指针走。


Level 1 · 你需要知道的

4.1 单链表:最简单的链式结构

4.1.1 节点定义

链表的基本构建块是节点(Node)。每个节点包含两部分:存储的数据和指向下一个节点的引用。

class ListNode:
    """单链表节点"""
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next  # 指向下一个节点,None 表示链表末尾

这个定义看似简单,却蕴含了一个重要的计算机科学概念:自引用数据结构(self-referential data structure)。节点的类型定义中引用了自身类型,这使得我们可以用有限的代码描述无限长的结构。

WHY — 为什么用类而不是元组/字典? 在 Python 中,你当然可以用 (val, next_ref) 元组或字典来模拟链表。但类的好处是:(1) 属性访问 node.next 比字典查找 node['next'] 快(CPython 中属性访问走 __dict__ 哈希表或 __slots__,而且 IDE 能自动补全);(2) 可以添加 __repr__ 方便调试;(3) LeetCode/面试中约定俗成使用类。

4.1.2 构建链表

def build_linked_list(values):
    """从列表构建链表,返回头节点"""
    if not values:
        return None
    head = ListNode(values[0])
    current = head
    for val in values[1:]:
        current.next = ListNode(val)
        current = current.next
    return head

# 示例:1 -> 2 -> 3 -> None
head = build_linked_list([1, 2, 3])

4.1.3 遍历

def traverse(head):
    """遍历链表并打印每个节点"""
    current = head
    while current:
        print(current.val, end=" -> ")
        current = current.next
    print("None")

遍历的时间复杂度是 O(n),空间复杂度是 O(1)。这是链表操作中最基本的模式——用一个指针从头走到尾。

4.1.4 插入操作

链表插入有三种情况:头部插入、尾部插入、中间插入。

def insert_at_head(head, val):
    """在链表头部插入新节点 — O(1)"""
    new_node = ListNode(val)
    new_node.next = head
    return new_node  # 新节点成为新的头

def insert_at_tail(head, val):
    """在链表尾部插入新节点 — O(n)"""
    new_node = ListNode(val)
    if not head:
        return new_node
    current = head
    while current.next:
        current = current.next
    current.next = new_node
    return head

def insert_after(node, val):
    """在指定节点后面插入 — O(1)(假设已有指向该节点的引用)"""
    if not node:
        return
    new_node = ListNode(val)
    new_node.next = node.next
    node.next = new_node

关键洞察:头部插入是 O(1),但尾部插入是 O(n)——因为你必须先遍历到末尾。这就是为什么很多实现会维护一个 tail 指针。

4.1.5 删除操作

def delete_node(head, target):
    """删除第一个值为 target 的节点"""
    # 特殊情况:删除头节点
    if head and head.val == target:
        return head.next
    
    current = head
    while current and current.next:
        if current.next.val == target:
            current.next = current.next.next  # 跳过目标节点
            return head
        current = current.next
    return head  # 未找到目标

删除操作的核心是:找到目标节点的前一个节点,然后让前一个节点的 next 跳过目标节点。这意味着你需要保持对前驱节点的引用。

4.1.6 搜索

def search(head, target):
    """在链表中搜索值,返回节点或 None — O(n)"""
    current = head
    while current:
        if current.val == target:
            return current
        current = current.next
    return None

时间复杂度总结:

操作 时间复杂度 说明
头部插入 O(1) 只需修改一个指针
尾部插入 O(n) 或 O(1) 取决于是否维护 tail 指针
在给定节点后插入 O(1) 已有指向该位置的引用
删除头节点 O(1) 只需返回 head.next
删除指定值 O(n) 需要先搜索
搜索 O(n) 必须线性遍历
随机访问第 k 个 O(n) 无法像数组一样索引

4.2 双向链表:前后自如

单链表的局限在于只能单向遍历。如果你站在某个节点上,想回到前一个节点,你做不到——除非从头再来一遍。双向链表(Doubly Linked List)通过在每个节点中增加一个 prev 指针解决了这个问题。

class DoublyListNode:
    """双向链表节点"""
    def __init__(self, val=0, prev=None, next=None):
        self.val = val
        self.prev = prev
        self.next = next

4.2.1 双向链表的插入与删除

def dll_insert_after(node, val):
    """在双向链表的 node 后面插入新节点 — O(1)"""
    new_node = DoublyListNode(val)
    new_node.next = node.next
    new_node.prev = node
    if node.next:
        node.next.prev = new_node
    node.next = new_node
    return new_node

def dll_delete(node):
    """删除双向链表中的指定节点 — O(1)
    前提:node 不是头节点且不是尾节点(或者使用哨兵节点)
    """
    if node.prev:
        node.prev.next = node.next
    if node.next:
        node.next.prev = node.prev

双向链表最大的优势:给定任意节点的引用,可以 O(1) 删除它。单链表做不到这一点——你必须找到前驱节点,而这需要从头遍历。

4.2.2 Python collections.deque 的底层

WHO — 出处:CPython 源码 Modules/_collectionsmodule.c。

Python 标准库的 collections.deque 底层并不是一个简单的双向链表,而是一个双向链表 + 定长数组块的混合结构。每个块(block)是一个固定大小 64 个元素的数组,多个块通过双向链表连接起来。

Block 0          Block 1          Block 2
[a,b,c,...,64] <-> [e,f,g,...,64] <-> [h,i,j,...,64]

WHY — 为什么不用纯双向链表? 纯链表的问题是:每个元素都需要一个节点对象(Python 对象开销约 56 字节),内存利用率极低。分块策略让每 64 个元素共享一对 prev/next 指针,大幅降低了内存开销,同时保持了两端 O(1) 的插入/删除能力。

这种设计也叫展开链表(Unrolled Linked List),由 Shao, Bodík, Goldstein 在 1994 年正式提出(论文标题为 "Space Efficient Conservative Garbage Collection",虽然展开链表的思想更早)。

4.3 循环链表

循环链表的末尾节点不指向 None,而是指回链表的某个节点(通常是头节点)。

def build_circular(values):
    """构建循环链表"""
    if not values:
        return None
    head = ListNode(values[0])
    current = head
    for val in values[1:]:
        current.next = ListNode(val)
        current = current.next
    current.next = head  # 尾部指回头部
    return head

4.3.1 约瑟夫环问题(Josephus Problem)

WHO — 出处:Flavius Josephus,公元 1 世纪犹太历史学家,在其著作《犹太战争》中描述了这个问题的原型。现代数学形式化由 W. Ahrens (1918) 完成。

问题描述:n 个人围成一圈,从第 1 个人开始报数,报到 k 的人出列,然后从下一个人重新开始报数,直到所有人出列。求出列顺序。

def josephus(n, k):
    """约瑟夫环 — 循环链表模拟解法"""
    # 构建循环链表
    head = ListNode(1)
    current = head
    for i in range(2, n + 1):
        current.next = ListNode(i)
        current = current.next
    current.next = head  # 形成环
    
    result = []
    prev = current  # prev 指向当前节点的前一个
    curr = head
    
    while curr.next != curr:  # 直到只剩一个人
        # 报数 k-1 次(移动 k-1 步)
        for _ in range(k - 1):
            prev = curr
            curr = curr.next
        # 出列
        result.append(curr.val)
        prev.next = curr.next  # 删除当前节点
        curr = curr.next
    
    result.append(curr.val)  # 最后一个人
    return result

# josephus(7, 3) => [3, 6, 2, 7, 5, 1, 4]

数学解法:约瑟夫环有一个优雅的递推公式 J(n, k) = (J(n-1, k) + k) % n,可以在 O(n) 时间内求出最后一个幸存者的编号,不需要模拟整个过程。

def josephus_survivor(n, k):
    """求最后幸存者编号(0-indexed)— O(n) 数学解"""
    pos = 0
    for i in range(2, n + 1):
        pos = (pos + k) % i
    return pos  # 转为 1-indexed: pos + 1

4.4 链表 vs 数组:全面对比

维度 数组(Python list) 链表
随机访问 O(1) O(n)
头部插入 O(n) O(1)
尾部插入 均摊 O(1) O(n) 或 O(1)*
中间插入 O(n) O(1)**
删除 O(n) O(1)**
内存布局 连续 分散
Cache 友好度 极高 极低
额外空间 无(或少量预分配) 每节点一个/两个指针
大小是否固定 动态扩容(有拷贝开销) 天然动态

*维护 tail 指针时。**已有指向该位置的引用时。

WHY — Cache 为什么重要? 现代 CPU 的 L1 缓存访问约 1ns,主内存访问约 100ns——差了 100 倍。数组的连续内存布局意味着 CPU 预取(prefetch)能提前把后面的数据加载到缓存;链表的节点散布在堆的各个角落,几乎每次 node.next 都会导致缓存未命中(cache miss)。

WHO — 出处:Bjarne Stroustrup(C++ 之父)在多次演讲中强调这一点,最著名的是 2012 年 GoingNative 大会的 keynote "Why you should avoid linked lists",他用实验数据展示了即使对于频繁插入/删除的场景,由于缓存效应,std::vector 也经常比 std::list 快。

4.5 常见操作

4.5.1 反转链表

这是链表最经典的操作,也是面试最高频的题目之一(LeetCode #206)。

def reverse_list(head):
    """迭代法反转链表 — 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  # prev 现在是新的头节点

图解过程:

初始:  None <- 1    2 -> 3 -> None
               prev  curr

一步后:None <- 1 <- 2    3 -> None
                     prev  curr

两步后:None <- 1 <- 2 <- 3
                          prev  curr=None

递归版本:

def reverse_list_recursive(head):
    """递归法反转链表 — O(n) 时间, O(n) 空间(栈)"""
    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

4.5.2 合并两个有序链表(LeetCode #21)

def merge_two_sorted(l1, l2):
    """合并两个有序链表 — O(n+m) 时间, O(1) 空间"""
    dummy = ListNode(0)  # 哨兵节点
    tail = dummy
    
    while l1 and l2:
        if l1.val <= l2.val:
            tail.next = l1
            l1 = l1.next
        else:
            tail.next = l2
            l2 = l2.next
        tail = tail.next
    
    tail.next = l1 if l1 else l2  # 拼接剩余部分
    return dummy.next

4.5.3 找链表中点(快慢指针)

def find_middle(head):
    """快慢指针找中点 — O(n) 时间, O(1) 空间
    偶数长度时返回靠左的中间节点
    """
    slow = fast = head
    while fast.next and fast.next.next:
        slow = slow.next
        fast = fast.next.next
    return slow

WHY — 为什么是快慢指针? 如果你先遍历一遍计算长度 n,再走 n/2 步找中点,需要 1.5n 步。快慢指针只需要 n 步——快指针走完时,慢指针正好在中间。虽然都是 O(n),但常数因子更小。更重要的是,快慢指针法不需要知道链表长度,适用于无法预知长度的流式数据。

4.5.4 删除倒数第 N 个节点(LeetCode #19)

def remove_nth_from_end(head, n):
    """删除倒数第 n 个节点 — 一次遍历法"""
    dummy = ListNode(0)
    dummy.next = head
    fast = slow = dummy
    
    # fast 先走 n+1 步
    for _ in range(n + 1):
        fast = fast.next
    
    # fast 和 slow 同时走,直到 fast 到达末尾
    while fast:
        fast = fast.next
        slow = slow.next
    
    # 此时 slow 是要删除节点的前驱
    slow.next = slow.next.next
    return dummy.next

技巧:让快指针先走 n+1 步而不是 n 步,这样当快指针到达末尾 None 时,慢指针恰好在目标节点的前一个位置,方便执行删除。

4.6 常见错误

错误 1:忘记处理空链表

# 错误!如果 head 是 None,head.val 会崩溃
def bad_search(head, target):
    if head.val == target:  # ❌ NoneType has no attribute 'val'
        return head
    ...

# 正确:先检查 head 是否为 None
def good_search(head, target):
    if not head:
        return None
    if head.val == target:
        return head
    ...

错误 2:丢失指针引用

# 错误!这样会断链
def bad_insert_after(node, val):
    new_node = ListNode(val)
    node.next = new_node      # ❌ 原来 node.next 的后续节点丢了!
    new_node.next = node.next  # 这里 node.next 已经是 new_node 了

# 正确:先保存,或者先设置新节点的 next
def good_insert_after(node, val):
    new_node = ListNode(val)
    new_node.next = node.next  # ✓ 先接上后面的
    node.next = new_node       # ✓ 再修改前面的

错误 3:遍历时修改链表结构

# 危险!在遍历中删除节点可能导致跳过元素或无限循环
current = head
while current:
    if should_delete(current):
        # 这里如何正确删除?你已经丢失了 prev 的引用!
        pass
    current = current.next

正确做法是维护 prev 指针,或使用哨兵节点。


Level 2 · 加深理解

4.7 快慢指针检测环(Floyd 判圈算法)

WHO — 出处:Robert W. Floyd,1967 年非正式提出(他本人未发表正式论文,但 Donald Knuth 在 The Art of Computer Programming 中将该算法归功于 Floyd)。也叫"龟兔赛跑算法"。

问题:给定一个链表,判断其中是否存在环。

核心思想:设置快慢两个指针,慢指针每次走一步,快指针每次走两步。如果存在环,快指针最终会追上慢指针(就像操场跑圈,快的人总会套慢的人一圈);如果不存在环,快指针会先到达 None。

def has_cycle(head):
    """Floyd 判圈 — O(n) 时间, O(1) 空间(LeetCode #141)"""
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow == fast:
            return True
    return False

WHY — 为什么一定会相遇? 设环长为 C。当慢指针进入环时,快指针已经在环内某处。此时快指针每步比慢指针多走一步,两者的距离每步缩小 1。最多经过 C 步,距离从某个值缩小为 0,即相遇。

更正式的证明:设慢指针走了 k 步到达环入口,此时快指针已走 2k 步。快指针在环内的位置是 (2k - 进入环前的步数) % C。两者进入环后,每步距离差减 1,所以最多 C 步后相遇。总时间复杂度 O(n)。

关于环检测的更深入讨论和变体问题,参见第 23 章《双指针与滑动窗口》。

4.8 找环入口的数学推导

问题:给定一个有环的链表,找到环的入口节点(LeetCode #142)。

def detect_cycle_entry(head):
    """找到环的入口节点"""
    slow = fast = head
    
    # 第一阶段:检测环
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow == fast:
            break
    else:
        return None  # 无环
    
    # 第二阶段:找入口
    # 将一个指针重置到 head,两个指针同步前进
    slow = head
    while slow != fast:
        slow = slow.next
        fast = fast.next
    return slow

数学推导:

设链表头到环入口的距离为 a,环入口到相遇点的距离为 b,相遇点继续走回到环入口的距离为 c。环长 C = b + c。

这意味着:从头节点走 a 步到环入口,等价于从相遇点走 (n-1) 圈再走 c 步也到环入口。所以第二阶段两个指针同步前进,相遇处就是环入口。

4.9 跳表(Skip List)简介

WHO — 出处:William Pugh,1990 年论文 "Skip Lists: A Probabilistic Alternative to Balanced Trees",发表于 Communications of the ACM。

跳表是在有序链表的基础上,通过添加多级"快速通道"来实现 O(log n) 搜索的数据结构。

Level 3: head ---------> 6 --------------------------> None
Level 2: head ---------> 6 ---------> 25 -----------> None
Level 1: head ---> 3 --> 6 ---> 9 --> 25 ---> 30 ---> None
Level 0: head -> 1 -> 3 -> 6 -> 7 -> 9 -> 12 -> 25 -> 30 -> 42 -> None

核心思想:每个节点随机决定自己出现在几层中。插入时以概率 p(通常 p=0.5 或 p=0.25)决定是否"提升"到上一层。查找时从最高层开始,利用高层的"快速通道"跳过大量节点。

import random

class SkipNode:
    def __init__(self, val, level):
        self.val = val
        self.forward = [None] * (level + 1)  # 每层一个 next 指针

class SkipList:
    MAX_LEVEL = 16
    P = 0.5  # 提升概率
    
    def __init__(self):
        self.header = SkipNode(-float('inf'), self.MAX_LEVEL)
        self.level = 0  # 当前最高层
    
    def random_level(self):
        """随机决定新节点的层数"""
        lvl = 0
        while random.random() < self.P and lvl < self.MAX_LEVEL:
            lvl += 1
        return lvl
    
    def search(self, target):
        """O(log n) 平均时间搜索"""
        current = self.header
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].val < target:
                current = current.forward[i]
        current = current.forward[0]
        return current and current.val == target
    
    def insert(self, val):
        """O(log n) 平均时间插入"""
        update = [None] * (self.MAX_LEVEL + 1)
        current = self.header
        
        for i in range(self.level, -1, -1):
            while current.forward[i] and current.forward[i].val < val:
                current = current.forward[i]
            update[i] = current
        
        new_level = self.random_level()
        if new_level > self.level:
            for i in range(self.level + 1, new_level + 1):
                update[i] = self.header
            self.level = new_level
        
        new_node = SkipNode(val, new_level)
        for i in range(new_level + 1):
            new_node.forward[i] = update[i].forward[i]
            update[i].forward[i] = new_node

WHY — Redis 为什么选跳表不选红黑树?

WHO — 出处:Salvatore Sanfilippo (antirez),Redis 作者,在 Redis GitHub issue 和 2014 年的多次回复中解释了这个决定。

antirez 的原话大意是:

  1. 实现简单:跳表的代码量大约是红黑树的一半,更容易理解、调试和维护。
  2. 范围查询天然高效:跳表的底层是有序链表,范围查询只需找到起点然后顺序遍历。红黑树的范围查询需要中序遍历,实现更复杂。
  3. 并发友好:跳表可以更容易地实现无锁并发版本(虽然 Redis 是单线程的,但这是一般性优势)。
  4. 内存局部性可调:通过调整 p 值,可以在时间和空间之间权衡。Redis 使用 p=0.25,使得平均每个节点只有 1.33 个指针。

跳表的期望复杂度:

操作 平均 最坏
搜索 O(log n) O(n)
插入 O(log n) O(n)
删除 O(log n) O(n)
空间 O(n) O(n log n)

最坏情况(所有节点都在最高层)概率指数级小,在实践中可以忽略。

4.10 链表在 LRU Cache 中的应用

WHO — 出处:LRU (Least Recently Used) 缓存淘汰策略的思想可追溯到 1960 年代操作系统页面置换算法的研究。作为面试题,它是 LeetCode #146。

LRU Cache 的核心需求:

解决方案:哈希表 + 双向链表

class LRUNode:
    def __init__(self, key=0, val=0):
        self.key = key
        self.val = val
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = {}  # key -> LRUNode
        # 哨兵节点:避免边界条件
        self.head = LRUNode()  # dummy head
        self.tail = LRUNode()  # dummy tail
        self.head.next = self.tail
        self.tail.prev = self.head
    
    def _remove(self, node):
        """从双向链表中移除一个节点 — O(1)"""
        node.prev.next = node.next
        node.next.prev = node.prev
    
    def _add_to_front(self, node):
        """将节点插入到 head 后面(表示最近使用)— O(1)"""
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node
    
    def get(self, key):
        if key in self.cache:
            node = self.cache[key]
            self._remove(node)
            self._add_to_front(node)
            return node.val
        return -1
    
    def put(self, key, value):
        if key in self.cache:
            node = self.cache[key]
            node.val = value
            self._remove(node)
            self._add_to_front(node)
        else:
            if len(self.cache) >= self.capacity:
                # 淘汰最久未使用的(tail 前面的节点)
                lru = self.tail.prev
                self._remove(lru)
                del self.cache[lru.key]
            node = LRUNode(key, value)
            self.cache[key] = node
            self._add_to_front(node)

WHY — 为什么需要双向链表? 哈希表提供 O(1) 查找,但无法维护使用顺序。链表维护顺序,但搜索是 O(n)。两者结合:哈希表存 key→node 映射实现 O(1) 定位,双向链表维护使用顺序且支持 O(1) 移动/删除任意节点。

WHY — 为什么用双向而不是单向? 因为 _remove(node) 需要同时修改 node 的前驱和后继。如果是单链表,你需要从头遍历找前驱,删除就变成 O(n) 了。

关于哈希表的详细讨论见第 8 章,LRU Cache 的更多变体和工业级实现见第 38 章。

4.11 递归 vs 迭代处理链表的权衡

链表的递归结构——一个节点加上"剩余的链表"——使得它天然适合递归处理。但递归并不总是最佳选择。

# 递归遍历
def recursive_traverse(head):
    if not head:
        return
    print(head.val)
    recursive_traverse(head.next)

# 递归求长度
def recursive_length(head):
    if not head:
        return 0
    return 1 + recursive_length(head.next)

对比分析:

维度 递归 迭代
代码简洁度 通常更简洁 可能更长
空间复杂度 O(n) 栈空间 O(1)
栈溢出风险 链表长度 > 递归深度限制时会崩 无此风险
尾递归优化 Python 不支持 不适用
调试难度 较难(调用栈深) 较易

WHY — Python 不支持尾递归优化:Guido van Rossum 在 2009 年的博文 "Neopythonic: Tail Recursion Elimination" 中明确表示 Python 不会实现尾递归优化,因为这会破坏 traceback 的可读性——Python 哲学认为清晰的错误报告比性能优化更重要。

实际建议:在 Python 中处理链表,几乎总是优先选择迭代。递归方案更适合教学和证明正确性。在 CPython 中,默认递归深度限制是 1000(可通过 sys.setrecursionlimit() 修改),这意味着超过 1000 个节点的链表会导致递归版本崩溃。


Level 3 · 深入底层

4.12 链表的历史

WHO — 出处:Allen Newell, Cliff Shaw, Herbert A. Simon,1955-1956 年在 RAND Corporation 和卡内基梅隆大学开发 IPL (Information Processing Language)。

链表的发明并不是为了解决某个具体的编程问题,而是为了实现人工智能。1955 年,Newell、Shaw 和 Simon 正在开发 Logic Theorist——历史上第一个 AI 程序,用于自动证明数学定理。他们需要一种灵活的数据结构来表示和操作符号表达式(逻辑公式),而当时的计算机只有固定大小的数组。

他们的解决方案是 IPL 语言,其中核心数据结构就是链表。IPL 中的每个"单元"(cell)包含一个符号值和一个指向下一个单元的地址。这个设计直接影响了 John McCarthy 在 1958 年创造的 LISP 语言——LISP 的名字就是 "LISt Processing" 的缩写,链表是整个语言的基础数据结构。

历史影响线:

IPL (1955-56, Newell/Shaw/Simon)
    ↓
LISP (1958, McCarthy) — cons cell 就是链表节点
    ↓
所有现代函数式语言的列表类型
    (Haskell, ML, Erlang, Clojure...)

趣事:Newell 和 Simon 因为在 AI 领域的开创性工作获得了 1975 年的图灵奖。链表只是他们工作的副产品,但它对计算机科学的影响比 Logic Theorist 本身还要深远。

4.13 XOR 链表:用异或节省指针空间

WHO — 出处:这个技巧在 1990 年代的系统编程社区中广为人知,最早的正式描述难以追溯,但在 Knuth 的 The Art of Computer Programming Vol.1 (1968) 的习题中有提及类似思想。

普通双向链表每个节点需要两个指针(prev 和 next),XOR 链表只存一个值:prev_addr XOR next_addr。

原理:

设节点 A, B, C 的内存地址分别为 addr(A), addr(B), addr(C)

普通双向链表:
  A.prev = None, A.next = addr(B)
  B.prev = addr(A), B.next = addr(C)
  C.prev = addr(B), C.next = None

XOR 链表:
  A.link = 0 XOR addr(B) = addr(B)
  B.link = addr(A) XOR addr(C)
  C.link = addr(B) XOR 0 = addr(B)

遍历方法:如果你在节点 B 上,且知道来的方向是从 A 来的(即知道 addr(A)),那么:

next_addr = B.link XOR addr(A)
          = (addr(A) XOR addr(C)) XOR addr(A)
          = addr(C)  (因为 X XOR X = 0)

Python 伪代码(Python 无法直接操作内存地址,这里用 id() 和字典模拟):

import ctypes

class XORNode:
    def __init__(self, val):
        self.val = val
        self.npx = 0  # XOR of prev and next addresses

def xor(a, b):
    """XOR two node addresses (using id)"""
    return a ^ b

def insert_at_head(head, val):
    new_node = XORNode(val)
    new_node.npx = id(head) if head else 0
    if head:
        # head.npx = 0 XOR old_next_addr
        # 新的 head.npx = id(new_node) XOR old_next_addr
        head.npx = xor(id(new_node), head.npx)
    return new_node

def traverse_xor(head):
    """遍历 XOR 链表"""
    prev_addr = 0
    current = head
    while current:
        print(current.val, end=" -> ")
        next_addr = xor(prev_addr, current.npx)
        prev_addr = id(current)
        # 需要从地址恢复对象(在 C 中直接解引用指针)
        current = ctypes.cast(next_addr, ctypes.py_object).value if next_addr else None
    print("None")

WHY — 为什么实际中很少用?

  1. 可读性极差:代码难以理解和维护。
  2. 调试困难:不能直接在调试器中看到前驱/后继。
  3. 与垃圾收集器冲突:GC 需要遍历所有引用来判断可达性。XOR 编码后的"指针"对 GC 不可见,节点可能被错误回收。这使得 XOR 链表在 Java/Python/Go 等有 GC 的语言中完全不可用。
  4. 现代内存充裕:当年每个指针 4 字节时,节省一个指针确实有意义。现在即使 8 字节指针,内存也通常不是瓶颈。

适用场景:极端嵌入式系统(内存以 KB 计),且使用 C/C++ 手动管理内存。

4.14 内存分配器中的自由链表(Free List)

WHO — 出处:自由链表是最古老的动态内存管理技术之一,追溯到 1960 年代的 ALGOL 编译器。Doug Lea 的 dlmalloc(1987 年)和后续的 ptmalloc2(glibc 默认分配器)都大量使用了自由链表的变体。

当你调用 malloc() 分配内存时,分配器如何知道哪些内存块是空闲的?答案就是自由链表。

核心思想:将所有空闲内存块用链表串起来。分配时从链表头部取一个块;释放时将块插回链表头部。

Free List (空闲块链表):
[16B block] -> [32B block] -> [16B block] -> [64B block] -> None

malloc(16):
  取出第一个 16B block,返回给用户
  Free List: [32B block] -> [16B block] -> [64B block] -> None

free(ptr):
  将 ptr 指向的块插回链表头部
  Free List: [16B block] -> [32B block] -> [16B block] -> [64B block] -> None

精妙之处:空闲块的链表指针存在空闲块自身的内存中!因为空闲块没人在用,它的前几个字节可以用来存 next 指针。这意味着维护自由链表不需要额外的内存开销。

// 简化的 free list 实现(C 伪代码)
typedef struct free_block {
    struct free_block *next;  // 存在空闲块自身的内存中
} free_block_t;

free_block_t *free_list = NULL;

void *my_malloc(size_t size) {
    // 在 free_list 中找到合适大小的块
    free_block_t *block = free_list;
    free_list = block->next;
    return (void *)block;
}

void my_free(void *ptr) {
    // 将释放的块插入 free_list 头部
    free_block_t *block = (free_block_t *)ptr;
    block->next = free_list;
    free_list = block;
}

现代分配器的优化:

4.15 Linux 内核的 list_head 侵入式链表

WHO — 出处:Linux 内核,include/linux/list.h。这个设计从 Linux 2.1 开始引入(约 1996 年),由多位内核开发者贡献。

传统链表(如我们上面实现的)是"非侵入式"的:链表节点包含数据。Linux 内核使用的是"侵入式"(intrusive)链表:数据结构中嵌入链表节点。

// Linux 内核的 list_head 定义
struct list_head {
    struct list_head *next, *prev;
};

// 使用方式:将 list_head 嵌入到你的数据结构中
struct task_struct {
    pid_t pid;
    char comm[16];
    struct list_head tasks;     // 进程链表
    struct list_head children;  // 子进程链表
    // ... 其他字段
};

关键技巧:container_of 宏。给定 list_head 的地址,如何找到包含它的外部结构体?

// container_of 宏:从成员指针反推出结构体指针
#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

// 遍历进程链表
struct task_struct *task;
struct list_head *pos;
list_for_each(pos, &init_task.tasks) {
    task = container_of(pos, struct task_struct, tasks);
    printk("PID: %d, Name: %s\n", task->pid, task->comm);
}

WHY — 为什么内核用侵入式链表?

  1. 零额外内存分配:不需要为每个链表节点单独 kmalloc。链表头嵌在已有结构体中,随结构体一起分配。
  2. 一个对象可以同时在多个链表中:比如 task_struct 同时在进程链表、运行队列、等待队列中,只需嵌入多个 list_head。
  3. 类型无关:list_head 的操作(插入、删除、遍历)不关心外部结构体是什么类型,代码高度复用。
  4. 缓存友好:链表节点和数据在同一块内存中,减少指针追踪和缓存未命中。

Python 中的类似思想:

class IntrusiveNode:
    """侵入式链表节点 mixin"""
    def __init__(self):
        self.list_prev = None
        self.list_next = None

class Process(IntrusiveNode):
    """进程对象,自身就是链表节点"""
    def __init__(self, pid, name):
        super().__init__()
        self.pid = pid
        self.name = name

class IntrusiveList:
    """侵入式双向链表"""
    def __init__(self):
        self.head = IntrusiveNode()
        self.head.list_prev = self.head
        self.head.list_next = self.head  # 空链表指向自己
    
    def insert(self, node):
        """在头部插入"""
        node.list_next = self.head.list_next
        node.list_prev = self.head
        self.head.list_next.list_prev = node
        self.head.list_next = node
    
    def remove(self, node):
        """移除指定节点"""
        node.list_prev.list_next = node.list_next
        node.list_next.list_prev = node.list_prev

Level 4 · 工程实践

4.16 Python 中链表很少用的原因

在实际 Python 项目中,你几乎永远不需要自己实现链表。原因如下:

1. Python list 已经足够好

Python 的 list 底层是动态数组(C 实现,PyObject ** 指针数组)。虽然头部插入是 O(n),但:

2. collections.deque 覆盖了大部分场景

from collections import deque

d = deque()
d.appendleft(1)  # O(1) 头部插入
d.append(2)      # O(1) 尾部插入
d.popleft()      # O(1) 头部删除
d.pop()          # O(1) 尾部删除

deque 两端操作都是 O(1),足以替代链表的大部分用途。

3. Python 对象开销巨大

每个 Python 对象至少占 56 字节(sys.getsizeof(object()) = 56)。一个 ListNode 对象:

import sys
node = ListNode(42)
print(sys.getsizeof(node))  # 48 (基本对象大小)
# 加上 __dict__: 额外 104 字节
# 总计:约 152 字节存储一个 int(本身只需要 28 字节)

相比之下,list 中存储一个 int 引用只需要 8 字节(一个指针)。链表的空间效率极差。

4. 什么时候链表仍然有用?

4.17 面试高频链表题

4.17.1 回文链表(LeetCode #234)

思路:找到中点 → 反转后半部分 → 逐一比较 → (可选)恢复链表

def is_palindrome(head):
    """O(n) 时间, O(1) 空间判断回文链表"""
    if not head or not head.next:
        return True
    
    # 1. 找中点
    slow = fast = head
    while fast.next and fast.next.next:
        slow = slow.next
        fast = fast.next.next
    
    # 2. 反转后半部分
    second_half = reverse_list(slow.next)
    
    # 3. 比较
    p1, p2 = head, second_half
    result = True
    while p2:
        if p1.val != p2.val:
            result = False
            break
        p1 = p1.next
        p2 = p2.next
    
    # 4. 恢复链表(好习惯)
    slow.next = reverse_list(second_half)
    
    return result

4.17.2 相交链表(LeetCode #160)

思路:两个指针分别从两个链表头出发。当一个到达末尾时,重定向到另一个链表的头部。如果有交点,两个指针会在交点相遇。

def get_intersection_node(headA, headB):
    """O(n+m) 时间, O(1) 空间"""
    if not headA or not headB:
        return None
    
    pA, pB = headA, headB
    
    while pA != pB:
        pA = pA.next if pA else headB
        pB = pB.next if pB else headA
    
    return pA  # 要么是交点,要么是 None(两者同时到达末尾)

WHY — 为什么这个方法有效? 设链表 A 长度为 a,链表 B 长度为 b,公共部分长度为 c。指针 A 走的路径:a + (b - c);指针 B 走的路径:b + (a - c)。两者相等:a + b - c = b + a - c。所以它们一定同时到达交点(或同时到达 None)。

4.17.3 排序链表(LeetCode #148 — 归并排序)

WHY — 为什么链表排序用归并排序? 快排需要随机访问(partition 操作),不适合链表。归并排序只需要顺序访问和合并操作,完美适配链表。而且链表的归并不需要额外 O(n) 空间(不像数组归并需要临时数组)。

def sort_list(head):
    """链表归并排序 — O(n log n) 时间, O(log n) 空间(递归栈)"""
    if not head or not head.next:
        return head
    
    # 找中点并断开
    slow, fast = head, head.next
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    
    mid = slow.next
    slow.next = None  # 断开前后两半
    
    # 递归排序两半
    left = sort_list(head)
    right = sort_list(mid)
    
    # 合并
    return merge_two_sorted(left, right)

进阶:自底向上的迭代归并排序可以做到 O(1) 空间(不使用递归栈),但代码复杂度显著增加。

4.17.4 K 个一组翻转链表(LeetCode #25)

这是链表题中的"Hard"级别,综合考察指针操作能力。

def reverse_k_group(head, k):
    """每 k 个节点一组进行翻转"""
    # 检查是否还有 k 个节点
    count = 0
    node = head
    while node and count < k:
        node = node.next
        count += 1
    
    if count < k:
        return head  # 不足 k 个,不翻转
    
    # 翻转前 k 个节点
    prev = None
    curr = head
    for _ in range(k):
        next_temp = curr.next
        curr.next = prev
        prev = curr
        curr = next_temp
    
    # head 现在是翻转后的最后一个节点
    # curr 是下一组的开始
    head.next = reverse_k_group(curr, k)  # 递归处理剩余部分
    
    return prev  # prev 是翻转后的新头

迭代版本(面试中更受青睐,因为不会栈溢出):

def reverse_k_group_iterative(head, k):
    """迭代版 K 个一组翻转"""
    dummy = ListNode(0)
    dummy.next = head
    group_prev = dummy
    
    while True:
        # 检查剩余节点是否够 k 个
        kth = group_prev
        for _ in range(k):
            kth = kth.next
            if not kth:
                return dummy.next
        
        group_next = kth.next
        
        # 翻转 group_prev.next 到 kth 之间的节点
        prev, curr = kth.next, group_prev.next
        for _ in range(k):
            next_temp = curr.next
            curr.next = prev
            prev = curr
            curr = next_temp
        
        # 更新连接
        tmp = group_prev.next
        group_prev.next = kth
        group_prev = tmp

# 测试:reverse_k_group_iterative(build_linked_list([1,2,3,4,5]), 2)
# 结果:2 -> 1 -> 4 -> 3 -> 5

4.18 哨兵节点(Dummy Head)技巧

哨兵节点(sentinel/dummy node)是链表编程中最实用的技巧之一。

WHY — 为什么需要哨兵? 链表操作中最烦人的是边界条件:头节点没有前驱,尾节点没有后继。插入/删除头节点需要特殊处理(因为要更新 head 引用)。哨兵节点通过在真正的头节点前面加一个假节点,统一了所有操作。

def remove_elements(head, val):
    """删除所有值为 val 的节点(LeetCode #203)
    不使用哨兵 vs 使用哨兵的对比
    """
    # 方法1:不使用哨兵 — 需要特殊处理头节点
    while head and head.val == val:
        head = head.next
    current = head
    while current and current.next:
        if current.next.val == val:
            current.next = current.next.next
        else:
            current = current.next
    return head
    
    # 方法2:使用哨兵 — 统一处理
    dummy = ListNode(0)
    dummy.next = head
    current = dummy
    while current.next:
        if current.next.val == val:
            current.next = current.next.next
        else:
            current = current.next
    return dummy.next

使用哨兵的好处:

  1. 不需要单独处理"删除头节点"的情况
  2. 代码逻辑统一,不容易出错
  3. 最后返回 dummy.next,它自然就是正确的新头(可能是原来的头,也可能头被删了)

经验法则:如果你的链表操作可能修改头节点,就用哨兵。这包括:

4.19 什么时候该用链表,什么时候不该

应该用链表(或其变体)的场景:

  1. 需要频繁在已知位置插入/删除:例如文本编辑器的行缓冲区(但现代编辑器多用 rope 或 piece table)。
  2. 需要 O(1) 拼接两个序列:两个链表的合并只需要改一个指针。数组合并是 O(n)。
  3. 实现特殊数据结构:LRU Cache、LFU Cache、Fibonacci Heap 的内部都使用链表。
  4. 内存碎片化严重,无法分配连续大块:嵌入式系统中常见。
  5. 元素极大,移动代价高:但在 Python/Java 中对象都是引用,移动只是移动指针,这个理由不成立。

不应该用链表的场景:

  1. 需要随机访问:链表的 get(i) 是 O(n)。
  2. 数据量小(小于几千个元素):数组的缓存友好性会碾压链表的理论优势。
  3. 需要二分搜索:链表上无法高效二分(除非用跳表)。
  4. Python/Java 等有 GC 的高级语言:对象开销使链表空间效率极差。
  5. 并行/向量化计算:现代 CPU 的 SIMD 指令需要连续内存。

总结性决策树:

需要随机访问?
  → 是:用数组
  → 否:
    需要两端操作?
      → 是:用 deque
      → 否:
        需要 O(1) 删除任意已知节点?
          → 是:用链表 + 哈希表
          → 否:
            数据量大且频繁中间插入?
              → 是:考虑链表或跳表
              → 否:用数组

本章小结

链表是理解指针/引用和动态数据结构的基石。尽管在现代高级语言中很少直接使用链表,但它的思想无处不在:

掌握链表,关键不在于背诵那些指针操作的代码模板,而在于理解:当逻辑顺序和物理位置解耦后,你获得了什么自由,又付出了什么代价。

下一章我们将学习栈和队列——它们是对链表和数组的"访问方式"进行限制后得到的抽象,这种"限制即力量"的设计哲学是计算机科学中反复出现的主题。

本章评分
4.5  / 5  (83 评分)

💬 留言讨论