哈希表的扩容与缩容:负载因子与动态调整

哈希表这东西,用起来确实爽——O(1)的查找速度,谁不爱?但你要是真把它当个无底洞往里塞数据,迟早要翻车。我早年做网络流量分析模块时就吃过这个亏:哈希表越用越慢,最后比链表还拉胯。问题出在哪?就是没管好扩容和缩容。

说白了,哈希表是个“空间换时间”的产物。它给你一块连续内存,你往里扔键值对。但内存是有限的,数据是无限的。怎么平衡?这就引出了我们今天的主角——负载因子

负载因子:哈希表的“健康指标”

负载因子(Load Factor)的定义很简单:

负载因子 = 已存储元素个数 / 哈希表总桶数

举个例子。你有个哈希表,底层数组有16个桶。现在存了8个键值对。那负载因子就是 8/16 = 0.5。

这个值能告诉你什么?

  • 负载因子太小:桶多数据少,内存浪费严重。你想想看,明明10个数据就能搞定的事,你非占100个位置,这不是败家吗?
  • 负载因子太大:桶少数据多,哈希冲突急剧上升。链表的长度越来越长,查找效率从O(1)退化到O(n)。

我个人的习惯是,把负载因子控制在0.6到0.75之间。低于0.5就考虑缩容,高于0.75就触发扩容。当然,这个阈值不是死的——有些场景追求极致性能,可以设到0.5;有些场景内存紧张,0.8也能忍。

核心要点:负载因子是哈希表性能与内存消耗的平衡点。没有绝对正确的值,只有适合你场景的值。

何时扩容?何时缩容?

这个问题,我当年也纠结过。后来踩了几个坑,总结出两条铁律:

扩容时机

  • 负载因子超过阈值(比如0.75):这是最常见的触发条件。
  • 链表/红黑树深度过大:即使负载因子没超标,但某个桶的冲突链特别长,也该扩容了。我在做日志分析系统时就遇到过——某些key被恶意构造,全挤到一个桶里,负载因子才0.3,但那个桶的链表已经100多个节点了。

缩容时机

  • 负载因子低于下限(比如0.25):数据删得差不多了,桶还占着,浪费内存。
  • 大量删除操作后:你删了80%的数据,但哈希表的大小没变。这时候就该缩容,把内存还给系统。

注意:缩容不是必须的。如果你的应用场景是“只增不减”,那缩容逻辑完全可以砍掉。我曾经为了省事,在嵌入式设备上直接禁用了缩容——反正内存够用,少个功能少个bug。

rehash操作:搬家是个技术活

扩容或缩容,本质上就是一次rehash。什么意思?

假设原来哈希表有8个桶,现在要扩到16个。你不能直接把数组长度改成16就完事——因为哈希函数依赖桶的数量。原来 key % 8 算出来的位置,换成 key % 16 后全变了。所以你得重新计算每个元素的位置,然后搬过去。

rehash的步骤很简单:

  1. 分配新数组(大小是原来的2倍或0.5倍)。
  2. 遍历旧数组的每个桶。
  3. 对桶里的每个元素,用新哈希函数重新计算位置。
  4. 插入到新数组的对应桶中。
  5. 释放旧数组。

代码实现大概长这样:

// 哈希表扩容
void hash_table_resize(HashTable *ht, size_t new_capacity) {
    // 1. 分配新数组
    HashNode **new_buckets = calloc(new_capacity, sizeof(HashNode *));
    
    // 2. 遍历旧数组
    for (size_t i = 0; i < ht->capacity; i++) {
        HashNode *node = ht->buckets[i];
        while (node != NULL) {
            HashNode *next = node->next;
            
            // 3. 重新计算位置
            size_t new_index = node->hash % new_capacity;
            
            // 4. 头插法插入新桶
            node->next = new_buckets[new_index];
            new_buckets[new_index] = node;
            
            node = next;
        }
    }
    
    // 5. 释放旧数组,更新指针
    free(ht->buckets);
    ht->buckets = new_buckets;
    ht->capacity = new_capacity;
}

这段代码看着简单,但有个坑:rehash期间,哈希表是不可用的。如果你在rehash过程中插入或查询数据,要么数据丢失,要么查到旧数据。这就是为什么很多哈希表实现会在rehash时加一把大锁。

小技巧:rehash时用头插法插入新桶,效率高。但要注意,头插法会反转链表的顺序。如果你依赖插入顺序,记得改用尾插法。

渐进式rehash:别让一次搬家卡死你

