第 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) 的习题中有提及类似思想。

普通双向链表每个节点需要两个指针(prevnext),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 评分)

💬 留言讨论