Files
flykhan 2536c937e3 feat: 完整中文翻译 maths-cs-ai-compendium(数学·计算机科学·AI 知识大全)
翻译自英文原版 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/ 构建缓存
2026-05-03 10:23:20 +08:00

15 KiB
Raw Permalink Blame History

链表、栈和队列

链表、栈和队列是更复杂数据结构的构建模块。本文件涵盖它们的运行机制,然后构建关键模式:快慢指针、单调栈和基于堆的优先队列,通过逐步增加难度的题目,并在每一步指出常见陷阱。

  • 数组提供了快速的随机访问但插入代价高。链表提供了快速插入但没有随机访问。队列将访问限制在一端或两端,而正是这种限制使它们强大:通过限制你能做的事情,它们简化了你需要考虑的事情。

链表

  • 单向链表是一个节点链。每个节点存储一个值和一个指向下一个节点的指针。最后一个节点指向 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)。如果 fastNone,调用 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)  # 弹出最小值,推入 numO(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) > 0if stack
忘记哨兵 直方图遗漏了最后的条形 追加 0 来清空栈
堆中缺少平局打破 比较不可比较的对象 向堆元组添加索引
遍历时修改链表 遍历时删除节点 使用 prev/curr 模式或虚拟头节点

课后练习题(NeetCode

链表

堆 / 优先队列