Chapter 02

核心数据结构

第 01 章用复杂度给解法空间画了边界——这一章填地基:每种数据结构的物理布局决定了它各项操作的复杂度,而正是这些复杂度让某个套路成立。

本章骨架

  • 数组与链表的取舍来自内存是否连续。
  • 栈与队列是访问顺序的两种约束。
  • 堆用"部分有序"换来 O(1) 取最值。
  • 单调栈用一次进出维持单调性。

§2.1数组 vs 链表

数组押注随机访问,链表押注就地插入——分歧的根源是内存是否连续。

数组把元素摆在一段连续内存里。第 i 个元素的地址等于首地址加 i × 元素大小,一次乘加就能算出,因此随机访问是 O(1)。代价在中间插入或删除:连续内存不允许"挤进去",必须把后面所有元素整体搬移,最坏 O(n)。

链表把元素拆成一个个节点,每个节点除了存值,还存一个指向下一个节点的指针。节点散落在内存各处,靠指针串成一条链。已知插入位置时,改两个指针即可完成插入,是 O(1);但访问第 i 个元素必须从头节点顺着指针走 i 步,是 O(n)。指针跳转还使链表对 CPU 缓存不友好——相邻节点的物理地址往往相距很远,缓存预取失效。

数组(连续内存) 10 20 30 40 50 [0] [1] [2] [3] [4] a[i] = 首地址 + i×4 ⇒ O(1) 链表(节点 + next 指针) 10 20 30 40 ∅ 访问 [i]:顺指针走 i 步 ⇒ O(n)
图 2.1同一组数据,数组靠连续地址做一次算术得到任意位置,链表靠指针逐跳定位。注意:链表节点的 next 指针指向的是散落各处的内存地址,图中曲线只是示意"物理上不相邻",这正是链表缓存不友好的来源。

例题:反转单链表(迭代版)

反转链表是链表操作的基本功:不新建节点,只逐个翻转每个节点的 next 指向。

reverse_list.pyPython
class ListNode:
    """单链表节点:存一个值 val,存一个指向下一节点的指针 next。"""
    def __init__(self, val: int, next: "ListNode | None" = None):
        self.val = val
        self.next = next


def reverse_list(head: ListNode | None) -> ListNode | None:
    """迭代反转单链表,返回新的头节点。时间 O(n),额外空间 O(1)。"""
    prev = None            # prev 指向已反转部分的头;初始为空
    cur = head             # cur 指向待处理的当前节点
    while cur is not None:
        nxt = cur.next     # 先存下后继,否则改向后就找不到它了
        cur.next = prev    # 核心:把当前节点的指针翻向前一个节点
        prev = cur         # prev 前进到当前节点
        cur = nxt          # cur 前进到原来的后继
    return prev            # cur 走到空时,prev 即为反转后的头


def to_list(head: ListNode | None) -> list[int]:
    """把链表读成 Python 列表,便于打印验证。"""
    out = []
    while head is not None:
        out.append(head.val)
        head = head.next
    return out


# 构造 1 -> 2 -> 3 -> 4 并反转
head = ListNode(1, ListNode(2, ListNode(3, ListNode(4))))
print(to_list(head))                 # [1, 2, 3, 4]
print(to_list(reverse_list(head)))   # [4, 3, 2, 1]

nxt = cur.next反转前必须先缓存后继节点。一旦执行 cur.next = prev,原来的后继就丢失了,这一步是迭代反转最容易写错的地方。

cur.next = prev整个算法的核心:把当前节点的指针从"指向后面"翻成"指向前面"。三个指针 prev / cur / nxt 像一个三步走的滑动窗口,每轮整体右移一位。

§2.2栈与队列

栈是后进先出(LIFO),队列是先进先出(FIFO)——两种相反的访问顺序约束。

