跳到内容

第 2 卷 / 第 2 章 / 第 2 课

2.2 链表 ​

前置:数组,约 45 分钟。本节把“链表插入是常数时间”补全为一份包含位置句柄、前驱、尾指针和所有权的准确合同。

从节点和链接开始 ​

链表用链接维持顺序,不用连续槽位维持下标。这项选择换来了局部改链的低成本,也放弃了按整数下标直接定位。

链表由链接定义 ​

单链表的逻辑结构是:

text
head
  │
  ▼
[1 | next] → [2 | next] → [3 | None]

实现通常为每个节点单独分配空间,因此节点可能不相邻。不过,“一定散落在内存各处”也说得太满。内存池、区域分配器或紧凑运行时可以把节点放得很近。链表的抽象合同只要求 next 建立顺序。

最小 Python 节点可以这样写:

python
class ListNode:
    def __init__(self, value, next_node=None):
        self.value = value
        self.next = next_node

head = ListNode(1, ListNode(2, ListNode(3)))

参数名用 next_node,避免遮蔽 Python 内置函数 next()。

按索引查找:先声明下标规则 ​

本章使用从 0 开始的索引。k = 0 返回头节点,负数不接受:

python
def get_kth(head, k):
    if k < 0:
        raise IndexError("index out of range")
    current = head
    for _ in range(k):
        if current is None:
            raise IndexError("index out of range")
        current = current.next
    if current is None:
        raise IndexError("index out of range")
    return current

到第 k 个节点要走 k 条链接,时间是 Θ(k + 1);当 k 与表长 n 同阶时,最坏为 Θ(n)。链表没有从 head 直接算出第 k 个节点地址的公式。

插入和删除:常数操作的前提 ​

已知前驱时插入 ​

python
def insert_after(previous, value):
    if previous is None:
        raise ValueError("previous node is required")
    node = ListNode(value, previous.next)
    previous.next = node
    return node

创建节点并写两条链接,操作数不随 n 增长,所以是 Θ(1)。前提是调用者已经持有属于这条链的 previous。

若调用者只给“在下标 i 后插入”,实现要先走到第 i 个节点,定位 Θ(i + 1),改链接 Θ(1),总时间为 Θ(i + 1),最坏 Θ(n)。

单链表删除需要前驱 ​

python
def delete_after(previous):
    if previous is None or previous.next is None:
        raise IndexError("no successor to delete")
    removed = previous.next
    previous.next = removed.next
    removed.next = None
    return removed

已知前驱时为 Θ(1)。只持有目标节点通常不足以删除单链表尾节点;找目标的前驱仍可能花 Θ(n)。

有一道面试技巧会把目标节点的后继值复制进目标,再删除后继。它改变了节点身份语义,对尾节点无效,也可能破坏外部持有的节点引用。不能把它当作通用删除合同。

哨兵与尾指针解决不同问题 ​

哨兵放在第一个业务节点之前,不保存业务值:

text
sentinel → first → ... → tail → None

它让空表与删除头节点都能写成“修改某个前驱的 next”。尾指针则让追加不用每次从头找尾部。

状态不变量
空表sentinel.next is None,tail is sentinel,size == 0
非空sentinel.next 是头,tail.next is None
每次插删后从头可达节点数等于 size,尾指向最后一个节点

哨兵减少分支,但不会自动维护这些不变量。漏改尾指针仍会让下一次追加接到错误位置。

一份可运行的单链表 ​

下面的 Python 3.10+ 实现为每个节点记录 _owner,用常数时间拒绝其他链表的节点和已经删除的节点。insert_after 与 delete_after 都要求前驱属于当前表。调用者可以持有返回的节点,但不能绕过容器直接改写链接或 _owner;生产接口通常封装这些字段。

这里把分配一个固定大小节点算作常数成本。真实分配器、垃圾回收以及元素本身的构造和析构成本,需要另行考虑。

python
# linked_list_demo.py

class ListNode:
    def __init__(self, value: object, next_node: "ListNode | None" = None) -> None:
        self.value = value
        self.next = next_node
        self._owner = None

