C语言hashtable如何实现高效查找与冲突处理?

99ANYc3cd6
预计阅读时长 28 分钟
位置: 首页 C语言 正文

哈希表是一种非常重要的数据结构,它通过一个“哈希函数”将键映射到数组中的一个位置,从而实现高效的插入、删除和查找操作,理想情况下,这些操作的平均时间复杂度可以达到 O(1)

c语言 hashtable
(图片来源网络,侵删)

下面,我将从以下几个方面来解释:

  1. 核心概念:哈希表是如何工作的?
  2. 哈希冲突:以及如何解决它。
  3. C 语言实现:一个完整的、可运行的哈希表示例。
  4. 优化与进阶:如何让哈希表更健壮、更高效。

核心概念

哈希表主要由三部分组成:

  1. 哈希函数:这是一个将“键”转换为数组索引(下标)的函数,一个好的哈希函数应该:

    • 计算速度快
    • 分布均匀:将不同的键均匀地散列到数组的各个位置,避免聚集。
    • 确定性:同一个键总是产生相同的索引。

    在 C 语言中,由于键的类型多种多样(如 int, char*, struct 等),我们通常需要一个通用的方法,最简单的方法是将键的内存地址转换为整数,然后对数组大小取模。

    c语言 hashtable
    (图片来源网络,侵删)
  2. 数组:这是哈希表的“桶”或“槽”,用于存储数据,数组的容量决定了哈希表的大小。

  3. 键值对:每个槽中存储的数据,包含一个 key 和一个 value

基本流程:

  • 插入:对 key 应用哈希函数,得到索引 i,然后将 (key, value) 存储在数组 i 的位置。
  • 查找:对 key 应用哈希函数,得到索引 i,然后直接访问数组 i 的位置,看是否存在该 key 对应的 value
  • 删除:与查找类似,找到 i 后,将该位置的数据移除。

哈希冲突

理想情况下,每个键都映射到唯一的索引,但现实是,键的数量可能远大于数组的大小,或者哈希函数不够完美,导致两个不同的键计算出相同的索引,这种情况称为哈希冲突

c语言 hashtable
(图片来源网络,侵删)

解决哈希冲突主要有两种方法:

A. 链地址法

这是最常用、最容易实现的方法。

  • 原理:将哈希表的数组从“存储单个值”变成“存储一个链表的指针”,每个数组元素都是一个链表的头指针。
  • 冲突处理:当发生冲突时(即两个键映射到同一个索引),就将新的键值对插入到该索引对应的链表的头部或尾部。
  • 优点:实现简单,内存利用率高,当冲突不多时,性能很好。
  • 缺点:如果冲突严重,某个链表过长,查找性能会下降到 O(n)。

B. 开放地址法

  • 原理:所有键值对都存储在哈希表数组本身中,不使用额外的链表。
  • 冲突处理:当发生冲突时,通过一个“探测序列”在数组中寻找下一个“空槽”来插入数据,常见的探测方法有:
    • 线性探测i = (hash(key) + probe_count) % capacityhash(key) 的位置被占了,就检查 hash(key)+1,再不行就检查 hash(key)+2,以此类推。
    • 二次探测i = (hash(key) + probe_count^2) % capacity
  • 优点:缓存友好性比链地址法好,因为数据是连续存储的。
  • 缺点:容易产生“聚集”现象,即连续的槽被占满,导致查找效率下降,删除操作也比较复杂。

我们将采用更直观、更常用的链地址法来实现。


C 语言实现(链地址法)

下面是一个完整的、带有注释的 C 语言哈希表示例,它支持 int 类型的键和 char* 类型的值。

代码结构

  1. 定义哈希表节点:每个节点存储一个键值对,以及一个指向下一个节点的指针(用于构建链表)。
  2. 定义哈希表:包含一个指向节点指针数组的指针,以及哈希表的大小。
  3. 核心函数
    • createHashTable: 创建并初始化哈希表。
    • hashFunction: 哈希函数(这里使用取模法)。
    • insert: 插入键值对。
    • search: 查找键并返回对应的值。
    • delete: 删除一个键值对。
    • freeHashTable: 释放哈希表占用的内存。

