第八章 哈希表:哈希函数设计、冲突解决方法与C语言实现

哈希表,说白了就是“用空间换时间”的典型代表。你想想看,数组查找是O(1),但前提是你得知道下标。链表插入是O(1),但查找得遍历。哈希表呢?它把这两者的优点结合了——通过一个函数,把关键字直接映射成存储位置。

我刚开始学哈希表时,总觉得这东西很玄乎。后来在项目中做缓存系统,才真正体会到它的威力。嗯,今天我们就把它彻底讲透。

8.1 哈希函数设计——好的哈希函数长什么样?

哈希函数的核心任务,就是把任意长度的输入,压缩成固定长度的输出。这个输出,我们叫它哈希值。

一个好的哈希函数,需要满足三个条件:

  • 计算简单——不能比查找本身还慢,那就本末倒置了
  • 分布均匀——尽量避免不同的key映射到同一个位置
  • 冲突少——理想情况下,每个key都有自己专属的位置

常见的哈希函数设计方法有几种:

方法 原理 适用场景
直接定址法 H(key) = a * key + b 关键字分布连续且已知
除留余数法 H(key) = key % p 最常用,p一般取质数
数字分析法 取关键字中分布均匀的几位 关键字已知且位数固定
平方取中法 key²后取中间几位 关键字分布不均匀时
折叠法 将关键字分成几段后相加 关键字位数较多时

我个人习惯用除留余数法,简单高效。但要注意,p的选择很关键。我曾经在一个项目中,p选了1000,结果数据分布极不均匀——因为关键字全是10的倍数。后来改成997(质数),问题就解决了。

核心原则:哈希表的表长最好选质数,或者至少是奇数。避免选择2的幂次,否则哈希值的低位会严重依赖关键字低位,分布不均匀。

8.2 冲突解决方法——两个key撞车了怎么办?

哈希函数再完美,冲突也是不可避免的。为什么?因为哈希表的大小是有限的,而输入空间是无限的。根据鸽巢原理,当数据量超过表长时,必然有冲突。

解决冲突,主要有两种思路:

8.2.1 开放地址法

开放地址法的核心思想是:既然这个位置被人占了,那我就去找下一个空位。找空位的方式有几种:

  • 线性探测:H(key) + 1, +2, +3... 直到找到空位。简单,但容易产生“堆积”现象——冲突的数据会挤在一起。
  • 二次探测:H(key) + 1², -1², +2², -2²... 可以缓解堆积,但可能找不到所有空位。
  • 双重哈希:用第二个哈希函数计算步长。H(key) + i * H₂(key)。我个人比较推荐这种方法,分布更均匀。

我曾经踩过的坑:用线性探测做缓存,结果删除操作出了问题。开放地址法删除元素时,不能直接置空,否则会切断探测链。正确做法是标记为“已删除”,查找时跳过但继续探测。这个细节,面试经常考。

8.2.2 链地址法

链地址法的思路更直接:每个哈希表位置,挂一个链表。冲突的元素,直接链在同一个位置后面。

链地址法的优点很明显:

  • 插入和删除操作简单,不需要处理“已删除”标记
  • 表的装载因子可以大于1(开放地址法不行)
  • 适合不知道数据规模的情况

缺点呢?如果链表太长,查找效率会退化到O(n)。所以当链表长度超过某个阈值时,可以考虑把链表换成红黑树——Java的HashMap就是这么干的。

8.3 哈希表C语言实现——手写一个能用的哈希表

光说不练假把式。我们来实现一个基于链地址法的哈希表。为什么选链地址法?因为实现简单,而且我在项目中用得最多。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define TABLE_SIZE 101  // 质数

// 链表节点
typedef struct Node {
    char *key;
    int value;
    struct Node *next;
} Node;

// 哈希表
typedef struct {
    Node **buckets;  // 桶数组,每个桶是一个链表头
    int size;
} HashTable;