栈只允许在一端进出:最后压入的元素最先弹出。函数调用栈、括号匹配、深度优先搜索(DFS)都依赖这种"后来居上"的顺序。队列只允许一端进、另一端出:最先入队的最先出队。广度优先搜索(BFS)和各类缓冲区都建立在这种"排队"顺序上。

在 Python 里,list 天然适合做栈:append 压栈、pop() 弹栈,两者都在列表尾部操作,均摊 O(1)。做队列则要用 collections.deque(双端队列),append 入队、popleft 出队,两端操作都是 O(1)。

陷阱 · list.pop(0) 的 O(n) 代价

用 list 做队列、靠 list.pop(0) 出队是常见的失败模式。list 是连续数组,删掉头元素后,后面每个元素都要向前搬移一格,单次 pop(0) 就是 O(n);放进循环里,整个队列退化成 O(n²)。队列一律用 deque,它的两端进出都是 O(1)。

例题:有效括号匹配

给一个只含 ()[]{} 的字符串,判断括号是否合法配对。每遇到右括号,需要和"最近一个未匹配的左括号"比对——"最近"二字正是栈 LIFO 的信号。

valid_parentheses.pyPython
def is_valid(s: str) -> bool:
    """判断括号串是否合法配对。时间 O(n),空间 O(n)。"""
    pairs = {")": "(", "]": "[", "}": "{"}   # 右括号 -> 对应的左括号
    stack: list[str] = []                    # 用 list 当栈,存未匹配的左括号
    for ch in s:
        if ch in pairs:                      # 遇到右括号
            # 栈空说明没有左括号可配;栈顶不匹配说明配错了
            if not stack or stack.pop() != pairs[ch]:
                return False
        else:                                # 遇到左括号,压栈等待匹配
            stack.append(ch)
    return not stack                         # 全部配完则栈应为空


print(is_valid("()[]{}"))   # True
print(is_valid("([)]"))     # False:右括号 ) 配到的栈顶是 [,不匹配
print(is_valid("(]"))       # False
print(is_valid("((("))      # False:结束时栈非空,有左括号没配上

stack.pop()弹出栈顶——也就是最近一个未匹配的左括号。这一步用到的正是栈的 LIFO 特性:右括号总是先和最里层的左括号配对。

return not stack遍历结束时栈必须为空。栈里若还剩左括号,说明它们始终没等到对应的右括号。

§2.3堆 / 优先队列

始终能 O(1) 取到最值的部分有序结构。

堆是一棵完全二叉树(除最后一层外每层填满,最后一层从左到右排列),因此可以紧凑地用一个数组存储,无需任何指针。下标从 0 开始时,节点 i 的左孩子是 2i+1、右孩子是 2i+2、父节点是 (i-1)//2。

堆只维护一个偏序:每个父节点都不大于(最小堆)或不小于(最大堆)它的孩子。它不保证兄弟之间、跨子树之间的顺序——这正是堆和"完全排序"的区别。因为约束更弱,堆得到这组复杂度:取顶(最值)O(1),插入与弹出 O(log n)(沿树高调整一条路径),自底向上建堆 O(n)。

完全二叉树形态(最小堆) 1 3 5 8 4 9 i=0 i=1 i=2 i=3 i=4 i=5 数组存储 1 3 5 8 4 9 0 1 2 3 4 5 i=0 → 左 2i+1=1,右 2i+2=2 i=1 → 子 3, 4
图 2.2同一个最小堆的两种视图:左边是逻辑上的完全二叉树,右边是物理上的数组;下标映射 i → 2i+1 / 2i+2 让数组无需指针就能"找到孩子"。注意:堆顶 1 是全局最小,但数组其余位置并不有序(如 8 排在 4 前面)——堆只保证父≤子的偏序,不保证整体有序。

例题:求 Top-K 最大元素

从 n 个数里取最大的 k 个。直觉是用最大堆,但更优的做法是用一个大小为 k 的最小堆:堆里始终保留当前见过的最大的 k 个,堆顶是这 k 个里最小的;新元素只要比堆顶大就替换堆顶。

