HashMap 原理与跨语言对比分析

从一次集合查询出发,逐步拆解哈希表的 O(1) 之谜,深入 Java HashMap 实现原理、红黑树与平衡二叉树,最后对比 C++ 的几种 HashMap 实现。


目录


术语表

术语 英文 说明
哈希表 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 = 4hash(5028) = 5028 % 8 = 4——这就是哈希冲突(Hash Collision)

解决冲突的主流方式有:

  1. 拉链法(Separate Chaining)——Java HashMap 使用的方式:数组每个位置存储一个链表(或红黑树),冲突的 key 挂在同一位置
  2. 开放地址法(Open Addressing)——ThreadLocalMap、CPython dict 使用的方式:冲突时往后找空位
  3. 再哈希法——冲突时用第二个哈希函数重新计算

冲突对性能的影响:当大量 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 何时触发扩容

扩容由两个指标控制:

  1. size > threshold:每次 put 完成后检查,threshold = capacity × loadFactor
  2. 链表转树前检查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)
底层结构 数组 + 链表 + 按桶加锁
冲突处理 拉链法
特有操作 countfind + accessor(读/写锁)、inserterase
适用场景 多线程高频读写,无需外部互斥量
#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,每种实现在内存布局、性能、引用稳定性、线程安全上做了不同的取舍。理解这些取舍,才能在合适的场景做出正确的选择。

posted @ 2026-06-10 09:13  getmoon  阅读(14)  评论(0)    收藏  举报