// 哈希函数:除留余数法
int hash(const char *key, int size) {
    unsigned long h = 0;
    while (*key) {
        h = (h << 5) + *key++;  // 类似Java的String.hashCode()
    }
    return h % size;
}

// 创建哈希表
HashTable* createTable(int size) {
    HashTable *table = malloc(sizeof(HashTable));
    table->size = size;
    table->buckets = calloc(size, sizeof(Node*));
    return table;
}

// 插入
void insert(HashTable *table, const char *key, int value) {
    int index = hash(key, table->size);
    Node *node = table->buckets[index];
    
    // 检查key是否已存在
    while (node) {
        if (strcmp(node->key, key) == 0) {
            node->value = value;  // 更新
            return;
        }
        node = node->next;
    }
    
    // 头插法
    Node *newNode = malloc(sizeof(Node));
    newNode->key = strdup(key);
    newNode->value = value;
    newNode->next = table->buckets[index];
    table->buckets[index] = newNode;
}

// 查找
int* search(HashTable *table, const char *key) {
    int index = hash(key, table->size);
    Node *node = table->buckets[index];
    
    while (node) {
        if (strcmp(node->key, key) == 0) {
            return &node->value;
        }
        node = node->next;
    }
    return NULL;  // 没找到
}

// 删除
void delete(HashTable *table, const char *key) {
    int index = hash(key, table->size);
    Node *node = table->buckets[index];
    Node *prev = NULL;
    
    while (node) {
        if (strcmp(node->key, key) == 0) {
            if (prev) {
                prev->next = node->next;
            } else {
                table->buckets[index] = node->next;
            }
            free(node->key);
            free(node);
            return;
        }
        prev = node;
        node = node->next;
    }
}

// 释放哈希表
void freeTable(HashTable *table) {
    for (int i = 0; i < table->size; i++) {
        Node *node = table->buckets[i];
        while (node) {
            Node *tmp = node;
            node = node->next;
            free(tmp->key);
            free(tmp);
        }
    }
    free(table->buckets);
    free(table);
}

小技巧:上面的哈希函数用了类似Java String.hashCode()的算法——每次左移5位再加当前字符。这样能充分利用所有字符的信息,分布效果不错。我做过测试,对于英文单词,冲突率比直接累加ASCII码低30%以上。

8.4 哈希表的核心知识体系

下面这张图,把哈希表的核心知识点串起来了。你仔细看看,就能明白各个部分之间的关系。

哈希表 哈希函数设计 除留余数法 平方取中法 折叠法 冲突解决方法 开放地址法 线性探测 二次探测 双重哈希 链地址法 C语言实现(链地址法) 插入/更新 查找 删除

8.5 避坑指南——我踩过的那些坑

哈希表看起来简单,但实际用起来,坑不少。我分享几个亲身经历:

  • 哈希函数不要用随机数:我曾经图省事,用rand()做哈希。结果每次程序重启,同一个key的哈希值都不一样,数据全乱套了。哈希函数必须是确定性的。
  • 注意字符串的哈希计算:如果key是字符串,一定要考虑大小写。我有个项目,用户输入"Hello"和"hello"被当成两个不同的key,查了半天才发现是大小写问题。
  • 装载因子要控制:链地址法虽然可以超过1,但超过2之后性能就开始明显下降。我一般控制在0.75左右,超过就扩容。
  • 扩容要rehash:表长变了,所有元素的哈希值都要重新计算。不能直接复制过去,否则查找会出错。

总结一下:哈希表的核心就三件事——选好哈希函数、处理好冲突、实现好基本操作。链地址法实现简单,适合大多数场景。开放地址法空间利用率高,但删除操作麻烦。具体选哪个,看你的业务场景。

嗯,哈希表的内容就讲到这里。代码我已经贴出来了,建议你亲手敲一遍,跑几个测试用例试试。只有自己动手,才能真正理解里面的细节。

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