top_k.pyPython
import heapq

def top_k_largest(nums: list[int], k: int) -> list[int]:
    """返回最大的 k 个元素。维护大小为 k 的最小堆,时间 O(n log k)。"""
    heap: list[int] = []                 # 最小堆,heapq 默认就是最小堆
    for x in nums:
        if len(heap) < k:
            heapq.heappush(heap, x)      # 还没攒够 k 个,直接入堆
        elif x > heap[0]:                # heap[0] 是当前 k 个里最小的
            heapq.heapreplace(heap, x)   # 弹出最小、压入 x,一步完成 O(log k)
    return sorted(heap, reverse=True)    # 堆内无序,按需排序输出


print(top_k_largest([3, 1, 5, 12, 2, 11, 8], 3))   # [12, 11, 8]
print(top_k_largest([4, 4, 4, 1], 2))              # [4, 4]

x > heap[0]heap[0] 是这 k 个候选里的最小值。新元素只有大于它才有资格进入 Top-K——此时把最小的那个挤出去。

heapq.heapreplace"先弹最小、再压新值"合成一次操作,比分开的 heappop + heappush 少一次调整,依然是 O(log k)。

表 2.1 · 求 Top-K 的几种方法
方法时间复杂度适用场景
全排序后取前 k 个O(n log n)实现最简单;k 接近 n 或需要全序时可用
大小为 k 的最小堆O(n log k)k ≪ n 时最优;天然支持流式 / 海量数据
quickselect 划分O(n) 平均只取一次、允许原地打乱、不在意最坏 O(n²)
想一想

求"最大的 k 个",为什么用最小堆而不是最大堆?

展开解析

关键在于"淘汰谁"。维护 Top-K 时,需要快速找到候选里最弱的那个(最小的),以便用更大的新元素把它替换掉。最小堆的堆顶恰好就是这 k 个里的最小值,O(1) 拿到、O(log k) 替换。

若改用最大堆,堆顶是最大值,而最大值永远不该被淘汰;要找最小值得遍历整个堆 O(k),反而把每步操作变慢。最大堆适合"反复取出全局最大"(如完整排序),不适合"维护一个 Top-K 窗口"。

§2.4单调栈

栈内元素始终保持单调(递增或递减)的栈,用一次进出换来 O(n) 求"下一个更大/更小元素"。

单调栈是普通栈加一条额外规则:新元素入栈前,先把栈顶所有"破坏单调性"的元素弹出。以求"下一个更大元素"为例,维护一个单调递减栈(栈底到栈顶递减)。新元素若比栈顶大,就说明它正是栈顶那个元素苦等的"下一个更大元素",弹出栈顶并记录答案,直到栈顶不再小于新元素,再把新元素入栈。

每个元素最多入栈一次、出栈一次,进出各 O(1),总共 2n 次操作,所以整体均摊 O(n)——虽然内层有 while 弹栈循环,但所有弹栈次数加起来不超过入栈次数。"下一个更大元素""每日温度""柱状图中最大矩形"都是单调栈的典型应用。

数组(存的是下标,标注的是值) 2 1 5 6 3 处理到值 5(下标 2)时 5 比栈顶 1 大 → 弹出 1,记录 ans[1]=5 5 比新栈顶 2 大 → 弹出 2,记录 ans[0]=5 栈空 → 5 入栈 弹栈前 1 2 顶 弹出 1、2 弹栈后入 5 5 顶 答案数组 ans(−1 表示右侧无更大元素) 5 5 6 -1 -1
图 2.3处理到 5 时,栈顶 1 和其下的 2 都比 5 小,被连续弹出并各自记下答案 5;这一步同时确定了两个元素的"下一个更大元素"。注意:栈里实际存的是下标而非值,这样弹栈时才能用下标回填 ans 数组的正确位置;图中格内写值只为直观。

例题:下一个更大元素

