一、动态扩容(解决负载因子过高导致的冲突激增)

为什么需要?

        哈希表的数组大小固定时,随着元素增多,「负载因子」(已用槽位 / 总槽位)会变大,冲突概率急剧上升(链表变长或探测链变长),导致操作效率从 O (1) 退化到 O (n)。
动态扩容就是通过扩大数组容量,降低负载因子,保证效率稳定。

核心思路:
  1. 设定负载因子阈值(如 0.75):当元素数量 ÷ 数组长度 > 阈值时触发扩容。
  2. 扩容策略:通常将数组长度扩大为原来的 2 倍(便于哈希值重新计算,利用位运算优化)。
  3. 重新哈希:扩容后,所有原有元素需要重新通过哈希函数计算新位置,放入新数组中(这个过程叫「rehash」)。
简单代码实现(Python 风格):
class SimpleHashMap:
    def __init__(self):
        self.size = 4  # 初始数组大小(2的幂,便于扩容)
        self.slots = [[] for _ in range(self.size)]  # 链地址法:每个槽位是链表
        self.count = 0  # 当前元素数量
        self.load_factor_threshold = 0.75  # 负载因子阈值

    def hash_func(self, key):
        # 简单哈希函数:取模(实际会更复杂,如扰动函数)
        return key % self.size

    def resize(self):
        """扩容:数组大小翻倍,重新哈希所有元素"""
        old_slots = self.slots
        self.size *= 2  # 2倍扩容
        self.slots = [[] for _ in range(self.size)]  # 新数组
        self.count = 0  # 重置计数,重新添加
        
        # 重新哈希所有旧元素到新数组
        for slot in old_slots:
            for key, value in slot:
                self.put(key, value)  # 复用put方法

    def put(self, key, value):
        # 检查是否需要扩容
        if self.count / self.size > self.load_factor_threshold:
            print(f"负载因子过高({self.count/self.size}),触发扩容到{self.size*2}")
            self.resize()
        
        # 计算哈希位置,插入元素(链地址法)
        index = self.hash_func(key)
        # 先检查是否已有该key,有则更新
        for i, (k, v) in enumerate(self.slots[index]):
            if k == key:
                self.slots[index][i] = (key, value)
                return
        # 无则新增
        self.slots[index].append((key, value))
        self.count += 1

    def get(self, key):
        index = self.hash_func(key)
        for k, v in self.slots[index]:
            if k == key:
                return v
        return None  # 未找到
代码理解:
  • size 是数组长度,初始为 4,扩容时翻倍(4→8→16...)。
  • put 方法中,先判断负载因子(count/size)是否超过 0.75,超过则调用resize。
  • resize 是核心:创建新数组,将旧数组中的所有元素重新计算哈希位置,放入新数组(解决旧数组拥挤问题)。
  • 效果:通过降低负载因子,保证每个链表的平均长度较短,查找 / 插入效率维持在 O (1) 附近。

        这么多东西我怕大家理解不了,所有接下来我们逐步来理解这个代码。

(一)初始结构(空哈希表)

代码初始化时的结构如下:

size = 4(数组长度为4)
slots = [ [], [], [], [] ]  # 4个空链表(槽位)
count = 0(元素数量为0)
load_factor_threshold = 0.75(负载因子阈值)

图示:

索引: 0 → []
索引: 1 → []
索引: 2 → []
索引: 3 → []

(二)插入元素过程(put 方法)

我们通过插入几个元素来演示整个过程:

1. 插入 (key=10, value="A")
  • 计算哈希值:hash_func(10) = 10 % 4 = 2 → 对应索引 2
  • 索引 2 的链表为空,直接添加 → slots[2] = [(10, "A")]
  • count变为 1

图示:

索引: 0 → []
索引: 1 → []
索引: 2 → [(10, "A")]  ← 新增元素
索引: 3 → []
2. 插入 (key=18, value="B")
  • 计算哈希值:18 % 4 = 2 → 同样对应索引 2(发生冲突)
  • 索引 2 的链表已有元素,直接追加到链表 → slots[2] = [(10, "A"), (18, "B")]
  • count变为 2

