数据结构 哈希基础 哈希函数 哈希冲突及解决

哈希表基础知识详解

一、哈希表基本概念

1.1 什么是哈希表?

哈希表(Hash Table)是一种通过哈希函数将键映射到存储位置 的数据结构,支持快速的插入、删除和查找操作。

1.2 核心思想

复制代码
键(key) → 哈希函数 → 哈希值(hash value) → 存储位置(index)

1.3 理想情况 vs 现实情况

  • 理想:每个键映射到唯一的存储位置 → O(1) 时间复杂度

  • 现实 :不同键可能映射到同一位置 → 哈希冲突

二、哈希函数设计原则

2.1 好的哈希函数应具备的特性

复制代码
// 1. 确定性:相同输入 → 相同输出
hash("Alice") == hash("Alice")  // 必须成立

// 2. 快速计算:O(1) 或 O(k)(k为键长度)
// 3. 均匀分布:键应均匀分布在哈希表中
// 4. 低碰撞率:不同键尽量映射到不同位置

2.2 常见哈希函数

2.2.1 整数哈希
复制代码
// 1. 除留余数法(最常用)
int hash_div(int key, int size) {
    return key % size;  // size最好为质数
}

// 2. 乘法哈希法
int hash_multi(int key, int size) {
    double A = 0.6180339887;  // 黄金分割比例
    double val = key * A;
    val = val - (int)val;      // 取小数部分
    return (int)(size * val);
}

// 3. 平方取中法
int hash_mid_square(int key) {
    long square = (long)key * key;
    // 取中间几位作为哈希值
    return (square >> 16) & 0xFFFF;
}
2.2.2 字符串哈希
复制代码
// 1. 简单加法哈希(不推荐)
unsigned int hash_simple(const char* str, int size) {
    unsigned int hash = 0;
    while (*str) {
        hash += *str++;  // 容易产生聚集
    }
    return hash % size;
}

// 2. djb2算法(推荐)
unsigned long hash_djb2(const char* str) {
    unsigned long hash = 5381;
    int c;
    while ((c = *str++)) {
        hash = ((hash << 5) + hash) + c;  // hash * 33 + c
    }
    return hash;
}

// 3. FNV-1a算法
unsigned int hash_fnv1a(const char* str) {
    const unsigned int FNV_prime = 16777619U;
    const unsigned int offset_basis = 2166136261U;
    
    unsigned int hash = offset_basis;
    while (*str) {
        hash ^= (unsigned int)(*str++);
        hash *= FNV_prime;
    }
    return hash;
}

三、哈希冲突的必然性

3.1 为什么会有冲突?

  • 鸽巢原理:有10个鸽笼,11只鸽子,至少1个笼子有2只鸽子

  • 数学证明:设哈希表大小为m,键数量为n

    • 当 n > m 时,必然有冲突

    • 当 n ≤ m 时,也可能有冲突(生日悖论)

3.2 生日悖论

在23个人中,至少有两个人同一天生日的概率超过50%!

同理,哈希表中元素远少于容量时,冲突概率可能很高。

四、哈希冲突解决方法

4.1 链地址法(Separate Chaining)

4.1.1 基本原理
  • 每个桶是一个链表

  • 冲突元素链接到同一个桶的链表中

复制代码
typedef struct Node {
    int key;
    int value;
    struct Node* next;
} Node;

typedef struct HashTable {
    Node** buckets;  // 桶数组
    int capacity;    // 桶数量
    int size;        // 元素数量
} HashTable;
4.1.2 操作示例
复制代码
// 插入操作(头插法)
void insert_chaining(HashTable* table, int key, int value) {
    int index = hash(key) % table->capacity;
    
    Node* newNode = createNode(key, value);
    newNode->next = table->buckets[index];
    table->buckets[index] = newNode;
    table->size++;
}

// 查找操作
Node* search_chaining(HashTable* table, int key) {
    int index = hash(key) % table->capacity;
    Node* current = table->buckets[index];
    
    while (current != NULL) {
        if (current->key == key) {
            return current;
        }
        current = current->next;
    }
    return NULL;
}
4.1.3 优缺点

优点:

  • 简单易实现

  • 可存储无限元素(链表可无限增长)

  • 删除操作简单

缺点:

  • 需要额外的指针存储空间

  • 缓存不友好(节点分散在内存中)

  • 最坏情况:所有元素在一个链表中 → O(n)

4.2 开放地址法(Open Addressing)

4.2.1 基本原理
  • 所有元素都存放在哈希表数组中

  • 发生冲突时,按照某种探测序列寻找下一个空槽

