Skip to content

第2章 数据结构:从接口到缓存

🔥 开篇故事:一次真实的生产事故

2023年,某电商平台在大促期间遭遇严重的订单处理延迟。客服系统后台显示:

上午 10:00 - 订单处理正常,平均耗时 2 秒
上午 11:00 - 订单积压开始,平均耗时 15 秒  
中午 12:00 - 系统告警,平均耗时超过 5 分钟
下午 13:00 - 客服投诉暴增,处理时间突破 50 分钟

运维团队排查后发现,订单处理队列的实现代码是这样的:

python
orders = []

def add_order(order):
    orders.append(order)  # 入队

def process_next():
    return orders.pop(0)  # 出队 - 取第一个元素

看起来很正常?问题在于 pop(0)

每次从列表头部删除一个元素,Python 需要把后面所有元素向前移动一个位置。10 万个订单排队时,处理一个订单的移动成本是 10 万次操作;处理 10 万个订单的总成本接近:

10万 + 9.99万 + 9.98万 + ... ≈ 50亿次操作

原本 5 分钟能处理完的任务,变成了 50 分钟。

修复只需要一行代码:

python
from collections import deque
orders = deque()

def process_next():
    return orders.popleft()  # 双端队列的头部删除是 O(1)

修复后,10 万订单的处理时间回到了 5 分钟。


这不是"学数据结构没用"的故事。恰恰相反:数据结构的知识在 agent 时代更重要

因为 coding agent 很擅长生成看起来能跑的代码,但它不知道你的系统要处理 10 万个订单,也不知道 pop(0) 在大规模下会变成性能杀手。


为什么 agent 时代更需要懂数据结构

Coding agent 很擅长根据一句话生成代码。问题是,它经常会生成能跑但结构不合适的代码。

例如:

python
def has_duplicate(names):
    seen = []
    for name in names:
        if name in seen:
            return True
        seen.append(name)
    return False

这段代码看起来很自然,也能通过小测试。但 name in seen 会在线性列表里逐个查找。最坏情况下,第 1 次查 0 个,第 2 次查 1 个,第 3 次查 2 个,总代价接近:

text
0 + 1 + 2 + ... + (n - 1) = Θ(n²)

如果改用集合:

python
def has_duplicate(names):
    seen = set()
    for name in names:
        if name in seen:
            return True
        seen.add(name)
    return False

查找和插入通常就是常数级别,整体变成 Θ(n)

这不是语法差异,而是数据结构差异。你不懂数据结构,就很难发现 agent 把一个本该线性的任务写成了平方级。


📊 复杂度直觉:为什么要关心增长量级

看到 Θ(n)Θ(n²) 时,不要只把它们当成数学符号。它们回答的是一个非常现实的问题

当数据量翻倍,程序会变慢多少?

一个朴素的理解方式

复杂度n=10n=100n=1000n=10000数据翻倍时
Θ(1)1 次操作1 次操作1 次操作1 次操作不变
Θ(log n)~3 次~7 次~10 次~13 次几乎不变
Θ(n)10 次100 次1000 次10000 次慢 2 倍
Θ(n log n)~30 次~700 次~10000 次~130000 次略多于 2 倍
Θ(n²)100 次10000 次100万次1亿次慢 4 倍

生活中的类比

  • Θ(1) 像直接跳到书架上某一格,不管书有多少格,跳过去的时间一样。
  • Θ(log n) 像翻字典,每次比较后排除一半候选。
  • Θ(n) 僇一页页翻书找某个词。
  • Θ(n²) 僇每次翻书都要从头开始,做 n 次这样的查找。

为什么 Θ(n²) 在大规模下会崩溃

假设每秒能执行 1 亿次操作:

复杂度n=100万n=1000万n=1亿
Θ(n)0.01 秒0.1 秒1 秒
Θ(n log n)0.2 秒2.3 秒26 秒
Θ(n²)10,000 秒 (2.8 小时)100,000 秒 (27 小时)1 亿秒 (3 年)

这就是开篇故事里发生的事情:pop(0) 把一个 Θ(n) 的队列操作变成了 Θ(n²) 的总处理成本。


🗺️ 问题诊断表:从需求信号到数据结构

当你拿到一个任务时,不要直接问"用什么数据结构"。先问:

我要频繁做哪些操作?这些操作有什么特征?

下面的诊断表可以帮助你快速定位:

核心操作信号 → 候选数据结构

问题信号核心需求最可能的结构复杂度常见错误替代
判断某元素是否存在成员查询set期望 Θ(1)list (Θ(n))
按唯一 id 查找对象键值映射dict期望 Θ(1)list 遍历
按顺序追加,按顺序遍历顺序存储listΘ(1) append无(正确)
按提交顺序处理先进先出deque 队列Θ(1) 两端list.pop(0) (Θ(n))
撤销最近操作后进先出listΘ(1) 末尾无(正确)
反复取最值(优先级)取最大/最小heapqO(log n)每次排序 (Θ(n log n))
按范围查询区间过滤有序结构 + bisectΘ(log n) 查位置dict (不支持)
动态分组合并合并集合并查集近 Θ(1)每次扫描全部 (Θ(n))
按位置快速访问下标访问list 数组Θ(1)链表 (Θ(n))
在已知位置插入删除节点操作链表Θ(1) 改指针数组 (Θ(n) 移动)

