53、哈希表:如何用 C 实现一个简单的哈希表?

哈希表,说白了就是「用空间换时间」的典型代表。你想想看,数组查找是 O(1),但插入删除麻烦;链表插入删除快,但查找是 O(n)。哈希表呢?它把这两者的优点结合起来了——通过一个哈希函数,把 key 映射到数组的某个位置,然后在这个位置上挂一个链表处理冲突。

我刚开始学哈希表的时候,总觉得这东西很玄乎。后来在嵌入式项目里做配置参数缓存,才真正体会到它的威力。嗯,今天我们就手撸一个最简单的哈希表,把核心逻辑讲透。

哈希表的核心结构

一个哈希表,本质上就是一个「数组 + 链表」的组合。数组用来快速定位,链表用来解决哈希冲突(也就是不同的 key 算出了同一个下标)。

我个人习惯这样定义节点和表结构:

// 哈希表节点
typedef struct HashNode {
    char *key;
    int value;
    struct HashNode *next;
} HashNode;

// 哈希表本体
typedef struct {
    HashNode **buckets;  // 桶数组,每个元素是一个链表头指针
    int size;            // 桶的数量
    int count;           // 当前存储的元素个数
} HashTable;

这里有个细节:buckets 是一个二级指针,指向一个指针数组。每个数组元素又是一个链表的头指针。为什么要用二级指针?因为我们要动态分配桶数组的大小。

哈希函数:选对了一半

哈希函数的质量,直接决定了你的表性能好不好。如果哈希函数把所有的 key 都映射到同一个桶里,那哈希表就退化成了链表——查找 O(n),还不如直接用数组。

我常用的一个简单哈希函数是这样的:

unsigned int hash(const char *key, int table_size) {
    unsigned int h = 0;
    while (*key) {
        h = (h << 5) + *key++;  // 相当于乘以 32 再加当前字符
    }
    return h % table_size;
}

这个算法叫「DJB2 变种」,实际效果还不错。为什么用左移 5 位?因为乘以 32 比直接乘 31 快,而且能更好地打散数据。我在项目中测试过,对于字符串 key,这个函数的冲突率比简单的累加求模低很多。

小提示: 哈希函数的输出范围一定要大于表的大小,然后取模。不要直接返回一个 0~size-1 的值,那样会丢失信息,增加冲突概率。

核心操作:插入、查找、删除

有了结构和哈希函数,剩下的就是三个基本操作。我们一个一个来看。

1. 插入

void ht_insert(HashTable *ht, const char *key, int value) {
    unsigned int index = hash(key, ht->size);
    HashNode *node = ht->buckets[index];

    // 先检查 key 是否已存在
    while (node) {
        if (strcmp(node->key, key) == 0) {
            node->value = value;  // 更新值
            return;
        }
        node = node->next;
    }

    // 不存在则创建新节点,头插法
    HashNode *new_node = (HashNode *)malloc(sizeof(HashNode));
    new_node->key = strdup(key);  // 注意:要复制字符串,不能直接赋值
    new_node->value = value;
    new_node->next = ht->buckets[index];
    ht->buckets[index] = new_node;
    ht->count++;
}

这里有个坑:strdup 不是标准 C 函数,但在 POSIX 系统上都有。如果你在嵌入式环境里没有这个函数,自己写一个也不难——就是 malloc + strcpy

注意: 插入时一定要先检查 key 是否已经存在。我曾经在项目中漏掉这个检查,结果同一个 key 插入了两次,导致查找时返回了旧值,排查了半天才发现是插入逻辑的问题。

2. 查找

int ht_find(HashTable *ht, const char *key) {
    unsigned int index = hash(key, ht->size);
    HashNode *node = ht->buckets[index];

    while (node) {
        if (strcmp(node->key, key) == 0) {
            return node->value;
        }
        node = node->next;
    }
    return -1;  // 没找到,返回一个特殊值
}

查找的逻辑很简单:算哈希 -> 定位桶 -> 遍历链表。时间复杂度取决于链表的长度。如果哈希函数设计得好,每个桶里的节点数应该接近 count / size,也就是平均 O(1)。

3. 删除

void ht_delete(HashTable *ht, const char *key) {
    unsigned int index = hash(key, ht->size);
    HashNode *node = ht->buckets[index];
    HashNode *prev = NULL;

    while (node) {
        if (strcmp(node->key, key) == 0) {
            if (prev) {
                prev->next = node->next;
            } else {
                ht->buckets[index] = node->next;
            }
            free(node->key);
            free(node);
            ht->count--;
            return;
        }
        prev = node;
        node = node->next;
    }
}

删除要维护链表的前驱指针,这是链表操作的基本功。嗯,这里要注意:free(node->key) 别忘了,因为 key 是用 strdup 分配的,不释放就会内存泄漏。

哈希表的完整生命周期

除了增删查,我们还需要创建和销毁:

HashTable *ht_create(int size) {
    HashTable *ht = (HashTable *)malloc(sizeof(HashTable));
    ht->size = size;
    ht->count = 0;
    ht->buckets = (HashNode **)calloc(size, sizeof(HashNode *));
    return ht;
}

void ht_destroy(HashTable *ht) {
    for (int i = 0; i < ht->size; i++) {
        HashNode *node = ht->buckets[i];
        while (node) {
            HashNode *tmp = node;
            node = node->next;
            free(tmp->key);
            free(tmp);
        }
    }
    free(ht->buckets);
    free(ht);
}

销毁时一定要遍历所有桶,释放每个节点。我曾经见过有人只释放了桶数组,没释放链表节点——结果内存泄漏得一塌糊涂。

哈希表的核心逻辑图

下面这张图展示了哈希表的核心工作流程:

哈希表核心工作流程 输入 key 哈希函数 hash(key) index = h % size 桶数组[index] 遍历链表查找 key 找到? 返回 value 插入新节点 / 返回 -1

哈希表的性能关键

哈希表的性能,说白了就三个因素:

因素 影响 建议
哈希函数质量 决定冲突率 选择成熟的算法,如 DJB2、MurmurHash
负载因子 决定平均链表长度 负载因子 = count / size,建议控制在 0.7 以下
内存分配 影响插入/删除速度 嵌入式场景下考虑使用内存池

负载因子超过 0.7 时,冲突会急剧增加。这时候就需要「rehash」——重新分配一个更大的桶数组,把所有元素重新插入。rehash 的代价是 O(n),但可以分摊到每次插入中,这就是「均摊分析」的思想。

核心要点: 哈希表不是万能的。它适合「key 值分布均匀」的场景。如果你的 key 有明显的规律(比如全是连续整数),直接用数组更简单高效。我在项目中就犯过这个错——用哈希表存 0~1000 的连续 ID,结果哈希函数反而增加了开销。

避坑指南

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

  • 字符串 key 的内存管理: 插入时一定要复制字符串,不要直接保存传入的指针。因为调用方可能释放或修改那个字符串。
  • 哈希函数不要用简单的累加: 比如 return key[0] + key[1] + key[2],这种函数会把 "abc" 和 "cba" 映射到同一个位置,冲突率极高。
  • 删除后记得释放内存: 嵌入式系统内存有限,泄漏一点就可能出问题。
  • 线程安全: 上面的实现不是线程安全的。多线程环境下需要加锁,或者使用无锁哈希表。

嗯,哈希表的内容就讲到这里。代码虽然简单,但核心思想都在里面了。你可以在自己的项目里试试,把配置参数、设备状态这些用哈希表来管理,效果会很不错。


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