完整代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 1. 定义哈希表节点
typedef struct HashNode {
    int key;             // 键
    char* value;         // 值
    struct HashNode* next; // 指向下一个节点的指针,用于解决冲突
} HashNode;
// 2. 定义哈希表
typedef struct {
    int size;            // 哈希表的大小(桶的数量)
    HashNode** buckets;  // 指向桶数组的指针,每个桶是一个链表的头指针
} HashTable;
// 哈希函数:简单的取模法
int hashFunction(int key, int size) {
    return abs(key) % size;
}
// 创建一个新的哈希节点
HashNode* createNode(int key, const char* value) {
    HashNode* newNode = (HashNode*)malloc(sizeof(HashNode));
    if (newNode == NULL) {
        fprintf(stderr, "内存分配失败\n");
        exit(EXIT_FAILURE);
    }
    newNode->key = key;
    // 为值分配内存并复制
    newNode->value = strdup(value); // strdup 会分配内存并复制字符串
    if (newNode->value == NULL) {
        fprintf(stderr, "内存分配失败\n");
        free(newNode);
        exit(EXIT_FAILURE);
    }
    newNode->next = NULL;
    return newNode;
}
// 创建并初始化哈希表
HashTable* createHashTable(int size) {
    HashTable* ht = (HashTable*)malloc(sizeof(HashTable));
    if (ht == NULL) {
        fprintf(stderr, "内存分配失败\n");
        exit(EXIT_FAILURE);
    }
    ht->size = size;
    // 为桶数组分配内存
    ht->buckets = (HashNode**)calloc(size, sizeof(HashNode*));
    if (ht->buckets == NULL) {
        fprintf(stderr, "内存分配失败\n");
        free(ht);
        exit(EXIT_FAILURE);
    }
    return ht;
}
// 向哈希表中插入键值对
void insert(HashTable* ht, int key, const char* value) {
    int index = hashFunction(key, ht->size);
    HashNode* newNode = createNode(key, value);
    // 情况1:桶为空,直接插入
    if (ht->buckets[index] == NULL) {
        ht->buckets[index] = newNode;
    } 
    // 情况2:桶不为空,发生冲突,插入到链表头部
    else {
        newNode->next = ht->buckets[index];
        ht->buckets[index] = newNode;
    }
    printf("插入成功: Key = %d, Value = %s\n", key, value);
}
// 在哈希表中搜索键
const char* search(HashTable* ht, int key) {
    int index = hashFunction(key, ht->size);
    HashNode* current = ht->buckets[index];
    // 遍历链表以查找键
    while (current != NULL) {
        if (current->key == key) {
            return current->value; // 找到,返回值
        }
        current = current->next;
    }
    return NULL; // 未找到
}
// 从哈希表中删除键值对
void delete(HashTable* ht, int key) {
    int index = hashFunction(key, ht->size);
    HashNode* current = ht->buckets[index];
    HashNode* prev = NULL;
    // 遍历链表查找要删除的节点
    while (current != NULL && current->key != key) {
        prev = current;
        current = current->next;
    }
    // 情况1:未找到
    if (current == NULL) {
        printf("删除失败: Key %d 不存在\n", key);
        return;
    }
    // 情况2:找到了
    // 如果是链表的第一个节点
    if (prev == NULL) {
        ht->buckets[index] = current->next;
    } else { // 如果是中间或最后一个节点
        prev->next = current->next;
    }
    // 释放节点的内存
    free(current->value); // 先释放值
    free(current);        // 再释放节点本身
    printf("删除成功: Key = %d\n", key);
}
// 释放哈希表的所有内存
void freeHashTable(HashTable* ht) {
    for (int i = 0; i < ht->size; i++) {
        HashNode* current = ht->buckets[i];
        while (current != NULL) {
            HashNode* temp = current;
            current = current->next;
            free(temp->value);
            free(temp);
        }
    }
    free(ht->buckets);
    free(ht);
}
int main() {
    // 创建一个大小为10的哈希表
    HashTable* ht = createHashTable(10);
    // 插入一些数据
    insert(ht, 1, "Apple");
    insert(ht, 2, "Banana");
    insert(ht, 12, "Cherry"); // 12 % 10 = 2, 与 2 冲突
    insert(ht, 22, "Date");   // 22 % 10 = 2, 与 2, 12 冲突
    // 查找数据
    printf("\n--- 查找测试 ---\n");
    const char* val1 = search(ht, 1);
    printf("Key 1 的值: %s\n", val1 ? val1 : "未找到");
    const char* val12 = search(ht, 12);
    printf("Key 12 的值: %s\n", val12 ? val12 : "未找到");
    const char* val99 = search(ht, 99);
    printf("Key 99 的值: %s\n", val99 ? val99 : "未找到");
    // 删除数据
    printf("\n--- 删除测试 ---\n");
    delete(ht, 2);
    delete(ht, 99); // 尝试删除不存在的键
    // 再次查找以验证删除
    printf("\n--- 删除后查找 ---\n");
    val1 = search(ht, 1);
    printf("Key 1 的值: %s\n", val1 ? val1 : "未找到");
    val12 = search(ht, 12);
    printf("Key 12 的值: %s\n", val12 ? val12 : "未找到");
    // 释放哈希表
    freeHashTable(ht);
    return 0;
}

