目录
Unordered_set/Unordered_set对哈希的封装
unordered系列关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 ,即最差情况下 需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次 数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑 树结构的关联式容器使用方式基本类似,只是其底层结构不同,本文中只对unordered_map和 unordered_set进行介绍,unordered_multimap和unordered_multiset的用法可以类比于multimap和multiset。
unordered_map
unordered_map的文档介绍
- unordered_map是存储键值对的关联式容器,其允许通过keys快速的索引到与其对应的 value。
- 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键 和映射值的类型可能不同。
- 在内部,unordered_map没有对按照任何特定的顺序排序, 为了能在常数范围内找到key所 对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
- unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率 较低。
- unordered_maps实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问value。
- 它的迭代器至少是前向迭代器 (forward iterators)
unordered_set
底层结构
unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经 过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( logN)(以2为底),搜索的效率取决 于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过 某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函 数可以很快找到该元素。
那么当存储一组数据时候,比如开辟了一组256空间的int数组,int arr[256],我们按照下标的位置来存储数据,0就存储在0的位置,256就存储在256的位置,这样会造成,有大量的空间没有被使用而造成空间的浪费。为了避免这个情况,我们可以使用哈希,当开辟了10个数组的空间,我们将key值模上数组的大小,这样数组存放的下标就不会超过10,使得256就存储在6的位置,使空间减小,并且搜索效率提高。
那么又有一个新的问题,当数据多了(在不超过数组大小的情况下),会造成冲突,比如2,12,22,32这组数据,在数组空间大小为10的时候,都会放在2这个位置,造成了哈希冲突。
哈希冲突的解决
闭散列:
也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那 么可以把key存放到冲突位置中的“下一个” 空位置中去。
1、线性探测
插入:
用除留余数法求出key值的关键码,并将它放到对应的位置上。如果该位置已经存在数据被占用了,那么继续寻找下一个位置,也就是+1的位置,如果+1的位置已经有数据,那么继续+1。
查找:
当查找哈希中的数据,在除留余数后依次找到对应位置,查找的条件是数组对应的位置不为空,但是如果某一个位置的值删除,再次查找一个数据,可能本来存在的,却因为中途遇到空而停止,找不到该数据。比如2,12,22,32这组数据,第一次按序排列,能够找到,删除12后,再次从2开始查找,遇到了空停下来,找不到了。
所以第一种解决方法,是在删除后将后面的数填到该删除的位置,依次的填入,但是这样很麻烦,所以第二种方法:我们用一个枚举,将数组中每个数据的状态记录一下,所以就有了存在,空和删除这三种状态,当数据被删除了,状态变为删除。但是这个数据还没有从数组中的位置删除,这样数组该位置并不是空,不会因为数据被删除而停下来。并且没有挪动数据那么麻烦。
因为数据多了,很大概率会造成哈希碰撞,本来在原有的位置,但是因为线性探测,将此位置占用。连续位置值冲突比较多,会引发踩踏洪水效应。所以还有一种二次探测的方法。
2、二次探测
二次探测和线性探测差不多,唯一的不同就是线性探测是遇到冲突的位置就+1个位置,而二次探测会+i平方的位置,第一次冲突找1*1的位置,第二次冲突找2*2的位置,也就是+4的位置。这样使得挤在一起的数据,有些许的缓和。
我们来用代码实现一下:
代码实现
哈希基本结构
这里我们暂且用kv的结构来实现,后续如果要用map/set封装,我们之后再改。
我们将每个哈希数据都放在一个数组中。
//枚举数据状态
enum status
{
EXIST,
EMPTY,
DILTE
};
//哈希每个数据状态
template<class K,class V>
struct HashData
{
pair<K, V> _kv;
Status _status = EMPTY;
};
//哈希表结构
template<class K,class V>
class HashTable
{
public:
private:
vector<HashData<K,V>> _tables;
size_t _n;//有效数据个数
};
插入
插入思路:插入的数据用除留余数法求出关键码,如果该码位置的状态是空,就插入数据;如果不是空,就继续线性探测找到空的位置。循环条件就是数组位置状态不是空。
线性探测和二次探测,下面代码中我们只要改变start+i的数据,就能控制,二次还是线性。
bool Insert(const pair<K, V>& kv)
{
size_t start = kv.first % _tables.size();
size_t i = 0;
size_t index = start;
while (_tables[index]._status == EMPTY)//如果位置被占用
{
i++;
//index = start + i;//线性探测
index = start + i^i;//二次探测
index %= _tables.size();//防止加之后的数大于表格长度,模一下,无论多大的值,都在这个表格内。
}
_tables[index]._kv = kv;
_tables[index]._status = EXIST;
++_n;
}
思考:哈希表什么情况下进行扩容?如何扩容?
也就是说扩容并不一定是数据满了才扩容,在负载因子大于0.7的时候,就需要扩容,这样同时也避免了更多的哈希冲突。
扩容的方法:
我们用一个负载因子来记录是否要扩容,如果负载因子大于0.7,那么就扩容。第一个方法是:开辟一个新的vector,将旧的_table的vector依次插入到新的vector中,但是这种方法,和插入的代码形成了冗余。所以我们用另一种方法:开辟一个新的哈希表HashTable<K, V> newHT,依次遍历旧的_table表,将_table表中的数据插入到哈希类的表中,此时插入新的哈希表,会复用Insert,newHT.Insert(_table[i]._kv),当再次使用Insert,因为newHT的size已经扩容到2倍了,所以不存在负载因子过大,这样就不会走扩容这个条件语句,进而直接走下面的插入代码。此时kv.first%_tables.size(),这里的kv是旧的_table[i]的kv数据,要模上newHT对象的_tables大小。这样就完成了Insert的复用。最后将旧的_tables和新的哈希表的_tables交换,最后走出这个栈帧,自动将旧的_talbes销毁,不用自己释放。
bool Insert(const pair<K, V>& kv)
{
HashData<K, V>* ret = Find(kv.first);
if (ret)
{
return false;
}
if (_tables.size()==0 || _n * 10 / _tables.size() >= 7)
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newHT;
newHT._tables.resize(newSize);
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._status == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
size_t start = kv.first % _tables.size();
size_t i = 0;
size_t index = start;
while (_tables[index]._status != EMPTY)//如果位置被占用
{
i++;
index = start + i;//线性探测
//index = start + i^i;//二次探测
index %= _tables.size();//防止加之后的数大于表格长度,模一下,无论多大的值,都在这个表格内。
}
_tables[index]._kv = kv;
_tables[index]._status = EXIST;
++_n;
return true;
}
查找find
在上面的代码还有一个问题,就是插入后,可能会插入重复的值,按上面的代码如果插入了重复的值,那么会插入到一个新的没有被占用的空间,也就是不等于空的空间。如果是del标识,那么它会覆盖。但是哈希不需要重复的值,所以我们用find函数来查找一下,是否有重复的值。
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t start = key%_tables.size();
size_t i = 0;
size_t index = start;
while (_tables[index]._status != EMPTY)
{
if (_tables[index]._kv.first == key && _tables[index]._status==EXIST)
{
return &_tables[index];
}
++i;
index = start + i;
index %= _tables.size();
}
return nullptr;
}
查找的思路和插入大致相似。如果不是空,那么一直查找,直到找到key这个数。直到遍历到空位置都找不到,那么就返回nullptr。用这个返回值类型,是为了方便在插入函数中,如果找到重复的数据,那么会返回该数据的地址,插入函数直接返回false;如果没有找到,就会返回nullptr,说明还没有插入过这个数字,插入函数继续继续向下执行。
这里需要注意,当第一次插入的时候,先执行find函数,因为find函数需要通kv.first和_tables.size()取模,此时第一次插入_tables的大小是0,会让程序崩溃。所以find函数最开始要加上一个判断条件,判断_tables.size()是不是0.是0的话返回nullptr