哈希概述
哈希(hash)又称散列,其基本想法是,将存储的值与其存储位置建立某种映射,因此哈希的查找效率非常高,是一种支持常数平均时间查找的结构。与红黑树相比,哈希的效率表现是以统计为基础的,不需要依赖输入数据的随机性。
本文以C++泛型技法对哈希表进行实现,文章结构如下:
建立值-址映射
建立哈希结构的第一步是将“值”(数据)与“址”(存储位置,即下标)联系起来,便于利用“值”快速找到对应的元素。直接定址和除留余数是两种常用的映射方式,为了按索引快速访问,这两种方法的底层存储都使用一个数组。
直接定址法
直接定址法适用于数据的分布范围集中时的情况。映射时,直接以数据的值作为 key,映射到数组中对应的位置。
这种方式的缺点显而易见:
- 当数据之间的差值较大时,会有大量的空余空间,存在大量空间浪费现象,相对映射虽然能够缓解数据基数较大时产生的空间浪费,但对此处的问题也无能为力。
- 当数据较大时(例如INT_MAX级别的数据),这种方式就需要申请数GB的内存空间,这种开销是不切实际的。
除留余数法
除留余数法适用于数据的分布范围较为分散的情况。对于数据 x ,假设数组大小为 tableSize,先进行x % tableSize
得到一个范围在[0, tableSize)
的数 index,将 index 作为 x 的索引。
除留余数法可以避免使用一个太大的数组,但是会带来一个问题:正如上图所示,可能会有不同的元素被映射到相同的位置,即拥有相同的索引。这种现象被称为哈希碰撞(hash collision)。
哈希碰撞
哈希碰撞是无法避免的,因为元素数量往往会大于数组容量。解决哈希碰撞有很多种方式,例如线性探测(linear probing)、二次探测(quadratic probing)和开链(separate chaining)等。使用以上方式解决哈希碰撞的效率与数组的填满程度有很大的关联。
负载因子(loading factor)描述了数组的填满程度,负载因子 = 有效数据个数 / 数组容量
。负载因子越高,数组的填满程度越高,发生哈希碰撞的概率越高,空间利用率越高,反之同理。为了保证哈希表的效率,同时不至于浪费太多的内存空间,往往会将负载因子控制在一定范围内,这一点会在下文体现。
下文会对线性探测、二次探测做一概览性介绍,主要针对开链法做详细分析。
哈希函数
在详细介绍解决哈希碰撞的方式之前,我们需要意识到一个亟待解决的问题:当数据类型为正整数时,我们可以轻松通过上述方式进行直接定址或者取模定址,但当数据类型为其他类型,例如字符串类型时,将无法拿来作为数组的索引。解决方法是,将其他类型的数据通过一个函数映射为非负整数类型,即对于无法直接映射的类型,需要做两层映射。
将数据转化并映射为一个大小可接受的索引,完成这样的工作的函数被称为哈希函数(hash function)。为了保证输出索引的合法性,同时尽量减小第一层映射后的冲突概率,哈希函数的设计有下面几个原则:
- 哈希函数应尽可能简单;
- 哈希函数的值域必须在哈希表格的范围之内;
- 哈希函数的值域应尽可能均匀分布,即取每个位置应该近似是等概率的(尽量减小哈希冲突的概率)。
为了增加灵活性,一般将哈希函数设计为一个仿函数,便于用户可以在某些情况下自定义哈希函数。下面实现的分别是针对整型、浮点型的,以及针对字符串类型的两个哈希函数对象。
template<class Key>
struct HashFuncDefault
{
//可以针对正负整型、浮点型
size_t operator()(const Key& key)
{
//为了方便,不再在哈希函数内部进行取模运算
return (size_t)key;
}
};
template<>
struct HashFuncDefault<std::string>
{
//针对字符串类型
size_t operator()(const string& str)
{
size_t hash = 0;
//使用BKDR算法,尽量减小转化后的冲突概率
for (auto& ch : str) {
hash = hash * 131 + ch;
}
return hash;
}
}
使用开放定址法的闭散列结构
线性探测和二次探测
当计算出某个元素的插入位置,而该位置上的空间已不再可用时,线性探测的策略是循序往下一一寻找,如果到达尾端就绕道头部继续寻找,直到找到一个可用空间(空位置)为止。
通过这种方式,只要哈希表格够大,就一定能够找到一个安放位置,但是要花多少时间就与哈希表格中的数据分布情况有关了。
进行搜索操作时,原理与插入类似,如果计算出的索引位置上的数据与目标不符,就循序向下寻找,直到找到吻合的数据,或者找到空位置结束。进行删除操作时,因为在闭散列中,哈希表中的每一个元素不仅表述它自己,也关系到其它元素的排列,所以只对删除目标标记删除记号,而不是真正删除,即进行惰性删除(lazy deletion)。
线性探测看似非常好地解决了哈希碰撞,但是通过下图就能很容易发现问题:
接续上图所示的状态,0、1、2、3 索引位置都已经被占用,无论新插入元素的位置是 0、1、2 还是 3,都会继续向后探测至 4 索引位置并插入,除非新元素经过计算后的索引恰好落在 4~7,否则位置 4~7 就不会被直接使用。这凸显了一个问题:平均插入成本的增长幅度远高于负载因子的增长,这样的现象被称为主集团(primary clustering)。此时数组中有的是一大团紧凑的、被占用过的位置,新插入元素很可能在主集群中不断向后探测以解决碰撞问题,最后又增加了主集群的面积。
二次探测(quadratic probing)可以用来解决主集群问题。在线性探测中,如果遇到冲突位置,就向后走一步进行检查,而在二次探测中,如果遇到冲突位置,向后走的步长为 i2
。如果计算出的索引为 h,而对应位置已被占用,那么就尝试位置 h + 12, h + 22, h + 32, …, h + i2
,而不是像线性探测那样尝试 h + i 。
二次探测可以解决主集团问题,缓解数据的拥堵踩踏,却有可能造成次集团(secondary clustering)。消除次集团的方法及后续问题在这里不做探讨。
闭散列的元素定义
对于闭散列,本文只做简单实现,不对迭代器和封装进行讨论。闭散列的元素定义中包含两部分:有效数据和状态信息,其中有效数据以 Key-Value 模型表现,每个数据位置有三个状态:存在(EXIST)、空(EMPTY)和删除(DELETE),其中空状态和删除状态都属于空闲状态,初始时数据位置为空状态。
//位置状态
enum STATU
{
EXIST,
EMPTY,
DELETE
};
template<class Key, class Value>
struct HashData
{
pair<Key, Value> _data;
STATU _statu;
HashData()
:_statu(EMPTY)
{ }
};
闭散列的完整代码
下满是闭散列的代码,当负载因子大于0.75
时,要对表格进行扩容,并重新计算元素的索引位置重新插入。将负载因子控制在 0.75 ,可以同时兼顾查找插入效率和空间使用状况。
enum STATU
{
EXIST,
EMPTY,
DELETE
};
//哈希函数#1
template<class Key>
struct HashFuncDefault
{
size_t operator()(const Key& key)
{
return (size_t)key;
}
};
//哈希函数#2,针对字符串类型
template<>
struct HashFuncDefault<std::string>
{
size_t operator()(const string& str)
{
size_t hashNum = 0;
for (const auto& e : str) {
hashNum = hashNum * 113 + e;
}
return hashNum;
}
};
//元素定义
template<class Key, class Value>
struct HashData
{
pair<Key, Value> _data;
STATU _statu;
HashData()
:_statu(EMPTY)
{ }
};
template<class Key, class Value, class HashFunc = HashFuncDefault<Key>>
class hash_table
{
private:
typedef HashData<Key, Value> dataType;
public:
//初始时为_table开10个空间
hash_table()
:_size(0)
{
_table.resize(10);
}
bool insert(const pair<Key, Value>& data)
{
//判断负载因子,决定是否扩容
if ((double)_size / _table.size() > 0.75)
{
//扩容
size_t newSize = _table.size() * 2;
//直接开一个临时哈希表,复用insert对元素索引进行重新计算,重新插入元素
hash_table* newHash = new hash_table;
newHash->_table.resize(newSize);
//重新计算元素的哈希索引并放入新表
for (int i = 0; i < _table.size(); ++i)
{
if (_table[i]._statu == EXIST) {
newHash->insert(_table[i]._data);
}
}
//将新表交换给_table
_table.swap(newHash->_table);
}
//计算哈希索引
HashFunc hf;
int hashi = hf(data.first) % _table.size();
//线性探测插入
//找到一个空闲位置(没有有效数据)插入
while (_table[hashi]._statu == EXIST)
{
//对应的Key已经存在
if (_table[hashi]._data.first == data.first) {
return false;
}
hashi = (hashi + 1) % _table.size();
}
_table[hashi]._data = data;
_table[hashi]._statu = EXIST; //修改数据位置的状态
++_size; //修改有效数据个数
return true;
}
bool erase(const Key& key)
{
HashFunc hf;
int hashi = hf(key) % _table.size();
//直到找到空位置结束寻找
while (_table[hashi]._statu != EMPTY)
{
//找到键值为key的有效数据,进行删除
if (_table[hashi]._statu != DELETE &&
_table[hashi]._data.first == key)
{
_table[hashi]._statu = DELETE; //修改状态和_size
--_size;
return true;
}
hashi = (hashi + 1) % _table.size();
}
return false;
}
pair<Key, Value>* find(const Key& key)
{
HashFunc hf;
int hashi = hf(key) % _table.size();
//直到找到空位置结束寻找
while (_table[hashi]._statu != EMPTY)
{
if (_table[hashi]._statu != DELETE &&
_table[hashi]._data.first == key)
{
return &(_table[hashi]._data);
}
hashi = (hashi + 1) % _table.size();
}
return nullptr;
}
private:
vector<dataType> _table; //使用vector作为底层存储容器
size_t _size; //记录有效数据个数
};
使用开链法的开散列结构
与开放定址法不同的是,当遇到哈希冲突后,开链法不向后寻找并占用表中的其他位置,而是开出一个单向链表,将数据链到对应位置的“下面”。这种方式类似于将数据挂到一个数组上,被挂起来的单链表被称为哈希桶(hash bucket),在同一个桶中,每个节点数据的索引相同。
在开链法中,数组中的每个位置维护一个单链表,当进行插入时,不管对应位置是否被占用,直接将新数据的节点头插入桶中;查找时,先找到索引位置,再在桶中依次寻找指定值;删除时,直接找到元素位置删除对应节点即可。
在开散列中,负载因子可能会大于 1。虽然在哈希桶中的操作只能是一种线性操作,但如果 list 够短,仍然可以保证效率,为了保证 list 够短,需要将负载因子控制在 1 以内,即保持平均每一个桶中只挂一个数据。
哈希桶的节点定义
哈希桶的节点与单链表的节点相同,包含一个后继指针和一个有效数据。使用单链表而非双链表,是因为单链表完全可以满足这里的需求,并且尽可能减少了哈希桶的开销。
template<class T>
struct HashNode
{
private:
typedef HashNode<T> Node;
public:
T _data; //有效数据
Node* _next; //后继指针
//节点的构造函数
HashNode(const T& data)
:_data(data),
_next(nullptr)
{ }
};
开散列中对哈希的定义与闭散列类似,不同的是数组存的是节点指针,并且在开散列的析构中,需要逐个遍历哈希桶完成资源的释放。
/*
这里有4个模板参数,第4个模板参数为哈希函数对象,缺省值为默认哈希函数
与红黑树类似,T为哈希表真正存储的有效数据类型,当T为pair<>时,Key和KeyOfT是为了
便于拿到pair<>中的第一个成员(Ke)
*/
template<class Key, class T, class KeyOfT, class HashFunc = HashFuncDefault<Key>>
class hash_table
{
private:
typedef HashNode<T> Node;
public:
//初始化时给数组10个空间
hash_table()
:_size(0)
{
_table.resize(10);
}
//析构
~hash_table()
{
//逐个释放哈希桶
for (int i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
while (cur)
{
Node* nextNode = cur->_next;
delete cur;
cur = nextNode;
}
_table[i] = nullptr;
}
}
private:
vector<Node*> _table;
size_t _size; //有效数据个数
};
迭代器
哈希表的迭代器是对桶节点指针的封装,除此之外,哈希表的迭代器必须一直维系着与 buctets vector 的联系,因为在进行前进操作时,如果当前节点是哈希桶的最后一个位置,则需要向后寻找下一个桶。
/*
此处HashNode和hash_table的声明是为了解决__hash_table_iterator类与
hash_table类互相依赖的问题,__hash_table_iterator的成员包含了hash_table,
且hash_table中使用了__hash_table_iterator
*/
template<class T>
struct HashNode;
template<class Key, class T, class KeyOfT, class HashFunc = HashFuncDefault<Key>>
class hash_table;
template<class Key, class T, class Ref, class Ptr, class KeyOfT, class HashFunc>
struct __hash_table_iterator
{
private:
typedef HashNode<T> Node;
typedef __hash_table_iterator<Key, T, T&, T*, KeyOfT, HashFunc> iterator;
typedef __hash_table_iterator<Key, T, Ref, Ptr, KeyOfT, HashFunc> Self;
typedef shr::HashBucket::hash_table<Key, T, KeyOfT, HashFunc> HashTable;
public:
Node* _node; //桶节点指针
//hash_table的迭代器必须维系着与buctets vector的联系,以便于寻找下一个桶
const HashTable* _hash_table;
__hash_table_iterator(Node* node, const HashTable* hash_table)
:_node(node),
_hash_table(hash_table)
{ }
//对于iterator,这个函数是拷贝构造
//对于const_iterator,这个函数是构造
//使用iterator构造const_iterator
__hash_table_iterator(const iterator& it)
:_node(it._node),
_hash_table(it._hash_table)
{ }
Ref operator*()
{
return _node->_data;
}
Ptr operator->()
{
return &(_node->_data);
}
Self& operator++()
{
KeyOfT kot;
HashFunc hf;
//下一个节点不为空,直接向后走一步即可
if (_node->_next) {
_node = _node->_next;
}
else //下一个节点为空
{
//寻找下一个不为空的哈希桶
int hashi = hf(kot(_node->_data)) % _hash_table->_table.size();
for (int i = hashi + 1; i < _hash_table->_table.size(); ++i)
{
if ((_hash_table->_table)[i])
{
_node = (_hash_table->_table)[i];
return *this;
}
}
_node = nullptr;//未找到有数据的哈希桶,赋值为空
}
return *this;
}
Self operator++(int)
{
Self tmpIt = *this;
operator++();
return tmpIt;
}
bool operator==(const Self& it)
{
return _node == it._node;
}
bool operator!=(const Self& it)
{
return _node != it._node;
}
};
template<class Key, class T, class KeyOfT, class HashFunc>
class hash_table
{
//因为在迭代器中要访问hash_table的私有成员vector<Node*>,
//所以将迭代器类型声明为hash_table的友元
template<class Key, class T, class Ref, class Ptr, class KeyOfT, class HashFunc>
friend struct __hash_table_iterator;
public:
typedef __hash_table_iterator<Key, T, T&, T*, KeyOfT, HashFunc> iterator;
typedef __hash_table_iterator<Key, T, const T&, const T*, KeyOfT, HashFunc> const_iterator;
/*…………*/
iterator begin()
{
//找到第一个不为空的哈希桶的头结点
for (int i = 0; i < _size; ++i)
{
if (_table[i]) {
return iterator(_table[i], this);
}
}
return iterator(nullptr, this);
}
iterator end()
{
return iterator(nullptr, this);
}
const_iterator begin() const
{
//找到第一个不为空的哈希桶的头结点
for (int i = 0; i < _size; ++i)
{
if (_table[i]) {
return const_iterator(_table[i], this);
}
}
return const_iterator(nullptr, this);
}
const_iterator end() const
{
return const_iterator(nullptr, this);
}
/*…………*/
在开散列的迭代器中,要额外定义一个构造函数,如上面代码 23 行所示。这是因为在以 hash table 对 unordered_set 进行封装时,某些接口的返回值会发生迭代器类型不兼容的情况,此时需要主动进行构造以解决问题,详细会在对 unordered 系列容器的封装中解释。
元素操作
对哈希表中的数据的操作,其实是对哈希桶中数据的操作,本质是对单向链表的操作。
以键值 key 查找元素时,首先用哈希函数计算出元素索引,再在对应的哈希桶中寻找。
iterator find(const Key& key) const
{
HashFunc hf;
KeyOfT kot;
int hashi = hf(key) % _table.size(); //找到目标节点的索引
Node* cur = _table[hashi];
//遍历哈希桶寻找
while (cur)
{
if (kot(cur->_data) == key) {
return iterator(cur, this);
}
cur = cur->_next;
}
return iterator(nullptr, this); //未找到,返回nullptr作为end迭代器
}
插入新元素时,先根据新元素的 key 计算出哈希索引,再创建新节点头插到哈希桶中。为了保证效率,当负载因子为 1 时对 buckets vector 进行扩容,即保持平均每个桶中仅有一个元素。扩容后重新整理旧元素的位置时,不建议像闭散列那样直接复用 insert 接口,因为旧节点的空间已经存在,没有必要再申请空间创建新节点、销毁旧节点,而是直接将旧节点重新链入新表即可。
pair<iterator, bool> insert(const T& data)
{
HashFunc hf;
KeyOfT kot;
iterator retFind = find(kot(data));
if (retFind != end()) {
return make_pair(retFind, false);
}
//当负载因子为 1 时进行扩容以保证查找效率
if (_size == _table.size())
{
//扩容
size_t newSize = _table.size() * 2;
vector<Node*> newTable; //创建一个新的临时哈希表
newTable.resize(newSize);
//将旧节点链入新表
for (int i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
while (cur)
{
Node* nextNode = cur->_next;
//计算新的索引
int hashi = hf(kot(cur->_data)) % newSize;
//将节点重新插入新表中
cur->_next = newTable[hashi];
newTable[hashi] = cur;
cur = nextNode; //继续向后遍历
}
}
//将新表交换给hash_table
_table.swap(newTable);
}
//找到索引位置
size_t hashi = hf(kot(data)) % _table.size();
//将新节点头插入哈希桶中
Node* newNode = new Node(data);
newNode->_next = _table[hashi];
_table[hashi] = newNode;
++_size; //更新有效数据个数
return make_pair(iterator(newNode, this), true);
}
删除元素时,先计算出目标元素的哈希索引,再在对应的桶中寻找目标,进行单向链表节点的删除即可。
iterator erase(const Key& key)
{
HashFunc hf;
KeyOfT kot;
int hashi = hf(kot(key)) % _table.size(); //找到目标节点的索引
Node* cur = _table[hashi];
Node* preNode = nullptr; //记录前驱节点,便于删除
//遍历哈希桶找到目标值
while (cur)
{
if (kot(cur->_data) == key)
{
Node* nextNode = cur->_next;
//删除哈希桶的头结点
if (preNode == nullptr) {
_table[hashi] = nextNode;
}
else { //删除单链表的中间节点
preNode->_next = nextNode;
}
iterator retIt = ++iterator(cur, this);
delete cur;
--_size;
return retIt;
}
preNode = cur;
cur = cur->_next; //向后遍历
}
return iterator(nullptr, this);
}