4.2.2 线性探测(Linear Probing)
复制代码
// 插入操作
int insert_linear(int* table, int size, int key) {
    int index = hash(key) % size;
    
    // 线性探测:index, index+1, index+2, ...
    for (int i = 0; i < size; i++) {
        int probe_index = (index + i) % size;
        if (table[probe_index] == EMPTY) {
            table[probe_index] = key;
            return probe_index;
        }
    }
    return -1;  // 表满
}

// 查找操作
int search_linear(int* table, int size, int key) {
    int index = hash(key) % size;
    
    for (int i = 0; i < size; i++) {
        int probe_index = (index + i) % size;
        
        if (table[probe_index] == EMPTY) {
            return -1;  // 未找到
        }
        if (table[probe_index] == key) {
            return probe_index;  // 找到
        }
    }
    return -1;  // 未找到
}

问题: 线性探测容易产生聚集(Clustering)

4.2.3 二次探测(Quadratic Probing)
复制代码
// 探测序列:h(k), h(k)+1², h(k)-1², h(k)+2², h(k)-2², ...
int quadratic_probe(int index, int i, int size) {
    int offset = ((i % 2 == 0) ? 1 : -1) * ((i + 1) / 2) * ((i + 1) / 2);
    return (index + offset + size) % size;
}

优点: 减少聚集
缺点: 可能无法找到空槽,即使表未满

4.2.4 双重哈希(Double Hashing)
复制代码
// 使用两个哈希函数
int hash1(int key) { return key % size; }
int hash2(int key) { return 1 + (key % (size - 1)); }

int double_hash_probe(int key, int i, int size) {
    return (hash1(key) + i * hash2(key)) % size;
}

优点: 最均匀的探测序列
要求: hash2(k) 必须与 size 互质

4.2.5 开放地址法的删除问题
复制代码
// 不能直接置空,否则会破坏查找链
// 需要特殊标记:DELETED

#define EMPTY -1
#define DELETED -2

int delete_open_addressing(int* table, int size, int key) {
    int index = search(table, size, key);
    if (index != -1) {
        table[index] = DELETED;  // 标记为已删除
        return 1;
    }
    return 0;
}

// 查找时需要处理DELETED标记
int search_with_deleted(int* table, int size, int key) {
    int index = hash(key) % size;
    
    for (int i = 0; i < size; i++) {
        int probe_index = (index + i) % size;
        
        if (table[probe_index] == EMPTY) {
            return -1;
        }
        if (table[probe_index] == key) {
            return probe_index;
        }
        // 跳过DELETED,继续查找
    }
    return -1;
}

4.3 再哈希法(Rehashing)

当哈希表达到一定负载因子时,创建更大的表,重新插入所有元素。

复制代码
typedef struct HashTable {
    Entry** table;
    int capacity;
    int size;
    double load_factor;
} HashTable;

void rehash(HashTable* ht) {
    int old_capacity = ht->capacity;
    Entry** old_table = ht->table;
    
    // 扩容(通常翻倍)
    ht->capacity *= 2;
    ht->table = calloc(ht->capacity, sizeof(Entry*));
    ht->size = 0;
    
    // 重新插入所有元素
    for (int i = 0; i < old_capacity; i++) {
        Entry* entry = old_table[i];
        while (entry != NULL) {
            insert(ht, entry->key, entry->value);
            Entry* next = entry->next;
            free(entry);
            entry = next;
        }
    }
    free(old_table);
}

五、性能分析比较

5.1 时间复杂度(平均情况)

方法 插入 查找 删除
链地址法 O(1) O(1+α) O(1+α)
线性探测 O(1/(1-α)) O(1/(1-α)) O(1/(1-α))
二次探测 O(1/(1-α)) O(1/(1-α)) O(1/(1-α))
双重哈希 O(1/(1-α)) O(1/(1-α)) O(1/(1-α))

其中 α = n/m(负载因子)

5.2 负载因子建议

方法 最大负载因子建议
链地址法 0.75-1.0
线性探测 0.5-0.7
二次探测 0.5-0.7
双重哈希 0.5-0.7

5.3 空间复杂度

  • 链地址法:需要额外指针空间

  • 开放地址法:空间利用率更高

六、实际应用选择建议

6.1 选择链地址法的情况

  • 不确定元素数量

  • 需要频繁删除

  • 内存充足,对缓存不敏感

  • 实现简单性优先

