4. unordered_map的元素访问
对比map/set的区别:
1.map和set的遍历是有序的,unordered系列是无序的
2.map和set是双向迭代器,unordered系列是单项
基于上面就功能阐述而言,map和set更强大,为什么还要引入unordered系列?
--综合而言,大量数据时,增删查改的效率更优秀,尤其是查找;
哈希冲突
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
但是如果查找的位置的前一个被删除了,这时候去线性探测,就会出现问题,到了空但是没找到其实是在下一个,但是就没找到退出了;所以我们还得需要一个标志数据
负载因子越小冲突概率越低;负载因子越大冲突概率越大;
什么时候扩容?满足一个基准,负载因子到一个基准值就扩容;
基准值越大,冲突越多,效率越低,空间利用率越高;
基准值越小,冲突越小,效率越高,空间利用率越低;
下面是关于线性探测哈希表的写法的实现:
hashtable:
设计中我们会遇见string的取模问题(会通过计算来规避这个问题)(不能完全避免)
string是不能取模的,上篇文章在红黑树里面string是可以比较大小的;有些值可以取模有些值不能取模,怎么办?unordered_map有一个参数就是解决这个问题的;
hash<key>是一个仿函数,当你给的东西不能取模的时候,要配一个仿函数:转成一个可以取的值,再映射一次。
这样写又会有这种的冲突问题,而且string作key的频率也很高,是个不容忽视的问题
解决办法:乘了一个新值131(如果记不清的话随便乘一个值应该也是问题不大的)但是131应该是经过数学计算的最优解;
下面是线性探测增删查的实现
#pragma once
using namespace std;
enum State
{
EMPTY,//查找的时候停止的位置
EXIST,//EXIST此位置已经有元素
DELETE//查找的时候不停,但是可以继续往里面存值
};
template<class K,class V>
struct HashData
{
pair<K, V> _kv;
State _state=EMPTY;
};
template<class K>
struct HashFunc//目标是把string转成一个无符号的整形,之后就可以取模了;
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc<string>
{
//BKDR算法,大佬针对string类型转换成整形写的算法,也被用于Java和数据库的搜索引擎中;
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
//库里面我们调用unordered_map<make_pair(string,int)>的时候就不需要调用仿函数,这是因为string作为
//常见的类型我们可以用特化
//template<>
//struct HashFunc<string>
//{
// size_t operator()(const string& key)
// {
// size_t val = 0;
// for (auto ch : key)
// {
// val += ch;
// }
// return val;
// }
//};
但是string不支持转,所以我们就针对string写一个
//struct HashFuncString
//{
// size_t operator()(const string& key)
// {
// //怎么把一个string变成整形;
// //如果用string的首字母来比较他们的ASCII码值,很多单词的首个单词都是相同的,就会一直冲突,这样
// //算冲突值太大了;
// size_t val = 0;
// for (auto ch : key)
// {
// val += ch;//去取每个字符的字面,把ASCII码去比较,
// }
// return val;
// }
//};
template<class K,class V,class Hash=HashFunc<K>>//这里给一个缺省类型。
class HashTable
{
public:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))//已经有了就可以不插入了
return false;
//if (_size / _table.size() >= 0.7)//基准值给的0.7,java的库里面给的是0.75;
//只存我们认为的70%(可以改),剩下的30%空间会被浪费
if (_tables.size()==0||10*_size / _tables.size() >= 7)//扩容
{
size_t newSize = _tables.size() == 0 ? 10 : 2 * _tables.size();
/*vector<HashData<K, V>>newTables;
newTables.resize(newSize);*/
HashTable<K, V,Hash> newHT;
newHT._tables.resize(newSize);//开辟了原表长度的2倍;
//旧表的数据映射到新表
for (auto e:_tables)
{
newHT.Insert(e._kv);//哈希表对象再去调用insert,之后复用下面的线性探测部分完成拷贝
}
_tables.swap(newHT._tables);//交换完之后老的是局部对象,出了作用域会调用vector的析构
}
Hash hash;//仿函数的对象建立了;
size_t hashi = hash(kv.first) % _tables.size();
//size_t hashi = kv.first % _tables.size();//必须是在size内,如果是capacity的话就会[]越界
//实际上size应该等于capacity,多了也没用,存不进去,这里不是push_back进去的,而是映射进去的
//这里即使是负数也不会有问题:比如说这里模是操作符,c语言中是这样讲的,当操作符两边的数据不是
//相同类型的,比如你是一个有符号,会被提升到范围更大的内一个,是无符号;
//线性探测
while (_tables[hashi]._state==EXIST)//说明这个地方已经有值,得线性探测
{
hashi++;
hashi %= _tables.size();//回到起点的位置
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_size;
return true;
}
HashData<K,V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
Hash hash;
size_t start = hash(key) % _tables.size();
size_t hashi = start;
while (_tables[hashi]._state!=EMPTY)//不为空就继续走
{
if (_tables[hashi]._state!=DELETE&&_tables[hashi]._kv.first == key)
{
return &_tables[hashi];//找到就返回data的地址
}
hashi++;
hashi %= _tables.size();//一直模,防止越界
if (hashi == start)//没有发生增容,虽然--_size;但是表里面可能全是del,没有空了;(极端情况)
{
break;
}
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_size;
return true;
}
else
{
return false;
}
}
void Print()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
{
printf("[%d %d]", i,_tables[i]._kv.first);
}
else
{
printf("[%d *]", i);
}
}
cout << endl;
}
private:
vector<HashData<K, V>>_tables;
size_t _size=0;//数据是间隔放,这个是用来看存储了多少个有效数据
};
void TestHT1()
{
HashTable<int, int> ht;//哈希表实际上V才是要存的值,K只是它的位置
int a[] = {1,11,4,15,26,7,44,9};
//ht.Insert
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Erase(4);
ht.Print();
/*cout << ht.Find(44)->_kv.first << endl;
cout << ht.Find(4) << endl;*/
ht.Insert(make_pair(-2, -2));//复数也能被存
ht.Print();
}
void TestHT2()
{
string arr[] = { "苹果","西瓜","苹果","西瓜","香蕉","香蕉","苹果","西瓜","香蕉"};
//HashTable<string,int,HashFuncString>countHT;//隐式的int内些可以通过缺省自己调用计算,这里得用我们写的显示的去传
//HashTable<string, int, HashFunc<string>>countHT;
HashTable<string, int>countHT;//特化之后可以传也可以不传,传缺省会优先走特化
for (auto& str : arr)
{
auto ptr = countHT.Find(str);
if (ptr)
{
ptr->_kv.second++;
}
else
{
countHT.Insert(make_pair(str, 1));
}
}
}
void TestHT3()
{
HashFunc<string>hs1;
cout << hs1("abcd") << endl;
cout << hs1("bacd") << endl;
cout << hs1("cadb") << endl;
cout << hs1("bcad") << endl;
}
极端情况下给1,11,21,31,41,2;
这种就会互相占用位置;
之后我们给了二次探测这种解决方法,二次探测不是探测两次,线性探测是每次hash+i(i>=0),二次探测是+i^2(i>=0),都是先模之后再按这两条规则探测;
但是还是没有从根本上解决问题,线性是挨着占位,二次探测是跳着占位,这种方式有更好的解决方式,哈希桶(开散列)--拉链法
namespace HashBucket
{
template<class K,class V>
struct HashNode
{
pair<K, V> _kv;
//这里删除直接就把结点干掉,不需要状态了;
HashNode<K, V>* _next;
HashNode(const pair<K,V>&kv)
:_kv(kv)
,_next(nullptr)
{}
};
template<class K,class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
~HashTable()
{
//vector不会释放自己的桶
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
inline size_t __stl_next_prime(size_t n)
{
static const size_t __stl_num_primes = 28;
static const size_t __stl_prime_list[__stl_num_primes] =
{
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
};
for (size_t i = 0; i < __stl_num_primes; i++)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return -1;
}
bool insert(const pair<K, V>& kv)
{
//去重
if (Find(kv.first))//如果已经有了要插入值的first,先find之后就不插入了;
{
return false;
}
//扩容
//库里面是负载因子到1就扩容
if (_size == _tables.size())
{
//size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
//我们不能像上面闭散列那种写法去复用insert,因为闭散列,数据不是像这样在结点上可以被拿下来
//只需要拷贝,开散列这里得移动,insert会开辟新结点;所以采取新的方式
vector<Node*> newTables;//自己去挂
//newTables.resize(newSize, nullptr);
newTables.resize(__stl_next_prime(_tables.size()),nullptr);
Hash hash;
//旧表中的节点移动映射到新表
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];//这是取table的数据就是一个一个的指针
while (cur)
{
Node* next = cur->_next;
size_t hashi = hash(cur->_kv.first) % newTables.size();//这时候的位置要模新表
//算出在新表的位置,之后头插进去;
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
Hash hash;
size_t hashi = hash(kv.first) % _tables.size();
//模出来就可以开始挂了;严格意义上头插尾插无所谓,但是multi版本肯定是头插的,这里我们也头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_size;
return true;
}
Node* Find(const K& key)
{
if (_tables.size() == 0)
return nullptr;
Hash hash;
size_t hashi = hash(key )% _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)//找到了
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
//删除这里我们不能用find先找,再去删除,这里相当于链表的删除,我们这里设计的是单链表,直接find
//会找不到被删的前一个结点,导致删除无法正常完成;
bool Erase(const K& key)
{
if (_tables.size() == 0)
return nullptr;
Hash hash;
size_t hashi = hash(key) % _tables.size();
Node* cur = _tables[hashi];//算出位置,走到桶
Node* prev = nullptr;
while (cur)
{
//找到当前位置还得找到它的前一个
if (cur->_kv.first == key)//找到了
{
//这里会有两种情况,第一种是cur是链表的第一个结点,那么这时prev就是nullptr
//这种情况就单独处理,用指针数组指向cur的next;
//1.头删
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
//2.中间删
else
{
prev->_next = cur->_next;
}
delete cur;
_size--;
return true;
}
prev = cur;//没找到就先跟新prev;之后再++
cur = cur->_next;
}
return false;
}
size_t Size()
{
return _size;
}
size_t BucketNum()//桶的数量(有多少个桶被用了)
{
size_t num = 0;
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i] != nullptr)
{
++num;
}
}
return num;
}
size_t TableSize()//表的长度
{
return _tables.size();
}
size_t MaxBucketLenth()
{
size_t Maxlen = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
size_t len = 0;
Node* cur = _tables[i];
while (cur)
{
++len;
cur = cur->_next;
}
if (len > Maxlen)
{
Maxlen = len;
}
}
return Maxlen;
}
private:
//表不存值,它是一个指针数组;
vector<Node*> _tables;
size_t _size=0;//存储有效数据的个数
};
void TestHT1()
{
HashTable<int, int> ht;//哈希表实际上V才是要存的值,K只是它的位置
int a[] = { 1,11,4,15,26,7,44,9 };
for (auto e : a)
{
ht.insert(make_pair(e, e));
}
ht.insert(make_pair(22, 22));
}
void TestHT2()
{
string arr[] = { "苹果","西瓜","苹果","西瓜","香蕉","香蕉","苹果","西瓜","香蕉" };
HashTable<string, int>countHT;
for (auto& str : arr)
{
auto ptr = countHT.Find(str);
if (ptr)
{
ptr->_kv.second++;
}
else
{
countHT.insert(make_pair(str, 1));
}
}
}
}
也有人说哈希如果扩容开的倍数如果每次都是素数,冲突会降低,我们看库里对这里的实现是写了一个素数表,规定好的,最后resize的时候是去调用这个表,因为如果刚开始给表长+1我们正常扩容的逻辑是乘2,但是乘2之后肯定就不是素数了,素数(只能有1和它本身两个因子)。所以还是比较麻烦的,用素数表这种方法就得到解决了;
最大的接近整形的最大值42亿;
这里有一个语法特例,整形的const可以给缺省值,其他的不行,会语法报错;
哈希要说有什么缺点也就是插入慢,搜索还是很快的,如果挂的链表再某些极限情况下是很长的,我们可以改成挂红黑树来提升效率;哈希插入慢的原因是要重新计算位置;对比红黑树的插入,红黑树的消耗在于变色,和旋转,但是哈希比vector还麻烦重新计算再挂值;
有了开散列的实现,我们下来就可以模拟实现unordered_set, unordered_map;
我们搭了一个基础的架子,剩下的就是去写迭代器,上层的unordered_map和unordered_set只需要调用我们底层哈希桶的结构,但是需要略微的修改,对模板参数的修改,为了达到了两个结构使用一个底层,跟之前前面红黑树的封装是一样的,在哈希桶的底层的把第二个原来的k改成T因为这个参数是未知的;
我们只需要这个来自定义是set的k还是map的v来决定它是map的kv还是set的k。当然第一个k这个也不能删除,它要用于搜索;
把哈希结点也给一个模板参数,它需要用来上层unordered_map和unordered_set的node来实例化它,所有也给一个参数,并且我们把仿函数的缺省也给上层,因为下层也不确定它到底是什么,所以需要上层的确认;
下来我们来写它的迭代器,这里是无序,不像之前红黑树,红黑树是二叉树,走中序就有序,这里是不需要排序的只需要按部就班的去走即可;
namespace HashBucket
{
template<class T>
struct HashNode
{
//pair<K, V> _kv;
T _data;
//这里删除直接就把结点干掉,不需要状态了;
HashNode<T>* _next;
HashNode(const T&data)
:_data(data)
,_next(nullptr)
{}
};
//代码由上到下,这里得用到哈希表,但是哈希表的实现在下面,所以得在这里声明:
//前置声明
template<class K, class T, class Hash, class KeyOfT >
class HashTable;
template<class K, class T, class Hash, class KeyOfT >
struct __HashIterator
{
typedef HashNode<T> Node;
typedef HashTable<K, T, Hash, KeyOfT> HT;
typedef __HashIterator<K, T, Hash, KeyOfT> Self;
Node* _node;
HT* _pht;
__HashIterator(Node*node,HT*pht)
:_node(node)
,_pht(pht)
{}
T& operator*()
{
return _node->_data;
}
T* operator->()
{
return &_node->_data;
}
//单项迭代器:因为是单链表,不支持--;想要支持--,这里下层就需要一个双向链表
Self& operator++()
{
if (_node->_next != nullptr)
{
//在当前桶里面迭代
_node = _node->_next;
}
else
{
//找下一个桶;先算自己当前在哪个桶里面
Hash hash;
KeyOfT kot;
//先得把结点里面的data的key取出来,在把这个key用hash转成可以取模的整形值,再去模table的
//size;
size_t i = hash(kot(_node->_data)) % _pht->_tables.size();
//从下一个桶去找;
++i;
for (; i < _pht->_tables.size(); i++)
{
if (_pht->_tables[i] != nullptr)
{
_node = _pht->_tables[i];
break;
}
}
//说明后面没有数据的桶了
if (i == _pht->_tables.size())
{
_node = nullptr;//返回空作为结尾
}
return *this;
}
}
//bool operator !=(Self& s)const
//{
// return _node != s._node;//用结点的指针比较
//}
//报错的原因是,你在使用 != 运算符比较itr和end()时,要求右操作数也要能够接受类型为
//HashBucket::__HashIterator<K, T, Hash, KeyOfT>的参数。根据报错信息,可以推测右操作数的类型
//是HashBucket::__HashIterator<K, T, Hash, KeyOfT>,而在你的代码中,end()函数返回的迭代器对象
//是常量类型的迭代器
//operator!=运算符被定义为成员函数,而且该函数使用了const修饰符。这意味着该成员函数可以在const
//对象上被调用,并且在函数内部不会修改对象的状态。当你使用 != 运算符进行比较操作时,如果其中一个
//操作数是const类型的对象,编译器将选择匹配的const成员函数进行调用。因此,当你在代码中使用 !=
//运算符时,编译器会自动选择匹配的const版本的运算符函数进行调用。通过为iterator结构体中的
//operator != 函数添加const修饰符,你为const对象提供了一个适当的重载函数,从而解决了编译器无法
//匹配的问题,并消除了错误。
bool operator!=(const Self& s) const
{
return _node != s._node;
}
bool operator==(const Self& s)const
{
return _node == s._node;//用结点的指针比较
}
};
//template<class K,class V, class Hash = HashFunc<K>>
template<class K, class T, class Hash,class KeyOfT >
class HashTable
{
typedef HashNode<T> Node;
public:
//这里迭代器跟哈希表相互调用,但是这里的tables是私有成员,只能让迭代器做哈希的友元;
//模板友元的声明得把类型标上;
template<class K, class T, class Hash, class KeyOfT >
friend struct __HashIterator;
typedef __HashIterator<K, T, Hash, KeyOfT> iterator;
iterator begin()
{
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i])//一个一个去找,如果第i个桶不为空,则构造它的迭代器返回;
{
return iterator(_tables[i],this);//第二个参数this就是哈希表的指针
}
}
return end();//一个桶都有的话;
}
iterator end()
{
return iterator(nullptr, this);//给个空的匿名对象的构造
}
~HashTable()
{
//vector不会释放自己的桶
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
inline size_t __stl_next_prime(size_t n)
{
static const size_t __stl_num_primes = 28;
static const size_t __stl_prime_list[__stl_num_primes] =
{
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
};
for (size_t i = 0; i < __stl_num_primes; i++)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return -1;
}
//bool insert(const T& data)
pair<iterator,bool> insert(const T& data)
{
Hash hash;
KeyOfT kot;
//去重
//if (Find(kot(data)))//如果已经有了要插入值的first,先find之后就不插入了;
//{
// return false;
//}
iterator ret = Find(kot(data));
if (ret != end())
{
return make_pair(ret, false);//插入失败了,返回已经有的迭代器;
}
//扩容
//库里面是负载因子到1就扩容
if (_size == _tables.size())
{
//size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
//我们不能像上面闭散列那种写法去复用insert,因为闭散列,数据不是像这样在结点上可以被拿下来
//只需要拷贝,开散列这里得移动,insert会开辟新结点;所以采取新的方式
vector<Node*> newTables;//自己去挂
//newTables.resize(newSize, nullptr);
newTables.resize(__stl_next_prime(_tables.size()), nullptr);
//旧表中的节点移动映射到新表
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];//这是取table的数据就是一个一个的指针
while (cur)
{
Node* next = cur->_next;
size_t hashi = hash(kot(cur->_data)) % newTables.size();//这时候的位置要模新表
//算出在新表的位置,之后头插进去;
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = hash(kot(data))% _tables.size();
//模出来就可以开始挂了;严格意义上头插尾插无所谓,但是multi版本肯定是头插的,这里我们也头插
Node* newnode = new Node(data);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_size;
//return true;
return make_pair(iterator(newnode, this), true);
}
//Node* Find(const K& key)//不能只要第二个参数,find需要第一个k,map,find的时候只能用k找
iterator Find(const K& key)//不能只要第二个参数,find需要第一个k,map,find的时候只能用k找
{
if (_tables.size() == 0)
return end();
Hash hash;
KeyOfT kot;//所有比较的地方都得加这个仿函数
size_t hashi = hash(key )% _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)//找到了
{
//return cur;
return iterator(cur, this);
}
cur = cur->_next;
}
return end();
}
//删除这里我们不能用find先找,再去删除,这里相当于链表的删除,我们这里设计的是单链表,直接find
//会找不到被删的前一个结点,导致删除无法正常完成;
bool Erase(const K& key)
{
if (_tables.size() == 0)
return false;
Hash hash;
KeyOfT kot;
size_t hashi = hash(key) % _tables.size();
Node* cur = _tables[hashi];//算出位置,走到桶
Node* prev = nullptr;
while (cur)
{
//找到当前位置还得找到它的前一个
if (kot(cur->_data) == key)//找到了
{
//这里会有两种情况,第一种是cur是链表的第一个结点,那么这时prev就是nullptr
//这种情况就单独处理,用指针数组指向cur的next;
//1.头删
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
//2.中间删
else
{
prev->_next = cur->_next;
}
delete cur;
_size--;
return true;
}
prev = cur;//没找到就先跟新prev;之后再++
cur = cur->_next;
}
return false;
}
size_t Size()
{
return _size;
}
size_t BucketNum()//桶的数量(有多少个桶被用了)
{
size_t num = 0;
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i] != nullptr)
{
++num;
}
}
return num;
}
size_t TableSize()//表的长度
{
return _tables.size();
}
size_t MaxBucketLenth()
{
size_t Maxlen = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
size_t len = 0;
Node* cur = _tables[i];
while (cur)
{
++len;
cur = cur->_next;
}
if (len > Maxlen)
{
Maxlen = len;
}
}
return Maxlen;
}
private:
//表不存值,它是一个指针数组;
vector<Node*> _tables;
size_t _size=0;//存储有效数据的个数
};
}
unordered_map
#pragma once
#include"HashTable.h"
namespace lrx
{
template<class K,class V, class Hash = HashFunc<K>>//把缺省的套在这一层,平常不用底层的哈希
class unordered_map
{
//这里实现mapkeyofT让我们知道这是一个pair
struct MapKeyOfT
{
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
public:
//区分不出来这是静态变量还是类型,静态变量也可以用类域去取;
typedef typename HashBucket::HashTable<K, pair<K, V>, Hash, MapKeyOfT>::iterator iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
//bool Insert(const pair<K, V>& kv)
pair<iterator,bool> Insert(const pair<K, V>& kv)
{
return _ht.insert(kv);//直接套一层外壳
}
V& operator[](const K& key)
{
pair<iterator, bool>ret = _ht.insert(make_pair(key, V()));
return ret.first->second;//ret.first是迭代器,之后再取它的->second
}
private:
//跟之前红黑树底层实现map和set一样,由第二个参数决定我们这里存什么;
HashBucket::HashTable<K, pair<K,V>, Hash,MapKeyOfT> _ht;
};
void test_map()
{
unordered_map<string, string> dict;
dict.Insert(make_pair("sort","排序"));
dict.Insert(make_pair("string", "字符串"));
dict.Insert(make_pair("left", "左"));
dict.Insert(make_pair("right", "右"));
for (auto& ch : dict)
{
cout << ch.first << " " << ch.second << endl;
}
unordered_map<string,string>::iterator u1 = dict.begin();
while (u1 != dict.end())
{
cout << u1->first << ":" << u1->second << endl;
++u1;
}
}
void test01()
{
unordered_map<string, int>s1;
string arr[] = { "苹果","西瓜","苹果","橘子","西瓜" };
for (auto& ch : arr)
{
s1[ch]++;//ch在就++,不在就插入并且返回0;
}
for (auto& kv : s1)
{
cout << kv.first << ":" << kv.second << endl;
}
}
}
unordered_set
#pragma once
#include"HashTable.h"
namespace lrx
{
template<class K, class Hash = HashFunc<K>>//把缺省的套在这一层,平常不用底层的哈希
class unordered_set
{
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
public:
typedef typename HashBucket::HashTable<K, K, Hash, SetKeyOfT>::iterator iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
//bool Insert(const K& key)
pair<iterator, bool> Insert(const K&key)
{
return _ht.insert(key);//直接套一层外壳
}
private:
//跟之前红黑树底层实现map和set一样,由第二个参数决定我们这里存什么;
HashBucket::HashTable<K, K, Hash, SetKeyOfT> _ht;
};
void TestSet()
{
unordered_set<int> h1;
h1.Insert(1);
h1.Insert(3);
h1.Insert(2);
h1.Insert(5);
}
void TestIterator()
{
unordered_set<int> h1;
h1.Insert(1);
h1.Insert(3);
h1.Insert(5);
h1.Insert(2);
h1.Insert(9);
}
}
set库里面还多了一个仿函数,就是我们在比较的时候我们自己写的==,库里面给的仿函数,可以自己写,针对不同类型的不同需求;
这里就有一个问题:一个类型K去做set和unordered_set的模板参数有什么要求?
set:1.要求能比较大小,要求支持小于比较,要想要大于比较,换一下参数顺序即可;(库里是默认less小于比较);2.或者显示提供比较的仿函数
unordered_set:1.K类型对象可以转换成整形取模或者支持提供转成整形的仿函数
2.K类型对象可以支持等于比较,(set小于往左走,大于往右走,间接的支持了等于)或者提供等于比较的仿函数;
总之哈希映射是一种思想,我们上面主要介绍了直接定值法和除留取余法;
下面介绍两个哈希的应用
具体步骤如下:
- 首先,通过将
x
除以 8,确定位所在的字符索引i
。这将确定_bits
中的哪个字符存储了该位。 - 接着,通过将
x
对 8 取模,确定位在字符中的偏移量j
。这将确定_bits[i]
字符中的哪个位被操作。 - 最后,使用位操作符
|=
将1
左移j
位得到的掩码与_bits[i]
进行按位或操作。这将将_bits[i]
字符对应位置上的位标记为 1,而其他位保持不变。
该过程的目的是将指定位置 x
的位设置为 1,而其他位保持不变。通过使用位操作符和位掩码,可以轻松实现这一目标。
需要注意的是,根据你提供的代码,bit_set
类的 _bits
成员变量是一个 vector<char>
类型,用于存储位集合的实际位。每个 char
元素都包含 8 个位,因此 _bits
向量的大小是根据 N
的值计算得出的。在 set()
函数中,通过对 _bits[i]
执行按位或操作,可以将指定位置 x
的位设置为 1。
#pragma once
#include<iostream>
#include<vector>
using namespace std;
namespace lrx
{
template<size_t N>
class bit_set
{
public:
bit_set()
{
_bits.resize(N/8+1, 0);//如果要开10个位,10/8==1,9会越界,+1防止类似情况出现;
}
//或等
void set(size_t x)//把x这个位置变成1
{
//先除8确认在哪个char
size_t i = x / 8;
//之后在模8看是第几个char
size_t j = x % 8;
//左移右移是升阶降阶,而非正常的方向
//把char对应的这个位标记为1,其他位不动,
_bits[i] |= (1 << j);//1或0=1,0或0=0,1或1=1;0与其他位或不会改变;这里可能原本就是1;
//(1 << j)是将1左移j位;
}
//与等
void reset(size_t x)//把值干掉(其他位不变,把第j位变0)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);//~是按位取反,!是逻辑取反,~在析构函数内里也用过是用来跟构造区分的;
}
//与完不改变这里的值,只做判断
bool test(size_t x)//看这个值在不在
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);//左移右移其实名字起的不好,左移就是往高位移动
}
private:
vector<char> _bits;
};
void test_bit_set()
{
bit_set<100> bs1;
bs1.set(8);
bs1.set(9);
bs1.set(20);
cout << bs1.test(8) << endl;
cout << bs1.test(9) << endl;
cout << bs1.test(20) << endl;
}
}
至此有了位图的实现上面的问题得到了解决
如果出现这种情况怎么解决,这是一个kv的模型,库里面已经有位图了,我们只需要开两个位图,表示三种情况即可,之前四十亿个数据里面找大约要开512mb的内存,现在开两个位图就是1g的内存;
三个位图就可以表示出8种出现次数的状态;
template<size_t N>
class two_bit_set
{
public:
void set(size_t x)
{
bool inset1 = _bs1.test(x);
bool inset2 = _bs2.test(x);
//00
if (inset1 ==false && inset2==false)
{
//->01
_bs2.set(x);
}
else if (inset1 == false && inset2 == true)
{
//->10
_bs1.set(x);
_bs2.reset(x);
}
}
void print_once()
{
for (int i = 0; i < N; i++)
{
//01
if (_bs1.test(i) == false && _bs2.test(i) == true)
{
cout << i << endl;
}
}
cout << endl;
}
void print_twice()
{
for (int i = 0; i < N; i++)
{
if (_bs1.test(i) == true && _bs2.test(i)==false)
{
cout << i << " ";
}
}
cout << endl;
}
private:
bit_set<N> _bs1;
bit_set<N> _bs2;
};
void test_two_bit_set()
{
int arr[] = { 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4, 1, 2, 2, 3,
2, 3, 3, 4, 2,3, 3, 4, 3, 4, 4, 5, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3,
4, 4, 5, 2, 3,3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 1, 2, 2, 3, 2, 3,
3, 4, 2, 3, 3,4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5,
6, 2, 3, 3, 4,3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6,
4, 5, 5, 6, 5,6, 6, 7, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 2,
3, 3, 4, 3, 4,4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4,
4, 5, 4, 5, 5,6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 2, 3, 3,
4, 3, 4, 4, 5,3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6,
5, 6, 6, 7, 3,4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 4, 5, 5, 6, 5,
6, 6, 7, 5, 6,6, 7, 6, 7, 7, 8 };
two_bit_set<256>tbs1;
for (auto ch : arr)
{
tbs1.set(ch);
}
tbs1.print_once();
tbs1.print_twice();
}
}
位图也可以达到排序加去重的目的
优点:快,节省空间;没有冲突(直接定值法)
缺点只能用于计算整数;但是如果出现字符串这种类型并且数据量很大这种情况怎么办?
--我们引出布隆过滤器这个结构解决这个问题;
存在误判:
在:是不准确的,存在误判的
不在:准确的,不存在误判
所以上面的思想是需要改进的;
首先要明确的一点是误判是无法去掉的,但是考研优化,降低误判率;
--每个值多去映射几个位;
只要有一个位置没有冲突,就会达到降低误判率,
理论而言:一个值映射的位越多,误判概率就会越低,但是你映射的过多就会消耗太多的空间,布隆过滤器的优势就是空间小,如果大肆的去开空间一直去映射,它的优势性就会降低;
在黑名单查找的情况下就可以用布隆过滤器:以此来提升效率
下面是对于布隆过滤器的实现,布隆过滤器在c++(stl)标准库里面没有;
公式计算出来插入一个值要开4.2个位置;
下面的对于布隆过滤器的简单实现
//下面三个算法是借鉴了别人写的高性能算法:
struct HashBKDR//目标是把string转成一个无符号的整形,之后就可以取模了;
{
//BKDR算法
size_t operator()(const string & key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
struct HashAP
{
size_t operator()(const string& key)
{
size_t hash = 0;
size_t ch;
for (size_t i = 0; i<key.size(); i++)
{
if ((i & i) == 0)
{
hash ^= ((hash << 7) ^ key[i] ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ key[i]^ (hash >> 5)));
}
}
return hash;
}
};
struct HashDJB
{
size_t operator()(const string& Key)
{
size_t hash = 5381;
for(auto ch:Key)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
//N表示准备要映射N个值
template<size_t N,class K =string,class Hash1=HashBKDR,class Hash2=HashAP,class Hash3=HashDJB>
//任何值都可以被转成一个整形,所以给一个模板参数
class BloomFilter
{
public:
void Set(const K&key)//把对应的比特位set成1;
{
//直接去算位置
size_t hash1 = Hash1()(key)%(_radio * N);//Hash1是一个仿函数
//用除留余数法再缩小一下,因为例如开了五千的空间,但是字符串转成整形个数可能是八千,所以得除一下
_bits.set(hash1);
size_t hash2 = Hash2()(key) %( _radio * N);//Hash1是一个仿函数
_bits.set(hash2);
size_t hash3 = Hash3()(key) % (_radio * N);//Hash1是一个仿函数
_bits.set(hash3);
}
bool Test(const K& key)//不是看某一个位,而是看三个位
{
size_t hash1 = Hash1()(key) % (_radio * N);
if (!_bits.test(hash1))//如果是真不能说明在,假就一定不在,有一个位为0就得return false
return false;//不在一定准确
size_t hash2 = Hash2()(key) % (_radio * N);
if (!_bits.test(hash2))
return false;//不在一定准确
size_t hash3 = Hash3()(key) % (_radio * N);
if (!_bits.test(hash3))
return false;//不在一定准确
return true;//这里的在可能存在误判
}
private:
const static size_t _radio = 5;
bitset<_radio* N>_bits;
//bitset<_radio* N>*_bits=new std::bitset<_radio*N>;//这里调用一个匿名对象,让它自己
//调用它的构造函数初始化
};
void TestBloomFilter1()
{
BloomFilter<10>BF1;
string arr[] = { "苹果","西瓜","苹果","橘子","西瓜" };
for (auto& ch : arr)
{
BF1.Set(ch);
}
for (auto& str : arr)
{
cout<<str<<": " << BF1.Test(str) << endl;//判断它们在不在
}
cout << endl;
string arr2[] = { "苹果1","西瓜22222","11苹果","橘子","西瓜" };
for (auto& str : arr2)
{
cout <<str<<": " << BF1.Test(str) << endl;//判断它们在不在
}
}
库里面位图有些小瑕疵,如果这里你用库里面的ratio并且这个值给的很大的话,这里是我们定义的变量是在栈区开的,如果ratio开的很大,这个位图开的值就会很大,在很大的情况下就有可能把栈撑爆了
堆区的空间大于栈区,所以我们可以把这个位图定义在堆区,这里就把这里定义成一个指针;
由于是结构体指针,调用函数的时候得用->;
这样空间消耗增多,优势削弱了;
下面是应用:
给两个文件,分别有100亿个query,我们只有1g内存,如何找到两个文件的交集?
1G大概是10亿字节,1m是100万字节;(数量级,非精确的)
这里就大约是300g,假设两个文件叫A和B
布隆这里也用不了,布隆有误判,这里的情景不允许出现错误;
用哈希切分:
如果切成300个小文件,对应一找,每个就是1g,内存可以使用;但是这样太慢了,a部分和b都得找一遍;
这样可以用Hash(),无论是BKDR还是DP算法都可以,依次读取文件A的查询(query),把它转成整形,i=Hash(query)%1000,这个query就进入编号为A i的小文件,从A0---A999;
另外一遍也是,B被切成1000,也是从B0---B999
之后编号相同的小文件,可以一起找交集,先放到内存中set里面去个重,之后再判断在不在,在就是交集;
我们把Query通过hash(例如BKDR)转成了整形,之后去模。这里的核心:相同的query,一定进入相同编号的小文件;
等加入到内存中,可以用哈希等数据结构解决;因为A和B是用同一个哈希函数转出来的,值肯定相同,模完之后的i肯定也相同,之后哈希进入的Ai,和Bi,不会存在A1和B2的值相同,这是哈希保证的;
这种算法的本质叫做哈希切分;(相当于相同的查询,进入到相同编号的文件,等于把他们的范围缩小了,进行了多次比较,最后对应小文件找交集)如果切分完,某个子文件很大,进不去内存怎么办?--把我们设计的算法设计成递归,在对这里切一次,换一个哈希函数,这次就得具体算一次文件大小,当成一个子问题处理;
跟上面同样的思路,注意建小堆;
哈希切割的本质不是平均切!!!它是让相同的ip进入相同的小文件,这个ip不可能在不同文件,之后再建立堆,这些问题就会解决