翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。 第01章 向量 | 第02章 矩阵 | 第03章 微积分 第04章 统计学 | 第05章 概率论 | 第06章 机器学习 第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音 第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络 第13章 计算与操作系统 | 第14章 数据结构与算法 第15章 生产级软件工程 | 第16章 SIMD与GPU编程 第17章 AI推理 | 第18章 ML系统设计 第19章 应用人工智能 | 第20章 前沿人工智能 翻译说明: - 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留 - mkdocs.yml 配置中文导航 + language: zh - README.md 已翻译为中文(兼 docs/index.md) - docs/ 目录包含指向各章文件的 symlink - 约 29,000 行中文内容,排除 .cache/ 构建缓存
15 KiB
链表、栈和队列
链表、栈和队列是更复杂数据结构的构建模块。本文件涵盖它们的运行机制,然后构建关键模式:快慢指针、单调栈和基于堆的优先队列,通过逐步增加难度的题目,并在每一步指出常见陷阱。
- 数组提供了快速的随机访问但插入代价高。链表提供了快速插入但没有随机访问。栈和队列将访问限制在一端或两端,而正是这种限制使它们强大:通过限制你能做的事情,它们简化了你需要考虑的事情。
链表
- 单向链表是一个节点链。每个节点存储一个值和一个指向下一个节点的指针。最后一个节点指向
null。
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
-
相对于数组的优势:在已知位置插入或删除是 $O(1)$(只需重新指向指针)。无需移动元素。
-
劣势:访问元素
i需要O(i)次遍历(无随机访问)。缓存局部性差(节点分散在内存中)。 -
双向链表增加了一个
prev指针,支持向后遍历。用于 LRU 缓存(常数时间删除任何节点)和浏览器历史(前进/后退)。
| 操作 | 单向 | 双向 |
|---|---|---|
| 按索引访问 | O(n) |
O(n) |
| 在头部插入 | O(1) |
O(1) |
| 在尾部插入 | O(n) 或 $O(1)$* |
O(1) |
| 删除给定节点 | $O(n)$** | O(1) |
| 搜索 | O(n) |
O(n) |
*有尾指针时。**需要前驱节点,需要遍历。
- 哨兵节点(虚拟头/尾节点)简化了边界情况。没有虚拟头节点时,在头部插入或删除头部需要特殊代码。有了虚拟节点,每个真实节点都有前驱。
# 无虚拟节点:头部删除需要特殊处理
def delete_head(head):
if not head:
return None
return head.next
# 有虚拟节点:统一逻辑
dummy = ListNode(0)
dummy.next = head
# 现在每次删除都是:prev.next = prev.next.next
- 陷阱:忘记处理空列表(
head is None)或单元素列表。始终测试这些边界情况。
模式:快慢指针(弗洛伊德算法)
- 使用两个以不同速度移动的指针来检测链表的属性。慢指针一次移动一步;快指针一次移动两步。
简单:环形链表
-
问题:判断一个链表是否有环。
-
模式:如果有环,快指针最终会追上慢指针(它们会相遇)。如果没有环,快指针会到达
null。
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
-
为什么有效:如果环的长度为 $c$,快指针每步缩小1个节点的差距。它们必在慢指针进入环后的
c步内相遇。 -
陷阱:检查
fast and fast.next(而不仅仅是fast.next)。如果fast是None,调用fast.next会崩溃。
中等:寻找链表的中间节点
-
问题:返回中间节点。
-
模式:当快指针到达末尾时,慢指针在中间。
def find_middle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow # slow 在中间(偶数长度时为第二个中间节点)
中等:环形链表 II(寻找环的起点)
-
问题:返回环开始的节点。
-
模式:在快指针和慢指针相遇后,将一个指针重置到头部。两者以速度1移动。它们在环的起点相遇。
def detect_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
# 将一个指针重置到头部
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
return None
- 为什么有效:设从头到环起点的距离为 $a$,从环起点到相遇点的距离为 $b$。慢指针走了 $a + b$。快指针走了 $2(a + b)$。差值为一整圈:$a + b = c$(环长)。所以 $a = c - b$:从头到环起点的距离等于从相遇点到环起点的距离(沿环向前走)。
困难:K个一组反转链表
- 问题:反转链表中每
k个连续节点。
def reverse_k_group(head, k):
# 检查是否还有 k 个节点
node = head
for _ in range(k):
if not node:
return head
node = node.next
# 反转 k 个节点
prev, curr = None, head
for _ in range(k):
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
# 当前 head 现在是反转后的组的尾节点
# 递归处理剩余部分
head.next = reverse_k_group(curr, k)
return prev # prev 是这组的新头节点
- 陷阱:原地反转模式(
prev, curr, nxt)值得记住。画出来:每一步,你将curr.next指回prev,然后推进所有三个指针。顺序搞错会破坏链表。
栈
-
栈是 LIFO(后进先出):最近添加的元素最先被移除。想象一堆盘子。
-
操作:
push(x)添加到顶部,pop()从顶部移除,peek()查看顶部不移除。全部 $O(1)$。 -
栈是递归(调用栈)、表达式求值(中缀转后缀)和撤销操作(每个操作被入栈,撤销时弹出最后一个)背后的隐式结构。
简单:有效的括号
-
问题:给定一个由括号
()[]{}组成的字符串,判断它们是否平衡。 -
模式:将左括号入栈。当看到右括号时,检查栈顶是否匹配。
def is_valid(s):
stack = []
matching = {')': '(', ']': '[', '}': '{'}
for char in s:
if char in matching:
if not stack or stack[-1] != matching[char]:
return False
stack.pop()
else:
stack.append(char)
return len(stack) == 0
- 陷阱:忘记最后检查
len(stack) == 0。字符串 "(((" 中没有不匹配的情况,但因为没有闭合的括号,它是无效的。
模式:单调栈
-
单调栈维护按排序顺序排列的元素(递增或递减)。当新元素会破坏顺序时,你弹出元素直到顺序恢复。
-
何时使用:问题要求"对每个元素,找到下一个/上一个更大/更小的元素。"栈的总时间复杂度为 $O(n)$,因为每个元素最多被入栈和出栈一次。
中等:每日温度
-
问题:给定每日温度,对于每一天,找到需要等多少天才会升温。
-
模式:使用一个索引栈。当当前温度高于栈顶时,弹出并记录距离。
def daily_temperatures(temperatures):
n = len(temperatures)
result = [0] * n
stack = [] # 索引栈,温度递减
for i in range(n):
while stack and temperatures[i] > temperatures[stack[-1]]:
prev = stack.pop()
result[prev] = i - prev
stack.append(i)
return result
-
每个元素被入栈一次,最多出栈一次:总计 $O(n)$。
-
陷阱:在栈中存储索引(而非值)。你需要索引来计算距离。
困难:柱状图中最大的矩形
-
问题:给定一个条形高度数组,找出最大矩形的面积。
-
模式:对于每个条形,找出它可以向左右延伸多远(即,每侧最近的更短条形)。单调递增栈高效地追踪这个信息。
def largest_rectangle(heights):
stack = [] # 索引栈,高度递增
max_area = 0
heights.append(0) # 哨兵,用于最后清空栈
for i, h in enumerate(heights):
start = i
while stack and stack[-1][1] > h:
idx, height = stack.pop()
max_area = max(max_area, height * (i - idx))
start = idx # 当前条形可以延伸到被弹出条形开始的位置
stack.append((start, h))
heights.pop() # 移除哨兵
return max_area
-
陷阱:
start = idx这行很微妙。当我们弹出一个比当前条形更高的条形时,当前条形可以向后延伸至被弹出条形开始的位置(因为中间的所有条形至少和被弹出条形一样高)。缺少这行会得到错误的面积。 -
陷阱:哨兵
heights.append(0)确保栈中所有剩余的条形被处理。没有它,那些右侧从未遇到更短条形的条形会被遗漏。
队列
-
队列是 FIFO(先进先出):元素从后面添加,从前面移除。想象商店里排队。
-
双端队列(deque)支持在两端
O(1)插入和删除。Python 的collections.deque是标准实现。 -
队列是 BFS(广度优先搜索,第14章文件04)、任务调度和消息传递背后的结构。
简单:用栈实现队列
-
问题:仅使用两个栈实现一个队列。
-
模式:使用一个栈进行入队操作,一个栈进行出队操作。当出队栈为空时,将所有元素从入队栈转移到出队栈(反转顺序)。
class MyQueue:
def __init__(self):
self.push_stack = []
self.pop_stack = []
def push(self, x):
self.push_stack.append(x)
def pop(self):
if not self.pop_stack:
while self.push_stack:
self.pop_stack.append(self.push_stack.pop())
return self.pop_stack.pop()
def peek(self):
if not self.pop_stack:
while self.push_stack:
self.pop_stack.append(self.push_stack.pop())
return self.pop_stack[-1]
def empty(self):
return not self.push_stack and not self.pop_stack
- 每次操作的平摊复杂度 $O(1)$:每个元素最多在两个栈之间移动一次。
优先队列和堆
-
优先队列总是返回最小(或最大)的元素,不论插入顺序。标准实现是二叉堆。
-
最小堆是一棵完全二叉树,其中每个父节点都小于其子节点。最小值总是在根节点。以数组形式存储:节点
i的子节点在位置2i + 1和 $2i + 2$。
| 操作 | 时间 |
|---|---|
| 插入 | O(\log n) |
| 获取最小值 | O(1) |
| 提取最小值 | O(\log n) |
| 从数组构建堆 | O(n) |
- Python 的
heapq模块提供了最小堆。对于最大堆,将值取反。
import heapq
# 最小堆
h = []
heapq.heappush(h, 5)
heapq.heappush(h, 2)
heapq.heappush(h, 8)
print(heapq.heappop(h)) # 2(最小)
# 最大堆技巧:取反
heapq.heappush(h, -10)
print(-heapq.heappop(h)) # 10(最大)
中等:数组中的第 K 个最大元素
-
问题:找到第 k 个最大的元素。
-
模式:维护一个大小为
k的最小堆。堆的根节点就是第 k 大的元素。如果堆有k个元素且新元素大于根节点,则替换根节点。
import heapq
def find_kth_largest(nums, k):
heap = nums[:k]
heapq.heapify(heap) # O(k)
for num in nums[k:]:
if num > heap[0]:
heapq.heapreplace(heap, num) # 弹出最小值,推入 num:O(log k)
return heap[0]
-
O(n \log k)时间,O(k)空间。当k \ll n时,这比排序($O(n \log n)$)好得多。 -
陷阱:使用大小为
n的最大堆并弹出k次也可行但较慢:$O(n + k \log n)$。大小为k的最小堆是最优方法。
困难:合并 K 个排序链表
-
问题:合并
k个已排序链表为一个排序链表。 -
模式:使用一个包含每个链表头节点的最小堆。弹出最小的节点,将其添加到结果中,并将其下一个节点推入堆中。
import heapq
def merge_k_lists(lists):
heap = []
for i, lst in enumerate(lists):
if lst:
heapq.heappush(heap, (lst.val, i, lst))
dummy = ListNode(0)
curr = dummy
while heap:
val, i, node = heapq.heappop(heap)
curr.next = node
curr = curr.next
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
-
$O(n \log k)$,其中
n是总节点数。堆中最多有k个元素。 -
陷阱:堆元组中的
i(索引)是用于打破平局的。没有它,当值相等时 Python 会尝试比较ListNode对象,这会崩溃因为ListNode不支持<。索确保了一有效的比较。
常见陷阱总结
| 陷阱 | 示例 | 修复 |
|---|---|---|
fast.next 上的空指针 |
循环检测中使用 while fast.next |
检查 fast and fast.next |
| 未处理空链表 | 反转 None |
添加 if not head 守卫 |
| 栈下溢 | 从空栈弹出 | 检查 len(stack) > 0 或 if stack |
| 忘记哨兵 | 直方图遗漏了最后的条形 | 追加 0 来清空栈 |
| 堆中缺少平局打破 | 比较不可比较的对象 | 向堆元组添加索引 |
| 遍历时修改链表 | 遍历时删除节点 | 使用 prev/curr 模式或虚拟头节点 |
课后练习题(NeetCode)
链表
- 反转链表 — 基础的原地反转
- 合并两个有序链表 — 双指针合并
- 环形链表 — 快慢指针
- 重排链表 — 找中间 + 反转 + 合并
- 删除链表的倒数第 N 个节点 — 间距为
n的双指针 - LRU 缓存 — 哈希表 + 双向链表
栈
堆 / 优先队列
- 数据流中的第 K 大元素 — 大小为
k的最小堆 - 最后一块石头的重量 — 最大堆模拟
- 最接近原点的 K 个点 — 按距离排序的最小堆
- 任务调度器 — 贪心 + 最大堆 + 冷却时间
- 数据流的中位数 — 双堆(下半部分用最大堆,上半部分用最小堆)