哈希表与哈希算法:原理、实现与优化
哈希表与哈希算法:原理、实现与优化
1. 哈希表基础原理
哈希表(Hash Table)是一种基于键值对(key-value)存储数据的数据结构,它通过计算键的哈希值来确定数据在内存中的存储位置,从而实现O(1)平均时间复杂度的插入、查找和删除操作。
1.1 哈希表的核心组件
- 哈希函数:将任意大小的输入数据映射到固定大小的输出(哈希值)
- 哈希桶(数组):存储数据的实际容器
- 冲突解决机制:处理不同键映射到相同位置的情况
1.2 哈希表的基本原理
哈希表通过以下步骤存储和检索数据:
- 通过哈希函数计算键的哈希值
- 将哈希值映射到哈希桶的索引位置
- 在该位置存储或检索数据
- 如果发生冲突,使用冲突解决策略处理
2. 哈希函数设计
哈希函数是哈希表性能的关键因素,一个好的哈希函数应该具备以下特性:
- 高效计算:计算速度快
- 均匀分布:不同输入产生的哈希值均匀分布在整个输出范围内
- 确定性:相同的输入总是产生相同的输出
- 雪崩效应:输入的微小变化会导致输出的显著变化
2.1 常见的哈希函数
2.1.1 整数哈希
对于整数类型的键,常用的哈希函数包括:
// 简单的取模哈希函数
size_t hash_mod(int key, size_t table_size) {
return key % table_size;
}
// 乘法哈希函数(Knuth推荐)
size_t hash_multiply(int key, size_t table_size) {
const double A = 0.6180339887; // (sqrt(5) - 1) / 2
double val = key * A;
val = val - floor(val);
return floor(val * table_size);
}
2.1.2 字符串哈希
对于字符串类型的键,几种经典哈希算法:
// BKDR哈希算法
size_t BKDRHash(const char* str, size_t table_size) {
unsigned int seed = 131; // 31, 131, 1313, 13131... 都是不错的选择
unsigned int hash = 0;
while (*str) {
hash = hash * seed + (*str++);
}
return hash % table_size;
}
// FNV哈希算法
size_t FNVHash(const char* str, size_t table_size) {
const unsigned int FNV_PRIME = 16777619;
const unsigned int OFFSET_BASIS = 2166136261;
unsigned int hash = OFFSET_BASIS;
while (*str) {
hash ^= *str++;
hash *= FNV_PRIME;
}
return hash % table_size;
}
// Jenkins哈希算法(one-at-a-time)
size_t JenkinsHash(const char* str, size_t table_size) {
unsigned int hash = 0;
while (*str) {
hash += *str++;
hash += (hash << 10);
hash ^= (hash >> 6);
}
hash += (hash << 3);
hash ^= (hash >> 11);
hash += (hash << 15);
return hash % table_size;
}
2.2 选择哈希表大小
哈希表的大小也是影响性能的重要因素。通常,选择质数作为哈希表大小可以减少冲突:
// 检查一个数是否为质数
bool isPrime(int n) {
if (n <= 1) return false;
if (n <= 3) return true;
if (n % 2 == 0 || n % 3 == 0) return false;
for (int i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0) return false;
}
return true;
}
// 找到大于n的下一个质数
int nextPrime(int n) {
if (n <= 1) return 2;
int prime = n;
bool found = false;
while (!found) {
prime++;
if (isPrime(prime)) found = true;
}
return prime;
}
3. 冲突解决策略
哈希冲突是指不同的键通过哈希函数计算后得到相同的哈希值。解决冲突的主要策略有两种:开放寻址和链式寻址。
3.1 链式寻址(Chaining)
链式寻址使用链表存储映射到同一位置的所有元素:
- 每个哈希桶维护一个链表
- 发生冲突时,将新元素添加到链表中
- 查找时,遍历链表找到目标元素
优点:
- 实现简单
- 对负载因子不敏感
- 插入操作不受表大小限制
缺点:
- 需要额外的内存存储链表
- 可能导致缓存不友好
- 搜索时间与链表长度相关
3.2 开放寻址(Open Addressing)
开放寻址在冲突发生时,通过某种探测序列查找下一个可用位置:
3.2.1 线性探测(Linear Probing)
冲突发生时,按顺序检查下一个位置:
size_t linear_probe(size_t hash, size_t i, size_t table_size) {
return (hash + i) % table_size;
}
优点:
- 缓存友好
- 内存利用率高
缺点:
- 容易造成聚集(clustering)
- 负载因子增大时性能下降明显
3.2.2 二次探测(Quadratic Probing)
使用二次方程确定探测序列:
size_t quadratic_probe(size_t hash, size_t i, size_t table_size) {
return (hash + i*i) % table_size;
}
优点:
- 减少了聚集问题
- 比线性探测性能更稳定
缺点:
- 可能无法探测表中所有位置
3.2.3 双重哈希(Double Hashing)
使用第二个哈希函数确定步长:
size_t double_hash(size_t hash1, size_t hash2, size_t i, size_t table_size) {
return (hash1 + i * hash2) % table_size;
}
优点:
- 消除二次聚集
- 提供更均匀的探测序列
缺点:
- 计算开销略大
4. 哈希表的C++实现
以下是使用链式寻址实现的哈希表:
#include <iostream>
#include <vector>
#include <list>
#include <string>
#include <functional>
template<typename K, typename V>
class HashMap {
private:
// 哈希表的桶,每个桶是一个链表
std::vector<std::list<std::pair<K, V>>> buckets;
size_t size_;
size_t capacity_;
double load_factor_threshold_;
// 哈希函数
size_t hash(const K& key) const {
return std::hash<K>{
}(key) % buckets.size();
}
// 重新哈希
void rehash() {
size_t new_capacity = nextPrime(capacity_ * 2);
std::vector<std::list<std::pair<K, V>>> new_buckets(new_capacity);
for (const auto& bucket : buckets) {
for (const auto& pair : bucket) {
size_t bucket_idx = std::hash<K>{
}(pair.first) % new_capacity;
new_buckets[bucket_idx].push_back(pair);
}
}
buckets = std::move(new_buckets);
capacity_ = new_capacity;
}
// 查找大于n的