数据结构之哈希表详解
基础介绍
哈希表概念
基本的哈希表数据结构大致分两种,一种是链式地址法实现的哈希表;另外一种是开放地址法实现的哈希表,其中开放地址法的哈希表的实现方式又可以细分为线性探测(一次探测),二次探测,双重哈希等具体实现。下面将这两种的数据结构的定义放到下面:
//链式地址法哈希表数据结构
template<typename K, typename V>
class hashTable
{
private:
struct Node{
K key;
V value;
Node* next;
}
std::vector<Node*> buckets; //桶数组
size_t size; //当前哈希表中元素的数量
float load_factor; //负载因子
}
//开放地址法哈希表数据结构
template<typename K, typename V>
class hashTable
{
private:
struct Node{
K key;
V value;
bool occupied; //该位置是否占用
bool delete; //是否删除
}
std::vector<Node> table; //哈希表数组结构
size_t size; //当前哈希表中元素的数量
size_t capacity; //表容量
}
哈希函数的概念
哈希函数的核心思想是对输入的内容通过一定的算法将得到一个数值,然后根据需求将这个数值进行一定运算得到一个值。下面举两个例子来说明这段话:
- 哈希表哈希函数:假设输入一个字符串"hello",哈希函数将这个字符串的每个字符对应的ASCII数值相加得到一个求和值,然后利用这个和与表长进行求模。
- 加密哈希函数:这是另外一种场景,即无论输入的内容长度有多长,都输出固定的输出。例如MD5
哈希函数很重要的思想是设计一个算法来对输入的内容进行转换得到一个数值。
c++标准库中的std::hash<char*>的计算方法如下:
inline size_t
__stl_hash_string(const char* __s)
{
unsigned long __h = 0;
for ( ; *__s; ++__s)
__h = 5 * __h + *__s; //字符串哈希转换算法
return size_t(__h);
}template<>
struct hash<char*>
{
size_t
operator()(const char* __s) const
{ return __stl_hash_string(__s); }
};
标准库中的哈希函数对于整数形式的输入没有做任何处理,直接返回原值;标准库中哈希函数不支持对浮点数进行哈希运算。下面是std::hash<int>中的实现,其余类型(short/unsinged short/unsigned char/long/unsiged int)类似:
template<>
struct hash<char>
{
size_t
operator()(char __x) const
{ return __x; }
};template<>
struct hash<int>
{
size_t
operator()(int __x) const
{ return __x; }
};
哈希函数的使用
- 使用标准库中的哈希函数,但是标准库中的哈希函数算法固定,且仅支持基本数据类型,如果是用户自定义类型,需要用户自己实现哈希函数
- 用户自定义哈希函数
- 使用开源的哈希函数,例如MurmurHash/xxhash/cityhash/FNV
各个开源哈希函数对比如下:
哈希函数 | 优点 | 适用场景 |
---|---|---|
MurmurHash | 速度快,分布均匀 | 通用哈希表、布隆过滤器 |
xxHash | 极快的速度,优秀的分布 | 高性能场景 |
FNV | 实现简单,速度适中 | 小型哈希表 |
CityHash | 针对字符串优化 | 字符串哈希 |
哈希表中桶的概念
在我以前的理解中认为桶的概念只在链式地址法实现的哈希表中存在,对于开放地址法实现的哈希表不存在桶的概念。经过研究发现这个概念是错误的,两种不同的实现方式实现的哈希表都存在桶的概念。在链式地址法实现的哈希表中每个桶可以存放多个值,一个桶内的值按照链表的方式进行连接;在开放地址法实现的哈希表中每个桶仅能存放一个元素。
桶对应到前面的哈希表的数据结构中的buckets.size()和table.size();
桶的大小的设计原则:
- 桶的大小与负载因子有关,一般而言,负载因子超过0.75需要对哈希表进行扩容,即调整桶的数量,并重新对哈希表进行重哈希
- 桶的大小一般大于预期数量
负载因子的概念与作用
负载因子的结算公式:负载因子=总元素数量/总桶数;
- 总元素数量:一个哈希表中所有元素的数量,以链式地址法实现的哈希表为例,假设一个哈希表中包含4个桶,第一个桶中包含4个元素,第二个桶中包含3个元素,第3个桶中包含1个元素,最后一个桶中没有元素,那么这个哈希表中的负载因子=(4+3+1)/4=8/4=2;如果是开放地址法实现的哈希表更容易理解,因为在开放地址法实现的哈希表中,一个桶最多只能存放1个元素,所以在开放地址法实现的哈希表中负载因子=存放元素的桶数/总桶数。
- 总桶数:哈希表的数据结构中表示用于存放数据的总长度。
一般而言,0.5<负载因子<0.75认为是比较好的范围,如果<0.5则认为控件利用率低,冲突率低。
负载因子注意事项
- 负载因子计算与元素在桶中的分布无关
- 只关注总元素数量和总桶数量
- 不考虑单个桶的负载情况
重哈希的概念及实现
重哈希是指当哈希表需要扩容或者性能下降时,通过改变哈希表的大小并重新计算所有键的哈希值,将现有元素重新分配到新的桶中的过程。
重哈希带来的影响:
- 减少冲突
- 优化查询时间
- 更均匀的数据分布
重哈希的代码实现示例如下:
//重哈希的实现,这里以链式地址法实现的哈希表为例
void rehash(size_t new_size)
{
//移动语义
std:vector<Node*> old_buckets = std::move(buckets);
//重新设置vector的大小
buckets.resize(new_size);
capacity = new_szie;
//将原有的数据重新哈希放到新的哈希表中
//首先遍历每个桶,然后遍历每个桶下的链表
for(int i= 0; i < old_buckets.size(); i++)
{
Node* current = old_buckets[i];
//只要链表不为空
while(current)
{
//获取该节点的key和值
K key = current->key;
V value = current->value;
Node* next = current->next;
//重新hash
size_t index = hash(key)%capacity;
//在该桶的位置插入节点,插入到链表头部,不重新生成节点,因为节点已经存在了
current->next = buckets[index];
buckets[index] = current;
current = next;
}
}
}
抗碰撞性概念
抗碰撞性指的是哈希函数在处理不同输入时,产生相同哈希值(碰撞)的难度。在哈希表中这个概念非常重要,因为碰撞会影响到哈希表的性能。
提供抗碰撞性的方法:
- 好的哈希函数设计
- 多重hash
链式地址法实现的哈希表
下面给出链式地址法实现的哈希表:
//链式地址法哈希表数据结构
template<typename K, typename V>
class hashTable
{
private:
struct Node{
public:
K key;
V value;
Node* next;
Node(K k, V v, Node* p){key=k;value=v;next=p;}
}
std::vector<Node*> buckets; //桶数组
size_t size; //当前哈希表中元素的数量
float load_factor; //负载因子
public:
size_t hash(const K& key)
{
.... //具体的哈希算法
return index;
}
//插入操作
void insert(const K& key, const V& value)
{
//获取索引
size_t index = hash(key)%buckets.size();
Node* current = buckets[index];
while(current)
{
//如果该key已经存在,则覆盖原来的数值
if(current->key == key)
{
current->value = value;
return ;
}
current = current->next;
}
//至此说明在没有找到这个节点,需要添加
//创建节点,该节点放在链表的头部,该节点指向链表的头结点
Node* newNode = new Node(key, value, buckets[index]);
//将该桶的节点替换为新节点,该新节点成为链表的头结点
buckets[index] = newNode;
}
//查找
V* find(const K& key)
{
//获取索引
size_t index = hash(key)%buckets.size();
//获取头结点
Node* current = buckets[index];
while(current)
{
//若找到对应的元素
if(current->key == key)
{
return ¤t->value;
}
current = current->next;
}
return nullptr;
}
//删除操作
void remove(const K& key)
{
size_t index = hash(key)%buckets.size();
Node* current = buckets[index];
Node* pre = nullptr;
while(current)
{
//找到对应的节点
if(current->key == key)
{
//如果前一节点为nullptr,则说明要删除的节点是头结点
if(pre == nullptr)
{
Node* next = current->next;
buckets[index] = next; //此处需要更新头结点
delete current;
return;
}
//要删除的节点不是头结点
else
{
//获取下一节点
Node* next = current->next;
pre->next = next;
delete current;
//此处不需要更新头结点
return;
}
}
//保存前一节点
pre = current;
current = current->next;
}
return;
}
}
开放地址法实现的哈希表示例
开发地址法实现的哈希表示例:
//开放地址法哈希表数据结构
template<typename K, typename V>
class hashTable
{
private:
struct Node{
K key;
V value;
bool occupied; //该位置是否占用
bool delete; //是否删除
}
std::vector<Node> table; //哈希表数组结构
size_t size; //当前哈希表中元素的数量
size_t capacity; //表容量
public:
//哈希函数
size_t hash(const K& key)
{
...
return index;
}
//查找索引
size_t findIndexInHash(const K& key)
{
size_t index = hash(key)%table.size();
if(table[index].occupied == false)
{
return index;
}
//如果该位置已经被占用,继续获取下一个位置的索引,这里以线性探测法为例,说明如何获取下一个位置
size_t i = 0;
while(true)
{
size_t curpos = (index + i)%table.size();
//该位置未被占用或者该key已经写入到哈希表中
if(table[curpos].occupied == false ||
table[curpos].key == key )
return curpos;
i++;
//哈希表已满,需要进行扩容
if(i > table.size())
return -1;
}
}
//插入操作
void insert(const K& key, const V& value)
{
size_t index = findIndexInHash(key);
if(index == -1)
return;
//如果未被占用
if(!table[index].occupied)
{
table[index].key = key;
table[index].value = value;
table[index].occupied= true;
table[index].delete = false;
}
}
//删除操作
void remove(const K& key)
{
...
//首先找到对应的索引,这里假设找到的索引为index
if(table[index].occupied )
{
table[index].delete = true;
}
}
}
-
线性探测法实现哈希函数
size_t probe(const K& key, size_t i) {
return (hash(key) + i) % table.size();
}
-
二次探测法实现的哈希函数
// 二次探测
size_t quadraticProbe(const K& key, size_t i) {
return (hash(key) + i*i) % table.size();
}
-
双重哈希
// 双重哈希
size_t doubleHash(const K& key, size_t i) {
size_t h1 = hash1(key);
size_t h2 = hash2(key);
return (h1 + i * h2) % table.size();
}
上述三种方法使用建议:
线性探测: - 数据量小 - 内存局部性重要 - 简单实现优先
二次探测: - 中等数据量 - 需要比线性探测更好的性能
双重哈希: - 大数据量 - 性能关键 - 可以接受额外的哈希计算开销
实际应用建议
1. 对于小型哈希表,线性探测可能更好(缓存友好)
2. 对于大型哈希表,考虑使用双重哈希
3. 保持负载因子在0.7以下
4. 使用好的哈希函数
5. 如果可能,预估大小并预分配空间