组合场景诊断

很多时候,单一结构不能满足所有需求。下面的组合模式很常见:

组合场景结构组合各结构职责
既要去重又要保持顺序set + listset 判断存在,list 保存顺序
既要按 id 查又要按时间查dict + list/有序结构dict 做 id 索引,时间结构做范围查询
既按优先级又按提交顺序heap + counterheap 取最高优先级,counter 保公平性
既查状态又计数量dict + 缓存计数dict 存完整状态,计数缓存加速统计

数据结构选择从操作需求开始:同一批数据,因为高频操作不同,会走向不同组织方式
数据结构选择从操作需求开始:同一批数据,因为高频操作不同,会走向不同组织方式

本章主线

本章会像普通算法教材一样讲清楚数据结构本身:

  • 抽象数据类型和操作接口
  • 数组、动态数组、链表、栈、队列
  • 集合、字典和哈希表
  • 树、有序结构、优先队列、堆和并查集
  • 摊还分析
  • 接口、缓存与 agent 时代的数据结构审查

但讲完概念不是终点。每个知识点都会回到一个现实动作:

当 agent 给出代码或推荐数据结构时,你如何判断它选得对不对?

你会反复练习一张数据结构选择卡

text
任务:
数据规模:
高频操作:
- 操作 A:频率 / 是否要求快
- 操作 B:频率 / 是否要求快

候选数据结构:
选择理由:
放弃其他结构的理由:
时间复杂度:
空间复杂度:
需要 agent 帮什么:
我必须自己审查什么:

这张卡是第二章的核心工具。它能把"帮我写个功能"改写成 agent 更容易执行、人也更容易审查的规格。


贯穿案例:在线课堂互动系统

为了让本章不变成一串孤立名词,我们会反复回到同一个现实任务:

设计一个在线课堂互动系统。学生可以进入课堂、提交问题、撤回最近一次提交;老师可以按提交顺序查看问题,也可以优先处理被标记为紧急的问题;系统还要统计每个学生提交了多少问题,并在课后导出记录。

这个系统看起来是一个产品功能,其实包含了很多数据结构问题:

子需求结构信号本章会用到
保存课堂中的学生判断是否已经加入set
按学生 id 找学生信息key-value 查询dict
保存所有问题记录按顺序追加和导出list
撤回最近一次提交后进先出
按提交顺序处理普通问题先进先出队列
优先处理紧急问题反复取最高优先级
按时间段导出记录范围查询有序结构
临时分组讨论合并集合、判断是否同组并查集
问题不断增长一串追加的总成本摊还分析
反复读取上下文复用已计算状态缓存

接下来每一节都会拿其中一块来拆。读的时候你可以不断问:

text
如果我只让 agent 直接写代码,它可能会怎么写?
这个写法在哪个操作上会慢?
我现在学到的数据结构能解决哪个瓶颈?

这样,数据结构就不再是抽象表格,而是你解决现实功能时的工具箱。


本章的专业读法

读数据结构时,不要只问"它是什么",而要沿着六个层次读:

层次要问的问题产出
接口它向外承诺哪些操作?ADT 或 API
表示它内部怎样组织数据?数组、节点、哈希桶、树形关系
不变量什么性质必须一直成立?左小右大、堆序、先进先出
成本每个操作随规模怎样增长?时间复杂度、空间复杂度
适用边界什么需求下它好,什么需求下它差?选择理由和放弃理由
审查问题Agent 可能在哪里选错或说错?反例、追问、改写提示词

这六层会贯穿本章。比如讲堆时,不只是会用 heapq,还要知道堆的不变量是什么、为什么 heappopO(log n)、为什么它适合反复取最小值、不适合完整排序。讲哈希表时,不只是会用 dict,还要知道 key 必须可哈希、冲突如何影响期望复杂度、为什么范围查询不是它的强项。

这也是 agent 时代学习数据结构的方式:你不一定要手写所有底层代码,但必须能审查接口是否符合需求、复杂度分析是否准确、实现是否维护了不变量。


本章各节


本章学习目标

学完本章,你应该能:

  • 用操作接口描述一个数据结构,而不是只背它的名字
  • 说清 Python 中常见结构的典型复杂度
  • 识别列表查找、头部删除、重复排序等隐藏成本
  • 根据操作频率选择数组、链表、栈、队列、集合、字典、树、堆或并查集
  • 区分最坏情况、期望情况和摊还情况
  • 用缓存视角理解数据结构接口、Skill、memoization 与 KV-cache
  • 审查 agent 给出的数据结构推荐,并写出接受或拒绝的理由

上一章:第1章 当你让 AI 帮你写代码时,你还需要懂算法吗?

下一节:2.1 抽象数据类型与操作契约

新时代的算法课程