图示:

索引: 0 → []
索引: 1 → []
索引: 2 → [(10, "A"), (18, "B")]  ← 冲突元素追加到链表
索引: 3 → []
3. 插入 (key=5, value="C")
  • 计算哈希值:5 % 4 = 1 → 对应索引 1
  • 索引 1 的链表为空,直接添加 → slots[1] = [(5, "C")]
  • count变为 3

此时负载因子 = 3/4 = 0.75(等于阈值,暂不扩容)

图示

索引: 0 → []
索引: 1 → [(5, "C")]  ← 新增元素
索引: 2 → [(10, "A"), (18, "B")]
索引: 3 → []
4. 插入 (key=22, value="D") → 触发扩容
  • 当前count=3,size=4,插入前检查负载因子:3/4 = 0.75(等于阈值)
  • 插入后count会变为 4,负载因子 = 4/4=1.0 > 0.75 → 触发扩容
  • 扩容过程:
    1. size变为 8(4×2)
    2. 创建新的slots数组(8 个空链表)
    3. 重新哈希所有旧元素到新数组:
      • (10, "A") → 10 % 8 = 2 → 新索引 2
      • (18, "B") → 18 % 8 = 2 → 新索引 2(仍冲突)
      • (5, "C") → 5 % 8 = 5 → 新索引 5

图示(扩容后):

新size=8
索引: 0 → []
索引: 1 → []
索引: 2 → [(10, "A"), (18, "B")]  ← 重新哈希后的元素
索引: 3 → []
索引: 4 → []
索引: 5 → [(5, "C")]  ← 重新哈希后的元素
索引: 6 → []
索引: 7 → []
  • 最后插入新元素 (22, "D"):
    • 22 % 8 = 6 → 新索引 6 → slots[6] = [(22, "D")]
    • count变为 4

最终图示:

索引: 0 → []
索引: 1 → []
索引: 2 → [(10, "A"), (18, "B")]
索引: 3 → []
索引: 4 → []
索引: 5 → [(5, "C")]
索引: 6 → [(22, "D")]  ← 新增元素
索引: 7 → []

(三)查找元素过程(get 方法)

查找 key=18 的过程:

  1. 计算哈希值:18 % 8 = 2 → 定位到索引 2
  2. 遍历索引 2 的链表:[(10, "A"), (18, "B")]
  3. 找到 key=18,返回对应值 "B"

(四)核心逻辑总结

  1. 哈希表结构:数组(slots)+ 链表(每个槽位的冲突元素)
  2. 插入流程:
    • 计算 key 的哈希值 → 定位到数组索引
    • 若索引处已有该 key,更新值;否则添加到链表
    • 检查负载因子,超过阈值则扩容(数组翻倍 + 重新哈希)
  3. 扩容目的:通过增大数组容量降低负载因子,避免链表过长导致效率下降

二、红黑树优化(解决链表过长导致的效率退化)

为什么需要?

        链地址法中,如果哈希函数设计不好(或遭遇恶意攻击,如故意传入大量哈希冲突的 key),某个槽位的链表可能变得极长(比如长度 1000),此时查找效率会从 O (1) 退化到 O (n)。
        红黑树优化:当链表长度超过阈值时,将链表转为「红黑树」(一种平衡二叉树),使查找 / 插入效率提升到 O (log n)。

核心思路:
  1. 阈值设定:链表长度≤8 时用链表(插入快),>8 时转为红黑树(查找快)。
  2. 退化机制:当红黑树节点数减少到 6 时,转回链表(红黑树维护成本高,节点少的时候链表更高效)。
  3. 红黑树优势:平衡二叉树,左右子树高度差不超过 1,保证查询、插入、删除都是 O (log n)。
        10          (根节点)
       /  \
      5   15        (第二层:左右子树高度均为2)
     / \  /
    3  7 12         (第三层:左右子树高度差≤1)
   /
  1                  (第四层:左子树高度为1,右子树为空,高度差1)

        树的高度指的是从某个节点到其最深叶子节点的最长路径上的边数(也有定义为节点数,需根据具体场景区分,通常算法中以 “边数” 为准)。

  • 叶子节点的高度:叶子节点没有子节点,所以高度为 0。
  • 根节点的高度:整棵树的高度,即从根到最深叶子的最长路径边数。
    A  (高度2)
   / \
  B   C(高度1)
 /