class SinglyLinkedList:
    def __init__(self) -> None:
        self._sentinel = ListNode(None)
        self._sentinel._owner = self
        self._tail = self._sentinel
        self._size = 0

    @property
    def size(self) -> int:
        return self._size

    def append(self, value: object) -> ListNode:
        node = ListNode(value)
        node._owner = self
        self._tail.next = node
        self._tail = node
        self._size += 1
        return node

    def insert_after(self, previous: ListNode | None, value: object) -> ListNode:
        if previous is None or previous._owner is not self:
            raise ValueError("previous node does not belong to this list")
        node = ListNode(value, previous.next)
        node._owner = self
        previous.next = node
        if self._tail is previous:
            self._tail = node
        self._size += 1
        return node

    def delete_after(self, previous: ListNode | None) -> object:
        if previous is None or previous._owner is not self:
            raise ValueError("previous node does not belong to this list")
        if previous.next is None:
            raise IndexError("no node exists after previous")
        removed = previous.next
        previous.next = removed.next
        if self._tail is removed:
            self._tail = previous
        removed.next = None
        removed._owner = None
        self._size -= 1
        return removed.value

    def get_kth(self, index: int) -> ListNode:
        if index < 0:
            raise IndexError("index out of range")
        current = self._sentinel.next
        for _ in range(index):
            if current is None:
                raise IndexError("index out of range")
            current = current.next
        if current is None:
            raise IndexError("index out of range")
        return current

    def middle_node(self) -> ListNode:
        slow = self._sentinel.next
        fast = self._sentinel.next
        if slow is None:
            raise IndexError("empty list has no middle node")
        while fast is not None and fast.next is not None:
            slow = slow.next
            fast = fast.next.next
        assert slow is not None
        return slow

    def reverse(self) -> None:
        old_head = self._sentinel.next
        previous = None
        current = old_head
        while current is not None:
            next_node = current.next
            current.next = previous
            previous = current
            current = next_node
        self._sentinel.next = previous
        self._tail = old_head if old_head is not None else self._sentinel

    def to_list(self) -> list[object]:
        result: list[object] = []
        current = self._sentinel.next
        for _ in range(self._size):
            assert current is not None
            result.append(current.value)
            current = current.next
        assert current is None
        return result

def main() -> None:
    linked = SinglyLinkedList()
    linked.append(1)
    second = linked.append(2)
    linked.append(3)
    linked.append(4)
    initial = linked.to_list()

    inserted = linked.insert_after(second, 9)
    after_insert = linked.to_list()
    removed = linked.delete_after(inserted)
    middle = linked.middle_node().value
    linked.reverse()

    assert initial == [1, 2, 3, 4]
    assert after_insert == [1, 2, 9, 3, 4]
    assert removed == 3
    assert middle == 9
    assert linked.to_list() == [4, 9, 2, 1]
    assert linked.size == 4

    print(f"初始:{initial}")
    print(f"插入后:{after_insert}")
    print(f"删除:{removed}")
    print(f"中点:{middle}")
    print(f"反转:{linked.to_list()}")
    print("链表检查通过")

if __name__ == "__main__":
    main()

运行:

bash
python3 linked_list_demo.py

输出:

text
初始:[1, 2, 3, 4]
插入后:[1, 2, 9, 3, 4]
删除:3
中点:9
反转:[4, 9, 2, 1]
链表检查通过

长度为偶数时,本章的快慢指针返回两个中点中的第二个。因此 [1, 2, 9, 4] 返回值为 9。接口若需要第一个中点,应改变快指针的初始位置或循环条件,并把约定写进测试。

单链表操作表 ​

操作已有条件时间需要留意
读取第 k 个节点只有头节点Θ(k + 1)越界和负数规则
头部插入有哨兵/头句柄Θ(1)更新头链接
尾部追加维护尾句柄Θ(1)空表与尾不变量
尾部追加只有头节点Θ(n)先找尾部
前驱后插入已知前驱Θ(1)前驱必须属于本表
删除后继已知前驱Θ(1)可能需要更新尾
按值删除只有头节点最坏 Θ(n)查找前驱占主导
删除尾节点只有头和尾Θ(n)单链表还要找前驱
完整遍历有头节点Θ(n)可能有环时先定策略

表里的“句柄”可以是节点引用、受控迭代器或容器提供的位置对象。只给整数下标时,链表需要遍历。

双链表 ​

双链表节点同时保存前驱和后继:

text
None ← [1] ⇄ [2] ⇄ [3] → None
python
class DoublyNode:
    def __init__(self, value, previous=None, next_node=None):
        self.value = value
        self.previous = previous
        self.next = next_node

def unlink(node, head, tail):
    if node.previous is None:
        head = node.next
    else:
        node.previous.next = node.next

    if node.next is None:
        tail = node.previous
    else:
        node.next.previous = node.previous

    node.previous = None
    node.next = None
    return head, tail

unlink 要求 node 是当前无环双链表中仍然有效的节点,且 head、tail 对应同一条链;重复删除不在这个函数的合同内。已知节点时,删除只改固定数量的链接,时间 Θ(1)。双向遍历也不必反复从头开始。代价是每个节点多一条链接、插删多几次写操作,以及更复杂的不变量。节点总内存不一定恰好翻倍,因为对象头、对齐和负载值仍占空间。

循环链表 ​

循环链表让尾节点指回头节点:

text
head → [A] → [B] → [C]
         ▲           │
         └───────────┘

它适合成员会增删的轮询路线。遍历时不能再等 None:

python
def circular_values(head):
    if head is None:
        return []
    result = [head.value]
    current = head.next
    while current is not head:
        result.append(current.value)
        current = current.next
    return result

这段遍历要求非空输入构成一个经过 head 的完整环。若链在别处进入环,循环不会回到 head;若中途断链,则会访问 None。循环终止依赖节点身份 is,不是值相等,节点值可以重复。访问 n 个节点需 Θ(n) 时间,返回的列表占 Θ(n) 输出空间。

