22、动态哈希表:简单哈希表的设计、动态扩容与缩容

哈希表,说白了就是“用空间换时间”的典型代表。你给它一个键,它直接算出位置,一步到位。但这里有个坑——算出来的位置可能撞车,也就是哈希冲突。更麻烦的是,表太小了频繁撞车,表太大了又浪费内存。怎么办?动态扩容和缩容就是解药。

我个人习惯把哈希表比作一个停车场。车多了车位不够,就得扩建;车少了空着大片车位,就得缩小。今天我们就来手写一个能自己长大的哈希表。

22.1 哈希表的核心结构

先搭个架子。一个动态哈希表至少需要三样东西:

  • 桶数组:存数据的槽位
  • 哈希函数:把键映射到桶下标
  • 负载因子:决定什么时候扩容/缩容

我在项目中遇到过最蠢的bug——哈希函数写得稀烂,所有键都挤到同一个桶里,哈希表直接退化成链表。所以选哈希函数要慎重。

下面是一个简单的哈希表结构定义:

typedef struct Entry {
    char* key;
    int value;
    struct Entry* next;  // 链地址法解决冲突
} Entry;

typedef struct HashTable {
    Entry** buckets;     // 桶数组
    int size;            // 当前元素个数
    int capacity;        // 桶的数量
    float load_factor;   // 负载因子阈值
} HashTable;

这里用了链地址法处理冲突。每个桶其实是一个链表的头指针。插入时先算哈希,再挂到对应链表的头部。

关键点:负载因子 = 元素个数 / 桶数量。一般建议阈值设在0.75左右。超过就扩容,低于0.25就缩容。

22.2 哈希函数与插入逻辑

哈希函数要尽量均匀。我常用的一个简单版本:

int hash(const char* key, int capacity) {
    unsigned long h = 5381;
    int c;
    while ((c = *key++)) {
        h = ((h << 5) + h) + c;  // djb2算法
    }
    return h % capacity;
}

这个djb2算法是我从一本老书上抄来的,效果出奇的好。你想想看,它只用移位和加法,速度飞快,而且分布很均匀。

插入时先检查负载因子,超了就扩容。然后算哈希,挂到链表头:

void insert(HashTable* table, const char* key, int value) {
    // 检查是否需要扩容
    if ((float)(table->size + 1) / table->capacity > table->load_factor) {
        resize(table, table->capacity * 2);
    }

    int index = hash(key, table->capacity);
    Entry* new_entry = malloc(sizeof(Entry));
    new_entry->key = strdup(key);
    new_entry->value = value;
    new_entry->next = table->buckets[index];
    table->buckets[index] = new_entry;
    table->size++;
}

小技巧:扩容时记得重新哈希所有旧数据。因为容量变了,哈希值也会变。我曾经偷懒没重新哈希,结果查找时全乱套了。

22.3 动态扩容与缩容

扩容和缩容的核心逻辑是一样的:申请新桶数组,重新计算每个元素的位置,然后释放旧数组。区别只是新容量的大小。

扩容时容量翻倍,缩容时容量减半。但要注意,缩容不能低于初始容量。我一般设个最小值,比如16。

void resize(HashTable* table, int new_capacity) {
    Entry** new_buckets = calloc(new_capacity, sizeof(Entry*));
    // 遍历旧桶,重新哈希
    for (int i = 0; i < table->capacity; i++) {
        Entry* entry = table->buckets[i];
        while (entry) {
            Entry* next = entry->next;
            int new_index = hash(entry->key, new_capacity);
            entry->next = new_buckets[new_index];
            new_buckets[new_index] = entry;
            entry = next;
        }
    }
    free(table->buckets);
    table->buckets = new_buckets;
    table->capacity = new_capacity;
}

缩容的触发条件我放在删除操作里。每次删除后检查负载因子,如果低于0.25就缩容。嗯,这里要注意,频繁的扩容缩容会导致性能抖动。我一般会加个“冷却”机制——两次调整之间至少间隔一定数量的操作。

避坑指南:我曾经在一个高并发场景下,哈希表频繁扩容缩容,导致内存碎片严重。后来我改用“按需扩容,懒缩容”策略——缩容时只标记,等到负载因子低于0.1才真正执行。

22.4 查找与删除

查找很简单:算哈希,然后遍历链表。删除稍微麻烦点,要处理链表节点的摘除。

int lookup(HashTable* table, const char* key) {
    int index = hash(key, table->capacity);
    Entry* entry = table->buckets[index];
    while (entry) {
        if (strcmp(entry->key, key) == 0) {
            return entry->value;
        }
        entry = entry->next;
    }
    return -1;  // 没找到
}

void delete(HashTable* table, const char* key) {
    int index = hash(key, table->capacity);
    Entry* entry = table->buckets[index];
    Entry* prev = NULL;
    while (entry) {
        if (strcmp(entry->key, key) == 0) {
            if (prev) prev->next = entry->next;
            else table->buckets[index] = entry->next;
            free(entry->key);
            free(entry);
            table->size--;

            // 检查是否需要缩容
            if (table->capacity > 16 &&
                (float)table->size / table->capacity < 0.25) {
                resize(table, table->capacity / 2);
            }
            return;
        }
        prev = entry;
        entry = entry->next;
    }
}

删除时别忘了释放内存。我见过有人只删了指针没free,内存泄漏到怀疑人生。

22.5 整体流程可视化

下面这张图展示了动态哈希表的核心流程:插入时触发扩容,删除时触发缩容,所有操作都经过哈希函数映射到桶。

动态哈希表核心流程 键值对输入 哈希函数 桶数组 [0] [1] [2] [3] [4] ... 节点A 节点B 节点C 负载因子检查 (扩容/缩容) 图例 输入 哈希计算 桶数组 链表节点 负载因子判断

22.6 性能与注意事项

操作 平均时间复杂度 最坏情况 触发条件
插入 O(1) O(n) 负载因子 > 阈值 → 扩容
查找 O(1) O(n)
删除 O(1) O(n) 负载因子 < 0.25 → 缩容
扩容/缩容 O(n) O(n) 手动或自动触发

注意:扩容和缩容是O(n)操作,如果哈希表很大,会有明显的卡顿。我一般在低峰期做扩容,或者用渐进式rehash——每次操作只迁移一部分数据。

最后说一句,动态哈希表不是银弹。如果你的数据量是固定的,静态哈希表更简单高效。但如果你不确定数据规模,动态版本能帮你省很多心。

好了,这一章就到这里。代码都在上面了,你可以直接拿去用。记得测试边界情况——空表、满表、反复插入删除。嗯,实践出真知。

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