D     (高度0)
  • 节点 D 是叶子,高度 = 0;
  • 节点 B 的子节点是 D(高度 0),所以 B 的高度 = 0+1=1;
  • 节点 C 没有子节点(或只有空节点),高度 = 0;
  • 根节点 A 的子节点 B(高度 1)和 C(高度 0),最长路径是 A→B→D,边数为 2,所以 A 的高度 = 2。

在平衡二叉树中,高度差指的是一个节点的左子树高度与右子树高度的差值(通常定义为 “左高 - 右高”)。
平衡二叉树的核心要求是:所有节点的高度差的绝对值 ≤ 1。

    A  (高度2)
   / \
  B   C(高度1)
 /
D     (高度0)
  • 节点 B 的左子树是 D(高度 0),右子树为空(高度 - 1,空树高度通常记为 - 1),高度差 = 0 - (-1)=1;
  • 节点 C 的左、右子树都为空,高度差 =(-1) - (-1)=0;
  • 根节点 A 的左子树 B(高度 1),右子树 C(高度 0),高度差 = 1 - 0=1;

此时所有节点的高度差绝对值都≤1,这棵树是平衡的。

如果某棵树出现节点高度差绝对值>1,就需要通过旋转(如 AVL 树的左旋、右旋)来调整结构,使其重新平衡。例如:

    A  (左子树高度2,右子树高度0,高度差=2 → 不平衡)
   /
  B   (左子树高度1,右子树高度0,高度差=1)
 /
C     (左子树高度0,右子树高度0,高度差=0)

根节点 A 的高度差为 2,违反平衡条件,需要旋转调整。


简单代码示意(突出判断逻辑,简化红黑树实现):
class SlotNode:
    """链表节点(同时可作为红黑树节点的基础)"""
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.next = None  # 链表指针
        # 红黑树相关属性(简化)
        self.left = None  # 左子树
        self.right = None  # 右子树
        self.color = "red"  # 红黑树节点颜色

class HashMapWithTree:
    def __init__(self):
        self.size = 4
        self.slots = [None for _ in range(self.size)]  # 每个槽位:初始是None,后续是链表或红黑树
        self.count = 0
        self.tree_threshold = 8  # 链表转树的阈值

    def hash_func(self, key):
        return key % self.size

    def put(self, key, value):
        index = self.hash_func(key)
        slot = self.slots[index]

        # 情况1:槽位是空的,直接插入新节点(链表)
        if slot is None:
            self.slots[index] = SlotNode(key, value)
            self.count += 1
            return

        # 情况2:槽位是链表或红黑树,先查找是否有key
        # (简化:假设当前是链表,判断长度)
        current = slot
        length = 1
        while current.next:
            current = current.next
            length += 1
        # 检查是否需要转红黑树
        if length >= self.tree_threshold:
            print(f"链表长度{length},转为红黑树")
            # 实际中会调用链表转红黑树的方法(此处简化)
            self.slots[index] = self.list_to_tree(slot)
        
        # 插入新节点(链表逻辑,红黑树插入更复杂)
        current.next = SlotNode(key, value)
        self.count += 1

    def list_to_tree(self, head):
        """简化:仅示意,实际红黑树构建需要平衡操作"""
        # 这里只是返回一个标记为树的节点(真实实现需按红黑树规则插入所有节点)
        print("链表转红黑树(实际会构建平衡树)")
        return head  # 实际返回红黑树根节点
代码理解:
  • 核心是put方法中的长度判断:当链表长度达到 8,触发list_to_tree转换。
  • 红黑树的复杂在于「平衡维护」(插入后通过旋转和变色保证平衡),初学者无需深究细节,只需知道其作用是将长链表的 O (n) 操作优化为 O (log n)。
  • 为什么是 8?Java 的 HashMap 源码中,这个值是基于「泊松分布」计算的:正常哈希下,链表长度超过 8 的概率极低(约千万分之一),只有恶意攻击或极差的哈希函数才会触发,此时转树能有效防御。

        这么多东西我怕大家理解不了,所有接下来我们逐步来理解这个代码。