环形缓冲区(ring buffer)通常由固定数组和首尾索引实现,不是循环链表的同义词。二者都能表达循环使用位置,但布局和复杂度合同不同。

三个基础算法 ​

原地反转 ​

每次保存后继,再把当前链接反向:

python
def reverse(head):
    previous = None
    current = head
    while current is not None:
        next_node = current.next
        current.next = previous
        previous = current
        current = next_node
    return previous

每个节点处理一次,时间 Θ(n);只保存三个节点引用,辅助空间 Θ(1)。

找中点 ​

python
def middle_node(head):
    if head is None:
        raise IndexError("empty list")
    slow = head
    fast = head
    while fast is not None and fast.next is not None:
        slow = slow.next
        fast = fast.next.next
    return slow

快指针每轮走两条链接,慢指针走一条。快指针到尾部时,慢指针走了约一半。时间 Θ(n),辅助空间 Θ(1)。当前写法对偶数长度返回第二中点。

Floyd 判环 ​

python
def has_cycle(head):
    slow = head
    fast = head
    while fast is not None and fast.next is not None:
        slow = slow.next
        fast = fast.next.next
        if slow is fast:
            return True
    return False

无环时,快指针到达 None。有环时,两个指针最终在环内相遇。设入环前有 μ 个节点,环长为 λ。两指针都进入环后,每轮相对距离增加 1(模 λ),最多再走 λ 轮就会重合。因此时间为 Θ(μ + λ),也就是按可达不同节点数计的 Θ(n),辅助空间 Θ(1)。比较的是节点身份,不是节点值。

语言里的链表与位置句柄 ​

Python ​

标准库没有通用 LinkedList 类型。队列或双端队列通常使用 collections.deque,它不是本章手写的节点链。手写节点适合学习算法或表达确实需要稳定节点身份的领域结构。

Python 垃圾回收可以处理普通引用环,但文件、锁等外部资源仍应显式管理。不要把资源关闭依赖在链表节点何时回收。

Java 17+ ​

LinkedList<E> 是双链表,同时实现 List 与 Deque。一般索引访问可能遍历,但 addFirst、addLast、removeFirst、removeLast 直接操作两端。

LinkedList.add(0, value) 在首端操作,不需要遍历整个链表;中间索引仍需定位。用作普通队列或栈时,ArrayDeque 通常是更直接的候选;它不接受 null,也不是线程安全容器。

C++ ​

std::forward_list 是单链表,std::list 是双链表。标准 list 在持有有效迭代器时支持单个元素的常数时间插入和删除;批量操作还需按处理的元素数计费。插入保留已有元素的引用和迭代器;删除只使被删元素的引用和迭代器失效。

节点和元素析构遵循 C++ 所有权规则。教学用裸指针实现需要写析构、复制/移动策略和异常安全;业务代码通常先考虑标准容器或智能指针。

链表与缓存 ​

链表遍历包含依赖加载:读到当前节点后,才能知道下一节点地址。单独分配的节点还带来分配元数据、指针和对齐开销。数组顺序扫描更容易利用相邻槽位。

这些因素不会改变两者遍历都为 Θ(n) 的事实,却会改变常数。差距没有通用倍数;节点池、元素大小、访问模式、运行时与处理器都会影响结果。需要选型时,用真实操作组合做基准,而不是引用固定“快 10 倍”结论。

常见错误怎么定位 ​

症状先检查什么
访问第 0 个空表却返回 None接口应返回空值还是抛 IndexError?写进合同
插入后新节点不可达两条链接的赋值顺序是否覆盖了原后继
删除尾部后追加丢失删除时是否更新了 tail
反转后只有一个节点改链接前是否保存 next_node
遍历永不结束输入是否有环,循环终止条件是否仍等 None
双链表反向断裂是否同时更新两侧的 previous 与 next
已删除节点仍连着链是否按接口需要清空其链接,外部是否仍持有句柄

动手维护链接不变量 ​

  1. 给示例类增加 prepend(value),验证空表和非空表都维持尾指针不变量。
  2. 合并两个互不共享节点的无环有序单链表,不创建新业务节点;写明输入链会被重连。
  3. 把 middle_node 改为偶数长度返回第一个中点,并为长度 0、1、2、4 写断言。
  4. 实现 cycle_entry(head),返回环入口而不只返回布尔值。
  5. 设计一个双哨兵双链表,验证空表时 head_sentinel.next is tail_sentinel。

先说清调用者手里有什么 ​

分别实现“已知前驱后插入”和“按下标插入”。前者只改常数条链接,后者还要从 head 走到目标位置。给空表、单节点、删除尾节点和越界位置写断言,并始终核对 head、tail 与 size 不变量。

再对反转、中点和 Floyd 判环分别写出时间与辅助空间。若输入可能有环,任何以 None 为唯一终止条件的遍历都需要重新审查。

下一页把数组与链表放进同一张表。比较时不只看“插入”两个字,还要写明定位方式、移动量、缓存和迭代器合同。

打开数组与链表决策页

Built with VitePress | Software Systems Atlas