如何编译和运行

  1. 将上述代码保存为 hashtable.c
  2. 使用 gcc 进行编译:
    gcc hashtable.c -o hashtable
  3. 运行程序:
    ./hashtable

预期输出

插入成功: Key = 1, Value = Apple
插入成功: Key = 2, Value = Banana
插入成功: Key = 12, Value = Cherry
插入成功: Key = 22, Value = Date
--- 查找测试 ---
Key 1 的值: Apple
Key 12 的值: Cherry
Key 99 的值: 未找到
--- 删除测试 ---
删除成功: Key = 2
删除失败: Key 99 不存在
--- 删除后查找 ---
Key 1 的值: Apple
Key 12 的值: Cherry

优化与进阶

这个基础的实现已经展示了哈希表的核心思想,但在实际应用中,我们还需要考虑更多:

  1. 动态扩容

    • 问题:当哈希表中的元素数量越来越多,冲突的概率也会急剧增加,导致链表变长,性能下降。
    • 解决方案:设置一个“负载因子”(Load Factor = 元素数量 / 哈希表大小),当负载因子超过某个阈值(如 0.75)时,创建一个更大的新哈希表(通常是原来的两倍),然后将所有旧元素重新哈希并插入到新表中,这是一个复杂但至关重要的优化。
  2. 更复杂的哈希函数

    • 对于 int 键,简单的取模法就足够了。
    • 对于 char* (字符串) 键,可以使用更复杂的算法,如 DJB2FNV-1,这些算法能更好地将字符串的字符分布到哈希值中。
  3. 更通用的键类型

    • 目前的实现只支持 int 键,要支持 struct 或其他自定义类型,需要用户自定义一个“哈希函数”和一个“比较函数”,并将其作为参数传递给哈希表,C++ 的模板和函数对象(Functor)就是为了解决这个问题而设计的。
  4. 更完善的错误处理

    • search 函数中,如果键不存在,返回 NULL 可能会有歧义(因为 value 也可能是 NULL),更好的做法是返回一个指向节点的指针,让调用者自己判断 node->value,或者使用一个特殊的结构体来封装查找结果。
  5. 使用开放地址法

    可以尝试实现开放地址法(特别是线性探测),并比较它与链地址法在缓存性能和内存占用上的差异。

希望这份详细的解释和代码示例能帮助你完全理解 C 语言中的哈希表!

-- 展开阅读全文 --
头像
织梦网文章为何禁止粘贴?
« 上一篇 03-14
dede5.7采集教程如何正确配置与使用?
下一篇 » 03-14

相关文章

取消
微信二维码
支付宝二维码

目录[+]