(一)初始结构(空哈希表)

self.size = 4  # 数组长度为4
self.slots = [None, None, None, None]  # 4个槽位,初始为空
self.tree_threshold = 8  # 链表转红黑树的阈值

图示:

索引0: None
索引1: None
索引2: None
索引3: None

(二)插入元素过程(链表阶段)

当插入元素时,哈希表先用链表存储冲突元素,直到链表长度达到阈值。

1. 插入前 7 个冲突元素(链表长度 < 8)

假设插入的元素哈希值都为2(全部冲突到索引 2):

  • 插入 (10, A) → 索引 2: 10 → None(长度 1)
  • 插入 (18, B) → 索引 2: 10 → 18 → None(长度 2)
  • ...(继续插入 5 个元素)
  • 插入第 7 个元素后 → 索引 2: 10 → 18 → 26 → ... → 第7个元素(长度 7)

图示(索引 2 的链表):

10 → 18 → 26 → 34 → 42 → 50 → 58  (长度7,未达阈值8,仍为链表)
2. 插入第 8 个冲突元素(触发转红黑树)

插入第 8 个哈希值为 2 的元素(66, H):

  • 此时链表长度变为 8,达到tree_threshold
  • 触发list_to_tree方法,将链表转为红黑树

图示(索引 2 的结构变化):

# 转换前(链表):
10 → 18 → 26 → 34 → 42 → 50 → 58 → 66  (长度8)

# 转换后(红黑树,简化示意):
        34  (黑节点)
       /  \
     18   50  (红节点)
    /  \  /  \
  10  26 42  58  (黑节点)
                   \
                    66  (红节点)

(三)核心逻辑图解

整个哈希表的结构变化如下:

  • 未达阈值时(链表存储):
索引0: None
索引1: None
索引2: [10 → 18 → 26 → ... → 58]  (链表,长度7)
索引3: None
  • 达到阈值后(红黑树存储):
索引0: None
索引1: None
索引2: [红黑树结构]  (原链表转换而来)
索引3: None

(四)为什么要这样设计?

  • 链表优势:插入简单(只需修改指针),适合元素少的情况。
  • 红黑树优势:当元素多(>8)时,查询 / 插入效率从链表的 O (n) 提升到 O (log n)。
  • 阈值 8:基于概率统计,正常哈希下链表长度超过 8 的概率极低,只有哈希冲突严重时才触发转树,平衡了效率和实现复杂度。

三、布谷鸟哈希(解决开放寻址法的探测链过长问题)

为什么需要?

        开放寻址法中,当负载因子较高时,探测链会很长(找一个空槽需要遍历多个位置),效率下降。
        布谷鸟哈希:使用 2 个哈希函数和 2 个数组,通过「踢出 - 重新放置」的方式减少冲突,实现快速插入和查找。

核心思路:
  1. 两个哈希函数:hash1(key) 和 hash2(key),分别对应两个数组table1和table2。
  2. 插入规则:
    • 先尝试将 key 放入table1[hash1(key)],如果为空则插入;
    • 若冲突,尝试放入table2[hash2(key)],如果为空则插入;
    • 若仍冲突,随机踢出其中一个位置的元素(比如table1中的),将新 key 放入,然后把被踢出的元素重新用另一个哈希函数放置(重复此过程,直到找到空槽或触发扩容)。
  3. 查找规则:只需检查table1[hash1(key)]和table2[hash2(key)]两个位置,最多两次查找,效率 O (1)。
