selection - guide
12. 数据结构选型指南
0. 本章先解决什么问题
学完一堆结构后,真正难的是实际问题里怎么选。
不要问:
哪个数据结构最好?
要问:
我的数据是什么?
我最频繁的操作是什么?
我必须保证什么?
我可以牺牲什么?
本章把前面的结构收束成一套选型方法。
这张图怎么读
这张图的用法很简单:不要从“我想用哪个结构”开始,而是从“我最频繁的操作是什么”开始。一个真实需求通常不只对应一个结构,而是多个结构一起维护同一批数据的不同视图。
1. 先列操作,不要先选结构
面对需求,先写操作表。
例子:
维护一批记录
按 id 查询
按时间排序输出
删除过期记录
统计数量
操作:
| 操作 | 频率 | 约束 |
|---|---|---|
| 按 id 查询 | 很高 | 必须快 |
| 按时间输出 | 中等 | 需要有序 |
| 删除过期 | 周期性 | 范围删除 |
| 插入记录 | 很高 | 不能抖动太大 |
这时单个结构可能不够。
可能组合:
哈希表按 id 查
有序结构按时间维护
两者存同一记录引用或 id
2. 是否需要按下标访问
如果需要:
快速访问第 i 个元素
优先考虑:
- 数组。
- 动态数组。
- 分块数组。
不适合:
- 链表。
- 普通树。
- 哈希表。
按下标访问依赖连续存储和地址计算。
3. 是否需要按 key 精确查找
如果主要操作是:
给 key 找 value
判断 key 是否存在
优先考虑:
- 哈希表。
- 位图。
- 布隆过滤器。
- 有序映射。
选择规则:
| 条件 | 候选 |
|---|---|
| 只需精确查找,不要有序 | 哈希表 |
| key 是小范围整数 | 位图 |
| 可以误判存在,想省空间 | 布隆过滤器 |
| 还要有序和范围 | 平衡树、跳表、B+ 树 |
4. 是否需要有序和范围
如果需要:
按顺序遍历
范围查询
找前驱后继
找大于某值的最小元素
考虑:
- 排序数组。
- 平衡树。
- 跳表。
- B+ 树。
| 结构 | 适合 |
|---|---|
| 排序数组 | 静态数据,查询多,更新少 |
| 平衡树 | 内存中动态有序集合 |
| 跳表 | 实现相对直观的有序结构 |
| B+ 树 | 页/磁盘友好,大规模索引 |
哈希表不适合范围查询。
5. 是否需要频繁插入删除
插入删除要分位置。
| 位置 | 候选 |
|---|---|
| 尾部追加 | 动态数组 |
| 头部/尾部 | 双端队列、链表 |
| 已知节点位置 | 链表 |
| 按序位置 | 平衡树、跳表 |
| 任意 key 删除 | 哈希表、树 |
注意:
链表删除 O(1) 的前提是已知节点或前驱。
如果还要查找位置,不能忽略查找成本。
6. 是否需要快速取最大/最小
如果要反复:
取当前最小
取当前最大
考虑:
- 堆。
- 优先队列。
- 平衡树。
| 需求 | 候选 |
|---|---|
| 只取最值 | 堆 |
| 还要删除任意元素 | 平衡树或堆 + 辅助结构 |
| 还要有序遍历 | 平衡树、跳表 |
| Top K | 大小为 K 的堆 |
堆不擅长查找任意元素和范围查询。
7. 是否是关系问题
如果问题里出现:
依赖
连接
路径
可达
最短
环
拓扑顺序
考虑图。
建模问题:
- 顶点是什么?
- 边是什么?
- 是否有方向?
- 是否有权重?
- 图是稀疏还是稠密?
- 需要 BFS、DFS、拓扑、最短路还是连通性?
图问题的第一步永远是建模。
8. 是否是字符串问题
如果问题涉及:
- 前缀查询。
- 自动补全。
- 子串搜索。
- 多关键词匹配。
- 路径匹配。
考虑:
| 需求 | 候选 |
|---|---|
| 完整词查找 | 哈希表 |
| 前缀查找 | Trie |
| 单模式匹配 | KMP |
| 多模式匹配 | 自动机 |
| 任意子串索引 | 后缀结构 |
同时确认字符编码和大小写规则。
9. 是否可以接受误判
如果你只需要过滤不存在项,并且能接受少量“可能存在”的误判:
布隆过滤器
如果不能接受误判:
使用精确结构
布隆过滤器适合做第一道门:
它说不存在 -> 直接拒绝
它说可能存在 -> 查真实数据
不要把它当最终存储。
10. 是否有内存和缓存压力
如果数据量很大,内存布局非常重要。
| 结构 | 内存特点 |
|---|---|
| 数组 | 紧凑、连续 |
| 链表 | 指针多、分散 |
| 哈希表 | 桶空位和冲突结构 |
| 树 | 节点指针多 |
| 位图 | 极紧凑 |
| Trie | 节点可能很多 |
如果热数据放不进 Cache,程序会慢。
优化方向:
- 使用连续结构。
- 压缩字段。
- 分离冷热数据。
- 减少指针。
- 批量处理。
11. 是否需要组合结构
真实系统经常组合多个结构。
例子:LRU 缓存。
需求:
按 key 快速查找
按最近使用顺序淘汰
组合:
哈希表 -> key 定位节点
双链表 -> 维护最近使用顺序
例子:任务调度。
堆 -> 按优先级取任务
哈希表 -> 按 id 查任务状态
队列 -> 等待执行
组合结构的关键是同步不变量:
一个结构更新时,另一个结构也要保持一致。
机制深挖:组合结构的本质是维护多份索引
单个数据结构通常只优化一种视图。真实需求经常需要多种视图:
按 id 查
按时间扫
按优先级取
按状态过滤
这时系统会维护“真实数据 + 多份索引”:
record store
-> id index
-> time index
-> priority index
-> status buckets
难点从“某个操作快不快”变成“所有索引是否一致”。
| 操作 | 必须同步维护什么 |
|---|---|
| 插入记录 | 写真实数据,再加入所有相关索引 |
| 删除记录 | 从真实数据和所有索引中移除 |
| 更新 key 字段 | 旧索引删除,新索引插入 |
| 更新非索引字段 | 不应无意义触碰索引 |
| 操作失败一半 | 需要回滚、重试或修复一致性 |
一个组合结构的正确性不变量通常是:
索引能找到的 id,真实数据必须存在
真实数据中满足条件的记录,相关索引必须能找到
同一个记录在每个索引里的 key 必须来自同一版本
因此,数据结构选型不是“多加几个索引就结束”,而是要设计更新顺序、失败恢复和一致性校验。
设计反例:一个结构满足一个指标,常常牺牲另一个指标
假设要维护一批在线任务:
按 task_id 精确查询
按 deadline 扫描即将超时的任务
按 priority 取下一个最重要任务
取消任务时要立刻不可见
如果只选哈希表:
task_id -> task
按 id 查很快,但 deadline 扫描只能全表遍历,priority 取最大也要全表找。任务数量一大,慢点会固定出现在“扫描”和“取最值”。
如果只选堆:
按 priority 组织任务
取堆顶很快,但按 id 取消任务会很麻烦。没有额外位置索引时,找到任意任务可能要扫完整个堆;如果使用延迟删除,堆顶弹出时还要检查这个任务是否已经取消。
如果只选有序树:
按 deadline 或 priority 排序
范围能力变强,但只能天然服务一个排序维度。你按 deadline 建树,就不能同时用同一棵树快速取 priority 最大;按 priority 建树,又不能自然扫 deadline 区间。
所以真实设计通常长这样:
真实任务表: task_id -> task
deadline index: deadline -> task_id
priority index: priority -> task_id
cancelled / version: 判断索引项是否仍有效
这个反例说明:数据结构选型不是寻找“最强结构”,而是把需求拆成多个访问视图,再判断哪些视图需要独立索引,哪些视图可以延迟计算。
边界条件:多索引结构的失败常发生在半更新
组合结构最危险的时刻是更新了一半:
- 真实任务表写入 task_7
- priority index 写入 task_7
- deadline index 写入失败
这时系统出现裂缝:
按 id 能找到 task_7
按优先级能取到 task_7
按 deadline 扫不到 task_7
读路径越多,这种裂缝越隐蔽。一个用户从优先级视图看到任务,另一个维护流程从 deadline 视图看不到任务,最后就会出现“任务存在但不会被清理”“任务被取消但仍被调度”这类问题。
设计多索引结构时,要明确三件事:
| 问题 | 设计要求 |
|---|---|
| 更新顺序 | 先改哪份真源,后改哪些派生索引 |
| 失败恢复 | 半更新后是回滚、重试、还是后台修复 |
| 读时校验 | 从索引读出的 id 是否要回真源核验版本 |
| 定期校验 | 能否扫描真源和索引,发现丢索引、多索引、旧版本 |
| 删除语义 | 立即物理删除,还是先打 tombstone 再异步清理 |
多索引结构真正的难点是:每个索引都在加速一种读法,同时也在增加一种不一致入口。
12. 选型速查表
| 需求 | 首选候选 |
|---|---|
| 按下标访问 | 数组、动态数组 |
| 尾部追加多 | 动态数组 |
| 头尾操作多 | 双端队列 |
| 最近未完成 | 栈 |
| 按到达顺序处理 | 队列 |
| 按 key 精确查 | 哈希表 |
| 小范围整数存在性 | 位图 |
| 允许误判的预过滤 | 布隆过滤器 |
| 有序查找和范围 | 平衡树、跳表、B+ 树 |
| 反复取最值 | 堆、优先队列 |
| 层级关系 | 树 |
| 复杂关系和路径 | 图 |
| 前缀查询 | Trie |
| 子串匹配 | KMP、自动机、后缀结构 |
13. 联系实际:从需求到结构
需求:
做一个排行榜,支持更新用户分数、查询某用户分数、取前 100 名、按排名范围查看。
分析:
- 按用户查询:需要 key -> score。
- 排名有序:需要按 score 有序。
- 更新频繁:有序结构要支持删除旧分数、插入新分数。
- Top 100 和范围:需要顺序能力。
可能组合:
映射表: user_id -> score
有序结构: score -> users
如果只取 Top 100,不需要范围,可能用堆加映射。
如果要完整排名和范围,平衡树或跳表更合适。
这就是选型:先需求,后结构。
13.1 选型案例:一个任务池怎么设计
需求:
按 id 查任务
按优先级取下一个任务
按创建时间清理过期任务
能快速判断某任务是否存在
单个结构很难同时满足:
| 需求 | 适合结构 | 原因 |
|---|---|---|
| 按 id 查 | 哈希表 | 等值查找快 |
| 取最高优先级 | 堆 / 优先队列 | 动态最值 |
| 清理过期 | 有序结构 | 按时间范围扫描 |
| 判断存在 | 哈希表 / 位图 / 布隆过滤器 | 取决于 key 范围和误判容忍度 |
这时更像是在维护多份索引:
任务真实数据
-> id 索引
-> 优先级索引
-> 时间索引
难点不在“会不会写某个结构”,而在一致性:
插入任务时,所有索引都要更新。
删除任务时,所有索引都要删除。
任务状态变化时,相关索引也要调整。
所以数据结构选型最终会回到不变量:每个索引必须和真实数据保持一致。
设计卡:为一个需求写结构选择说明
结构选型不要只写“使用某结构,因为快”。更完整的说明至少包含五句:
| 句子 | 你要说明什么 |
|---|---|
| 需求句 | 最频繁、最关键的操作是什么 |
| 结构句 | 选择哪种结构承载主操作 |
| 成本句 | 时间、空间、扩容、缓存或维护成本 |
| 退化句 | 最坏情况下会发生什么 |
| 替代句 | 为什么没有选择另一个候选结构 |
练习:为“按优先级处理任务,同时能取消指定任务”写一段选型说明。你可能需要组合优先队列和一个按 ID 定位的索引结构。这个练习的重点是承认现实需求经常不是单一结构能优雅完成,而是需要组合并维护一致性。
14. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 面对需求时如何先列操作和频率?
- 按下标、按 key、有序范围、最值、关系、字符串分别该考虑哪些结构?
- 为什么哈希表和树不是互相替代,而是能力不同?
- 为什么真实系统经常组合多个结构?
- 如何把误判、内存、缓存、更新频率纳入选型?
- 如何从排行榜、缓存、任务调度这类真实需求推导结构组合?
数据结构选型的核心不是背答案,而是把需求拆成操作、约束和不变量,再选择最合适的成本模型。