您现在的位置是:首页 >技术交流 >C++实现哈希表网站首页技术交流
C++实现哈希表
文章目录
前言
本文主要是对哈希表这种数据结构进行介绍。含义是根据存储的值和存储的位置建立映射关系从而快速检索查找存储的数据,哈希表是一种非常高效的数据结构。本文会用C++介绍哈希表的相关实现。
1.哈希表的相关介绍
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立映射的关系,那么在查找时通过该函数可以很快找到该元素。
基于这种思想,哈希表便应运而生了。
哈希表的核心就是通过存储位置和存储的数据之间建立映射关系,来达到快速检索的目标。通常数据与其存储的位置是通过哈希函数来表示的。通过哈希函数构造出来的结构称为哈希表(Hash Table)(或者称散列表)
常见的哈希函数有直接定址法,除留余数法。
直接定址法
Hash(Key)= A*Key + B,优点:简单、均匀,缺点:需要事先知道关键字的分布情况,使用场景:适合查找比较小且连续的情况。
这种方式是有局限性的,只有在特定的处境中才比较合适。
除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。这是一种比较常用的哈希函数。通常是用存储的数据来对哈希表的空间大小进行取模,所得的数值即为哈希地址,也是就是映射后的地址。
这就是一种除留余数法是表示方法,我们由此可以建立起存储数据和起存储位置的映射关系。但是这种方法也会出现哈希冲突的问题。
:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
哈希函数在计算哈希地址会可能出现冲突,由此我们需要解决哈希冲突。解决哈希冲突有两种方式:
闭散列和开散列。
闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
采用 线性探测的方式。线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
这种解决哈希冲突的方式其实通过抢占其他位置来解决的,当随着哈希冲突越来越多,被抢占的位置也可能越来越多,导致后续的其他的元素也要继续抢占位置。由此出现了开散列这种方式来解决哈希冲突。
开散列
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
这种方法相比之前的开放定址法更优雅一点。关于哈希表的相关概念我们介绍完了,接着就是来实现这两种不同形式的哈希表了。
2.哈希表的实现
这里的哈希函数我们选用除留余数法。哈希表的底层一般都是顺序表,这里我们用vector作为底层容器,这样方便存储数据时的后续扩容。而且既然有现成的容器,也不用在去重复造个顺序表了。毕竟我们主要的研究对象是哈希表。
1.开放定址法实现哈希表
在使用开放定址法之前,我们有几个的小细节,提前处理一下。
1.我们的选用的哈希函数是除留余数,这里我们选取vector的空间数据的size作为模除对象,为啥不选容量呢?
我们知道随着存储数据的增多,哈希冲突的概率会越来大,所需要空位越多。为了保证哈希的效率和合理的使用空间,我们将哈希表和大小和存储的数据控制在一定的比例,在适合的时机对vector进行扩容。这样为方便后续的扩容我们选用vector的size作为模对象。2.删除数据,如果将数据从哈希表中真实的删除,这无疑的会涉及数据的挪动的,这就会影响到其他数据,因此我们采用伪删除方式,将每个位置会有一个状态,来表示这这个位置是否为空位置,这个位置的数据是否已经被删除。
这样的话就解决的删除的问题。3.这里哈希表中存储的真实数据我们用pair来表示,这样方便我们后续将其改造成unordered_map和unordered_set。
每个位置的状态表示
enum State
{
EMPTY,//空状态
EXIST,//删除状态
DELETE//删除状态
};
存储数据的hash节点
//存储的hash节点
template<class K, class V>
struct HashData
{
pair<K, V>_kv;
State _state = EMPTY;
};
//哈希表
template<class K, class V>
class HashTable
{
private:
vector<HashData<K,V>> _table;
size_t _n = 0;//记录存储数据的个数
};
每个vector中存储的都是hash节点。节点包括了存储数据和该存储的位置状态。
1.插入
在插入节点时,我们知道使用线性探索的时候,解决哈希冲突的方法是占据其他的空位置,当哈希的表的容量越大,这种抢占空位置的方式对后续的节点影响就越小,哈希查找效率也越高。但是哈希表的空间越大所浪费的空间也就可能越多。为了平衡这这种两种优缺点,有一种比较折中的方式就控制这个哈希表的负载因子。负载因子就是哈希表中存储的数据个数和哈希表长度之比。这个比例控制在0.7以下较为合适,这是通过相关数据证明出来的。
因此在负载因子超过0.7的时候就需要进行扩容操作。这里因为size都是整形我们先扩大10倍在进行除法。
bool Insert( pair<K, V> kv)
{
if (Find(kv.first))
{
return false;
}
if (_table.size() == 0 || _n * 10 > _table.size() >= 7)
{
size_t newsize = _table.size() == 0 ? 10 : 2 * _table.size();
HashTable<K, V>newtable;
newtable._table.resize(newsize);
for (auto data : _table)
{
if (data._state == EXIST)
{
newtable.Insert(data._kv);
}
}
_table.swap(newtable._table);
}
size_t hashi = kv.first % _table.size();
//线性探测
size_t index = hashi;
size_t i = 1;
while (_table[index]._state == EXIST)
{
index = hashi + i;
index %= _table.size();
++i;
}
_table[index]._kv = kv;
_table[index]._state = EXIST;
_n++;
return true;
}
这里在扩容之前还是有个问题,就是扩容之后,
因为哈希表的长度发生改变,之前原来哈希表中的数据是通过之前的哈希表的长度进行映射的,这里就需要把之前的数据重新进行一遍映射,在插入数据。
为了解决这个问题,上述示例中提供一种较为优雅的书写方式。先定义一个哈希表对象,将这个哈希表对象的size设置为扩容后的大小,这个对象在调用insert接口将之前的哈希表的数据进行转移,之后在交换这个对象的_table即可。这个线性探索就是一步步往后试探的过程。注意这里往后试探也是取模运算,往后试探也是重新计算哈希地址的过程,这里的哈希函数要保持一致。
2.查找
这里查找也是根据要查找的数据计算出对应的哈希地址,从得到的哈希地址开始一步一步往后逐步试探。如果该哈希地址不为空状态也不为删除状态就可以和查找的数据进行比对。这里我们要注意一种特殊的场景就是大量的空状态或者是删除状态,这样就会陷入死循环.
因此这里加上判断,如果已经走了一圈了就跳出循环即可。
HashData<K, V>* Find(const K& key)
{
if (_table.size() == 0)
{
return nullptr;
}
size_t hashi = key % _table.size();
size_t index = hashi;
size_t i = 1;
while (_table[index]._state == EMPTY)
{
if (_table[index]._state == EXIST && _table[index]._kv.first == key)
{
return &_table[index];
}
index = hashi + i;
index = index % _table.size();
++i;
//避免死循环
if (index == hashi)
{
break;
}
}
return nullptr;
}
3.删除
删除节点是伪删除就是把存储节点位置的状态设置为了删除即可。
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
到了这里开放地址法的哈希表就实现完了。相对红黑树来说总体实现过程不算太难,注意一些细节即可。
2.链地址法(开链法)实现哈希表
链地址法解决哈希冲突是将映射到同一个哈希地址上的数据链接起来形成单链表的结构。既然链表那么哈希表中存储就是节点指针,这里我们是用通过new节点进行数据插入,所以这里就必须实现析构函数了将new的节点给释放掉。
哈希节点
template<class K,class V>
struct HashNode
{
HashNode(const pair<K, V>& kv)
:_next(nullptr)
, _kv(kv)
{}
HashNode<K, V>* _next;
pair<K, V> _kv;
};
template<class K,class V,class Hash= HashFunc<K>>
class HashTable
{
public:
typedef HashNode<K, V> Node;
private:
vector<Node*>_table;
size_t _n = 0;//记录存储有效数据个数
};
1.插入节点
bool Insert(const pair<K, V>& kv)
{
Hash hash;
if (Find(kv.first))
{
return false;
}
if (_n ==_table.size())
{
size_t newsize = _table.size() == 0 ? 10 : 2 * _table.size();
vector<Node*> newtable(newsize, nullptr);
for (Node* & cur :_table)
{
while (cur)
{
Node* tem = cur->_next;
size_t hashi = hash(cur->_kv.first )% newtable.size();
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = tem;
}
}
_table.swap(newtable);
}
size_t hashi = hash(kv.first) % _table.size();
Node* newnode = new Node(kv);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_n;
return true;
}
这里插入节点是采用头插法,这样来一个节点就可以插入一个节点,如果采用尾插节点还需要去遍历每个位置上的链接找到尾节点,在进行插入。每个存储的哈希节点都可以是一条链表的头节点,这里在扩容转移之前的数据时需要注意一下。
2.查找
这里查找先根据哈希函数计算出要查找的数据所在的哈希地址,在去遍历这个哈希地址上的单链表进行比对。当size为0的时候需要单独判断处理一下。
Node* Find(const K& key)
{
Hash hash;
if (_table.size() == 0)
{
return nullptr;
}
size_t hashi = hash(key )% _table.size();
Node* ret = _table[hashi];
while (ret)
{
if (ret->_kv.first == key)
{
return ret;
}
ret = ret->_next;
}
return nullptr;
}
3.删除
因为这个链地址法的存储特性,我们在删除节点的时候是在某天单链表上进行操作。就是本质就是删除单链表上的某个节点,我们需要一个变量保存要删除节点的前驱节点,这样我们才你能保证删除节点后,这个链表节点的指向关系是正确的。
这里删除节点就是尾删法,因此删除头结点的时候需要单独判断一下。
bool Erase(const K& key)
{
Hash hash;
size_t hashi = hash(key) % _table.size();
Node* cur = _table[hashi];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first==key)
{
if (prev == nullptr)
{
_table[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
4.相关的一些补充
实现这里哈希表是有3个模板参数的,前面两个参数一眼看得出来是和存储数据相关的。
第三个参数是用来将数据转成可运算的整形的,试想一下我们存储string的话还需要将其转成整形来计算,不然无法通过哈希函数来计算哈希地址。
这里转化方式就还是比较有讲究的,我们要尽量避免相近的字符串转化的整形值是一样的。下面提供C语言之父提出的一种转化方式。
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
return hash;
}
};
对于一些内置类型比如整形来说,就可以直接进行哈希地址的计算,但是string就需要转化,因此这里提供了模板的特化处理。对于string来说会走专门的特殊处理进行转化。
这里转化也是重载了括号,类似于仿函数的处理方式。
之前提到了,我们每个哈希位置存储相当于一条链表,对于每个new的节点最后需要释放空间,因此需要编写对应的析构函数。
析构函数
~HashTable()
{
for (Node*& cur : _table)
{
while (cur)
{
Node* tem = cur->_next;
delete cur;
cur = tem;
}
cur = nullptr;
}
}
其实虽然说每个存储的位置相当于都有一条单链表,但是实际上这个单链表的长度非常短,大多数都是只有一个节点,我们可以通过下面函数查找哈希表中最长的链表的节点个数,进行验证。
size_t MaxBucketSize()
{
size_t max = 0;
int cnt = 1;
for (auto cur : _table)
{
size_t i = 0;
while (cur)
{
++i;
cur = cur->_next;
}
if (max < i)
{
max = i;
}
}
return max;
}
这里我们采用这种链地址法的时候这个负载因子控制在1,也是可以的,就是size慢了在进行扩容,这样空间利用率就比较高了。这也是常常采用这种方式来解决哈希冲突的原因。
3.封装unordered_map与unordered_set
1.封装前的改造
和之前用红黑树封装map和set一样,这里也是用哈希表封装unordered_map和unordered_set,这两个容器都会复用哈希表的代码。哈希表在之前的基础上稍加改动之后,这两个容器就可以直接复用其接口来实现各自相关的功能了。
因为有之前用红黑树封装map和set的基础,这里如果用哈希表来封装,我们就需要4个参数,一个参数确定k值的类型,一个参数确定存储数据的类型,是键值对还是k值,一个参数用来将拿到k值,另一个函数用来进行k值的转化,因为字符串这种复杂的自定义类型需要用到这个函数。
template<class T>
struct HashFunc
{
size_t operator()(const T &key)
{
return key;
}
};
template<>
struct HashFunc< string>
{
size_t operator()( const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
return hash;
}
};
template<class T>
struct HashNode
{
HashNode<T>* _next;
T _data;
HashNode(const T& data)
:_next(nullptr)
, _data(data)
{}
};
template<class K, class T, class KeyOfT, class Hash>
class HashTable
{
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
friend struct __HashIterator;
typedef HashNode<T> Node;
public:
typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> iterator;
typedef __HashIterator<K, T, const T&, const T*, KeyOfT, Hash> const_iterator;
~HashTable()
{
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
cur = nullptr;
}
}
iterator Find(const K& key)
{
if (_tables.size() == 0)
return end();
KeyOfT kot;
Hash hash;
size_t hashi = hash(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
return iterator(cur, this);
}
cur = cur->_next;
}
return end();
}
bool Erase(const K& key)
{
Hash hash;
KeyOfT kot;
size_t hashi = hash(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
pair<iterator, bool> Insert(const T& data)
{
KeyOfT kot;
iterator it = Find(kot(data));
if (it != end())
{
return make_pair(it, false);
}
Hash hash;
// 负载因因子==1时扩容
if (_n == _tables.size())
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newtable(newsize, nullptr);
for (Node*& cur : _tables)
{
while (cur)
{
Node* tem = cur->_next;
size_t hashi = hash(cur->_data) % newtable.size();
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = tem;
}
}
_tables.swap(newtable);
}
size_t hashi = hash(kot(data)) % _tables.size();
// 头插
Node* newnode = new Node(data);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return make_pair(iterator(newnode, this), true);
}
size_t MaxBucketSize()
{
size_t max = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
auto cur = _tables[i];
size_t size = 0;
while (cur)
{
++size;
cur = cur->_next;
}
if (size > max)
{
max = size;
}
}
return max;
}
private:
vector<Node*> _tables; // 指针数组
size_t _n = 0; // 存储有效数据个数
};
}
其实这里没有太多的改动,主要就是加上了对应的模板参数和之前实现红黑树类似,这里比较重要点就是这个迭代器的实现。
2.迭代器的实现
和之前实现迭代器一样,这里迭代器也是采用写一个对应的迭代器类。但是这里的迭代器类的模板参数会变多。
首先我们根据之前的经验知道首先必须得有3个模板参数,这是为了重载->和重载&以及const的迭代器的复用。我实试想一下如果要实现迭代器++那么我们我们需要依次遍历每个位置上的哈希链表,如果当前位置是链表的空或者遍历到尾节点了,下次就要移动到新的位置上进行遍历,也就说需要计算哈希位置。因此我们需要哈希表,一来是需要计算哈希地址,二来还需要通过哈希表的节点进行遍历移动。那么哈希表中的模板参数,这个迭代器也需要一份。这就造成了迭代器的模板的参数比较多。
// 前置声明
template<class K, class T, class KeyOfT, class Hash>
class HashTable;
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
struct __HashIterator
{
typedef HashNode<T> Node;
typedef HashTable<K, T, KeyOfT, Hash> HT;
typedef __HashIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;
typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> Iterator;
Node* _node;
const HT* _ht;
__HashIterator(Node* node, const HT* ht)
:_node(node)
, _ht(ht)
{}
__HashIterator(const Iterator& it)
:_node(it._node)
, _ht(it._ht)
{}
};
这里需要前置申明,因为前我们迭代器中需要哈希表,哈希表中需要迭代器,就必须要前置申明。
这里为了方便,我们直接定义一个哈希表指针和节点指针作为成员变量。因为我们成员变量是哈希表指针,我们在哈希表中实现begin接口的时候,可以直接使用this指针进行构造。
Ref operator*()
{
return _node->_data;
}
Ptr operator->()
{
return &_node->_data;
}
bool operator!=(const Self& s)
{
return _node != s._node;
}
这里我们先把简单的迭代器接口先实现了再说,这3个接口相对来说比较简单,我们再来看看++重载操作。
Self& operator++()
{
if (_node->_next != nullptr)
{
_node = _node->_next;
}
else
{
KeyOfT kot;
Hash hash;
size_t hashi = hash(kot(_node->_data)) % _ht->_tables.size();
++hashi;
while (hashi < _ht->_tables.size())
{
if (_ht->_tables[hashi])
{
_node = _ht->_tables[hashi];
break;
}
else
{
++hashi;
}
}
if (hashi == _ht->_tables.size())
{
_node = nullptr;
}
}
return *this;
}
};
如果节点指针的next不为空的话,我们直接就向前遍历,如果为空的话我们重新计算哈希地址向前遍历。这里遍历的时候需要判断一下,如果遍历的位置和哈希表的长度相等的话,就说明已经遍历到尾了,_node需要置为空。
这里需要注意一下的就是,在哈希表中我们将vector设置为私有的成员变量,但是我们在迭代器类的时候需要访问vector的size,这样的话为了便于访问,我们在哈希表中将其设置友元类。这样就可以正常访问了。
public:
typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> iterator;
typedef __HashIterator<K, T, const T&, const T*, KeyOfT, Hash> const_iterator;
iterator begin()
{
Node* cur = nullptr;
for (size_t i = 0; i < _tables.size(); ++i)
{
cur = _tables[i];
if (cur)
{
break;
}
}
return iterator(cur, this);
}
iterator end()
{
return iterator(nullptr, this);
}
const_iterator begin() const
{
Node* cur = nullptr;
for (size_t i = 0; i < _tables.size(); ++i)
{
cur = _tables[i];
if (cur)
{
break;
}
}
return const_iterator(cur, this);
}
const_iterator end() const
{
return const_iterator(nullptr, this);
}
这里begin的接口只用按次序遍历这个vector即可,我们只用找到每个位置的头节点即可。之前就说过这里直接传入this指针进行构造即可。
3.unordered_map和unordered_set复用
这里复用和之前封装set于map一样,直接调用对应的哈希表的接口即可
unorder_map
template<class K, class V, class Hash = HashFunc<K>>
class unordered_map
{
public:
struct MapKeyOfT
{
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
public:
typedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::iterator iterator;
typedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::const_iterator const_iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
const_iterator begin() const
{
return _ht.begin();
}
const_iterator end() const
{
return _ht.end();
}
pair<iterator, bool> insert(const pair<K, V>& kv)
{
return _ht.Insert(kv);
}
V& operator[](const K& key)
{
pair<iterator, bool> ret = insert(make_pair(key, V()));
return ret.first->second;
}
iterator find(const K& key)
{
return _ht.Find(key);
}
bool erase(const K& key)
{
return _ht.Erase(key);
}
private:
HashBucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
};
}
unordered_set
template<class K, class Hash = HashFunc<K>>
class unordered_set
{
public:
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
public:
typedef typename HashBucket::HashTable<K, K, SetKeyOfT, Hash>::const_iterator iterator;
typedef typename HashBucket::HashTable<K, K, SetKeyOfT, Hash>::const_iterator const_iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
const_iterator begin() const
{
return _ht.begin();
}
const_iterator end() const
{
return _ht.end();
}
pair<iterator, bool> insert(const K& key)
{
return _ht.Insert(key);
}
iterator find(const K& key)
{
return _ht.Find(key);
}
bool erase(const K& key)
{
return _ht.Erase(key);
}
private:
HashBucket::HashTable<K, K, SetKeyOfT, Hash> _ht;
};
这里就实现了对应的容器的封装,以上内容如有问题,欢迎指正!