简单代码实现:
class CuckooHash:
    def __init__(self, size=4):
        self.size = size
        self.table1 = [None] * size  # 第一个表
        self.table2 = [None] * size  # 第二个表
        self.max_attempts = 5  # 最大踢出次数,避免无限循环

    def hash1(self, key):
        return key % self.size

    def hash2(self, key):
        return (key // self.size) % self.size  # 第二个哈希函数(简单示例)

    def resize(self):
        """扩容:解决踢不出的情况"""
        old_table1 = self.table1
        old_table2 = self.table2
        self.size *= 2
        self.table1 = [None] * self.size
        self.table2 = [None] * self.size
        # 重新插入所有旧元素
        for key in old_table1:
            if key is not None:
                self.put(key)
        for key in old_table2:
            if key is not None:
                self.put(key)

    def put(self, key):
        # 先尝试插入table1
        pos1 = self.hash1(key)
        if self.table1[pos1] is None:
            self.table1[pos1] = key
            return
        # 再尝试插入table2
        pos2 = self.hash2(key)
        if self.table2[pos2] is None:
            self.table2[pos2] = key
            return
        # 冲突,开始踢元素(先踢table1的)
        current_key = key
        current_pos = pos1
        current_table = self.table1  # 当前操作的表

        for _ in range(self.max_attempts):
            # 踢出当前位置的元素,放入新key
            kicked_key = current_table[current_pos]
            current_table[current_pos] = current_key
            # 被踢出的元素用另一个哈希函数重新放置
            if current_table is self.table1:
                # 从table1被踢,用hash2尝试table2
                new_pos = self.hash2(kicked_key)
                new_table = self.table2
            else:
                # 从table2被踢,用hash1尝试table1
                new_pos = self.hash1(kicked_key)
                new_table = self.table1
            # 检查新位置是否为空
            if new_table[new_pos] is None:
                new_table[new_pos] = kicked_key
                return
            # 仍冲突,继续踢
            current_key = kicked_key
            current_pos = new_pos
            current_table = new_table

        # 超过最大尝试次数,触发扩容
        print("踢不出元素,触发扩容")
        self.resize()
        self.put(key)  # 扩容后重新插入

    def get(self, key):
        # 只需查两个位置
        if self.table1[self.hash1(key)] == key:
            return True
        if self.table2[self.hash2(key)] == key:
            return True
        return False  # 不存在
代码理解:
  • 核心是put方法的「踢人逻辑」:当两个位置都冲突时,通过踢出已有元素并重新放置,避免开放寻址法的长探测链。
  • 查找效率极高:无论数据量多少,最多检查两个位置,实现 O (1) 查找。
  • 为什么需要max_attempts?极端情况下可能出现两个元素互相踢对方(死循环),此时通过扩容解决。

         这么多东西我怕大家理解不了,所有接下来我们逐步来理解这个代码。

(一)初始结构(两个空表)

代码初始化时,会创建两个大小相同的数组(哈希表):

self.size = 4  # 初始大小为4
self.table1 = [None, None, None, None]  # 第一个表
self.table2 = [None, None, None, None]  # 第二个表
self.hash1(key) = key % 4  # 第一个哈希函数
self.hash2(key) = (key // 4) % 4  # 第二个哈希函数(简单示例)

图示(两个表初始状态):

table1: [None, None, None, None]  # 索引0-3
table2: [None, None, None, None]  # 索引0-3

(二)插入元素过程(分情况演示)

我们通过插入几个 key,逐步看布谷鸟哈希如何处理插入和冲突。

1. 插入 key=10(无冲突)
  • 计算两个哈希位置:
    • hash1(10) = 10 % 4 = 2 → table1[2]
    • hash2(10) = (10//4) % 4 = 2 % 4 = 2 → table2[2]
  • 先尝试插入 table1 [2],发现为空,直接放入:

图示:

table1: [None, None, 10, None]  # 10放在table1[2]
table2: [None, None, None, None]
2. 插入 key=20(无冲突)
  • 计算两个哈希位置:
    • hash1(20) = 20 % 4 = 0 → table1[0]
    • hash2(20) = (20//4) % 4 = 5 % 4 = 1 → table2[1]
  • 先尝试插入 table1 [0],为空,直接放入:

图示:

table1: [20, None, 10, None]  # 20放在table1[0]
table2: [None, None, None, None]
3. 插入 key=6(冲突,首次踢人)
  • 计算两个哈希位置:
    • hash1(6) = 6 % 4 = 2 → table1 [2](已有 10,冲突)
    • hash2(6) = (6//4) % 4 = 1 % 4 = 1 → table2 [1](为空)
  • 尝试 table2 [1],为空,直接放入:

图示:

table1: [20, None, 10, None]
table2: [None, 6, None, None]  # 6放在table2[1]
4. 插入 key=14(冲突,需要踢人)
  • 计算两个哈希位置:
    • hash1(14) = 14 % 4 = 2 → table1 [2](有 10,冲突)
    • hash2(14) = (14//4) % 4 = 3 % 4 = 3 → table2 [3](为空)
  • 尝试 table2 [3],为空,直接放入:

图示:

table1: [20, None, 10, None]
table2: [None, 6, None, 14]  # 14放在table2[3]
5. 插入 key=2(冲突,多次踢人)
  • 计算两个哈希位置:
    • hash1(2) = 2 % 4 = 2 → table1 [2](有 10,冲突)
    • hash2(2) = (2//4) % 4 = 0 % 4 = 0 → table2 [0](为空)
  • 尝试 table2 [0],为空,直接放入:

图示:

table1: [20, None, 10, None]
table2: [2, 6, None, 14]  # 2放在table2[0]
6. 插入 key=34(冲突,触发踢人循环)
  • 计算两个哈希位置:
    • hash1(34) = 34 % 4 = 2 → table1 [2](有 10,冲突)
    • hash2(34) = (34//4) % 4 = 8 % 4 = 0 → table2 [0](有 2,冲突)
  • 两个位置都冲突,开始踢人:
    1. 先踢 table1 [2] 的 10,将 34 放入 table1 [2];
    2. 被踢的 10 需要重新放置,用 hash2 计算位置:hash2(10)=2 → table2 [2](为空),放入 10。

图示(踢人后):

table1: [20, None, 34, None]  # 34踢走10,占据table1[2]
table2: [2, 6, 10, 14]        # 被踢的10放入table2[2]
7. 插入 key=38(冲突,踢人失败触发扩容)
  • 计算两个哈希位置:
    • hash1(38) = 38 % 4 = 2 → table1 [2](有 34,冲突)
    • hash2(38) = (38//4) % 4 = 9 % 4 = 1 → table2 [1](有 6,冲突)
  • 开始踢人:
    1. 踢 table1 [2] 的 34,放入 38;
    2. 34 用 hash2 计算位置:hash2(34)=0 → table2 [0](有 2,冲突),踢走 2;
    3. 2 用 hash1 计算位置:hash1(2)=2 → table1 [2](有 38,冲突),踢走 38;
    4. 38 用 hash2 计算位置:hash2(38)=1 → table2 [1](有 6,冲突),踢走 6;
    5. 6 用 hash1 计算位置:hash1(6)=2 → table1 [2](有 38,冲突)... 循环超过max_attempts=5。
  • 触发扩容:size 变为 8,重新插入所有元素。

(三)查找过程(get 方法)

查找任意 key 时,只需检查两个位置:

  • 例:查找 key=10 → 检查table1[hash1(10)=2]和table2[hash2(10)=2],在 table2 [2] 找到,返回 True。
  • 例:查找 key=99 → 检查table1[99%8]和table2[(99//8)%8],都没有,返回 False。

(四)核心逻辑总结

  1. 双表双哈希:用两个表和两个哈希函数,每个 key 有两个可能的位置。
  2. 踢人机制:冲突时踢走已有元素,被踢元素用另一个哈希函数重新找位置。
  3. 快速查找:最多检查两个位置,实现 O (1) 查找效率。
  4. 扩容保障:避免踢人循环,通过扩容降低冲突概率。


总结:初学者如何理解?

  1. 动态扩容:核心是「通过扩大容量降低冲突概率」,记住「负载因子 0.75」和「2 倍扩容 + rehash」这两个关键动作。
  2. 红黑树优化:核心是「长链表转平衡树,将 O (n) 优化为 O (log n)」,重点理解为什么需要(防攻击、提效),而非红黑树的具体实现。
  3. 布谷鸟哈希:核心是「双哈希 + 双表 + 踢人机制」,实现 O (1) 查找,适合对查询速度要求极高的场景。

更多推荐