哈希表基础知识详解
一、哈希表基本概念
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:如何设计一个好的哈希函数?
回答要点:
-
均匀分布性
-
计算速度快
-
确定性
-
考虑键的特性(字符串、整数等)
问题2:链地址法和开放地址法的区别?
回答要点:
-
存储方式不同(链表 vs 数组)
-
负载因子容忍度不同
-
删除操作复杂度不同
-
缓存友好性不同
问题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++;
}
总结
哈希表的核心是平衡速度 和空间:
-
好的哈希函数是基础
-
合理的冲突解决策略是关键
-
动态调整机制保证性能
-
根据应用场景选择合适的方法