unordered_map/unordered_set的用法
它和我们前面所说的map和set还是有点区别的,首先最大的区别就是其是无序的,这一点从其名字上就可以看出。
哈希表有一个重要的性质,就是快。其增删查的时间复杂度都是O(1)!!!
我们下面会有专门的检测其效率的代码。
我们来简单的用一用:
#include<iostream> #include<unordered_set> #include<unordered_map> #include<string> #include<set> #include<time.h> using namespace std; namespace std { void test_unordered_set() { unordered_set<int> us; us.insert(2); us.insert(1); us.insert(3); us.insert(4); us.insert(5); us.insert(6); us.insert(6); unordered_set<int>::iterator it = us.begin();//其是无序的 while (it != us.end()) { cout << *it << endl; ++it; } cout << endl; //迭代器遍历、同样支持迭代器遍历 auto pos = us.find(2);//其是unorder_set的专用算法。使用哈希特性查找,效率高--O(1) //类似如果是set,效率是O(logN) pos = find(us.begin(), us.end(), 2);//所有容器都可以使用,通用算法 //缺点是其为暴力查找 O(N) 可复用 if (pos != us.end()) { cout << "找到了" << endl; } else { cout << "找不到" << endl; } } void test_unordered_map1() { unordered_map<string,string> dict; dict.insert(make_pair("sort", "排序")); dict["left"] = "左边"; dict["right"] = "右边"; unordered_map<string, string>::iterator it = dict.begin(); while (it != dict.end()) { cout << it->first << ":" << it->second << endl; ++it; } cout << endl; } void test_op() //检测哈希表相较于红黑树的效率 { int N = 1000000; vector<int> v; //先给出一些值 v.reserve(N); srand(time(0)); for (int i = 0; i < N; i++) //然后产生一些随机值 { v.push_back(rand()); //将产生的随机值放到一个vector当中 } unordered_set<int> us; //分别定义一个unordered_set和一个Set set<int> s; time_t begin1 = clock(); for (auto e : v) { us.insert(e); } time_t end1 = clock(); //计算插入完毕前后的时间差 time_t begin2 = clock();//同样,也对set计算插入完毕前后的时间差 for (auto e : v) { s.insert(e); } time_t end2 = clock(); size_t begin3 = clock(); //同样可以再计算一下查找的效率(通过计算查找前后的时间差) for (auto e : v) { s.find(e); } size_t end3 = clock(); size_t begin4 = clock(); for (auto e : v) { us.find(e); } size_t end4 = clock(); cout << "set_insert:" << end2 - begin2 << endl; //将结果打印出来 cout << "unorded_set_insert:" << end1 - begin1 << endl; cout << endl; cout << "set_find_insert:" << end3 - begin3 << endl; cout << "unorded_set_insert:" << end4 - begin4 << endl; } } int main() { test_unordered_set(); test_unordered_map1(); test_op();//我们用来检测哈希表的效率 return 0; }
至于unordered_map/unordered_set的构造、容量查询、迭代器等基本的接口,我们就不再赘述了,和其他的容器是一样的。如果想了解更多,同学们可以自行查阅有关文献。
cplusplus.com - The C++ Resources Network
在此说明一下,通过查阅文献,我们会发现其有一些关于桶的接口。这些接口等我们将其底层原理介绍完毕之后大家自然就明白了。
unordered_map/unordered_set的底层原理
我们前面说到过,unordered_map/unordered_set的效率极高,可以达到O(1),这正是因为其底层使用了哈希表。
那哈希表是什么呢?
在我们之前的顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( logN ),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(假设该函数叫hashFunc) 使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
那么上面所提到的哈希函数又是啥呢?
实际上是一种转换方法。
通过Hashfunc(哈希函数)来进行一种转化。
我们下面会再提到。
这里的哈希方法有很多,我们主要将两种,并着重讲一种。
1、直接定址法:
我们可以从简单的例子开始举起。
这就有点像我们之前的计数排序了。
比如,现在我有这么几个数
10,6,5,3,1,2,4,9
那么我可以开辟出来10个空间(形成一个顺序表),然后将每个数直接放在其所对应的下标的位置处。
如图:
即是什么数,就存储到下标相应为多少的地方。
当然,我们也可以进行一个区间的划分,比如所有的数都是在100-150之间的,那么我就可以直接从下标100的开始开辟,而不用从0开始开辟了。这样的话,可以少开辟100个空间。(如果我们用一个公式来说明的话,可以理解成Hash(Key)= A*Key + B)
这就叫做直接定址法。
其优点优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
直接定址法有一个非常明显的缺点,就是当数据比较分散的时候,会造成大量的空间浪费。
所以,有人就又设计出了第二种方法:
2、除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p(也可以不为质数)作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
举个例子:
我如果现在有这么几个数:
10,26,65,94,503,1001,2002,1
我在这里可以就假设我开辟了11个数据的空间(即上述的地址数为11),那么我取p = 10,对上述的数分别进行取模。
即让上述的8个数分别模10。根据余数来判断存储在哪个位置。
如图:
看起来是那么的美好。
可是问题马上又出现了:如果两个数的余数相同怎么办?
我如果上面的数不是26,而是25,那么其和65的余数不就相同的吗?
这个时候该怎么办呢?
这个情况,我们叫做哈希冲突。
对于两个数据元素的关键字 和 (ki != kj),有 i != j ,但有:Hash(ki) == Hash(kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞
关于解决哈希冲突的方式,我们有两种方法。
一种是闭散列,一种是开散列。
闭散列:
也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
那如何去寻找下一个位置呢?
比如还是上面的那个场景,我们将数据26改成25,
在65去模10的时候,余数为5的位置已经被占,那么就顺着该元素循环地往后找第一个没有被占的位置。这就叫线性探测。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
上述的过程,实际上就是数据元素的插入过程。
那如果我想要删除呢?
如何删除我想要删除的数据呢?
直接删吗?
还是上面的那个例子,假如说,我想要删除25。
如果我现在直接就将其删掉了,那么请问,删掉之后,5的结点里面存啥?是一个nullptr?还是0?nullptr是存在问题的,因为每个结点存储的不是指针而是实实在在的数。0也是存在问题的,如果我存储的元素就是0,那到底有没有算删掉呢?这是一个问题。
况且,之后我如果想要去找65,还能找到吗? ->肯定是找不到的了。因为我们是一种线性探测,还是以65为例,如果我们要找这个数,肯定是从下标为5的位置开始,然后依次往后探测,直到探测到某一个没有存储数据的结点为止。此时,你的下标为5的位置已经是没有数据的了,那么探测到这里的时候,就不会再往后去找了,所以我们得到的结果就会是65不存在。所以这又是一个问题。
那到底该怎么办?
我们可以采用一种伪删除的方法:即我们给每一个结点三种状态:EMPTY、FILL、DELETE。
当删除元素的时候,我们将结点的状态改为DELETE,而当查找的时候,我们遇到EMPTY再停止。这样就解决了这个问题。
另外,我们还需注意一个问题:就是我们的空间(就是那个vector数组)要开多大的问题。
首先,我们肯定直到,数组的大小绝不能小于元素的个数。
但是数组开得太大,会浪费空间,数组开得太小,会降低效率。
我们用载荷因子来调节其平衡。载荷因子 = 填入表中数据的个数 / 数组(散列表)的长度。
一般,我们会将载荷因子控制在0.65-0.7左右,当其超过了这个数之后,就要对其进行扩容。
我们再来说一下Hashfunc,通过上面的叙述,我们知道,因为要进行取模,所以必须要将数据弄成整型才行。那我如果传过来是一个string呢?如何将其转化成整型?
这个时候,我们就可以用Hashfunc(说明一下,Hashfunc是一个自定义的类,也就是说,是要你自己去写的,当然也可以不叫这个名字,随便起都可以,其作为仿函数传到哈希表的模板参数中)
那顺带就引出了另一个问题:Hashfunc该怎么写?
我们难道直接用atoi? 那如果是“abcd”和“dcba”这样的,所转换成的整型不就一样了吗?这样会造成哈希冲突的。这就不太友好了,其中的原因之一是“abcd”和“dcba”明明相差十万八千里,为啥还要弄成了个哈希冲突?
那如何解决?
通过一些大牛的人的一些奇妙的手段以及大量的实验,最后得到的结论是加上每个字符后,再乘以131(我们后面的代码中会有)。至于为啥,可能是一个比较玄学的问题了。可能是通过大量的实验、各种奇奇怪怪的数学模型弄出来的一个数,这样的方法,使得哈希冲突相对来说减少了不少。
这种方法,叫做BKDR哈希。
这个是从网上找的资料:
那现在,我们就先来模拟实现一下这个闭散列吧(附有详细注释)
#pragma once #include<iostream> #include<vector> #include<string> using namespace std; namespace jxwd_CloseTable { enum State //首先定义三种状态 { EMPTY, FILL, DELETE }; struct IntHashFunc //定义hashfunc函数,为转化的仿函数 { int operator()(int i) { return i; } }; template<class K> struct Hash { size_t operator()(const K& key) { return key; } }; template<> struct Hash<string> //可以用特化来去实现缺省,即如果传的是string,为常用类型,我们可以将模板进行特化,这样其就不会走上面的那个了 { size_t operator()(const string& s)//字符串转成对应的一个整型值。 { size_t value = 0; for (auto ch : s) { value += ch; value *= 131;//BKDR哈希,这是一种比较合适的、能使哈希冲突尽可能减少的转化方式 } return value; } }; template<class K, class V> struct HashData //每一个结点中的数据——我们给一个pair和一个状态 { pair<K, V> _kv; State _state = EMPTY; }; template<class K, class V, class Hashfunc = Hash<K>> class HashTable //哈希表 { public: bool insert(const pair<K, V>& kv) //插入 { HashData<K, V>* ret = Find(kv.first); //先通过kv.first去查找,找到了就返回地址,找不到就返回空 Hashfunc hf; if (ret) //如果找到了,即ret 不为空,那就直接退出(我们默认相同的值只能够插入一次) { return false; } if (_table.size() == 0) //如果此时哈希表的大小仍然是0, { _table.resize(10); //那就增容 } //负载因子大于0.7,就增容 else if (n * 10 / _table.size() > 7) { //增容 HashTable<K, V, Hashfunc> newHT; //扩容后,原有的存储的相对关系会被全部打乱,所以我们就先创建一个新的哈希表,重新插入再交换 newHT._table.resize(_table.size() * 2); //我们增容两倍 for (auto& e : _table) { if (e._state == FILL) { newHT.insert(e._kv); } } _table.swap(newHT._table); //再交换 } //扩容之后,哈希表中的数据位置需要重新计算 size_t start = hf(kv.first) % _table.size(); //利用hf计算出其关键码(因为我们需要支持string等类型,需要hashfunc转化),然后计算出其开始的位置 size_t index = start; //记录一下(之所以要记录,是为了我们后面在说开散列的时候也能够用这个代码) size_t i = 1; while (_table[index]._state == FILL)//如果该位置被占用,那就探测后面的位置 { index = start + i; //往后探测i个位置(i从1开始,每次都++) index %= _table.size(); //防止找到哈希表外面去,保证一直index一直都在哈希表的范围内,同时可以实现循环查找 i++; } _table[index]._kv = kv; //探测到没有占用的位置的时候就出来,然后将该给的值都给一下 _table[index]._state = FILL; ++n; //数据个数++ return true; //返回真 } HashData<K, V>* Find(const K& key) { if (_table.size() == 0) //如果哈希表为空,直接返回空 { return nullptr; } Hashfunc hf; size_t start = hf(key) % _table.size(); size_t index = start; size_t i = 1; //与插入进行同样的线性探测方法 while (_table[index]._state != EMPTY)//注意这里判断的条件是不为空,只要不为空,我们就继续往后找 { if (_table[index]._state == FILL && _table[index]._kv.first == key) //如果其是FILL状态并且与key的值相等 { return &_table[index]; //我们才能返回,就算找到了 } index = i + start; //要不然就继续往后找 index %= _table.size(); //防止找到哈希表外面去,保证一直index一直都在哈希表的范围内,同时可以实现循环查找 i++; } return nullptr; //如果找到了空还没有找到,就直接返回空 } bool Erase(const K& key) { HashData<K, V>* ret = Find(key); //先去找,找位置 if (ret == nullptr) //如果为空(说明元素不存在),那就直接返回 { return false; } else //否则,直接伪删除,将其状态改为DELETE, { ret->_state = DELETE; --n; //然后将数据个数-- return true; } } private: vector<HashData<K, V>> _table; size_t n = 0; //存储有效数据的个数 }; }
各位如果有兴趣,可以写个测试用例试试,如果有啥问题欢迎指正。我在这里就不写了哈
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”。
即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。那有没有缓解的方法呢?
我们可以用二次探测的方法。
二次探测
二次探测实际上也很简单,就是将我们上面的+i换成+i^2即可。
也就是说,对于寻找下一个空位置的方法,Hi = (H0 + i^2)% m.
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。然而实际在库里的增容一般都是以0.65-0.7之间的某一个数为界,我们上面说过。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
解决哈希冲突的第二种方式:开散列
开散列
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
所以,有时候我们也将这个哈希表称作哈希桶。
如果用图示的话,可以这样理解:
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
那关于开散列的增容呢?
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能。
因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突。
因此,我们可以考虑在元素个数刚好等于桶的个数时,可以给哈希表增容。
所以,开散列的平衡因子我们可以考虑设为1
还需要说明的一点就是:当一个结点所挂的桶较多的时候,我们就可以考虑把原来的链表毁掉,重新挂一个红黑树,这样就避免的数据过多时遍历链表效率较低的问题。(Java里的哈希就是这样实现的)
那么我们现在来考虑实现一下开散列:(注释很详细了)
#pragma once #include<iostream> #include<vector> #include<string> using namespace std; namespace OpenHash { template<class K> struct Hash { size_t operator()(const K& key) { return key; } }; template<> struct Hash<string> //可以用特化来去实现缺省,即如果传的是string,为常用类型,我们可以将模板进行特化,这样其就不会走上面的那个了 { size_t operator()(const string& s)//字符串转成对应的一个整型值。 { size_t value = 0; for (auto ch : s) { value += ch; value *= 131;//BKDR哈希 } return value; } }; template<class K,class V> struct HashNode { pair<K, V> _kv; HashNode<K, V>* _next; HashNode(const pair<K, V>& kv) :_next(nullptr) , _kv(kv) {} }; template<class K,class V,class Hashfunc = Hash<K>> class HashTable { typedef HashNode<K, V> Node; public: HashTable() = default; bool insert(const pair<K, V> kv) { Hashfunc hf; auto ret = Find(kv.first); if (ret) { return false; } if (_n == _table.size())//平衡因子到1,增容 { vector<Node*> newtable; size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2; newtable.resize(newSize); for (size_t i = 0; i < _table.size(); i++) { if (_table[i])//如果第一个结点不是空,进去 { Node* cur = _table[i]; //结点存储起来,这是在旧表里的i while (cur) //如果其不为空 { Node* next = cur->_next; //下一个结点存储起来 size_t index = hf(cur->_kv.first) % newtable.size();//当前结点要被存储在新表中的位置 //难点: cur->_next = newtable[index]; //cur->_next意味着cur先插到了表头结点的前面 newtable[index] = cur;//再把头结点给到最前面,注意不是cur = newtable[index], //不重要的是newtable[index],它只是我们用来找数据的标识符 //而cur是不能够轻易换的 cur = next; } } newtable[i] = nullptr; //如果是空,那么让newtable[i]也变成空 } _table.swap(newtable); //再去交换 } size_t index = hf(kv.first) % _table.size(); //正常插入就行 Node* newnode = new Node(kv); newnode->_next = _table[index]; //先将其插在头节点的前面 _table[index] = newnode; //再将其给头结点 return true; } Node* Find(const pair<K,V>& key) { Hashfunc hf; if (_table.size() == 0) { return nullptr; } size_t index = hf(key.first) % _table.size(); //算出index的位置 Node* cur = _table[index]; while (cur) { if (cur->_kv.first == key) //找到就返回 { return cur; } else //找不到继续在桶里面往后找 { cur = cur->_next; } } return nullptr; } bool Erase(const K& key) { Hashfunc hf; size_t index = hf(key) % _table.size(); Node* prev = nullptr; Node* cur = _table[index]; while (cur) { if (cur->_kv.first == key) { if (_table[index] == cur) //如果删除的就是头节点 { _table[index] = cur->_next; //头结点要存储一下 } else { prev->_next = cur->_next;//如果不是,将cur->_next保存一下 } delete cur; //删除cur return true; } cur = cur->_next; //如果不是想要的key,那就继续往下走 } return false; } private: vector<HashNode<K,V>> _table; size_t _n; }; }
各位如果有兴趣,可以写个测试用例试试,如果有啥问题欢迎指正。然后我就不在这里写了哈。
Unordered_map和Unordered_set的封装
好,那么接下来,我们用哈希来实现Unordered_map和Unordered_set的封装。
我们仿照map和set的封装来进行。
重点还是在迭代器上面。
Hash.h #include<iostream> #include<unordered_set> #include<unordered_map> #include<string> #include<set> using namespace std; namespace OpenHash { template<class K> struct Hash { size_t operator()(const K& key) { return key; } }; template<> struct Hash<string> //可以用特化来去实现缺省,即如果传的是string,为常用类型,我们可以将模板进行特化,这样其就不会走上面的那个了 { size_t operator()(const string& s)//字符串转成对应的一个整型值。 { size_t value = 0; for (auto ch : s) { value += ch; value *= 131;//BKDR哈希 } return value; } }; template<class T> struct HashNode { HashNode<T>* _next; T _data; HashNode(const T& kv) :_next(nullptr) , _data(kv) {} }; //相互依赖,就需要前置声明 //这里的T,可以是K(unordered_set) ,也可以是pair<K,V>(unordered_map),类比于封装map和set时的即可,我们就不再赘述了 template<class K, class T, class KOfT, class Hashfunc>//这里需要给一个KOfT,即比较方法。即如果是自定义等类型,需要传一个提供比较方法的类 class HashTable; template<class K, class T, class KOfT, class Hashfunc = Hash<K>> struct __HTIterator //迭代器封装 { typedef HashNode<T> Node; typedef HashTable<K, T, KOfT, Hashfunc> HT; typedef __HTIterator<K, T, KOfT, Hashfunc> Self; //各种重命名,这里就不赘述了 typedef __HTIterator<K, T, KOfT, Hashfunc> iterator; Node* _node; //定义一个结点指针 HT* _pht; //定义出一个哈希表指针 Hashfunc hf; //定义出一个仿函数对象 __HTIterator(Node* node, HT* pht) //初始化 :_node(node) , _pht(pht) {} Self operator++() //返回的时 { if (_node->_next)//1、当前桶中还有数据,那么就在当前桶中往后走 { //2、当前桶走完了,走下一个桶 _node = _node->_next; } else //当前桶走完了,走下一个桶 { size_t index = hf(KOfT()(_node->_data)) % _pht->_table.size(); index++; while (index < _pht->_table.size()) { if (_pht->_table[index]) { _node = _pht->_table[index]; return *this; } index++; } //return iterator(nullptr, _pht); _node = nullptr; return *this; } return *this; } T& operator*() //重载一些运算符 { return _node->_data; } T* operator->() { return &_node->_data; } bool operator !=(const Self& s) const { return !(_node == s._node); } bool operator==(const Self& s)const { return _node == s._node; } }; template<class K, class T, class KOfT, class Hashfunc = Hash<K>> class HashTable //哈希表对象 { typedef HashNode<T> Node; public: typedef __HTIterator<K, T, KOfT, Hashfunc> iterator; //... template<class K, class T, class KOfT, class Hashfunc> friend struct __HTIterator; HashTable() = default; //指定生成默认构造函数 HashTable(const HashTable& ht)//这里可以不用带模板——拷贝构造 { _n = ht._n; _table.resize(ht._table.size()); for (size_t i = 0; i < ht._table.size(); i++) { Node* cur = ht._table[i]; while (cur) { Node* copy = new Node(cur->_data); copy->_next = _table[i]; _table[i] = copy; cur = cur->_next; } } } HashTable& operator=(HashTable ht) //赋值拷贝 { _table.swap(ht._table); swap(_n, ht._n); return *this; } ~HashTable() //析构 { for (size_t i = 0; i < _table.size(); i++) //循环遍历整个表 { Node* cur = _table[i]; while (cur) //删除每一个桶里的结点 { Node* next = cur->_next; delete cur; cur = next; } _table[i] = nullptr; } } iterator begin() //迭代器 { size_t i = 0; while (i < _table.size()) { if (_table[i]) { return iterator(_table[i], this); //注意一下这里的返回值,第一个是Node*,第二个是哈希表指针 } ++i; } return end(); } iterator end()//同上 { return iterator(nullptr, this); } pair<iterator, bool> Insert(const T& data) { KOfT kot; Hashfunc hf; auto ret = Find(kot(data)); //需要用kot进行转化,这里的kot正是告诉了我们应该如何比较大小 if (ret != end())// 找到了,则返回true,找不到,返回false,继续下面的insert return make_pair(ret, false); if (_n == _table.size())//负载因子到1的时候,进行增容 { vector<Node*> newtable; size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2; newtable.resize(newSize); Hashfunc hf; //遍历取旧表中的结点,重新算映射新表当中的位置。重新挂到表中 for (size_t i = 0; i < _table.size(); i++) { if (_table[i]) { Node* cur = _table[i]; while (cur) { Node* next = cur->_next; size_t index = hf(kot(cur->_data)) % newtable.size(); cur->_next = newtable[index]; newtable[index] = cur; cur = next; } } _table[i] = nullptr; } _table.swap(newtable); } size_t index = hf(kot(data)) % _table.size(); Node* newnode = new Node(data); newnode->_next = _table[index]; _table[index] = newnode; //Node* ps = _table[index]; //if (ps == nullptr) //{ // ps = newnode; //} //else //{ // Node* next = ps->_next; // ps->_next = newnode; // newnode->_next = next; //也可以这么写 //} ++_n; return make_pair(iterator(newnode, this), true); } iterator Find(const K& key) { Hashfunc hf; KOfT kot; if (_table.size() == 0) { return end(); } size_t index = hf(key) % _table.size(); Node* cur = _table[index]; while (cur) { if (kot(cur->_data) == key) { return iterator(cur, this); } else { cur = cur->_next; } } return end(); } bool Erase(const K& key) { Hashfunc hf; KOfT kot; size_t index = hf(key) % _table.size(); Node* prev = nullptr; Node* cur = _table[index]; while (cur) { if (kot(cur->_data) == key) { if (_table[index] == cur) { _table[index] = cur->_next; } else { prev->_next = cur->_next; } delete cur; return true; } cur = cur->_next; } return false; } private: vector<Node*> _table; size_t _n = 0; }; }
接下来:
Unordered_map.h #pragma once #include"Hash.h" namespace jxwd { template<class K, class V> class unordered_map { struct MapkeyOfT //告诉我们应该如何去比较的类对象 { const K& operator()(const pair<K, V>& kv) //重载operator()就可以,形成仿函数 { return kv.first; } }; public: typedef typename OpenHash::HashTable<K, pair<K,V>, MapkeyOfT>::iterator iterator; pair<iterator, bool> insert(const pair<K, V>& kv) //这里的插入,就调用Hash.h里的插入 { return _ht.Insert(kv); } V& operator[](const K& key) //重载operator[],我们可以直接调用插入,然后返回_ht[i].second { //也就是pair的第二个元素 pair<iterator ,bool> ret = _ht.Insert(make_pair(key, V())); return ret.first->second; } iterator begin() //直接调用hash.h里的迭代器 { return _ht.begin(); } iterator end() //同理 { return _ht.end(); } private: OpenHash::HashTable<K, pair<K, V>, MapkeyOfT> _ht; }; } 同理,进行Unordered_set的封装 Unordered_set.h 这项文件和Unordered_map是一样的思路,就不再赘述了。 #pragma once #include"Hash.h" namespace jxwd { template<class K> class unordered_set { struct SetkeyOfT { const K& operator()(const K& key) { return key; } }; public: typedef typename OpenHash::HashTable<K, K, SetkeyOfT>::iterator iterator; iterator begin() { return _ht.begin(); } iterator end() { return _ht.end(); } pair<iterator,bool> insert(const K k) { return _ht.Insert(k);; } private: OpenHash::HashTable<K, K, SetkeyOfT> _ht; }; } 截至目前,我们就封装完毕了。 我们可以尝试着用一下: 我们就简单地测试一下就可以了,有兴趣的小伙伴可以继续尝试: test.cpp #include"Hash.h" #include"Unordered_map.h" #include"Unordered_set.h" void test_unordered_set1() { jxwd::unordered_set<int> us; us.insert(2); us.insert(900); us.insert(4); us.insert(52); us.insert(633); us.insert(21); jxwd::unordered_set<int>::iterator it = us.begin(); while (it != us.end()) { cout << *it << endl; ++it; } cout << endl; } void test_unordered_map1() { jxwd::unordered_map<string, string> dict; dict.insert(make_pair("sort","排序")); dict.insert(make_pair("left", "左边")); dict.insert(make_pair("right", "右边")); jxwd::unordered_map<string, string>::iterator it = dict.begin(); while (it != dict.end()) { cout << it->first<<" "<<it ->second <<" "; ++it; } cout << endl; } int main() { test_unordered_set1(); test_unordered_map1(); return 0; }
运行截图:
哈希的应用
说是哈希的应用,实际上笔者觉得,这里和哈希表的关系已经不大了。我们将重点来介绍两类思想——位图和布隆过滤器
位图:
先说位图:
位图的诞生,可以伴随着这样一个经典的问题:
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中?
难道我们将40亿个数全部加载到内存中去,再一一遍历每一个数?先去排序?我们的内存够吗?即使你的内存很大,那是不是及其浪费空间呢?有没有更好的方法?答案就是位图。
位图到底是什么呢?
我们还是用上面的那个来举例。
由于仅仅是判断存在还是不存在,这不就是非真即假的关系吗?
如果我们用每一个比特位上的1来表示存在,0表示不存在,那么我们就可以同时判断约300多亿个数据(300多亿是怎么来的:1024*1024*1024*4*8)(按照4G内存计算)
当然,由于int、long等类型长度的限制,我们可能达不到这么多 。
我们画图来举例就是:
(上图中哪一端为低位、哪一端为高位取决于不同的机器)
所以,所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的
那我们来实现一个呗
实际上也很简单了
template class BitSet { public: BitSet() { _bit.resize(N / 32 + 1, 0); //构造函数,将每一位都初始化成0 } void Set(size_t x)//把x映射的位标记为1 { assert(x < N); size_t i = x / 32;//算出x映射的位在第几个整数 size_t j = x % 32;//算出x映射的位在这个整数的第几个位 _bit[i] |= (1 << j); } void ReSet(size_t x) //类似于删除 { assert(x < N); size_t i = x / 32; size_t j = x % 32; _bit[i] &= (~(1 << j));//逻辑与插入刚好相反 } bool Test(size_t x) //测试某个数是否存在 { assert(x < N); size_t i = x / 32; size_t j = x % 32; return _bit[i] & (1 << j); } private: vector _bit; };
思路就是上述代码
各位可以自己尝试去找几个数试试看。
位图的应用:
1. 快速查找某个数据是否在一个集合中
2. 排序
3. 求两个集合的交集、并集等
4. 操作系统中磁盘块标记
那我现在要变形一下,1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数,怎么做到?
其实也很简单。
我们每一个数为其分为两个比特位,这样00表示不存在;01表示出现了一次;10表示出现了两次...
我们开两个位图就可以。
void Sets(size_t x) { if (!_bs1.Test(x) && _bs2.Test(x)) { _bs1.Set(x); //b1的变成1 } else if (!_bs1.Test(x) && _bs2.Test(x)) { _bs1.Set(x); //把b1的变成0,b2的变成1 _bs2.ReSet(x); } else if (_bs1.Test(x) && !_bs2.Test(x)) { //不处理 //也可以再变 } else { assert(false); } }
布隆过滤器:
我们在使用新闻客户端看新闻时,它每次推荐时要去重,去掉那些已经看过的内容。那新闻客户端推荐系统如何实现推送去重的?
包括我们日常的起名字,如何能够快速判断一个昵称是否被用过?
这里肯定需要做到快速查找,那如何快速查找呢?
1. 用哈希表存储用户记录,缺点:浪费空间
2. 用位图存储用户记录,缺点:不能处理哈希冲突,即每一个数值相同的元素只能出现一次。
3. 将哈希与位图结合,即布隆过滤器
布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间
简而言之,我通过多个不同的HashFunc,映射到位图的不同位置。
如果我在位图当中映射的所有位置均显示已被占用,那么其就判断为存在;只要有一个地方未被占用,那么这个在之前就不存在。
用图来展示:
(如果我要插入baidu这个字符串,那么我通过三种不同的Hash,得到三个不同的位置,然后将这些位置都标记上1)
(插入tencent字符串同理)
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判。因为有可能所存在的位都是其他元素占用过的了,而该元素本身并不存在。
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu”元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
不过硬要设计,也可以有一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删
除操作。
缺陷:
1. 无法确认元素是否真正在布隆过滤器中
2. 存在计数回绕
布隆过滤器优点
1. 增加和查询元素的时间复杂度为:O(K), 常数个(K为哈希函数的个数,一般比较小),与数据量大小无关
2. 哈希函数相互之间没有关系,方便硬件并行运算
3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
缺陷:
1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
2. 不能获取元素本身
3. 一般情况下不能从布隆过滤器中删除元素
4. 如果采用计数方式删除,可能会存在计数回绕问题
我们将刚刚的位图的文件设为Bitset.h
那么我们的布隆过滤器就可以引用这个头文件,来实现布隆过滤器的封装:
#include"BitSet.h" #include #include using namespace std; struct HashBKDR //可以用特化来去实现缺省,即如果传的是string,为常用类型,我们可以将模板进行特化,这样其就不会走上面的那个了 { size_t operator()(const string& s)//字符串转成对应的一个整型值。 { size_t value = 0; for (auto ch : s) { value += ch; value *= 131;//BKDR哈希 } return value; } }; struct HashAP //所对应的三种不同的Hash方式 { size_t operator()(const string& s) { size_t hash = 0; size_t ch; for (long i = 0; i < s.size(); i++) { ch = s[i]; if ((i & 1) == 0) { hash ^= ((hash << 7) ^ ch ^ (hash >> 3)); } else { hash ^= ((hash << 11) ^ ch ^ (hash >> 5)); } } return hash; } }; struct HashDJB { size_t operator()(const string& s) { size_t hash = 5381; for (auto ch : s) { hash += (hash << 5) + ch; } return hash; } }; template class K = string, class Hash1 = HashBKDR, class Hash2 = HashAP, class Hash3 = HashDJB > class BloomFilter { public: void Set(const K& key) { /*Hash1 hf; hf(key);*/ size_t i1 = Hash1()(key) % N; size_t i2 = Hash2()(key) % N; size_t i3 = Hash3()(key) % N; // cout << i1 << " " << i2 << " " << i3 << std::endl; _bitset.Set(i1); _bitset.Set(i2); _bitset.Set(i3); } bool Test(const K& key) { size_t i1 = Hash1()(key) % N; if (_bitset.Test(i1) == false) { return false; } size_t i2 = Hash2()(key) % N; if (_bitset.Test(i2) == false) { return false; } size_t i3 = Hash3()(key) % N; if (_bitset.Test(i3) == false) { return false; } return true;//这里三个位都在,也有可能是其他key占了,在是不准确的,存在误判;但是不存在是准确的 } private: jxwd::BitSet _bitset; //vector }; 我们可以做一个简单的测验: void TestBloomFilter() { /*BloomFilter<100> bf; bf.Set("张三"); bf.Set("李四"); bf.Set("牛魔王"); bf.Set("红孩儿");*/ size_t N = 100; BloomFilter<1000> bf; vector v1; for (size_t i = 0; i < N; i++) { string url = "https://microsoftedgewelcome.microsoft.com/zh-cn/update/99?channel=stable&version=99.0.1150.30&form=MT0067";//随便找的链接 url += to_string(1234 + i); v1.push_back(url); } size_t n1 = 0; for (auto& str : v1) { bf.Set(str); if (bf.Test(str)) { n1++; } } //cout << n1 << endl << endl; vector v2; for (size_t i = 0; i < N; i++) { string url = "https://microsoftedgewelcome.microsoft.com/zh-cn/update/99?channel=stable&version=99.0.1150.30&form=MT0067"; url += to_string( 6789 + i); v2.push_back(url); } size_t n2 = 0; for (auto& str : v2) { if (bf.Test(str)) { n2++; } } cout << "相似字符串误判率" << (double)n2 / (double)N << endl << endl; vector v3; for (size_t i = 0; i < N; i++) { string url = "https://meeting.tencent.com/p/asdasdnppadns"; url += to_string(3456 + i); v3.push_back(url); } size_t n3 = 0; for (auto& str : v3) { if (bf.Test(str)) { n3++; } } cout << "不相似字符串误判率" << (double)n3 / (double)N << endl << endl; }
我们看看其误判率有多少:
(运行结果)
我们最后再来说说哈希切割吧
哈希切割
还是先给一个场景:
给两个文件,分别有100亿个字符串,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法?
关于近似算法,就是用布隆过滤器就可以了。
即每个字符串都对应着几个位置,将一个文件用布隆过滤器存储之后,再将第二个文件中的一一读入,重复的部分就是两个文件的交集。
那精确一点呢?
我们可以把一个文件(假设为文件A)分成100份,标号0~99,然后分别去读取,将原有文件中的100亿个字符串按照某种哈希关系,一一映射到下面100个小的文件当中;
然后,对另一个文件(假设为文件B)进行同样的操作。这样,我们将一个大的数据文件,分成了若干个小的数据文件;
这就是哈希切割。
切割完了以后,我们再在文件A和文件B的相同标号的子文件中去找交集(比如A[0]号文件和B[0]号文件找交集,A[1]号文件和B[1]号文件找交集...)。
如果这个时候文件的内容很少的话,我们可以直接借助红黑树等方法,来直接进行查找即可。
我们这么做的一个核心的原理就是两个文件中相同的字符串一定会被切分到下标相同的子文件中。
实际上,哈希的用途还是很广泛的,比如加密过程等等。我们这里肯定就不会细说了,有兴趣的可以看看下面的链接,不过也是做到了解就可以。
哈希(Hash)与加密(Encrypt)的基本原理、区别及工程应用 - T2噬菌体 - 博客园 (cnblogs.com)