
说句实话散列表、红黑树、B树、跳表、布隆过滤器这五个词几乎是国内技术面试题库里的“钉子户”。你随便翻开一份后端或基础架构的岗位要求都能看到它们的身影你随便问一个工作三年以上的工程师数据库索引为什么用B树、Redis的ZSet为什么用跳表他多半能答上一两句。可真要到“讲透”的程度——能把它们放在同一个坐标系里比较能说清楚各自在什么场景下不可替代能动手实现一个并解释每一行代码的动机——能做到的人就少很多了。这篇文章想做的就是把这几大经典数据结构放到一张“牌桌”上。我会先讲清楚它们各自的定位、核心机制和权衡取舍再给出可直接复现的实现思路最后落回到工程选型和那些只有真正动手踩过坑才会知道的小细节。无论你是准备面试、正在做系统设计还是单纯想把这些概念内化成自己的知识体系这篇都值得花二十分钟慢慢读。1. 先理清这五兄弟的关系都不是用来背的1.1 一句话先给它们定位很多人把这五种结构当成五个孤立的知识点去背这是最大的误区。它们其实是沿着两条需求线演化出来的散列表Hash Table解决的是“等值查询”的性能问题——给我一个key我要以 O(1) 的平均代价拿到 value。红黑树、跳表、B树解决的是“有序数据的管理问题”——我要支持范围查询、顺序遍历、按排名取元素同时增删改还不能太慢。布隆过滤器Bloom Filter解决的是一个更刁钻的问题——“这个key到底存不存在”并且代价要比任何存储结构都小得多。你可以把前四种结构想象成不同类型的“书架”散列表是抽屉柜凭标签直接抽开红黑树是自动保持平衡的二叉树书架跳表是多层索引的目录B树是适合磁盘这种慢速外设的分层目录册。而布隆过滤器它根本不存书它只在你走进书房前用一张快速检查单告诉你“这本书大概率不在”省得你白跑一趟。记住这个定位之后后面所有细节都能顺着逻辑推出来。1.2 它们各自镇守的场景五种结构具体对应哪些最经典的工程场景先列一张对照表后面再逐个展开结构核心不变量典型场景代价上限散列表key均匀散列缓存系统、字典、索引等值匹配均摊 O(1)红黑树任意节点到叶子的黑色高度相同内存中的有序映射、定时器管理O(log n)B树磁盘页内聚树高矮MySQL InnoDB 索引、文件系统元数据O(log m n)m为页内分支数跳表随机层高提供概率平衡Redis ZSet、LevelDB MemTableO(log n) 期望布隆过滤器位数组 k个哈希函数缓存穿透防护、URL去重、黑名单近似 O(k)注意看这五个结构的“不变量”各有各的特点这也决定了它们为什么没法互相替代。散列表只存映射关系不保证顺序红黑树和跳表保证顺序但每个节点都要付出额外指针和状态位的开销B树把顺序和对磁盘块的友好性结合到了极致布隆过滤器则干脆牺牲了“确定存在”这个能力换来了极小的内存占用。2. 散列表与布隆过滤器内存里“查得快”的两大法宝2.1 散列表的原理重点在于“冲突”怎么处理散列表的核心其实不是哈希函数本身而是“冲突之后怎么办”。哈希函数把任意长度的key映射到有限大小的数组下标那么两个不同key映射到同一个槽位只是时间问题。主流的冲突解决方式有两种链地址法拉链法每个桶背后挂一个链表或红黑树Java 8 的 HashMap 在桶长度超过8且容量大于64时会树化。开放寻址法冲突了就往下一个空闲位置探测Redis 的字典和 Go 的 map 早期实现、以及很多高性能自研哈希表都会用线性探测或二次探测。我在面试里经常问一个问题为什么 Java HashMap 的默认负载因子是 0.75而不是 0.5 或 0.9答案是空间和时间的折中。负载因子定义是“已占用槽位 / 总槽位”。设得越低冲突越少但浪费的内存越多设得越高内存利用率上去了但冲突变多链表变长查询退化。0.75 这个数字有数学推导的影子对于随机哈希在负载因子 α 下链地址法一次成功的查找期望比较次数约为 1 α/2开放寻址法则更复杂。0.75 意味着散列表在空间利用率达到四分之三时就触发扩容把冲突概率控制在一个比较低的水位。真正工程里的哈希表冲突解决只是第一步。扩容策略同样关键。当元素数量超过阈值需要把桶数组扩大为原来的两倍通常并且所有已有元素必须重新计算哈希并搬入新桶——这个过程叫 rehash。它也是哈希表最大的性能隐患之一。如果你在做大流量缓存预热一次性插入几千万条记录中途扩容多次会出现可感知的停顿。Java 的 HashMap 在 JDK 8 里引入红黑树优化长链表就是为了缓解恶意哈希攻击导致链表过长的问题。我在项目里用过一种改进方式叫“分段扩容”不一次性搬完全部数据每次插入或查询时搬一部分老桶的数据到新桶把扩容的毛刺平摊到多次操作中。这种思路在很多生产级哈希表里都能看到比如 Go 的 map 就用了渐进式扩容。2.2 布隆过滤器的实现原理误判率是怎么算出来的布隆过滤器的设计思想简洁到让人拍案叫绝用一个长度为 m 的位数组bit array再用 k 个相互独立实际工程中常用双哈希派生的 k 个哈希函数的哈希函数。插入一个 key 时计算 k 个哈希值把对应的 k 个位全部置为 1。查询一个 key 时同样计算 k 个哈希值只要任何一个位是 0就说明 key 一定不存在如果这 k 个位全是 1则说明 key“可能存在”——因为可能是别的 key 的插入把这些位碰巧都置成了 1。这个“可能存在”就是布隆过滤器的误判来源它只会产生假阳性false positive绝不会产生假阴性false negative。用一句话概括布隆过滤器能确定地说“没有”但它说“有”的时候你要留个心眼。误判率的公式是P ≈ (1 - e^(-kn/m))^k其中 n 是已插入的元素数量。工程上一般先确定 n 和期望误判率 p然后反推最优的 m 和 k位数组长度m - (n * ln p) / (ln 2)^2哈希函数个数k (m / n) * ln 2举个例子。假如你要过滤 1000 万个用户 ID希望误判率不超过 1%那么 m ≈ - (10^7 * ln 0.01) / 0.48 ≈ 10^7 * 4.6 / 0.48 ≈ 9.58 × 10^7 个位也就是大约 11.4 MB。k ≈ (95.8 / 10) * 0.693 ≈ 6.6取 7 个哈希函数。11.4 MB 存 1000 万条用户ID任何真正的存储结构都做不到这么省但布隆过滤器做到了代价只是存在约 1% 的误判。布隆过滤器另一个常被忽略的特性是不可删除。因为某个位可能被多个 key 共享你删掉一个 key 时如果把这个位置回 0会把其他 key 也“误杀”。解决办法是使用 Counting Bloom Filter给每个位配上计数器删除时减一但代价是内存翻数倍。实际工程里很多场景宁可接受周期性地重建过滤器也不愿意付出那个内存代价。3. 红黑树与跳表有序数据世界的两种平衡策略3.1 红黑树的五条性质实质上是在约束什么红黑树本质是一棵自平衡的二叉查找树。二叉查找树BST在极端情况下会退化成链表插入有序数据时尤其明显。红黑树的平衡不是强制的左右子树高度差不超过 1那是 AVL 树的要求而是通过五条性质来维持一种“近似平衡”每个节点要么是红色要么是黑色。根节点必须是黑色。叶子节点NIL视为黑色。红色节点的两个孩子都必须是黑色——也就是说红节点的父节点也必须是黑节点不能有连续的两个红节点。从任意节点到其所有后代叶子的路径上包含相同数目的黑色节点。第 5 条是最关键的。它保证了最长的路径红黑相间最多比最短路径全黑长一倍。也就是说红黑树的高度最多是 2 * log2(n1)所以所有操作都是 O(log n)。很多初学者背得住这五条却不知道为什么是这五条。本质上第 4 条和第 5 条共同保证了一个事情任何一条从根到叶子的路径上黑色节点的数量都相等而红色节点只是“插入时的过渡态”。当你插入一个节点时如果它的父节点是黑的直接插入即可如果父节点是红的你就触发了需要修复的条件。修复的手段只有两种变色和旋转。变色是为了在局部调整黑高的差值旋转是为了改变树的形状让高度匹配不再靠大量染色来弥补。3.2 红黑树维护的关键细节插入看叔父删除看兄弟红黑树实现里最折磨人的是插入和删除的修复逻辑。我给出一个记忆锚点插入节点默认设为红色然后看它的叔父节点父节点的兄弟的颜色叔父是红色把父节点和叔父节点变黑祖父节点变红然后继续把祖父当作新插入的节点向上处理。叔父是黑色此时需要旋转。如果当前节点、父节点、祖父节点形成“之”字形先旋转父节点变成“直线”形再旋转祖父节点最后变色。父节点是黑色什么都不用做插入完成。删除的修复比插入更反直觉。删除一个黑色节点会破坏黑高这时要借颜色。核心思路从兄弟节点身上打主意兄弟是红色先旋转让兄弟变成黑色节点的父节点转化为兄弟为黑色的情况。兄弟是黑色且兄弟的两个孩子都是黑色把兄弟染红问题向上传递。兄弟是黑色且兄弟有一个红色孩子通过旋转和变色把这个红色孩子改变位置补上缺失的黑色。这里我建议不要死记硬背而是自己用工具比如 VisuAlgo 或者红黑树动画网站手动插入几组数据亲眼看着旋转发生肌肉记忆自然就形成了。我在带团队时有一个经验能不看文档手写红黑树的人对树形结构的理解一定远超平均水平——但这并不代表你在实际工作中需要手写它因为标准库已经帮你写好了。3.3 跳表用一枚硬币来决定你的高度跳表是对“有序链表”的加速优化。有序链表本身支持 O(n) 的查找跳表的发明者 William Pugh 想了个巧妙的办法给每个节点随机决定是否增加一层“快速通道”。高层的节点就像地铁的大站快车一次可以跨越多个低层节点。查找时从最高层出发若下一跳的 key 大于目标就降一层继续向右直到到达目标位置。每个新节点插入时用随机数决定它的层数通常以 1/2 的概率升级一层所以期望层数只有两层高这就是“随机化带来的概率平衡”。跳表不再需要旋转这种复杂的调整只需要在插入时记录每一层的前驱节点然后一层层地改指针即可。这也是它的实现难度远低于红黑树的原因——我教过很多刚接触数据结构的人两个下午能写出一棵能跑的跳表但同样基础的人写红黑树一周都未必能保证所有 case 都 cover 住。跳表的查找期望复杂度是 O(log n)但它没有红黑树那样的最坏保证——随机数如果连续给出极端的序列层高会失衡。不过这种概率极低工程上完全可接受。3.4 红黑树 vs 跳表工程选型看什么既然两者都支持有序性和 O(log n) 的查找选谁我在实际项目里总结出三条经验如果你需要极致的读性能且操作以查找和范围遍历为主红黑树因为缓存和内存布局的优势通常略快一些。如果你需要频繁插入删除、且还要做范围查询跳表的实现和维护成本低得多。如果你有并发需求跳表更容易做无锁化改造。因为插入只影响局部指针你可以通过 CAS比较并交换来更新指针层级红黑树的旋转涉及多个节点并发控制非常别扭。Redis 的 ZSet 选择跳表而非红黑树的官方理由官方文档里写得明白跳表实现简单、调试容易、并且可以做无锁化扩展。另外跳表还能很方便地支持“按排名取元素”——每个节点额外存一个 span 字段记录跨过的节点数就能在 O(log n) 时间内按排名访问红黑树要做到这一点需要每个节点维护子树大小实现上又增加一层复杂度。4. B树磁盘世界里的索引霸主4.1 B树和 B 树的差别“加”了一个链表“减”了一个数据B 树是一种多路平衡查找树每个节点可以存多个 key 和多个孩子孩子数范围由阶数决定。B 树在 B 树基础上做了两个关键改变所有数据只存在于叶子节点非叶子节点只存放索引 key 和子节点指针。所有叶子节点通过双向链表串联。这两个改动对磁盘存储来说意义重大。数据库的数据量往往远大于内存索引文件放在磁盘上。磁盘 I/O 的代价是内存访问的十万倍以上所以索引结构的目标是减少磁盘 I/O 次数。B树把每个节点大小设计成一个磁盘页通常是 16KB 或者 4KB一次 I/O 就能加载一整个节点。非叶子节点只存 key 和指针意味着同样的 16KB 可以容纳更多的分支树高就被压低了。以 InnoDB 为例一个非叶子节点里每个索引项大约十几个字节一个 16KB 的页大约能容纳上千个索引项。如果是三层 B树最底层能存储的记录数量大约是 1000 × 1000 × 1000 10 亿条量级。这意味着哪怕一张表有几千万行数据通过聚簇索引查询也只需要三次磁盘 I/O 就能找到记录——第一次读根页第二次读中间页第三次读叶子页然后紧接着读用户数据。4.2 为什么数据库偏偏选中 B 树而不是红黑树或哈希把 B树和红黑树放到磁盘场景下对比就非常直观了红黑树是二叉结构树高通常是 log2 n。一亿条记录红黑树的高度大约是 27 层。每个节点就是一个磁盘页的读写那查询一条记录最坏要碰 27 次磁盘。而 B树的开叉能力极强同样一亿条记录三层到四层就搞定了。差距是数量级的。那散列表行不行哈希索引确实常用于内存数据库和某些引擎如 Redis 的哈希类型但有一个致命问题不支持范围查询。你要查“age 在 20 到 30 之间的用户”哈希表毫无办法只能全表扫。而 B树因为所有数据在叶子节点上按 key 有序排列天然支持范围扫描——它只需要定位到最小的 key然后顺着叶子节点的链表顺序向后走即可。我见过一个很有意思的说法B树是“为磁盘量身定做的红黑树增强版”这个类比不算精确但确实抓住了要点——它们都在维护有序性但 B树通过扩大每个节点的分支度把树高压到了三到四层从而把“树高度导致的 I/O 次数”降到了最低。4.3 聚簇索引与二级索引的差异InnoDB 里主键索引是聚簇索引clustered index叶子节点直接存整行数据。而普通索引二级索引的叶子节点存的是索引列的值 主键值。一个查询如果走二级索引可能需要“回表”先通过二级索引找到主键再到聚簇索引里查完整行。所以尽量不要用select *去查那些没有覆盖索引的大表否则一次简单查询可能引发两次 B树搜索。这也是为什么通常建议给表加一个“紧凑型”主键比如自增 ID而不是用超长字符串当主键因为二级索引的叶子节点要存主键值主键越长每个页能容纳的索引项越少索引体积越大I/O 成本越高。这里补充一个我实测过的细节B树的“页分裂”是随插入发生的。当一个数据页满了InnoDB 会申请一个新页把一半数据挪过去并在父节点插入一个新 key。如果父节点也满了分裂会一路上溢直到根节点。这个动作的成本不低所以批量插入时如果能让数据按主键顺序写入就能有效减少页分裂。反过来说如果你的主键是 UUID 那种无序的字符串插入的随机性会导致频繁的页分裂和碎片写入性能和压缩率都会明显下降。这是生产中非常常见的坑。5. 动手实现模拟散列表与一个能用的布隆过滤器讲再多理论都不如手写一遍。下面给出两个我认为“性价比最高”的手写实验一个模拟散列表覆盖工作原理对应很多算法课程里的“模拟散列表”题目一个布隆过滤器覆盖原理与参数调优。这两个实现均可在自己电脑上十分钟内完成验证非常推荐亲自动手。5.1 模拟散列表手写一个支持插入和查询的哈希表很多算法题库包括 AcWing 等平台都有“模拟散列表”这题给定若干操作插入一个整数或查询一个整数是否存在。这里我们用开放寻址法实现核心代码很短#include cstring #include iostream const int N 200003; // 取一个大于题目数据规模 2~3 倍的质数 const int INF 0x3f3f3f3f; // 用一个大数标记“空位” int h[N]; int find(int x) { int k (x % N N) % N; // 处理负数取模 while (h[k] ! INF h[k] ! x) { k; if (k N) k 0; // 环形探测 } return k; } int main() { memset(h, 0x3f, sizeof h); // 以 INF 填充 int n; scanf(%d, n); while (n--) { char op[2]; int x; scanf(%s%d, op, x); int idx find(x); if (op[0] I) { h[idx] x; } else { puts(h[idx] x ? Yes : No); } } return 0; }这里有几个细节很有意思为什么 N 取 200003因为这是个质数而且比题目数据规模大 2 倍以上。取质数能让哈希函数对某些规律性数据比如全是偶数不那么容易产生聚集取 3 倍空间是为了降低探测长度——负载因子只有三分之一左右线性探测的平均性能非常优秀。为什么用(x % N N) % N因为 C 的取模运算对于负数会返回负值我们需要的下标必须是非负的。加上一个 N 再取模就能把负数的结果修正到 [0, N) 区间。为什么用0x3f3f3f3f当 INF因为这个数足够大超出题目数据范围通常绝对值不超过 10^9又能用memset一次性填充——memset 按字节填充0x3f3f3f3f每个字节是 0x3f恰好能填满 int。这个实现体现的就是开放寻址法的核心找到“第一个可能的空位或目标位”。它比链地址法省掉了 next 指针和链表的开销但在负载因子高时性能会剧烈下降。所以工程化的哈希表很少只用无限线性探测——当负载因子逼近某个阈值时就要扩容这就是 2.1 里说的那套机制。5.2 布隆过滤器的极简实现与调参实录我最早写布隆过滤器是在一个爬虫项目里做 URL 去重。几千万个 URL如果用哈希表存原始字符串内存轻松超过 1GB用布隆过滤器100MB 就够而且误判率可以控制在 0.1% 以内。下面这段 Python 代码我把 m、n、k 的计算都放进去可运行、可调参import math import mmh3 # 常见的非加密哈希库也可以换成 hashlib class BloomFilter: def __init__(self, expected_count: int, false_positive_rate: float): # 反推最优位数 m 和哈希函数个数 k self.m int(- (expected_count * math.log(false_positive_rate)) / (math.log(2) ** 2)) self.k max(1, int(round((self.m / expected_count) * math.log(2)))) self.bit_array bytearray(self.m // 8 1) def _locations(self, item: str): # 用双哈希的方式生成 k 个独立哈希值避免真的造 k 个不同哈希函数 h1 mmh3.hash(item, seed0) h2 mmh3.hash(item, seed1) return [(h1 i * h2) % self.m for i in range(self.k)] def add(self, item: str): for loc in self._locations(item): byte_index loc // 8 bit_offset loc % 8 self.bit_array[byte_index] | (1 bit_offset) def contains(self, item: str) - bool: for loc in self._locations(item): byte_index loc // 8 bit_offset loc % 8 if (self.bit_array[byte_index] (1 bit_offset)) 0: return False return True我自己跑过一次测试n10000目标误判率 0.01算出来 m 大约是 95851 位约 12 KBk7。随机生成 10000 个不存在的字符串去contains实际历史上误判率在 0.007~0.013 之间摆动和公式预测的 0.01 对得上——说明公式本身是可信的。这里的_locations用了双哈希的经典技巧只需要两个种子不同的哈希值 h1、h2就能通过h1 i * h2生成 k 个独立的哈希位置避免维护 k 个不同哈希函数的麻烦。5.3 我在调参时踩过的两个坑第一个坑布隆过滤器的 m 算出来可能是奇数对应到 byte 数组时要考虑整除和向上取整。上面的代码用self.m // 8 1多留出一个字节保证任何一个下标都不会越界。第二个坑哈希函数的质量决定一切。如果哈希函数分布不均匀会把大量 key 映射到同一片区域导致误判率急剧上升。我在生产里用的是 MurmurHash 的 64 位版本并且用两个不同的种子各算一次模拟“相互独立”的哈希效果。不要在布隆过滤器里用md5(str(i))这种重复性极强的哈希测试几次就会发现误判率远超公式值。6. 工程选型实战与常见问题排查6.1 一套典型的组合拳Redis 缓存 布隆过滤器 MySQL我参与过的一个电商系统遭遇过严重的缓存穿透问题大量请求携带不存在的商品 ID 打到 MySQL数据库压力瞬间拉满。当时做的方案是分层组合在 Redis 缓存之前加一层布隆过滤器拦截“肯定不存在”的 key。商品 ID 总量在千万级别指定误判率 1%内存成本约 12 MB完全可接受。布隆过滤器返回“可能存在”后再去查 Redis 缓存缓存没有再去查 MySQL。对“存在但缓存未命中”的 key回源数据库后写回 Redis 并设置过期时间。布隆过滤器无法删除数据所以如果商品下架我们不从布隆过滤器里删除也无法删除而是在业务逻辑里直接标记不可售——反正它只是用来挡“不存在”的流量多保留几条下架数据对误判率的影响微乎其微。这套组合拳上线后数据库的无效查询下降了 95% 以上而整体内存开销只多了十几兆。你看这五个结构在真正的系统里从来不是互斥的而是配合使用的。6.2 红黑树与跳表实现中的常见调试思路手写红黑树最崩溃的是插入后树结构不对但自己看不出来。我建议两个调试工具写一个“校验函数”递归验证每一条路径的黑色节点数量是否相等并且不存在连续红节点。每次插入或删除后都调用它破坏在第一时间暴露。打印树的层序结构用括号前缀手动画出来确认旋转之后父子关系正确。跳表调试的常见问题是层数更新错误导致高层索引指向的节点不在低层链表中。最有效的排查办法是写一个最底层的“全量遍历”确认所有节点都能从最底层完整走一遍然后再单独验证每一层的指针是否都落在合理的区间内。6.3 布隆过滤器在生产环境里的数据维护策略因为无法删除很多团队在实际部署时用的是“双层布隆过滤器”或者“定时重建模式”双层方案是维护 A、B 两个过滤器A 存最近一段时间的数据B 存上一段时间的数据。新数据写入 A查的时候先查 A 再查 B每到一定时间把 A 清空并切换角色。这样旧数据自然过期不会永久占用空间。定时重建适合数据每日全量刷新的场景凌晨低峰期从数据库全量导出 key重建一个新过滤器然后原子切换读指针。这个方法简单粗暴但是要注意切换瞬间的竞态条件——可以用双缓冲加一个原子指针来避免查询拿到“新旧混合”的过滤器。我做过的项目里还有一个经验不要对布隆过滤器的长度 m 吝啬。误判率从 1% 降到 0.1%只需要把 m 扩大大约 1.4 倍但打开的查询路径上的无效 MySQL 请求会减少一个数量级。很多团队只盯着内存占用忽略了“一次无效的数据库查询”比“多占 20 MB 内存”贵得多——在云数据库场景下尤其如此。6.4 面试与系统设计自查清单最后给一张我在准备系统设计类面试时会反复过的自查清单供你对照等值查询是主要模式且没有范围查询需求优先考虑散列表但要明确负载因子、扩容策略。需要有序遍历、范围查询且数据全在内存中红黑树或跳表二选一数据量大且并发需求高优先跳表。数据在磁盘上且需要按 key 扫描区间不考虑别的直接用 B树或其变体注意页大小和主键顺序。要挡掉大量“不存在”的请求且可接受一定的误判用布隆过滤器按公式算好 m 和 k不要拍脑袋。布隆过滤器需要支持删除吗如果需要别用普通版查一下 Counting Bloom Filter 的计数开销是否可接受。这些结构没有绝对的优劣只有“在约束条件下更合适”的选项。你能不能在面试现场把这些约束条件讲清楚才是比背诵定义更重要的能力。最后再分享一个我个人的体会我第一次把红黑树、跳表、B树画出同一张对照表的时候才真正意识到它们都是“同一个问题空间”里的不同解——那个问题就是“如何在动态变化的有序集合上做到高效查找”。散列表补上了“无序但更快”的另一块拼图布隆过滤器则把“内存效率”这个维度做到极致。想通这一层后面所有的特性都不再是死记硬背而是“在给定场景限制下最优解自然长成这样”。这大概就是把数据结构“讲透”和“背下来”之间最大的区别。