上面那种一次性rehash,有个致命问题:耗时不可控

你想想看,如果你的哈希表里有1000万个元素,rehash一次要搬多久?我实测过,在普通服务器上,光遍历和重新插入就要几百毫秒。这期间整个哈希表都锁着,所有请求都得排队等。对于高并发系统来说,这就是灾难。

怎么解决?渐进式rehash

核心思想很简单:不一次搬完,分多次搬

具体做法:

  • 同时维护两个哈希表:旧表和新表。
  • 每次插入、删除、查找操作时,顺便搬一部分数据(比如搬一个桶)。
  • 搬完一个桶,就把旧表的那个桶置空。
  • 直到所有桶都搬完,释放旧表。

这样,rehash的耗时被分摊到每次操作中。单次操作只多花一点点时间,但系统整体不会出现“卡死”现象。

Redis的哈希表就是这么干的。我当年看Redis源码时,看到这设计直呼内行——把大任务拆成小任务,用时间换平滑

渐进式rehash的伪代码:

// 渐进式rehash:每次搬一个桶
void incremental_rehash(HashTable *ht) {
    if (ht->rehash_idx == -1) {
        return; // 没有rehash在进行
    }
    
    // 从rehash_idx开始,搬一个非空桶
    while (ht->rehash_idx < ht->old_capacity) {
        if (ht->old_buckets[ht->rehash_idx] != NULL) {
            // 搬这个桶的所有元素到新表
            HashNode *node = ht->old_buckets[ht->rehash_idx];
            while (node != NULL) {
                HashNode *next = node->next;
                size_t new_idx = node->hash % ht->new_capacity;
                node->next = ht->new_buckets[new_idx];
                ht->new_buckets[new_idx] = node;
                node = next;
            }
            ht->old_buckets[ht->rehash_idx] = NULL;
            break;
        }
        ht->rehash_idx++;
    }
    
    // 检查是否搬完
    if (ht->rehash_idx >= ht->old_capacity) {
        // 全部搬完,释放旧表
        free(ht->old_buckets);
        ht->old_buckets = NULL;
        ht->buckets = ht->new_buckets;
        ht->capacity = ht->new_capacity;
        ht->rehash_idx = -1;
    }
}

这段代码里,rehash_idx记录当前搬到了哪个桶。每次操作前调用一次incremental_rehash,搬一个桶。如果那个桶是空的,就继续找下一个非空桶。直到所有桶搬完,收工。

关键设计:渐进式rehash期间,查找操作需要查两个表——先查新表,没找到再查旧表。插入操作只插新表。删除操作两个表都要删。这些细节,一个都不能漏。

一张图看懂哈希表动态调整

说了这么多,不如一张图来得直观。下面这个SVG图,把扩容缩容的整个流程串起来了:

哈希表动态调整流程 初始哈希表 容量=16, 元素=8 插入数据 负载因子 > 0.75? 触发扩容 新容量 = 旧容量 × 2 rehash 重新映射 rehash方式? 一次性搬完 全部搬完才可用 渐进式搬 每次操作搬一个桶 rehash完成 释放旧表,更新指针 扩容后的哈希表 容量=32, 负载因子降低

这张图把扩容流程走了一遍:从初始状态开始,插入数据导致负载因子超标,触发扩容,然后选择rehash方式,最后完成扩容。缩容的流程类似,只是方向相反——负载因子低于下限时触发,新容量减半。

避坑指南

最后,分享几个我踩过的坑:

  • rehash期间别做全表遍历:如果你在渐进式rehash期间遍历整个哈希表,得同时遍历旧表和新表,而且要注意跳过已经搬过的桶。我当年没处理好这个,导致遍历结果重复——查了半天才发现是rehash的锅。
  • 缩容阈值别设太高:有些人把缩容阈值设成0.5,结果频繁触发缩容和扩容,系统在“扩-缩-扩-缩”之间反复横跳。我建议缩容阈值设成扩容阈值的一半以下,比如扩容0.75,缩容0.25。
  • 多线程环境要加锁:rehash期间,哈希表的结构在变。如果多个线程同时操作,轻则数据错乱,重则程序崩溃。我见过最惨的一次,rehash到一半,另一个线程插入数据,直接把新表的内存踩坏了——排查了三天才找到原因。

好了,哈希表的扩容与缩容就聊到这。记住一句话:负载因子是标尺,rehash是手段,渐进式是智慧。下次你设计哈希表时,希望这些经验能帮你少走弯路。


公众号:蓝海资料掘金营,微信deep3321