next_greater.pyPython
def next_greater(nums: list[int]) -> list[int]:
    """对每个元素,求其右侧第一个更大的元素;没有则为 -1。时间 O(n)。"""
    n = len(nums)
    ans = [-1] * n           # 默认右侧无更大元素
    stack: list[int] = []    # 单调递减栈,存的是下标
    for i, x in enumerate(nums):
        # 当前值 x 比栈顶下标处的值大 → x 就是那个元素的下一个更大元素
        while stack and nums[stack[-1]] < x:
            j = stack.pop()  # 弹出被解决的下标
            ans[j] = x       # 记录答案:j 右侧第一个更大的是 x
        stack.append(i)      # x 自己还没找到更大元素,入栈等待
    return ans               # 留在栈里的下标,右侧再无更大元素,保持 -1


print(next_greater([2, 1, 5, 6, 3]))   # [5, 5, 6, -1, -1]
print(next_greater([5, 4, 3, 2, 1]))   # [-1, -1, -1, -1, -1]

while ... nums[stack[-1]] < x何时弹栈:当前元素 x 大于栈顶下标处的值时。这意味着 x 正是那个旧元素一直在等的"下一个更大元素",可以连续解决多个栈中元素。

ans[j] = x弹栈时记录什么:被弹出下标 j 的答案就是当前值 x。栈里存下标而非值,正是为了能在这里精确回填 ans 的对应位置。

自测

  1. 链表已知位置插入是 O(1),按序号访问却是 O(n),为什么这两个复杂度相差这么大?
    参考答案

    插入只需改动相邻两个节点的 next 指针,与链表长度无关,是 O(1)。访问第 i 个元素时,链表节点散落内存、地址无法计算,只能从头节点顺着指针走 i 步,是 O(n)。根源是链表没有连续内存,因而失去了"按地址直接定位"的能力。

  2. 做队列为什么用 collections.deque 而不用 list?
    参考答案

    list 是连续数组,出队 pop(0) 会让后续所有元素前移一格,单次 O(n),循环里退化成 O(n²)。deque 是双端链式结构,两端的入队出队都是 O(1),正适合队列的"一端进、另一端出"。

  3. 堆取最值是 O(1),但找第二大元素要 O(log n),为什么?
    参考答案

    最大堆只保证堆顶(根)是全局最大,O(1) 可读。第二大元素必在根的两个孩子之一,但具体是哪个、它下面又如何,堆不维护这种全序。要确定第二大,需先弹出堆顶并执行一次下沉调整(O(log n)),新堆顶才是原来的第二大。偏序换来了取最值的快,代价是取"次值"不再免费。

  4. 单调栈内层有 while 弹栈循环,为什么整体仍是均摊 O(n) 而非 O(n²)?
    参考答案

    摊还分析:每个元素一生只入栈一次、出栈一次。外层循环 n 次入栈,所有内层 while 的弹栈次数加起来不超过 n。总操作约 2n 次,因此 n 个元素均摊每个 O(1),整体 O(n)。单看某一步可能弹出多个元素,但被弹出的元素之后不会再被处理,成本已"预付"在它入栈那一刻。

挑战

用两个栈实现一个队列

只用两个栈(仅支持 push / pop / peek / empty),实现一个队列,要求 push 和 pop 的均摊复杂度都是 O(1)。

提示(摊还分析)

设 in_stack 负责入队、out_stack 负责出队。push 时元素压入 in_stack,恒 O(1)。pop 时若 out_stack 为空,就把 in_stack 的元素全部弹出并压入 out_stack——一次倒腾后顺序恰好反转,out_stack 顶就是最早入队的元素。

关键在均摊:每个元素一生最多经历"进 in、出 in、进 out、出 out"四次操作,与队列长度无关。某次 pop 触发的整体搬移看似 O(n),但搬移成本均摊到那 n 个元素身上,每个仍是 O(1)。这与单调栈"每个元素进出各一次"的摊还论证如出一辙。