HashMap 原理与跨语言对比分析
从一次集合查询出发,逐步拆解哈希表的 O(1) 之谜,深入 Java HashMap 实现原理、红黑树与平衡二叉树,最后对比 C++ 的几种 HashMap 实现。
目录
- 1. 从一个简单问题说起:集合查询的复杂度
- 2. Java HashMap 实现原理
- 3. 扩容机制(Resize)
- 4. 红黑树(Red-Black Tree)
- 5. 平衡二叉树(AVL Tree)
- 6. C++ 的几种 HashMap 实现对比
- 7. 总结
术语表
| 术语 | 英文 | 说明 |
|---|---|---|
| 哈希表 | Hash Table | 通过哈希函数将 key 映射到存储位置的键值对结构 |
| 哈希冲突 | Hash Collision | 两个不同的 key 计算出相同哈希值 |
| 拉链法 | Separate Chaining | 冲突的 key 用链表(或树)挂在同一位置 |
| 开放地址法 | Open Addressing | 冲突时线性/二次探测下一个空位 |
| 负载因子 | Load Factor | 已有元素数量与容量的比值,决定何时扩容 |
| 扰动函数 | Perturbation Function | 将高位信息混合到低位的哈希优化方法 |
| 红黑树 | Red-Black Tree | 近似平衡的二叉搜索树,保证最长 ≤ 2×最短路径 |
| 平衡因子 | Balance Factor | AVL 树中左子树高度减去右子树高度 |
| 左旋/右旋 | Left/Right Rotation | 保持二叉搜索树性质的树结构调整操作 |
| 缓存友好 | Cache-friendly | 数据在内存中连续排列,充分利用 CPU 缓存行 |
| SIMD | Single Instruction Multiple Data | 单指令多数据流,CPU 一次并行处理多个操作数 |
| 引用稳定性 | Reference Stability | 插入新元素后已有元素的指针/引用是否保持有效 |
注:术语按在本文中出现的先后顺序排列。
1. 从一个简单问题说起:集合查询的复杂度
1.1 数组查询:O(n) 的暴力遍历
假设我们有一组用户 ID,想判断某个 ID 是否在集合中。最直接的方式是用数组存储:
int[] userIds = {1001, 2045, 3012, 4890, 5234, 6789};
// 要查找 3012 是否存在
boolean exists(int target, int[] arr) {
for (int id : arr) { // 遍历整个数组
if (id == target) {
return true;
}
}
return false;
}
复杂度分析:最坏情况下需要遍历全部 n 个元素才能确定不存在——时间复杂度为 O(n)。当 n=100 万时,最坏情况需要 100 万次比较。
问题本质:数组通过下标存取是 O(1) 的,但我们存的不是"下标→值"的映射,而是"值→是否存在"的映射。数组不提供"按值查询"的索引结构,只能把全部元素扫一遍。
1.2 哈希函数:把"值"变成"位置"
如果能找到一个函数 f,使得:
f(1001) = 0
f(2045) = 1
f(3012) = 2
f(4890) = 3
...
那么查找时只需计算 f(target),直接去对应位置看即可——这就是哈希函数(Hash Function)的核心思想:将任意大小的输入映射到固定范围的整数。
int hash(int value, int capacity) {
return value % capacity; // 取模,将值映射到 [0, capacity-1]
}
1.2.1 优秀哈希函数需要具备哪些特性
上面的取模函数过于简单,实际生产环境的哈希函数需要满足一系列严格特性。一个优秀的哈希函数应当具备以下条件:
1. 确定性(Determinism)
相同的输入必须始终产生相同的输出。这是哈希表能够工作的前提——如果同一个 key 每次计算的哈希不一样,数据放入后就无法再找到了。
hash(key) 每次调用都返回相同值 ✓
hash(key) 可能返回不同值 ✗(哈希表不可用)
2. 均匀性(Uniformity)
哈希值在输出范围内应当均匀分布,避免大量 key 映射到少数几个 bucket 上。均匀性直接决定了冲突概率。
良好分布(均匀): ██ ██ ██ ██ ██ ██ ██ ██
糟糕分布(聚集): ████████████ ██ ██ ██ ██
均匀性不仅取决于哈希函数本身,还与取模前的"混合"操作有关——Java 的 (h >>> 16) ^ h 就是一种改善均匀性的扰动技术(见 §2.4)。
3. 高效性(Efficiency)
哈希计算必须足够快。如果哈希计算本身就消耗了大量 CPU,那么即便分布再均匀,整体性能也会下降。这就是为什么:
- Java 的
hashCode()设计成快速整数运算而非加密级哈希 - HashMap 用位运算
(n-1) & hash替代取模hash % n - C++ 的
std::hash<int>直接返回整数本身,零计算开销
4. 雪崩效应(Avalanche Effect)
输入的微小变化应当引起输出的大幅变化。好的哈希函数中,改变输入的 1 个 bit,应当导致输出中约一半的 bit 发生翻转。
输入:0x12345678 → 哈希:0xABCDEF01
输入:0x12345679 → 哈希:0x23456789(完全不同,≈50% bit 翻转)✓
输入:0x12345678 → 哈希:0xABCDEF01
输入:0x12345679 → 哈希:0xABCDEF00(只变了 1 个 bit)✗
雪崩效应好的函数,即使原始数据高度相似(如 "user1" 和 "user2"),哈希值也完全不同,从而均匀散列到整个表空间。
5. 非逆推性(Non-reversibility / 可选)
对于安全场景(如密码存储、数字签名),哈希函数还应满足:从哈希值不能反推出原始输入。但 HashMap 不需要这一特性——HashMap 中即使知道哈希值,也还需要通过 equals 比较 key 对象本身。
各种哈希函数的对比:
| 哈希函数 | 均匀性 | 速度 | 雪崩效应 | 适用场景 |
|---|---|---|---|---|
| Identity(直接返回 key) | 极差 | 最快 | 无 | 整数 key 已均匀分布时 |
| MD5 | 极好 | 慢 | 好 | 安全场景,不适合 HashMap |
| Java String hashCode | 好 | 快 | 一般 | Java HashMap 字符串 key |
| CRC32 | 好 | 较快 | 好 | 校验场景 |
| MurmurHash3 | 极好 | 快 | 极好 | 哈希表首选(LevelDB、Redis、Cassandra 使用) |
| CityHash / xxHash | 极好 | 极快 | 极好 | 大规模数据处理(Google、Facebook 使用) |
对于 Java HashMap 而言,用户定义的
hashCode()通常聚焦于均匀性,而 HashMap 内部的扰动函数(h >>> 16) ^ h进一步修补了用户 hashCode 可能的不足。这就是分层设计:上层保证基本均匀,下层做二次混合防止极端情况。
1.3 哈希表的查询:O(1) 的关键
哈希表(Hash Table)基于这个思想构建:
哈希函数
key (输入) ──────────────────→ 整数(下标)
│
▼
数组 table[]
┌─────┐
┌──→│ key │
│ ├─────┤
│ │ ... │
│ ├─────┤
└───│ key │
└─────┘
O(1) 的来源:查询时只需一步计算——hash(key) % capacity——得到数组下标,直接访问。不管集合里有 10 个元素还是 1000 万个,计算哈希和执行数组访问的时间基本不变。
本质:哈希表用哈希函数将"按值查询"转换成了"按下标访问",把对值的比较操作变成了对位置的直接计算。这就是 O(1) 的数学基础。
1.4 哈希冲突:O(1) 路上的第一个坑
不同 key 计算出的哈希值可能相同,例如 hash(3012) = 3012 % 8 = 4,hash(5028) = 5028 % 8 = 4——这就是哈希冲突(Hash Collision)。
解决冲突的主流方式有:
- 拉链法(Separate Chaining)——Java HashMap 使用的方式:数组每个位置存储一个链表(或红黑树),冲突的 key 挂在同一位置
- 开放地址法(Open Addressing)——ThreadLocalMap、CPython dict 使用的方式:冲突时往后找空位
- 再哈希法——冲突时用第二个哈希函数重新计算
冲突对性能的影响:当大量 key 落入同一个 bucket,链表变长,查询退化为 O(k),其中 k 是链表长度。当 k ≈ n(所有 key 都冲突),哈希表退化为 O(n)。这就是为什么:
- 哈希函数的质量至关重要
- 扩容机制不可或缺
- 链表长度超过阈值时需要优化(Java 8 引入红黑树)
2. Java HashMap 实现原理
2.1 数据结构:数组 + 链表 + 红黑树
Java 8 之后的 HashMap 采用数组 + 链表 + 红黑树三层结构:
table[] (Node<K,V>[])
┌─────┐
│ 0 │──→ null
├─────┤
│ 1 │──→ [Node A] ─→ [Node B] ─→ null (链表,长度 ≤ 7)
├─────┤
│ 2 │──→ [TreeNode C] ↔ [TreeNode D] ↔ ... (红黑树,长度 ≥ 8)
├─────┤
│ ... │
└─────┘
核心字段(简化版源码):
public class HashMap<K,V> {
// 默认初始容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量:2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表转红黑树的阈值:8
static final int TREEIFY_THRESHOLD = 8;
// 红黑树退化为链表的阈值:6
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转红黑树时要求的最小数组容量(若不到则优先扩容)
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储桶数组(核心存储结构)
transient Node<K,V>[] table;
// 键值对数量
transient int size;
// 扩容阈值 = capacity * loadFactor
int threshold;
// 负载因子
final float loadFactor;
}
Node 节点(链表节点):
static class Node<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; // 链表指针
}
TreeNode 节点(红黑树节点,继承自 LinkedHashMap.Entry):
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; // 左孩子
TreeNode<K,V> right; // 右孩子
TreeNode<K,V> prev; // 前驱(删除时需退化为链表)
boolean red; // 颜色标志
}
2.2 put 流程详解
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
详细步骤:
put(key, value)
│
├─ 1. 计算 hash
│ hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)
│
├─ 2. table 为空 → 调用 resize() 初始化(容量=16)
│
├─ 3. 计算桶下标:i = (n - 1) & hash
│
├─ 4. table[i] == null ?
│ ├─ YES → 直接创建 Node 放入 table[i]
│ └─ NO → 发生了哈希冲突,进入链表/树处理
│
├─ 5. 遍历该桶的链表或树
│ ├─ 找到相同 key(hash 相等且 equals 为 true)
│ │ └─ 替换旧值,返回旧值
│ │
│ └─ 没找到相同 key → 在尾部插入新节点
│ ├─ 如果当前是树 → 以树节点方式插入
│ └─ 如果当前是链表 → 在链表尾部插入
│ └─ 插入后检查链表长度 ≥ 8
│ ├─ 数组长度 < 64 → 扩容(resize)
│ └─ 数组长度 ≥ 64 → 链表转红黑树(treeifyBin)
│
└─ 6. 插入完成后,size++,若 size > threshold → 扩容
关键源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; // 延迟初始化
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 无冲突,直接放
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; // 头节点就匹配
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash); // 链表转树
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // 找到了相同 key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize(); // 检查扩容
afterNodeInsertion(evict);
return null;
}
2.3 get 流程详解
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 检查头节点
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 遍历桶
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
步骤总结:
get(key)
│
├─ 1. 计算 hash
├─ 2. table 为空 → 返回 null
├─ 3. 计算桶下标:i = (n - 1) & hash
├─ 4. table[i] == null → 返回 null
├─ 5. 检查头节点是否匹配 → 匹配则返回
├─ 6. 是 TreeNode → 红黑树查找(O(log n))
├─ 7. 否则 → 链表遍历查找(O(k))
└─ 8. 没找到 → 返回 null
2.4 hash 函数的设计
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么要右移 16 位再异或?
- hashCode() 返回 32 位 int
- 直接取模时只用到了低 n 位(容量小时高位信息丢失)
h ^ (h >>> 16)将高 16 位与低 16 位混合,让高位信息也参与定位- 这种扰动(perturbation)能让哈希分布更均匀,减少冲突
举例说明:
假设 hashCode = 0xFFFF1234
二进制:1111 1111 1111 1111 0001 0010 0011 0100
h >>> 16 = 0x0000FFFF
二进制:0000 0000 0000 0000 1111 1111 1111 1111
h ^ (h >>> 16) = 0xFFFFEDCB
二进制:1111 1111 1111 1111 1110 1101 1100 1011
当容量=16 时,桶下标 = (16-1) & hash
如果不做扰动:下标由低 4 位决定 → 0100 = 4
如果做了扰动:下标由低 4 位决定 → 1011 = 11
可以看到,高位的差异被引入了低位的计算,使分布更均匀。
2.5 为什么数组容量总是 2 的幂次
HashMap 保证 capacity = 2^n(16、32、64、128...)。原因:
1. 用位运算替代取模,大幅提升性能
// 取模运算:较慢(除法指令)
index = hash % capacity
// 位运算:极快(一条 AND 指令)
index = (capacity - 1) & hash
因为当 capacity = 2^n 时,hash % capacity = (capacity - 1) & hash。位运算比整数除法快一个数量级。
2. 扩容时数据分布更简单(后面会详述)
当容量翻倍后,每个 key 的新位置要么在原位置,要么在原位置 + oldCapacity——无需重新计算全部哈希,只需看 hash 新增的那个 bit 位是 0 还是 1。
3. 扩容机制(Resize)
3.1 何时触发扩容
扩容由两个指标控制:
- size > threshold:每次 put 完成后检查,
threshold = capacity × loadFactor - 链表转树前检查:
treeifyBin中若capacity < 64,则优先扩容而非转红黑树
举例:容量=16,loadFactor=0.75
threshold = 16 × 0.75 = 12
put 第 13 个 key 时 → size=13 > 12 → 触发扩容
3.2 扩容流程
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 容量翻倍
else if ((newCap = oldCap << 1) <= MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 阈值也翻倍
}
// ... 初始化或调整阈值 ...
// 创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 迁移数据(rehash)
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 帮助 GC
if (e.next == null)
// 只有一个节点 → 直接计算新位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 红黑树 → 拆成两棵小树或退化链表
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 链表 → 拆成低位链和高位链
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 关键判断:hash 的新增 bit 位是 0 还是 1
if ((e.hash & oldCap) == 0) {
// 低位链(位置不变)
if (loTail == null) loHead = e;
else loTail.next = e;
loTail = e;
} else {
// 高位链(位置 = 原位置 + oldCap)
if (hiTail == null) hiHead = e;
else hiTail.next = e;
hiTail = e;
}
e = next;
} while (e != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead; // 放回原位
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; // 放到高位
}
}
}
}
}
return newTab;
}
3.3 扩容时的 rehash 优化
关键洞察:容量从 2^n 变为 2^(n+1) 后,key 的哈希值中多了一位参与定位。这个新增的 bit 位决定了 key 的去向:
旧容量 = 16(二进制 10000)
旧掩码 = 15 (二进制 01111)
旧下标 = hash & 15
新容量 = 32(二进制 100000)
新掩码 = 31 (二进制 011111)
新下标 = hash & 31
新旧下标的差异仅在于最高位:
- 如果
hash & 16 == 0(新增 bit 位是 0):新下标 = 旧下标(留在原位) - 如果
hash & 16 == 1(新增 bit 位是 1):新下标 = 旧下标 + 16(移到高位)
举例:
keyA: hash = 00101 → 旧下标=5, 新下标=5 (hash & 16 == 0)
keyB: hash = 10101 → 旧下标=5, 新下标=21 (hash & 16 == 1)
这种设计使得扩容时无需重新计算全部哈希值,只需检查 hash & oldCap 一个位运算,而且链表自然拆分成两个子链表,保持相对顺序。
3.4 负载因子的影响
Java 默认负载因子 0.75,这是一个时间与空间的权衡。
| 负载因子 | 空间利用率 | 冲突概率 | 扩容频率 | 适用场景 |
|---|---|---|---|---|
| 0.5 | 较低 | 低 | 高 | 对性能敏感、内存充裕 |
| 0.75 | 适中 | 适中 | 适中 | 默认,均衡场景 |
| 1.0 | 高 | 高 | 低 | 内存紧张、可接受性能下降 |
0.75 的来源:过高的负载因子显著增加冲突,而过低的负载因子浪费大量空间。这个值在大量实际场景中表现良好,被 JDK 采纳为默认值。
4. 红黑树(Red-Black Tree)
4.1 为什么需要红黑树
当多个 key 冲突到同一个桶时,HashMap 用链表存储。但链表查找是 O(k) 的,如果恶意构造哈希冲突,可以让链表长度接近 n,使 HashMap 退化为 O(n)——这就是哈希碰撞拒绝服务攻击(Hash Collision DoS)的原理。
Java 8 引入红黑树作为链表的替代:当链表长度 ≥ 8 时转化为红黑树,使桶内查找从 O(k) 降为 O(log k)。即使一个桶内有成千上万个 key,查询也只是十几步的对数操作。
4.2 红黑树的定义与约束
红黑树是一种自平衡的二叉查找树(Binary Search Tree, BST),它在 BST 的基础上增加了颜色约束,保证树的高度始终不超过 2log(n+1)。
五条核心性质:
1. 每个节点要么是红色,要么是黑色
2. 根节点是黑色
3. 所有叶子节点(NIL 空节点)都是黑色
4. 红色节点的两个子节点必须都是黑色
(即:红色节点不能连续,不能有两个红色节点相邻)
5. 从任一节点到其每个叶子节点的所有路径上
包含相同数量的黑色节点(黑色高度一致)
关键推论:性质 4 和 5 共同保证了最长路径不超过最短路径的两倍——因为最短路径全是黑色节点,最长路径是红黑交替,黑色节点数相同但多了红色节点,最多多一倍。这个"近似平衡"的保证虽然没有 AVL 树严格,但插入删除的代价更低。
4.3 红黑树的平衡原理
红黑树通过旋转和颜色变换来维持平衡,具体有三种操作:
1. 左旋(Left Rotation)
y x
/ \ / \
x C ===> A y
/ \ / \
A B B C
2. 右旋(Right Rotation)
x y
/ \ / \
A y ===> x C
/ \ / \
B C A B
3. 颜色变换(Color Flip)
将父节点和叔节点变黑,祖父节点变红。
插入修复流程(简化的 5 种情况):
插入新节点(红色)
│
├─ 情况1: 父节点是黑色 → 无违规,插入完成 ✓
│
└─ 情况2: 父节点是红色(违反性质4,需修复)
│
├─ 2.1: 叔节点是红色
│ └─ 颜色变换:父/叔变黑,祖父变红 → 在祖父处继续检查
│
└─ 2.2: 叔节点是黑色(或不存在)
├─ LL 或 RR 型 → 右旋或左旋 + 变色
└─ LR 或 RL 型 → 先旋转成 LL/RR + 再旋转 + 变色
4.4 HashMap 中链表转红黑树的条件
// 链表长度 ≥ 8 才转树
static final int TREEIFY_THRESHOLD = 8;
// 但数组必须 ≥ 64,否则优先扩容
static final int MIN_TREEIFY_CAPACITY = 64;
为什么阈值是 8?
这是基于泊松分布(Poisson Distribution)计算的。在理想的随机哈希下,同一个桶中元素数量的分布满足:
概率分布:
0个: 0.6065
1个: 0.3033
2个: 0.0758
3个: 0.0126
4个: 0.0016
5个: 0.0002
6个: 0.00002
7个: < 10^-6
8个: < 10^-7 (亿分之几的概率)
因此 ≥8 的情况极不可能由随机哈希产生。阈值设为 8 意味着:
- 正常场景中几乎不会触发树化(链表长度极少超过 8)
- 一旦触发,极有可能是恶意攻击或哈希函数有严重问题
- 此时用红黑树的 O(log n) 替代链表的 O(n),提供保护
为什么同时要求数组 ≥ 64?
如果数组很小(如 16),链表长可能是因为 key 太少而冲突严重,扩容就能解决问题,不必引入更复杂的树结构。数组容量达到 64 后,链表还长到 8,说明哈希确实不均匀,此时转为红黑树更合理。
5. 平衡二叉树(AVL Tree)
5.1 定义与平衡因子
AVL 树(Adelson-Velsky and Landis 树,1962 年发明)是严格平衡的二叉搜索树。
平衡条件:任意节点的左子树和右子树的高度差不超过 1。
平衡因子 = 左子树高度 - 右子树高度
合法值:-1, 0, 1
若平衡因子为 ±2 或更差 → 需要旋转恢复平衡
10 (BF=0)
/ \
5 15 (BF=0)
/ \
3 7 (BF=0)
所有节点的平衡因子都在 [-1, +1] 范围内 → 合法的 AVL 树
10 (BF=2) ← 不平衡!
/ \
5 15 (BF=0)
/
3 (BF=0)
/
1 (BF=0)
节点 10 的平衡因子 = 左子树高度(3) - 右子树高度(1) = 2
5.2 旋转操作
AVL 树的四种旋转情况:
| 类型 | 检测条件 | 操作 | 示意图 |
|---|---|---|---|
| LL(左左) | 节点 BF > 1,左子 BF ≥ 0 | 右旋 | 见下文 |
| RR(右右) | 节点 BF < -1,右子 BF ≤ 0 | 左旋 | 见下文 |
| LR(左右) | 节点 BF > 1,左子 BF < 0 | 左旋左子 + 右旋节点 | 见下文 |
| RL(右左) | 节点 BF < -1,右子 BF > 0 | 右旋右子 + 左旋节点 | 见下文 |
LL 型——右旋:
z (BF=2) y
/ \ / \
y T4 ===> x z
/ \ / / \
x T3 T1 T3 T4
/
T1
LR 型——左旋左子再右旋节点:
z (BF=2) z y
/ \ / \ / \
x T4 ===> y T4 ===> x z
/ \ / \ / \ / \
T1 y x T3 T1 T2 T3 T4
/ \ / \
T2 T3 T1 T2
RR 型——左旋:
z (BF=-2) y
/ \ / \
T1 y ===> z x
/ \ / \ / \
T2 x T1 T2 T3 T4
/ \
T3 T4
RL 型——右旋右子再左旋节点:
z (BF=-2) z y
/ \ / \ / \
T1 x ===> T1 y ===> z x
/ \ / \ / \ / \
y T4 T2 x T1 T2 T3 T4
/ \ / \
T2 T3 T3 T4
5.3 红黑树 vs AVL 树对比
| 维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡程度 | 严格平衡(高度差 ≤ 1) | 近似平衡(最长 ≤ 2×最短) |
| 最大高度 | ≈ 1.44 log(n) | ≈ 2 log(n) |
| 查找速度 | 更快(更矮) | 略慢(稍高) |
| 插入代价 | 高,可能需要多级旋转 | 较低,最多 2 次旋转 |
| 删除代价 | 高,可能多级旋转 | 较低,最多 3 次旋转 |
| 实现复杂度 | 简单规则,复杂的旋转场景 | 复杂规则,旋转场景少 |
| 适用场景 | 读多写少的数据库索引 | 通用场景,如 HashMap、TreeMap、C++ std::map |
为什么 HashMap 用红黑树而不是 AVL?
HashMap 的写入(插入)非常频繁——每次 put 都可能触发树操作。使用插入代价更小的红黑树,能在写入性能上获得更好表现。而 AVL 树虽然查找更快,但 HashMap 中桶内元素数量受哈希分散和扩容控制,通常不会太大,多几层查找差距微乎其微,而频繁的旋转操作反而成为瓶颈。
6. C++ 的几种 HashMap 实现对比
6.1 std::unordered_map
C++11 标准引入的哈希表实现,是大多数 C++ 开发者的默认选择。
数据结构:
- 数组 + 链表(单向)——使用拉链法处理冲突
- 每个 bucket 是一个单向链表(或链表 + 单个元素的优化)
核心特性:
| 特性 | 值 |
|---|---|
| 底层结构 | 数组 + 单向链表 |
| 冲突处理 | 拉链法(Separate Chaining) |
| 负载因子默认值 | 1.0(C++ 标准要求 max_load_factor ≥ 1.0) |
| 扩容策略 | 每次扩容大约翻倍(质数优先?实际因实现而异) |
| 迭代器稳定性 | 扩容时所有迭代器失效 |
| 内存布局 | 链表节点分散分配(每个 Node 独立 new,cache-unfriendly) |
| 自定义哈希 | 支持 std::hash 特化或自定义函数对象 |
| 底层 buckets | 在某些实现(如 libstdc++)中链接时 bucket 数目可能接近质数 |
性能瓶颈:
std::unordered_map<int, std::string> map;
map.reserve(1000000); // 预分配很重要!
// 插入 100 万条
for (int i = 0; i < 1000000; ++i) {
map[i] = "value_" + std::to_string(i);
}
// 查询
auto it = map.find(500000); // 平均 O(1),但 cache miss 严重
- 每个 Node 是独立
new分配的,内存不连续,CPU 缓存命中率低 - 链表遍历对现代 CPU 的分支预测不友好
- 大量小对象分配导致内存碎片
6.2 abseil SwissMap (flat_hash_map / raw_hash_map)
Google Abseil 库(C++ 标准库委员会核心成员开发)的现代哈希表实现,正被考虑纳入未来 C++ 标准。
数据结构:
- 开放地址法 + 元数据数组(Metadata Array)
- 核心创新:SSE/AVX 元数据加速,利用 SIMD 指令一次比较 16 个 bucket
核心设计:
控制字数组(每个 bucket 1 字节) 数据数组(连续存储 key/value)
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ ┌────┬────┬────┬────┬────┐
│1│0│0│0│1│1│0│0│0│1│0│0│1│0│0│ │K/V │K/V │K/V │K/V │K/V │
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ └────┴────┴────┴────┴────┘
^ ^
| 控制字含义:
| 0b11111111 = empty
| 0b10000000 = tombstone(已删除)
| 0b0xxxxxxx = 哈希值低 7 位
核心特性:
| 特性 | 值 |
|---|---|
| 底层结构 | 开放地址法 + 元数据数组 |
| 冲突处理 | 开放地址法(线性探测 + 元数据加速) |
| 负载因子默认值 | 0.875 |
| 扩容策略 | 翻倍扩容,迁移时保持缓存友好 |
| 迭代器稳定性 | 扩容时失效(SwissMap 提供指针稳定性变体) |
| 内存布局 | 所有数据连续存放,极端 cache-friendly |
| 查找速度 | 批量 SIMD 比较 → 扫描数据 → 完整哈希验证 |
性能优势:
#include <absl/container/flat_hash_map.h>
absl::flat_hash_map<int, std::string> map;
map.reserve(1000000);
// 查找时利用 SSE/AVX 指令,一次比较 16 个 bucket
auto it = map.find(500000); // 比 unordered_map 快 2-5 倍
- 元数据数组使得查找时无需访问实际数据就能跳过大量不匹配的 bucket
- 实际数据在另一块连续内存中,遍历时 L1/L2 缓存友好
- 平均插入/查找速度是
std::unordered_map的 2~5 倍 - 但不支持引用稳定性:插入后数据可能移动(SwissMap)——
flat_hash_map不允许获取指向数据的稳定指针
6.3 folly::F14HashMap
Facebook(Meta)的 Folly 库中的哈希表实现,F14 代表 "Fast 14"——一次处理 14 个 bucket。
数据结构:
- 开放地址法 + 分块 + SIMD 探测
- 核心创新:将 bucket 划分为多个 14 槽位的 chunk,每个 chunk 有自己的元数据
核心特性:
| 特性 | 值 |
|---|---|
| 底层结构 | 分块开放地址法(14 bucket / chunk) |
| 冲突处理 | 开放地址法(chunk 内线性探测 + chunk 间二次探测) |
| 负载因子 | 约 0.84 |
| 迭代器稳定性 | F14ValueMap 支持,F14FastMap 不支持 |
| 内存布局 | Chunk 内连续,chunk 间可能分散但高度局部 |
| 独特优势 | 提供 指针/引用稳定性(F14NodeMap 变体) |
性能优势:
- 类似 SwissMap,利用 SIMD 一次比较 14 个 bucket
- 支持引用稳定性:在插入后 key/value 地址不变——这在 C++ 中非常重要,因为允许获取指向 map 中元素的指针/引用并安全持有
- 内存开销比 SwissMap 略大,但功能更丰富
#include <folly/container/F14Map.h>
folly::F14FastMap<int, std::string> map;
map.reserve(1000000);
// 支持更丰富的语义
auto [it, inserted] = map.try_emplace(42, "hello");
std::string& ref = it->second; // 引用在后续插入中保持有效
6.4 tbb::concurrent_hash_map
Intel TBB(Threading Building Blocks)提供的线程安全哈希表。
核心特性:
| 特性 | 值 |
|---|---|
| 线程安全性 | 读写并发安全(内部使用 reader-writer lock per bucket) |
| 底层结构 | 数组 + 链表 + 按桶加锁 |
| 冲突处理 | 拉链法 |
| 特有操作 | count、find + accessor(读/写锁)、insert、erase |
| 适用场景 | 多线程高频读写,无需外部互斥量 |
#include <tbb/concurrent_hash_map.h>
tbb::concurrent_hash_map<int, std::string> map;
// 写操作
map.insert({1, "one"});
// 读操作(读锁)
tbb::concurrent_hash_map<int, std::string>::const_accessor it;
if (map.find(it, 1)) {
// it->second 受读锁保护
}
// 写操作(写锁)
tbb::concurrent_hash_map<int, std::string>::accessor writer;
if (map.insert(writer, 2)) {
writer->second = "two"; // 受写锁保护
}
6.5 综合对比表
| 实现 | 冲突策略 | 内存布局 | 查找复杂度 | 缓存友好 | SIMD加速 | 引用稳定 | 线程安全 | 适用场景 |
|---|---|---|---|---|---|---|---|---|
std::unordered_map |
拉链法 | 分散链表 | 平均 O(1),最差 O(n) | 差 | 无 | 是 | 否 | 通用基础场景 |
absl::flat_hash_map |
开放地址 | 连续数组 | 平均 O(1) | 极好 | SSE/AVX | 否 | 否 | 高性能场景首选 |
folly::F14FastMap |
开放地址+分块 | 分块连续 | 平均 O(1) | 很好 | SSE/AVX | 部分变体支持 | 否 | 高性能 + 引用稳定 |
tbb::concurrent_hash_map |
拉链法+按桶锁 | 链表 | 平均 O(1) | 一般 | 无 | 是 | 是 | 多线程共享读写 |
选用建议:
需要高性能、不需要引用稳定
└→ absl::flat_hash_map
需要高性能、需要引用稳定
└→ folly::F14NodeMap (或 F14ValueMap)
多线程并发读写
└→ tbb::concurrent_hash_map
└→ 或 tbb::concurrent_unordered_map(C++17 风格)
兼容性优先、性能次之
└→ std::unordered_map(所有 C++11+ 编译器都支持)
为什么 C++ 标准库自带的是拉链法?
C++ 标准要求 unordered_map 的迭代器在插入后(非扩容时)保持有效,且不能使已有元素的引用失效。拉链法天然满足这个要求:每个节点独立分配,插入新节点不改变已有节点的地址。而开放地址法在插入时可能移动已有元素的位置。
这也是为什么 abseil 和 folly 提供了多个变体——让用户在性能与引用稳定性之间做选择。
7. 总结
回顾整篇文章,可以从一条主线串联所有内容:
1. 哈希表的核心思想——用哈希函数将"按值查询"转换为"按下标访问",从 O(n) 变为 O(1)。这是一次思维上的飞跃:不是去"找"数据,而是直接"算"出数据的位置。
2. 冲突处理是哈希表实现的关键——Java 用拉链法 + 红黑树,空间效率好且能抗恶意攻击;现代 C++ 实现(abseil/folly)用开放地址法 + SIMD 加速,利用 CPU 特性极致提升吞吐。
3. 扩容是 O(1) 稳定的保障——Java 每次翻倍,利用 2 的幂次特性将 rehash 简化为位运算;负载因子 0.75 在时间与空间之间取得了良好的平衡。
4. 红黑树和 AVL 树是"冲突恶化时的安全网"——红黑树放宽了 AVL 的严格平衡要求,换取更低的插入删除代价,更适合 HashMap 这种写操作频繁的场景。
5. 没有银弹——Java HashMap、C++ unordered_map、abseil SwissMap、folly F14,每种实现在内存布局、性能、引用稳定性、线程安全上做了不同的取舍。理解这些取舍,才能在合适的场景做出正确的选择。

浙公网安备 33010602011771号