数据结构与算法 -- 后端工程师视角
一、为什么后端工程师需要学数据结构与算法
大多数后端工程师对数据结构与算法的第一反应是”刷 LeetCode”。但面试只是副产品——真正重要的,是你每天用的每一个中间件、每一行代码,底层都是数据结构的精巧组合。
几个让你无法反驳的场景:
- 你写了一条 SQL
SELECT * FROM t WHERE id BETWEEN 1000 AND 2000,MySQL 为什么能在几百毫秒内从几千万行数据中找到这 1000 行?因为 InnoDB 在磁盘上组织了一棵 B+Tree。 - 你在 Redis 里插入几十万条
ZADD命令,为什么ZRANGEBYSCORE还能在毫秒级返回?因为 ZSet 底层是跳表 (Skip List)。 - 你给 Kafka 发消息,Producer 怎么决定发到哪个分区?可能是
hash(key) % partition_count,这涉及哈希函数和分区策略。 - ES 的搜索建议(”你搜的是不是…”)为什么能在你敲击键盘的瞬间给出联想?背后是 Trie 树和有限状态转换器 (FST)。
- JVM Full GC 为什么这么慢?你在优化 O(n²) 算法时和 GC 线程在竞争同一块堆结构的时间片。
数据结构不是八股文,而是每个中间件作者的工程决策记录。 这篇文章的目的不是带你刷题,而是让你把脑子里零散的 DSA 知识点,和你实际工作中使用的系统一一对应起来。
二、数组与链表
2.1 本质差异:连续内存 vs 链式内存
数组和链表是最基础的数据结构,但它们的差异不是”一个能随机访问、一个不能”这么简单。真正的差异在于内存布局,而内存布局决定了 CPU 缓存的友好程度。
1 | 数组 (连续内存): |
| 特性 | 数组 | 链表 |
|---|---|---|
| 内存布局 | 连续 | 离散 |
| 随机访问 | O(1) | O(n) |
| 插入/删除(中间) | O(n)(需要移动) | O(1)(改指针) |
| CPU L1/L2/L3 Cache 友好性 | 极高(空间局部性) | 极差(cache miss 频繁) |
| 内存开销 | 0(仅数据本身) | 每节点额外存指针(8/16 bytes) |
CPU 缓存友好性详解: 现代 CPU 每次从主存取数据时,会以 cache line(通常 64 bytes)为单位把整块数据加载到 L1/L2/L3 缓存中。数组元素连续排列,一次 cache miss 能一次性预取相邻的多个元素。链表节点散布在内存各处,每遍历一个节点都是几乎必定的 cache miss——在 L1 缓存命中约 1ns,主存访问约 100ns,这意味着遍历链表比遍历数组慢两个数量级。
这是为什么很多”理论上 O(1) 插入的链表”在实际基准测试中跑不过 ArrayList 的根本原因。
2.2 在 MySQL Page 中的应用
InnoDB 的磁盘管理以 Page(默认 16KB)为单位。每个 Page 内部是一块连续的 16KB 内存:
1 | InnoDB Page 结构 (16KB 连续空间): |
- User Records 区域:行数据按插入顺序紧挨着存储,本质上是一个物理紧凑的数组。每个 Record 通过链表指针(next_record,2 bytes 偏移量)串联成逻辑链表。这就是”物理数组 + 逻辑链表”的混合结构。
- Page Directory:每个 Page 尾部有槽 (Slots),每个槽指向一组记录中最大的那条记录,构成稀疏索引。二分查找页内数据时,先二分定位 Slot,再在 Slot 所指向的小范围内顺序扫描——这是”数组二分 + 链表顺序”的组合技。
详细 Page 结构参见 MySQL 深度解析。
2.3 在 Redis quicklist 中的应用
Redis 3.2 之前的 List 底层使用 linkedlist 或 ziplist,Redis 3.2+ 统一为 quicklist:
1 | quicklist 结构: |
设计哲学: 每个 ziplist(或 listpack)是一段连续内存,内部元素连续存储,具有数组的 cache 友好性。多个 ziplist 通过链表串联,解决了单个连续块无限膨胀的问题。list-max-ziplist-size 控制每个 ziplist 最多多少元素或多大字节。
这是典型的混合结构——在空间局部性和插入灵活性之间取平衡。插入中间元素时,只需改目标 ziplist 内部的元素移动,而不需要像纯数组一样移动所有后续元素。
2.4 在 Java 集合中的应用
ArrayList 扩容:
1 | // ArrayList 扩容核心逻辑 (JDK 8+) |
扩容涉及 Arrays.copyOf → System.arraycopy —— 这是一个 native 方法,调用 memmove/memcpy 进行内存块的整块拷贝,CPU 能利用 SIMD 指令并行拷贝,速度极快。但扩容时所有元素都要搬运,所以不要在构造 ArrayList 时不传初始容量、然后插入几百万条数据——扩容 30 多次,每次都搬运全部元素,时间复杂度实际上是 O(n²)。
LinkedList 的最佳使用场景: 如果你确认需要在 List 头部频繁插入/删除(如实现一个 FIFO 队列),LinkedList 的 addFirst/removeFirst 是 O(1)。但绝大多数场景下 ArrayList 更快——哪怕是中间插入,System.arraycopy 搬运内存的速度也能抵消遍历链表的 cache miss 开销。
三、栈与队列
3.1 栈:后进先出
JVM 操作数栈与帧栈
每个 JVM 线程都对应一个 Java 虚拟机栈 (Java Virtual Machine Stack),每个方法调用对应一个 栈帧 (Stack Frame):
1 | JVM 线程栈: |
操作数栈 (Operand Stack) 是字节码指令执行的空间。比如 int a = 3 + 5,字节码是:iconst_3(压栈)→ iconst_5(压栈)→ iadd(弹出两个、相加、结果压栈)→ istore_1(弹出结果存入局部变量表)。整个计算过程就是在操作数栈上做 push/pop。
方法调用栈 也是栈的经典用途——当异常发生时,JVM 打印的 stacktrace 其实就是在遍历当前线程的方法调用栈。递归过深导致的 StackOverflowError,就是线程栈空间不足以容纳新的栈帧。
括号匹配问题 — 后端无处不在
1 | // 一个实际的括号匹配检查 —— 这在 RPC 框架的序列化/反序列化代码中频繁出现 |
这不是面试题——你写的任何 DSL 解析器(如 Spring EL 表达式、MyBatis 动态 SQL 的 <if>/<foreach> 标签解析)、JSON/Protobuf 的编解码器,底层都大量使用栈做嵌套结构的校验。
3.2 队列:先进先出
Kafka 分区队列
Kafka 的每个 Partition 在物理上是一个不可变的追加日志——本质上是一个只追加的数组队列:
1 | Kafka Partition: |
- Producer 端:追加写入(append-only),相当于
enqueue - Consumer 端:顺序消费,相当于
dequeue - Offset:消费游标,记录消费者”读到了队列的哪个位置”
Kafka 高性能的关键之一就是这种 顺序追加 的数组式队列——磁盘的顺序写速度可达 600MB/s 以上(机械硬盘),远高于随机写。这比链表式的消息队列(随机写磁盘)快一到两个数量级。
线程池的工作队列
Java ThreadPoolExecutor 使用 BlockingQueue 存储待执行任务:
| 实现类 | 数据结构 | 特点 | 适用场景 |
|---|---|---|---|
ArrayBlockingQueue |
有界循环数组 | 固定大小,锁竞争集中在同一把锁 | 需要限制队列长度的场景 |
LinkedBlockingQueue |
单向链表 | 可选有界/无界,两把锁(putLock/takeLock) | 吞吐量要求高的场景 |
SynchronousQueue |
无容量 | 不存储任务,直接移交 | 希望任务立即被执行,不排队 |
DelayedWorkQueue |
堆(小顶堆) | 按延迟时间排序 | ScheduledThreadPoolExecutor |
ArrayBlockingQueue 的底层实现是一个循环数组——两个指针 putIndex 和 takeIndex 在数组上循环移动:
1 | ArrayBlockingQueue 循环数组 (capacity = 8): |
📖 独立文章:堆 (Heap) 与 TopK
四、哈希表
4.1 哈希函数设计:扰动函数
哈希函数的核心目标:把任意输入均匀、低碰撞地映射到一个整数区间。
Java HashMap 的哈希函数:
1 | // JDK 8 HashMap.hash() |
这行 (h = key.hashCode()) ^ (h >>> 16) 是经典的扰动函数——把高 16 位和低 16 位做异或,让高位的变化也能影响低位。为什么需要扰动?
因为 HashMap 通过 (n - 1) & hash 求桶的下标(n 是数组长度,通常是 2 的幂)。如果 n 较小(如默认 16),n-1 只有低 4 位有效(15 = 0b1111),高位信息在取模运算中被截断了。扰动函数让高位参与低位运算,减少低位相同的碰撞概率。
1 | 不扰动: hash = 0b11010010_01010101_10010001_01100110 |
Redis dict 的哈希函数: Redis 使用 MurmurHash2(Redis 4.0 改为 SipHash)——选择 SipHash 的安全考虑是防止 Hash DoS 攻击(攻击者构造大量哈希碰撞的 key 使 CPU 飙升)。
4.2 冲突解决:链表法 vs 开放寻址
链表法 (Separate Chaining) — Java HashMap
1 | HashMap 结构 (数组 + 链表): |
每个桶 (bucket) 是一个链表头节点。命中同一个桶的 key 串成链表。JDK 8 的优化:当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表树化为红黑树,将最坏 O(n) 的查找优化到 O(log n)。(详见第 5 章红黑树)
开放寻址 (Open Addressing) — ThreadLocalMap
Java Thread 每个线程内部有一个 ThreadLocalMap,它使用开放寻址而非链表法:
1 | // ThreadLocalMap 的 set 逻辑: |
碰撞后线性探测下一个位置 i+1 直到找到空位。为什么 ThreadLocalMap 选择开放寻址而非链表法?
- ThreadLocal 对象数量通常很少(一般几个到几十个),数据规模小,开放寻址缓存更友好
- 弱引用 Entry 需要清理——线性探测天然支持在 set/get 时顺便清理已回收的 key(
expungeStaleEntries)
更详细的 Redis 内部结构分析参见 Redis 深度解析。
4.4 MySQL 哈希索引
MySQL Memory 存储引擎支持 哈希索引——但 InnoDB 不支持用户创建的哈希索引,只有自适应哈希索引 (Adaptive Hash Index):
1 | 自适应哈希索引 (AHI): |
AHI 是 InnoDB 内部自动维护的,无法手动干预。它的作用是:对频繁访问的索引页建立哈希映射,绕过 B+Tree 的 O(log n) 查找,实现 O(1) 等值查询。但只对等值查询有效,范围查询仍走 B+Tree。
五、树
5.1 二叉搜索树与退化问题
普通的二叉搜索树 (BST) 在数据有序插入时会退化成链表:
1 | 插入顺序: 1, 2, 3, 4, 5 |
退化后的 BST 查找复杂度从 O(log n) 退化为 O(n)。这是所有平衡树(红黑树、AVL、B-Tree)要解决的核心问题:在插入和删除时通过额外操作维持树的平衡。
InnoDB 索引的详细分析(包括页内结构、联合索引、索引优化策略)参见 MySQL 深度解析。
- HBase:基于 HDFS 的 LSM-Tree 变体
1 |
|
小顶堆 (数组实现):
[2] ← arr[0]
/
[5] [7] ← arr[1], arr[2]
/ \ /
[8] [11] [10] ← arr[3], arr[4], arr[5]
数组表示: [2, 5, 7, 8, 11, 10, …]
父 → 子: left=2i+1, right=2i+2
子 → 父: parent=(i-1)/2
1 |
|
TopK 流式处理 (K=3):
数据流: 5, 9, 2, 15, 8, 1, 20, …
[2]
| 遍历过程:堆大小 < K 时直接插入;堆大小 = K 时,新元素与堆顶比较
[5] 如果 > 堆顶,弹出堆顶、插入新元素;如果 ≤ 堆顶,丢弃
|
[9] ← 当前堆内是 Top-3 候选
1 |
|
微服务依赖图: 消息流转图:
[A] → [B] → [D] Producer → [Partition_0] → Consumer_1
↘ ↗ ↘ [Partition_1] ↗
[C] → [E] → [Partition_2]
网络拓扑图: Maven/Gradle 构建依赖图:
[DB] ── [Cache]
│ \ / [common] ← [module-a]
[API] ── [MQ] ── [Worker] ↑ ↘
[module-b] [module-c]
1 |
|
八、经典算法
生产建议: 除非数据确实已经近似有序,否则不要手写排序。JDK 的 Arrays.sort 经过了二十年的工程调优(Dual-Pivot + TimSort + 归并 + 插入的混合策略 + 内联 SIMD 优化),自己写的排序几乎不可能比它好。
8.2 二分查找及变体
1 | // 查找到确切的值(或返回插入位置) |
四个经典变体(后端面试高频):
- 查找第一个等于 target 的位置(左边界):log 搜索时
arr[mid] >= target时right = mid - 1,记录ans = mid - 查找最后一个等于 target 的位置(右边界):
arr[mid] <= target时left = mid + 1,记录ans = mid - 查找第一个 ≥ target 的位置(lower_bound):
arr[mid] >= target时往左缩 - 查找第一个 > target 的位置(upper_bound):
arr[mid] > target时往左缩
实际场景: MySQL 页内二分查找记录、Java TreeMap.ceilingEntry() / floorEntry()、Redis ZSet 的 ZRANGEBYSCORE 需要先二分找到起始位置再顺序遍历。
📖 独立文章:分治思想与外部归并排序
8.3 递归与分治:MapReduce / GFS 的外排序
实现可以用数组 + 指针循环移动,也可以用 Redis ZSET(score 为时间戳,ZREMRANGEBYSCORE 删除过期元素,ZCARD 获取窗口内计数)。Sentinel/Gateway 的限流模块广泛使用滑动窗口算法。
8.5 位图 (BitMap) 与布隆过滤器
位图 (BitMap): 用一个 bit 表示一个元素是否出现。
1 | 用户签到 BitMap (31 天): |
Redis BitMap 的命令(SETBIT/GETBIT/BITCOUNT/BITOP)底层用 String 类型的 SDS 存储,每个 char 型可以存 8 个 bit。一年 365 天只需要 46 字节 per user,100 万用户约 44MB——比用 SET 存签到日期(每个日期一个元素、每个元素几十 byte)节省几十倍。
布隆过滤器 (Bloom Filter): BitMap 的加强版——用 K 个哈希函数映射到同一个位数组上,判断”一定不存在” vs “可能存在”。
1 | 布隆过滤器原理: |
它的核心用途是缓存穿透防护——大量查询不存在的 key 打到数据库,布隆过滤器可以精准拦截 99%+。
布隆过滤器的完整数学推导、Java 实现、Redis Bloom 模块、Counting Bloom Filter、布谷鸟过滤器等变种,详见 布隆过滤器。
HyperLogLog: 解决”基数统计”问题(UV 去重计数)。12KB 内存可以统计 2^64 个不同元素,误差约 0.81%。原理基于伯努利试验的概率模型 + 调和平均 + 分桶。
HyperLogLog 的数学推导和应用参见 Redis 深度解析。
九、算法在中间件中的映射速查表
| 数据结构 / 算法 | 产品 / 中间件 | 具体用途 |
|---|---|---|
| B+Tree | MySQL InnoDB | 聚簇索引、二级索引、页内组织 |
| B+Tree | PostgreSQL | 默认索引类型(与 MySQL 稍有差异) |
| B+Tree | MongoDB (WiredTiger) | 默认存储引擎的索引结构 |
| LSM-Tree | RocksDB, LevelDB | 存储引擎(LSM + SSTable + Compaction) |
| LSM-Tree | Cassandra, HBase | 写入优化的分布式存储引擎 |
| 哈希表 (dict) | Redis | 全局 key-value Dict、Hash 类型、Set 类型 |
| 哈希表 (渐进式 rehash) | Redis | 单线程下的无阻塞扩容 |
| 跳表 (Skip List) | Redis ZSet | 有序集合(score 排序 + 范围查询) |
| 跳表 | LevelDB MemTable | 内存表的有序数据结构 |
| 快速列表 (quicklist) | Redis List | 链表 + ziplist 混合结构 |
| 压缩数组 (ziplist/listpack) | Redis | 小数据量时的紧凑存储 |
| FST | Elasticsearch/Lucene | Term Index(术语索引前缀压缩) |
| Trie 树 | ES Completion Suggester | 搜索建议/自动补全 |
| Trie + AC 自动机 | 内容审核系统 | 多模式敏感词匹配 |
| 倒排索引 + 跳表 | Elasticsearch/Lucene | 倒排列表合并 (Posting List Intersection) |
| 位图 (BitMap) | Redis BitMap | 签到统计、在线状态、权限标记 |
| 布隆过滤器 | RedisBloom, Guava BloomFilter | 缓存穿透防护、去重 |
| 布隆过滤器 | HBase, Cassandra | LSM-Tree 读取加速(SSTable 预过滤) |
| 布隆过滤器 | LevelDB, RocksDB | SSTable 查询预排除 |
| HyperLogLog | Redis HyperLogLog | UV 统计、基数估算 |
| 一致性哈希 | Redis Cluster, Dubbo | 数据/请求分布式路由 |
| 一致性哈希 | CDN, Nginx upstream | 缓存服务器选路 |
| 时间轮 | Netty HashedWheelTimer | 百万级连接超时管理 |
| 时间轮 | Kafka | 延迟操作、请求超时 |
| 小顶堆 | ScheduledThreadPoolExecutor | 定时任务调度 |
| 小顶堆 | Java GC Finalizer | 对象终结队列 |
| 红黑树 | Java HashMap, TreeMap, TreeSet | 哈希桶树化、有序映射 |
| 红黑树 | Linux CFS 调度器 | 进程的 vruntime 排序 |
| 红黑树 | Nginx 定时器 | 事件超时管理 |
| 滑动窗口 | TCP 协议 | 流量控制 |
| 滑动窗口 | Sentinel, Gateway | 分布式限流 |
| 滑动窗口 | Kafka Consumer | 流式窗口聚合 |
| Dijkstra / 最短路径 | 服务网格, API 网关 | 延迟最优路由 |
| 拓扑排序 | Maven, Gradle, AirFlow | DAG 依赖解析 |
| 二分查找 | MySQL Page Directory | B+Tree 页内记录快速定位 |
| 归并排序 (外排) | MapReduce, Spark | 分布式数据排序 |
| 哈希扰动函数 | Java HashMap | 减少低位碰撞 |
| 开放寻址 | ThreadLocalMap, Netty FastThreadLocal | 小规模哈希表 |
十、生产实践
10.1 为什么”避免 O(n²)”在后端极其重要
O(n²) 不是面试题里的理论——它在生产环境中可以直接把系统打挂:
场景 1:嵌套循环 Join
1 | -- 这种写法在业务扩展后是灾难 |
正确的做法是:建立适当索引 → MySQL 使用 Index Nested Loop / Hash Join (8.0) → O(n log n) 或 O(n)。数据库优化器很聪明,但前提是 DBA 提供了好索引。
场景 2:批量插入中的 O(n²) 遍历
1 | // 坏代码:每插一条过滤一遍 |
在日常代码评审中,这类”数据结构选错导致 O(n²)”的问题非常常见。
10.2 算法复杂度与 QPS 的关系
每个请求处理时间的常数因子和算法复杂度的叠加,直接影响你能支撑的 QPS:
1 | 假设:单服务器 CPU 时间 = 100%,每个请求占用 t 秒 |
实际案例:
- 一个 O(n) 的遍历请求,n=1000 时 t=100μs → 10,000 QPS 没问题
- 同一个接口,n 增长到 100,000 时 t=10ms → 只剩下 100 QPS
- 如果算法不小心写成了 O(n²),n=100,000 时 t 直接爆炸
所以在设计 API 时,预估数据量的增长对算法复杂度的放大效应,是后端工程师的基本素养。
10.3 算法优化三原则
原则 1:时空权衡 (Space-Time Tradeoff)
1 | // 用空间换时间: |
Redis 缓存、布隆过滤器、倒排索引——本质上都是”用空间换时间”。
原则 2:缓存友好 (Cache-Friendly)
数据结构的理论复杂度和实际跑分可能天差地别——差别就是 CPU 缓存。同一份数据:
- 数组遍历:元素连续存储,每个 cache line (64B) 命中多个元素,L1/L2/L3 缓存层层加速
- 链表遍历:每个节点可能在不同的 cache line(甚至不同的 page),几乎每步都是 cache miss
以 100 万个整数为例:ArrayList 遍历约 0.5ms,LinkedList 遍历约 8ms——16 倍差距,不是因为复杂度不同(都是 O(n)),而是内存布局决定了一切。
CPU 缓存的详细分析参见 操作系统 内存管理章节。
原则 3:分治思想 (Divide and Conquer)
- MapReduce 的 Map → Shuffle → Reduce 是分治思想的分布式实现
- 数据库分库分表 是按 shard key 把数据分治到 N 个库
- LSM-Tree 的 Compaction 是分治——把多个小 SSTable 合并成大的
- Kafka 的 Partition 是把一个 Topic 的数据分治到多个分区,并行处理
- Redis Cluster 的分片 是把 key 空间分治到 16384 个 slot,再分给不同节点
遇到大规模问题时,先想:能不能把问题分解?能不能并行处理每个子问题?能不能在子问题层面做局部优化?
总结
数据结构与算法从来不是脱离工程的纯理论。从一行 HashMap.get() 到底层的链表→红黑树树化,从一个 ZADD 命令到底层的跳表 + dict 双结构,从一个 SELECT 到底层的 B+Tree 3 层索引——每一个你每天调用的 API 背后,都是前辈工程师在数据结构和算法之间做的精确取舍。
理解它们不是为了面试,而是为了看懂你用的每一个系统。当你真正理解了 InnoDB 为什么选 B+Tree、Redis 为什么选跳表、ES 为什么选 FST、LSM-Tree 为什么用顺序写换随机写——你就从一个”会用中间件”的工程师,变成了一个”理解中间件为什么这样设计”的工程师。这两者之间的差距,就是高级工程师与初中级工程师之间的分水岭。