Chapter 02
核心数据结构
第 01 章用复杂度给解法空间画了边界——这一章填地基:每种数据结构的物理布局决定了它各项操作的复杂度,而正是这些复杂度让某个套路成立。
本章骨架
- 数组与链表的取舍来自内存是否连续。
- 栈与队列是访问顺序的两种约束。
- 堆用"部分有序"换来 O(1) 取最值。
- 单调栈用一次进出维持单调性。
§2.1数组 vs 链表
数组押注随机访问,链表押注就地插入——分歧的根源是内存是否连续。
数组把元素摆在一段连续内存里。第 i 个元素的地址等于首地址加 i × 元素大小,一次乘加就能算出,因此随机访问是 O(1)。代价在中间插入或删除:连续内存不允许"挤进去",必须把后面所有元素整体搬移,最坏 O(n)。
链表把元素拆成一个个节点,每个节点除了存值,还存一个指向下一个节点的指针。节点散落在内存各处,靠指针串成一条链。已知插入位置时,改两个指针即可完成插入,是 O(1);但访问第 i 个元素必须从头节点顺着指针走 i 步,是 O(n)。指针跳转还使链表对 CPU 缓存不友好——相邻节点的物理地址往往相距很远,缓存预取失效。
next 指针指向的是散落各处的内存地址,图中曲线只是示意"物理上不相邻",这正是链表缓存不友好的来源。例题:反转单链表(迭代版)
反转链表是链表操作的基本功:不新建节点,只逐个翻转每个节点的 next 指向。
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 做队列、靠 list.pop(0) 出队是常见的失败模式。list 是连续数组,删掉头元素后,后面每个元素都要向前搬移一格,单次 pop(0) 就是 O(n);放进循环里,整个队列退化成 O(n²)。队列一律用 deque,它的两端进出都是 O(1)。
例题:有效括号匹配
给一个只含 ()[]{} 的字符串,判断括号是否合法配对。每遇到右括号,需要和"最近一个未匹配的左括号"比对——"最近"二字正是栈 LIFO 的信号。
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)。
i → 2i+1 / 2i+2 让数组无需指针就能"找到孩子"。注意:堆顶 1 是全局最小,但数组其余位置并不有序(如 8 排在 4 前面)——堆只保证父≤子的偏序,不保证整体有序。例题:求 Top-K 最大元素
从 n 个数里取最大的 k 个。直觉是用最大堆,但更优的做法是用一个大小为 k 的最小堆:堆里始终保留当前见过的最大的 k 个,堆顶是这 k 个里最小的;新元素只要比堆顶大就替换堆顶。
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)。
| 方法 | 时间复杂度 | 适用场景 |
|---|---|---|
| 全排序后取前 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 弹栈循环,但所有弹栈次数加起来不超过入栈次数。"下一个更大元素""每日温度""柱状图中最大矩形"都是单调栈的典型应用。
ans 数组的正确位置;图中格内写值只为直观。例题:下一个更大元素
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 的对应位置。
自测
- 链表已知位置插入是 O(1),按序号访问却是 O(n),为什么这两个复杂度相差这么大?
参考答案
插入只需改动相邻两个节点的
next指针,与链表长度无关,是 O(1)。访问第i个元素时,链表节点散落内存、地址无法计算,只能从头节点顺着指针走i步,是 O(n)。根源是链表没有连续内存,因而失去了"按地址直接定位"的能力。 - 做队列为什么用
collections.deque而不用list?参考答案
list是连续数组,出队pop(0)会让后续所有元素前移一格,单次 O(n),循环里退化成 O(n²)。deque是双端链式结构,两端的入队出队都是 O(1),正适合队列的"一端进、另一端出"。 - 堆取最值是 O(1),但找第二大元素要 O(log n),为什么?
参考答案
最大堆只保证堆顶(根)是全局最大,O(1) 可读。第二大元素必在根的两个孩子之一,但具体是哪个、它下面又如何,堆不维护这种全序。要确定第二大,需先弹出堆顶并执行一次下沉调整(O(log n)),新堆顶才是原来的第二大。偏序换来了取最值的快,代价是取"次值"不再免费。
- 单调栈内层有
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)。这与单调栈"每个元素进出各一次"的摊还论证如出一辙。