6.2 选择开放地址法的情况

  • 元素数量可预估

  • 内存受限

  • 需要良好的缓存局部性

  • 删除操作不频繁

6.3 现代哈希表的优化技巧

复制代码
// 1. 使用质数作为表大小(减少聚集)
int next_prime(int n) {
    while (!is_prime(n)) n++;
    return n;
}

// 2. 动态调整负载因子
if ((double)ht->size / ht->capacity > ht->load_factor) {
    rehash(ht);
}

// 3. 链表转红黑树(Java HashMap)
// 当链表长度超过阈值时,转为红黑树以提高性能

七、常见面试问题

问题1:如何设计一个好的哈希函数?

回答要点:

  1. 均匀分布性

  2. 计算速度快

  3. 确定性

  4. 考虑键的特性(字符串、整数等)

问题2:链地址法和开放地址法的区别?

回答要点:

  1. 存储方式不同(链表 vs 数组)

  2. 负载因子容忍度不同

  3. 删除操作复杂度不同

  4. 缓存友好性不同

问题3:如何处理哈希表扩容?

回答要点:

  1. 设置合适的负载因子阈值

  2. 选择合适的扩容策略(翻倍或找下一个质数)

  3. 平滑迁移数据(渐进式rehash)

八、实际代码示例

复制代码
// 综合示例:带动态扩容的链地址法哈希表
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define INITIAL_CAPACITY 16
#define LOAD_FACTOR_THRESHOLD 0.75

typedef struct Entry {
    char* key;
    int value;
    struct Entry* next;
} Entry;

typedef struct HashMap {
    Entry** buckets;
    int capacity;
    int size;
} HashMap;

// 字符串哈希函数(djb2)
unsigned long hash_string(const char* str) {
    unsigned long hash = 5381;
    int c;
    while ((c = *str++)) {
        hash = ((hash << 5) + hash) + c;
    }
    return hash;
}

// 创建哈希表
HashMap* create_hashmap() {
    HashMap* map = malloc(sizeof(HashMap));
    map->capacity = INITIAL_CAPACITY;
    map->size = 0;
    map->buckets = calloc(map->capacity, sizeof(Entry*));
    return map;
}

// 检查并执行rehash
void check_rehash(HashMap* map) {
    if ((double)map->size / map->capacity > LOAD_FACTOR_THRESHOLD) {
        // 执行rehash
        printf("触发rehash: %d -> %d\n", 
               map->capacity, map->capacity * 2);
        // ... rehash实现
    }
}

// 插入键值对
void hashmap_put(HashMap* map, const char* key, int value) {
    check_rehash(map);
    
    unsigned long hash = hash_string(key);
    int index = hash % map->capacity;
    
    // 检查键是否已存在
    Entry* entry = map->buckets[index];
    while (entry != NULL) {
        if (strcmp(entry->key, key) == 0) {
            entry->value = value;  // 更新值
            return;
        }
        entry = entry->next;
    }
    
    // 创建新条目(头插法)
    Entry* new_entry = malloc(sizeof(Entry));
    new_entry->key = strdup(key);
    new_entry->value = value;
    new_entry->next = map->buckets[index];
    map->buckets[index] = new_entry;
    map->size++;
}

总结

哈希表的核心是平衡速度空间

  • 好的哈希函数是基础

  • 合理的冲突解决策略是关键

  • 动态调整机制保证性能

  • 根据应用场景选择合适的方法

相关推荐
聆风吟º2 小时前
【数据结构手札】顺序表实战指南(五):查找 | 任意位置增删
数据结构·顺序表·查找·任意位置增删
曾几何时`2 小时前
滑动定窗口(十四)2831. 找出最长等值子数组
数据结构·算法
byzh_rc2 小时前
[算法设计与分析-从入门到入土] 查找&合并&排序&复杂度&平摊分析
数据结构·数据库·人工智能·算法·机器学习·支持向量机·排序算法
前端小白在前进11 小时前
力扣刷题:在排序数组中查找元素的第一个和最后一个位置
数据结构·算法·leetcode
LBJ辉14 小时前
第 4 章 串
数据结构·考研
似水এ᭄往昔15 小时前
【C++】--封装红⿊树实现mymap和myset
开发语言·数据结构·c++·算法·stl
山楂树の16 小时前
搜索插入位置(二分查找)
数据结构·算法
Ka1Yan16 小时前
[二叉树] - 代码随想录:二叉树的统一迭代遍历
数据结构·算法·leetcode
Sheep Shaun17 小时前
二叉搜索树(下篇):删除、优化与应用
数据结构·c++·b树·算法