前言:
相信有些厉害的童鞋,已经刷在LeetCode,PTA,牛客时就经常看见有人用哈希表进行解决一些相应的题,那么这节就通过学习哈希来解决一些问题。哈希查找就是unordered系列关联式容器,它在c++11的时候才出现。在c++98的时候虽然红黑树作为底层的一系列关联式容器,如果仅仅是插入和删除,哈希查找是比红黑树快的,红黑树查找和删除时间复杂度O(logn),Hash查找和删除的时间复杂度都是O(1)。哈希一般用于静态可预知大小的数据,很多场景也是需要它,所以c++11就把哈希加入进来。更重要的是,在不同的场景下我们可以通过多种结构相互结合使用,已到底解决问题的目的。
这个时候可能就有人出来问:为什么不叫哈希系列的关联式容器不叫哈希?而是用unordered命名,首先一点,哈希特点是无序的,再者就是为了大家已有c++98的心,因为map,set这些已经深得大家心,为了更好的建立这样一个对应关系,所以就有了unordered_map和unordered_set。该文章也只对unordered_map和unordered_set进行介绍。
为了不让大家思想固化的理解哈希仅仅是一种结构,我们需要理解哈希的概念:哈希即可以是一种数据结构,也可以是一种函数概念。哈希的思想:通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系
,那么在查找时通过该函数可以很快找到该元素。
目录
unordered系列关联式容器
我在学习的过程中,慢慢的体会到自己看英文文档是很有必要的,声明一下我英语也不好,但是我们可以通过查单词去慢慢理解。因为有的时候你通过自动翻译你会发现总感觉差点意思,自己有点理解不了。比如我们去文档中找unordered_map文档中第一句话,用百度翻译了还是有点蒙,还不如自己去慢慢理解。(有些问题总要自己去解决)
Unordered maps are associative containers that store elements formed by the combination of a key value and a mapped value, and which allows for fast retrieval of individual elements based on their keys.
百度翻译:无序映射是关联容器,用于存储由键值和映射值组合形成的元素,并允许根据键快速检索单个元素。
我们的理解:unordered_map是用于储存<key,value>元素的关联式容器,并且允许通过key进行快速查找单个元素(value)。
unordered_set和unordered_map其实与set和map功能是一样的,区别是在于底层的实现,set和map底层是红黑树,unordered_set和unordered_map底层则是哈希表。需要注意的是unordered_set和unordered_map插入数据后是无序的,在下面介绍的时候就可以留意观察一下。
unordered_set与unordered_map的介绍
unordered_set的前言
Unordered sets are containers that store unique elements in no particular order, and which allow for fast retrieval of individual elements based on their value.
In an unordered_set, the value of an element is at the same time its key, that identifies it uniquely. Keys are immutable, therefore, the elements in an unordered_set cannot be modified once in the container - they can be inserted and removed, though.
Internally, the elements in the unordered_set are not sorted in any particular order, but organized into buckets depending on their hash values to allow for fast access to individual elements directly by their values (with a constant average time complexity on average).
unordered_set containers are faster than set containers to access individual elements by their key, although they are generally less efficient for range iteration through a subset of their elements.
Iterators in the container are at least forward iterators.
关于unordered_set与unordered_map的接口说明,现在也用不着过多去讲解,相信大家通过手册的学习就能运用他们的接口。unordered_set在线手册,unordered_map在线手册。
unordered_set与unordered_map的接口测试
Test1主要展示unordered_set的使用,Test2则是展示unordered_map对于统计的使用。
#include<iostream>
#include<unordered_set>
#include<unordered_map>
#include<map>
#include<set>
#include<string>
using namespace std;
#include <time.h>
//unordered_set不重复,无序
void Test1()
{
unordered_set<int> us;
us.insert(5);
us.insert(1);
us.insert(4);
us.insert(3);
us.insert(4);
unordered_set<int>::iterator it = us.begin();
while (it != us.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
//unordered_map统计个数
void Test2()
{
string arr[] = { "苹果", "香蕉", "西瓜", "香蕉", "香蕉", "西瓜", "西瓜", "西瓜", "苹果", "苹果", "苹果", "香蕉", "香蕉" };
unordered_map<string, int> countMap;
for (auto& e : arr)
{
unordered_map<string, int>::iterator it = countMap.find(e);
if (it == countMap.end())
{
countMap.insert(make_pair(e, 1));
}
else
{
it->second++;
}
//countMap[e]++;
}
for (const auto& kv : countMap)
{
cout << kv.first << ":" << kv.second << endl;
}
}
对于Test3的测试,主要是set 与 unordered_set运行时间的对比。测试的性能最好将Debug版本切换的到Release版本,这里同时测试了insert和find,我们发现set在有序的情况下插入是比unordered_set快,但在无序的情况下unordered_set处于领先。但最有意义的是find(查找),因为我们发现查找unordered_set是非常快的,这个也是unordered_set存在的原因。
测试结果:
set insert:128 unordered_set insert:28
set find:233 unordered_set find:0
底层结构
经过上述运行时间的对比,我们也不难看出unordered_set的查找是非常高效。这高效性是离不开它的底层实现的,其底层使用了哈希结构。哈希结构在这里你可以理解成是一种映射思想,举个例子:比如警察叔叔可以通过你的身份证号可以快速的查找到你的相关信息。
哈希的概念
在以前学习的数组,链表,树,查找一个元素基本都是通过比较进行查找,回想在数组中查找一个元素,通过遍历确定相同值。在树阶段,是通过比较元素的大小缩小范围,最终相等后确定相同值。因为元素关键码与其存储位置之间没有对应的关系,所以查找一个元素时就需要多次比较。
如果在特定的情况下,我们只需要快速搜索,那么理想的搜索方法:它不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table) (或者称散列表)。
向该结构进行
●插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
例如:数据集合{1,7,6,4,5,9};
开辟一个元素个数为10的数组,key为元素的值;哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。因为这里总空间大小为10, capacity=10,通过取模得到该元素在数组上所对应的位置。
●搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置 取元素比较,若关键码相等,则搜索成功。用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快,但是按照上述哈希方式,向集合中插入元素44,会出现什么问题?
哈希冲突
此时发生了哈希冲突,4映射的位置与44映射的位置产生了冲突。不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。发生哈希冲突肯定是需要处理,这个时候就可以先从哈希函数下手。
哈希函数
哈希函数设计原则:
●哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值 域必须在0到m-1之间
●哈希函数计算出来的地址能均匀分布在整个空间中
●哈希函数应该比较简单
常见哈希函数
○直接定址法--(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
○除留余数法--(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数, 按照哈希函数:Hash(key) = key% p(p将关键码转换成哈希地址)
○平方取中法--(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址。
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
○折叠法--(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这 几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
○随机数法--(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中
random为随机数函数。通常应用于关键字长度不等时采用此法。
○数学分析法--(了解)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定 相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只 有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散 列地址。例如:
假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同 的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还 可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移 位、前两数与后两数叠加(如1234改成12+34=46)等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的 若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
哈希冲突解决
常见解决哈希冲突的两种方法有闭散列和开散列,闭散列是在原来的未被占用的空间上继续插入,开散列则是在该结构上基础上增加链式结构。两者其目的都是在出现冲突后能继续插入元素。
闭散列
闭散列也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。如何找下一个空位置,就通过线性探测。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
还是用上述数据集合{1,7,6,4,5,9}举例,当插入元素44时,经过哈希函数计算出哈希地址后,发现改地址已经被之前插入元素4占用,此时就通过线性探测,从4映射的位置依次向后探测,直到找到空位置(8)时,进行插入。
●插入
通过哈希函数获取待插入元素在哈希表中的位置
如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突, 使用线性探测找到下一个空位置,插入新元素 。
●删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素 会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{
EMPTY, EXIST, DELETE
};
当已经知道插入后需要通过线性探测解决冲突问题,与此同时插入的时候,还有可能是曾经插入时被删除。这个时候我们就需要在封装的哈希节点中增加一个状态,这里通过枚举实现。哈希表的结构实现大致如下:
//三种状态
enum State
{
EMPTY, //null
EXIST, //存在
DELETE, //删除
};
//封装的数据类型
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
//哈希表的实现
template <class K, class V>
class HashTable
{
typedef HashData<K, V> Date;
public:
//插入
bool Insert(const pair<K, V> &kv)
{
size_t hashi = kv.first % _table.size();
while (_table[hashi]._state == EXIST)
{
//线性探测
hashi++;
hashi %= _table.size();
}
_table[hashi]._kv = kv;
_table[hashi].state = EXIST;
++_n;
}
private:
vector<Date> _table;
size_t _n; //表中存储的有效数据个数
};
此时的哈希表中的插入是不够完善的,当插入数据的总数大于容量时,在循环中转一圈也没有找到插入位置,这时就需要扩容。现在还有一个问题,如果等到元素占满这个内存时扩容,这就意味着哈希冲突的概率会非常大,就需要扩容来降低哈希冲突,因此哈希表中元素是不会存满的。
所以就需要思考:哈希表在什么情况下进行扩容?如何扩容?
在什么情况下进行扩容
在合适的情况下扩容,是为了更好的调整哈希表性能,那么负载因子它可以影响哈希表的数据存取性能。
●散列表的载荷因子定义为: α=填入表中的元素个数/散列表的长度
●负载因子越小,冲突概率越小,消耗空间越多
●负载因子越大,冲突概率越大,空间利用率越高
负载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cachemissing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。
如何扩容
一般扩容是重新开辟一块更大的内存空间,不会在原有的基础增加一段内存空间。其原因是当开辟空间后,在原来基础上追加该空间可能已经被占用。
当我们重新开辟一块空间的时候(或者在原来的基础开辟空间),都会出现一个问题,打破了原来的映射关系,所以扩容后还需要重新建立映射关系。
insert新写法的逻辑思路:
●当负载因子大于7时扩容,需记住扩容等于重新开始。
●构建新的类(newHT),其目的是为了能够实现自我调用insert。这里不是递归,递归是当前this指针的调用,这里是新的一个类调用自己的insert。
●对旧表进行的范围for遍历,如果发生冲突,newHT调用insert进行对新表进行插入。这里的插入是newHT内部调用,因为新表已经开辟内存为旧表的2倍,所以不走负载因子这一步。向下进行线下探测,插入冲突的元素返回true,这里会调用拷贝构造与析构,最后新表与旧表进行交换。
bool Insert(const pair<K, V> &kv)
{
//if (Find(kv.first))
// return false;
//大于标定的负载因子时扩容
if (_n*10 / _table.size() > 7)
{
HashTable<K, V> newHT;//构建新类
newHT._table.resize(_table.size() * 2);//开辟内存为旧表的2倍
//对旧表进行遍历,如果旧表发生冲突,就通过构建的新表调用insert进行对新表进行插入
for (auto& e : _table)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
_table.swap(newHT._table);
}
size_t hashi = kv.first % _table.size();
while (_table[hashi]._state == EXIST)
{
//线性探测
hashi++;
hashi %= _table.size();
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
查找和删除
//查找
Data* Find(const K& key)
{
size_t hashi = key % _table.size();
while (_table[hashi]._state != EMPTY)
{
if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key)
{
return &_table[hashi];
}
++hashi;
hashi %= _table.size();//保证在数组内循环
}
return nullptr;
}
//伪删除
bool Erase(const K& key)
{
Data* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
将代码归总后,如果想测试字符串,我们发现是不行的。这里只针对整型,字符串的本质就是一串字符,字符类型也是整型家族的。这个时候就可以考虑使用仿函数,通过仿函数可以将相似类型转换为整型,如果特殊类型则需要增加特化的仿函数即可(string类型:将每个字符串的每个字母的ASCII码加起来)。
//float,char直接转成整型
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//string特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
关于字符转整型,在《The C Programming Language》一书中提到几种方法,其中一种是将字符的整型累乘上所研究的累乘因子,这种方法被Brian Kernighan与Dennis Ritchie的名字命名为BKDRHash。在博客园中有博主总结的字符串哈希算法,而且还对不同的方法进行测试,比较。
整体代码实现:https://gitee.com/hong-xin-qing/bite/blob/master/Hash/Hash/HashTable.h
二次探测
线性探测代码实现也是比较简单的,通过不断的i++进行查找,如果找到空位置后就插入。性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因此二次探测为了避免该问题,i^2++的方式进行。
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
经过二次探测比线性探测好上那么一点,但也没有从根本上解决冲突扩容问题。
开散列
开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
从上图看,其实开散列和闭散列结构其实差不多,因为可以将他们看做是除留余数法的两个分支。一个是纯数组,一个是在数组中存的单链表节点。最开始都是需要哈希函数计算,求出数组对应的位置然后进行插入。对单链表有所忘记的就从下图回顾。
对单链表的插入,从以前的知识告诉我们:单链表是便于头插的,不便于尾插,因为在单链表中尾插是需要遍历找到最后一个节点。在哈希桶中,是可以随便插入的,因为我们查找的时候是随机输入一个值进行查找,那么插入时就可以随便插入,所以这里我们选择头插。
在实现插入代码之前,还有个小问题:确定负载因子的大小。对于开散列来说,它的数据是存在表下面的,频繁的开辟空间代价也是不小的,所以开散列的负载因子是要比闭散列的负载因子大。在c++库是有明确规定的。
By default, unordered_map containers have a max_load_factor of 1.0.
默认情况unordered_map容器的最大负载因子为1。
开散列实现
这里需要讲一下开散列的插入,如果我们按照闭散列扩容的思路:
○通过查找,查找到key返回false
○当负载因子在规定的范围内,不扩容计算哈希函数值,直接头插。
○当负载因子大于规定值,则需扩容,重新声明newBT,将newBT中的新表扩容至两倍,再通过范围for把旧表(_tables)的数据插入(newBT调用自身的insert)到新表中,最后交换数据。
○注意1:交换数据后,编译器会调用析构将节点清理,所以需要写一个析构函数,因为这里是节点删除,vector自身的析构不能清除节点。
○注意2:调用insert时,需要开辟__tables空间,所以应该写一个构造函数。
闭散列扩容思路的insert代码
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
//当负载因子为1时,需要扩容
if (_tables.size() == _n)
{
HashTable<K, V, Hash> newBT;
newBT._tables.resize(_tables.size() * 2);
for (auto& cur : _tables)
{
while (cur)
{
newBT.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newBT._tables);
}
Hash hf;
size_t hashi = hf(kv.first) % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
通过闭散列扩容的思路,我们发现需要对节点进行拷贝之后再删除,那能不能直接对节点进行回收利用呢?
开散列增容新思路:
○建立新表,对新表扩容
○算取哈希函数值,然后对于新表进行插入,为了避免两个表指向一个节点(避免两次析构),这里把节点置空,最后新旧表交换。
新思路开散列增容的代码
//当负载因子为1时,需要扩容
if (_tables.size() == _n)
{
vector<Node*> newTables;
newTables.resize(2 * _tables.size()), nullptr);
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
rsize_t hashi = Hash()(cur->_kv.first) % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
由于是单链表,对于头结点的删除是比较容易的,但如果不是头结点删除,就需要保存前面节点的地址。
在stl3.0源码中,哈希表的大小是一个素数。因为模出来的值是相当来说是更加分散的,越分散那么哈希桶的效率就越高。它这里扩容的关系,是接近于二倍关系,找的一个二倍相近的素数。所以有了这个素数表,我们扩容就不需要去对旧表乘二倍,直接去表中取下一个值即可。
size_t GetNextPrime(size_t prime)
{
const int PRIMECOUNT = 28;
static const size_t primeList[PRIMECOUNT] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > prime)
return primeList[i];
}
return primeList[i];
}
注意:关于最大值的问题(4294967291),这个数是哈希表装的数据,可以想象一下42亿个数据,需要多少的内存?如果就简单存的类型是int,哈希表自身的结构还占有内存。42亿byte*12;4G*12=48G。很显然一般情况下是不可能存怎么多数据的。
哈希桶的代码的实现:https://gitee.com/hong-xin-qing/bite/blob/master/Hash_Bucket/Hash_Bucket/HashTable.h
模拟实现
哈希表的改造
模板参数列表的改造
// K:关键码类型
// V: 不同容器V的类型不同,如果是unordered_map,V代表一个键值对,如果是unordered_set,V 为 K
// KeyOfT: 因为V的类型不同,通过T取key的方式就不同,详细见unordered_map/set的实现
// Hash: 哈希函数仿函数对象类型,哈希函数使用除留余数法,需要将Key转换为整形数字才能取模
template<class K, class T, class Hash, class KeyOfT>
class HashTable;
增加迭代器操作
operator++()的实现是整个迭代器实现难度最大的,有必要讲解一下实现的大致思路。
如果当前节点不为nullptr,指向下一个节点
但如果我们指向桶最后一个节点时为nullptr,找到下一个桶的节点。方法有很多,在源码中是通过调用哈希表(HashTable),通过计算出当前桶在vector中的地址(hashi)。经过对++hashi操作到下一个桶,再对下一个桶的节点进行操作。
如果该桶是nullptr,直接跳到不为空的桶,退出循环。
Self& operator++()
{
if (_node->_next)
{
_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;
}
};
为什么const迭代器没有复用?
在实现红黑树时,const与非const都使用的是一个迭代器(复用)。部分实现内容,仅用于对比参考。
//迭代器的实现
template<class T, class Ref, class Ptr>
struct _RBTreeIterator{............}
//红黑树的实现
template<class K, class T ,class KeyOfT>
class RBTree
{
typedef RBTreeNode<T> Node;
public:
//实现const和非const
typedef _RBTreeIterator<T, T&, T*> iterator;
typedef _RBTreeIterator<T, const T&, const T*> const_iterator;............................................................
}
关于哈希表,在源码中是写了两个迭代器。一个const迭代器,一个非const迭代器。这里的问题的意思就是,为什么要写两个迭代器,复用一个不行吗?
//非const迭代器
template <class Value, class Key, class HashFcn,
class ExtractKey, class EqualKey, class Alloc>
struct __hashtable_iterator{............
node* cur;
hashtable* ht;}
//const迭代器
template <class Value, class Key, class HashFcn,
class ExtractKey, class EqualKey, class Alloc>
struct __hashtable_const_iterator{..............
const node* cur;
const hashtable* ht;}
//哈希表
template <class Value, class Key, class HashFcn,
class ExtractKey, class EqualKey,
class Alloc>
class hashtable {
public:typedef value_type* pointer;
typedef const value_type* const_pointer;
typedef value_type& reference;
typedef const value_type& const_reference;}
换个思路,当我们复用同一个迭代器,调用const版本的operator[],operator[]会调用insert,而insert会返回迭代器中的节点,迭代器会通过构造函数。
insert会返回迭代器中的节点 make_pair(iterator(newnode, this), true); 迭代器会通过构造函数 __HTIterator(Node* node, HT* ht) :_node(node) , _ht(ht) {}
那么问题来了,当前节点应该的类型是不是应该被const修饰呢?如果复用一个迭代器的话。很显然是不能解决这个问题的。
哈希桶,unordered系列的封装
https://gitee.com/hong-xin-qing/bite/tree/master/Hash_Bucket2/Hash_Bucket2
位图
这里有一个关于鹅厂的题:
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在 这40亿个数中。【腾讯】
解决这个题关键是:给40亿个不重复的无符号整数。40亿个整型是需要占有多少内存,16G。我们用数组的排序,红黑树存储不下。如果是比特位呢?
因为数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。
刚才我们已经知道40亿个整数要存16G,一个整数是4字节,一个字节8byte,那么一个整数是32byte,所以40亿比特位大致为:16G/32byte=0.5G=500M。500M对于内存是可以承受的。
我们知道开数组最小是以字符(char)类型开辟数组, 没有听说过开多少bit位的数组。如果按照char类型开辟数组,如何得知x映射的值在第几char对象上?x的映射的值,在这个char对象上的第几个bit位上?
不管开辟是char类型还是int类型,都是不重要的。如果开辟int类型需要找到x映射的位置,只需要对x/32,先找到该值在第几个int对象上。再对x%32,找到的这个int对象上再确定是第几个bit位。1个int类型是32bit位。这样就确定好了映射的位置。
例:找到64在数组中映射的位置
位图概念
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
位图的实现
大家还记得大小端吗?一般来说平常的机器都是小端机。这里我们用简单的代码证明一下我的vs2013是小端机,小端机:低对低,高对高 。
bit位的左移右移与大小端有关吗?答案是没有任何关系,操作系统会进行处理。
这里先用char类型进行进行理解,选取7,8作为例子。7位高地址,8位低地址。char的大小端顺序正好是和左移顺序契合的。如果向高位移动,低地址往高地址移动-左移,高地址往低地址移动-右移。
对于int,如果以它的大小端来确定往高位移动,我们会发现7,8是高到低,但(7,8)到(5,6)确是低到高。很显然在以前对int进行左移右移操作的时候,也是左移向高位移的,右移向低位移,那么关于大小端的处理,我们是不需要考虑,操作系统会帮助处理。
位图的三个函数解析
○set 将x对应比特位0置1
如何设置bits[i]的第j个比特位=1
呢?假设右边低左边高位进行存储
bits[i] |=(1<<j);//左移j位然后去那个位置或,实现设置j那个bit位为1。
○reset 将x对应比特位1置0
如何设置某一位reset为0呢?找一个数,那一位是0,其他位是1,按位与设置。
bits[i] & =(~(1<<j));//那个数就是1<<j位之后按位取~反就是了。
○test 查看x位置是0还是1
如何检验那一位是1?先找到你的区,再按个bit用1按位与,为1 说明那一位就在,为0代表不在。
return bits[i]&(1<<j);//int值被隐士类型转换为bool值
位图(BitSet)的代码实现
#pragma once
#include <vector>
namespace qhx
{
template<size_t N>
class bit_set
{
public:
bit_set()
{
//_bits.resize( (N >>3) + 1, 0);
_bits.resize(N / 8 + 1, 0);
}
void set(size_t x)
{
//size_t i = x >> 3;
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
void reset(size_t x)
{
//size_t i = x >> 3;
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
bool test(size_t x)
{
//size_t i = x >> 3;
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] &(1 << j);
}
private:
std::vector<char> _bits;
};
void test_bitset()
{
//bitset<100> bs1;
//bitset<-1> bs2;
bit_set<0xffffffff> bs2;
bs2.set(10);
bs2.set(10000);
bs2.set(8888);
cout << bs2.test(10) << endl;
cout << bs2.test(10000) << endl;
cout << bs2.test(8888) << endl;
cout << bs2.test(8887) << endl;
cout << bs2.test(9999) << endl << endl;
bs2.reset(8888);
bs2.set(8887);
cout << bs2.test(10) << endl;
cout << bs2.test(10000) << endl;
cout << bs2.test(8888) << endl;
cout << bs2.test(8887) << endl;
cout << bs2.test(9999) << endl;
}
位图的应用
1. 给定100亿个整数,设计算法找到只出现一次的整数?
100亿个整数存储,毫不犹豫选择位图解决。这里找到只出现一次的整数,那么一个bit位是不表达出这几种情况:0次出现整数,1一次出现整,1次以上出现整数。既然一个bit位不能解决这个问题,我们用两个bit位来表是:00表示0次出现整数的情况,01表示1次出现整数的情况,10表示1次以上出现整数的情况。这里有两种方法解决该问题。
○方法一,用一个数组,两个靠近的bit位来表示这几种情况。
○方法二,用两个数组_bits1,_bits2,通过单独对两个数组进行set,reset操作,其两个数组都为0则代表00;_bits2为1,_bits1为0则代表01;_bits2为0,_bits1为1则代表10,;其它情况;(推荐)
代码实现
template <size_t N>
class twobitset
{
public:
void set(size_t x)
{
if (!_bs1.test(x) && !_bs2.test(x)) //00
{
_bs2.set(x);//01
}
else if (!_bs1.test(x) && _bs2.test(x))//01
{
_bs1.set(x); //11
_bs2.reset(x); //10
}
//不变
}
void PrintOnce()
{
for (size_t i = 0; i < N; ++i)
{
if (!_bs1.test(i) && _bs2.test(i))
{
cout << i << " ";
}
}
cout << endl;
}
private:
bit_set<N> _bs1;
bit_set<N> _bs2;
};
void test_twobitest()
{
twobitset<100> tbs;
int a[] = { 1, 7, 5, 3, 5, 4, 5, 8, 10, 12, 15, 6, 7, 56 };
for (auto e : a)
{
tbs.set(e);
}
tbs.PrintOnce();
}
2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
两个文件开辟两个数组,对两个文件进行与运算,算出两个文件的交集即可。
3. 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
这里与上述题1 基本一样,这里通过逻辑判断后直接打印出0次出现整数的情况,1次出现整数的情况,2次出现整数的情况,多加一个else(两次已上)。
哈希切割
给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
在这里是找到出现次数最多的IP地址,并且是存储的是100G。已学过位图的知识,位图适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。但找到出现次数最多的IP地址的,这里就有重复,顾该问题位图是不能解决的。虽然map就能很好的解决该问题,但是100G存储是map不具备的。
这时候可以采用分治思想,将100G的文件切分成100个小文件,这样每个文件就只有1G大小,一个一个来统计次数,依次读取每个小文件,再依次统计次数,统计一个后,清除(clear)掉map,然后在统计下一个。
另外一个问题就出现了,每个文件都有不同的ip地址,当在A0中统计后,清理掉A0后,如果再想联系A1去统计次数,这个时候已经他们的联系被清理,所以不能平均切分,要哈希切分。
如何哈希切分,创建一个哈希函数HashFunc,i = HashFunc(ip) % 100, i是多少,ip就进入Ai号小文件。 这样每个文件都是相同ip地址,想要统计同样的ip文件,可以用HashFunc(ip)%100进行查找到Ai的文件,这里每个Ai文件就相当于一个哈希桶。
但在特殊情况下又会出现一个问题,如果Ai小文件出现了超过1G又当如何?分两种情况!
○这个小文件中冲突的ip很多,都是不同的ip,大多数是不重复的,map统计不下。(换个字符串哈希函数,递归再切分)。
○这个小文件中冲突的ip很多,大多都是相同的ip,大多数是重复的,map可以统计。
区分这两种情况:
如果是第1种情况,map的insert插入失败,那是没有内存,相当于new节点失败,new失败抛异常如果是第2种情况,直接用map统计,可以统计出来的,不会报错。
总结位图的应用:
1. 快速查找某个数据是否在一个集合中
2. 排序 + 去重
3. 求两个集合的交集、并集等
4. 操作系统中磁盘块标记
布隆过滤器
由名字得知,布隆过滤器是一个过滤器,而且不是用于存储数据的。这个就好比自来水过滤器一样,将水的的杂质排除来。那么布隆过滤器就是对大量的数据进行过滤,对一定不存在的数据进行过滤,再向可能存在的数据进行其他操作。
本质上布隆过滤器是哈希与位图的结合,位图有着节省空间,查询快的特点。但缺点也很明显:
1.一般要求范围相对集中,范围特别分散,空间消耗就上升,就例如存储两个数(1,99999)。
2.只能针对整型,因为底层是bit位。
但通过哈希的映射,就很好的解决范围相对集中,只针对整型这缺点;在位图的基础上又解决浪费空间的缺点。所以这句话就非常棒:不同的数据结构有不同的适用场景和优缺点,你需要仔细权衡自己的需求之后妥善适用它们,布隆过滤器就是践行这句话的代表。
布隆过滤器实质是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。
相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的。--陈蔚澜7
实现原理
首先来看一下布隆过滤器数据结构,这里有三个字符串,“hello”,“world”,“bit_set”,通过hashfunc映射到位图,如果为1则存在,0则不存在。
这个时候就需要确定一下,存在是准确的呢,还是不存在是准确的。
1.在是不准确的,因为可能本来不在,但是这个位置跟别人冲突,出现误判
2.不在是准确的,
1这种情况,比如又有一个字符串“worde”,通过映射处理,映射的位置与“world”一样,发生冲突。
出现误判了,又该如何处理呢?这个就好像我们在生活中那家店好吃,当问一个人时(他可能是吃不得辣),说不好吃;但是这样的结果可能并不是你想要的,那不妨多问几个人。降低误判率。
在这里的做法是映射多个位置 ,降低误判率,但不能消除误判, 布隆过滤器的改进。
布隆过滤器的实现
如何选择哈希函数个数和布隆过滤器长度
很显然,过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,起不到过滤的目的了。布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。
另外,哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。
由上图可知,当布隆过滤器越长报错率越小,哈希函数的个数越多报错率越小。但占用的内存会变大,这个之间取一个平衡。
代码的实现:
#pragma once
#include <bitset>
#include <iostream>
#include <vector>
#include <time.h>
using namespace std;
namespace qhx
{
struct BKDRHash
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& key)
{
unsigned int hash = 0;
int i = 0;
for (auto ch : key)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ (ch) ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ (ch) ^ (hash >> 5)));
}
++i;
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const string& key)
{
unsigned int hash = 5381;
for (auto ch : key)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
struct JSHash
{
size_t operator()(const string& s)
{
size_t hash = 1315423911;
for (auto ch : s)
{
hash ^= ((hash << 5) + ch + (hash >> 2));
}
return hash;
}
};
//假设N是最多存储的数据个数
template<size_t N, size_t X = 6,
class K = string,
class HashFunc1 = BKDRHash,
class HashFunc2 = APHash,
class HashFunc3 = DJBHash>
class BloomFilter
{
public:
void set(const K& key)
{
size_t hash1 = HashFunc1()(key) % (5 * N);
size_t hash2 = HashFunc2()(key) % (5 * N);
size_t hash3 = HashFunc3()(key) % (5 * N);
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
bool test(const K& key)
{
//判断都为不在是准确的,不存在误判
size_t hash1 = HashFunc1()(key) % (5 * N);
if (!_bs.test(hash1))
return false;
size_t hash2 = HashFunc2()(key) % (5 * N);
if (!_bs.test(hash2))
return false;
size_t hash3 = HashFunc3()(key) % (5 * N);
if (!_bs.test(hash3))
return false;
//可能存在误判,映射几个位置都冲突,会产生误判
return true;
}
private:
std::bitset<X * N> _bs;
};
void test_bloomfilter1()
{
string str[] = { "hello", "helle", "hella", "world", "test", "world2", "world3", "Test", "你好" };
BloomFilter<10> bf;
for (auto& str : str)
{
bf.set(str);
}
for (auto& s : str)
{
cout << bf.test(s) << endl;
}
cout << endl;
srand(time(0));
for (const auto& s : str)
{
cout << bf.test(s + to_string(rand())) << endl;
}
}
void test_bloomfilter2()
{
srand(time(0));
const size_t N = 100000;
BloomFilter<N> bf;
std::vector<std::string> v1;
std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
for (size_t i = 0; i < N; ++i)
{
v1.push_back(url + std::to_string(i));
}
for (auto& str : v1)
{
bf.set(str);
}
// v2跟v1是相似字符串集,但是不一样
std::vector<std::string> v2;
for (size_t i = 0; i < N; ++i)
{
std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
url += std::to_string(999999 + i);
v2.push_back(url);
}
size_t n2 = 0;
for (auto& str : v2)
{
if (bf.test(str))
{
++n2;
}
}
cout << "相似字符串误判率:" << (double)n2 / (double)N << endl;
// 不相似字符串集
std::vector<std::string> v3;
for (size_t i = 0; i < N; ++i)
{
string url = "zhihu.com";
url += std::to_string(i + rand());
v3.push_back(url);
}
size_t n3 = 0;
for (auto& str : v3)
{
if (bf.test(str))
{
++n3;
}
}
cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl;
}
}
布隆过滤器
1. 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出 精确算法和近似算法
2. 如何扩展BloomFilter使得它支持删除元素的操作
☺ [ 作者 ] includeevey
📃 [ 日期 ] 2023 / 3 / 26
📜 [ 声明 ] 到这里就该说再见了,若本文有错误和不准确之处,恳望读者批评指正!
有则改之无则加勉!若认为文章写的不错,一键三连加关注!
————————————————