提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
目录
前言
unordered系列关联式容器
unordered_map
unordered_map的文档介绍
unordered_map的接口说明
unordered_set
底层结构
哈希概念
哈希冲突
哈希函数
哈希冲突解决
闭散列
线性探测的实现并改造
二次探测
开散列
开散列概念
开散列实现并改造 + 迭代器的实现
开散列增容
开散列与闭散列比较
不同的类型转换成整型的操作
MyOrderedMap.h
MyOrderedSet.h
哈希的应用
位图
位图概念
位图的实现
位图应用
布隆过滤器
布隆过滤器提出
布隆过滤器概念
布隆过滤器的插入
布隆过滤器的查找
布隆过滤器删除
布隆过滤器优点
布隆过滤器缺陷
布隆过滤器的面试题
哈希切割
总结
前言
世上有两种耀眼的光芒,一种是正在升起的太阳,一种是正在努力学习编程的你!一个爱学编程的人。各位看官,我衷心的希望这篇博客能对你们有所帮助,同时也希望各位看官能对我的文章给与点评,希望我们能够携手共同促进进步,在编程的道路上越走越远!
提示:以下是本篇文章正文内容,下面案例可供参考
unordered系列关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到$log_2 N$,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好 的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个 unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,本文中只对unordered_map和unordered_set进行介绍, unordered_multimap和unordered_multiset学生可查看文档介绍。
unordered_map
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。
- 它的迭代器至少是前向迭代器。
unordered_map的接口说明
1. unordered_map的构造
函数声明 | 功能介绍 |
unordered_map | 构造不同格式的unordered_map对象 |
2. unordered_map的容量
函数声明 | 功能介绍 |
bool empty() const | 检测unordered_map是否为空 |
size_t size() const | 获取unordered_map的有效元素个数 |
3. unordered_map的迭代器
函数声明 | 功能介绍 |
begin | 返回unordered_map第一个元素的迭代器 |
end | 返回unordered_map最后一个元素下一个位置的迭代器 |
cbegin | 返回unordered_map第一个元素的const迭代器 |
cend | 返回unordered_map最后一个元素下一个位置的const迭代器 |
4. unordered_map的元素访问
函数声明 | 功能介绍 |
operator[] | 返回与key对应的value,没有一个默认值 |
注意:该函数中实际调用哈希桶的插入操作,用参数key与V()构造一个默认值往底层哈希桶中插入,如果key不在哈希桶中,插入成功,返回V(),插入失败,说明key已经在哈希桶中, 将key对应的value返回。
5. unordered_map的查询
函数声明 | 功能介绍 |
iterator find(const K& key) | 返回key在哈希桶中的位置 |
size_t count(const K& key) | 返回哈希桶中关键码为key的键值对的个数 |
注意:unordered_map中key是不能重复的,因此count函数的返回值最大为1
6. unordered_map的修改操作
函数声明 | 功能介绍 |
insert | 向容器中插入键值对 |
erase | 删除容器中的键值对 |
void clear() | 清空容器中有效元素个数 |
void swap(unordered_map&) | 交换两个容器中的元素 |
7. unordered_map的桶操作
函数声明 | 功能介绍 |
size_t bucket count()const | 返回哈希桶中桶的总个数 |
size_t bucket size(size_t n) const | 返回n号桶中有效元素的总个数 |
size_t bucket(const K& key) | 返回元素key所在的桶号 |
unordered_set
unordered_set文档介绍
底层结构
unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素 时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O($log_2 N$),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
- 搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity;capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。
哈希冲突
对于两个数据元素的关键字$k_i$和 $k_j$(i != j),有$k_i$ != $k_j$,但有:Hash($k_i$) == Hash($k_j$),即:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突 或哈希碰撞。
比如:5、25、45分别去%20,映射的位置都是5。
哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数:
1. 直接定址法--(常用)一一映射
- 取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
- 优点:简单、均匀
- 缺点:需要事先知道关键字的分布情况
- 使用场景:适合查找比较小且连续的情况
2. 除留余数法--(常用)
- 设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数, 按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
3. 平方取中法--(了解)
- 假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
- 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
- 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
4. 折叠法--(了解)
- 折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这 几部分叠加求和,并按散列表表长,取后几位作为散列地址。
- 折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
5. 随机数法--(了解)
- 选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中 random为随机数函数。
- 通常应用于关键字长度不等时采用此法
6. 数学分析法--(了解)
- 设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定 相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只 有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散 列地址。例如:
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列
闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有 空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
1. 线性探测
比如下图中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4, 因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突, 使用线性探测找到下一个空位置,插入新元素
查找
- i = key % 表的大小
- 如果i为不是要查找的key值,就线性往后查找,直到找到或者遇到空,如果找到表的结尾位置,还没有找到key值,要往头回绕。
删除
- 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素 会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影 响。因此线性探测采用标记的伪删除法来删除一个元素。
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE};
线性探测的实现并改造
// 开放定址法
namespace open_address
{
// 状态
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData // 类模板名:哈希表的数据是结构体的变量(数据和状态)
{
pair<K, V> _kv;
State _state = EMPTY;
// 标记默认初始化为空,一旦存进去值,标记为存在,删除值之后,标记位删除
};
template<class K>
struct HashFunc // 仿函数:将key转换成整型
{
size_t operator()(const K& key)
{
return (size_t)key;// 不传参数三,默认将key强转成整型
}
};
// 特化 ---> 在实践当中string经常做key,所以做特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto e : s)
{
hash += e;
hash *= 131;
}
return hash;
}
};
// stoi:只有阿拉伯的字符串数字"1224546"才能用stoi;像"比特"就不能用stoi
// 将字符串强制转换成整型
//struct HashFuncString
//{
// size_t operator()(const string& s)
// {
// // "abcd"
// // "bcad"
// // "aadd"
// size_t hash = 0;
// for (auto e : s)
// {
// // 将字符串中的每个字符ascll码值加起来
// hash += e;
// hash *= 131;// 这样可以避免ascll码值相加相等的情况
// }
//
// return hash;
// }
//};
// 参数三:默认缺省的仿函数Hash,没有传确定的仿函数,就用缺省的发仿函数HashFunc<K>
template<class K, class V, class Hash = HashFunc<K>>
class HashTable // 类模板名:哈希表
{
public:
HashTable(size_t size = 10)
{
_tables.resize(size);// 使用resize的话,size和capcacity就相等了
}
HashData<K, V>* Find(const K& key)
{
Hash hs; // 仿函数的对象
// 线性探测
size_t hashi = hs(key) % _tables.size();
while (_tables[hashi]._state != EMPTY)
{
if (key == _tables[hashi]._kv.first
&& _tables[hashi]._state == EXIST)
{
return &_tables[hashi];
}
++hashi;// 如果++超出size,则取模从头再来
hashi %= _tables.size();
}
return nullptr;
}
bool Insert(const pair<K, V>& kv)
{
// 如果已经有了,就返回false
if (Find(kv.first))
return false;
// 扩容的问题 不强制类型转换成double的话,会有7/10==0的情况
//if ((double)_n / (double)_tables.size() >= 0.7)
if (_n * 10 / _tables.size() >= 7)
{
// 方法一:
//size_t newSize = _tables.size() * 2;
// 不能在原表的空间上扩容空间,因为这样会使映射关系混乱
//vector<HashData> newTables(newSize); // 需要重新开辟一块新空间
遍历旧表,重新映射到新表,那么就得此处再次写一遍线性探测的代码,再让两个表交换一下
....
//_tables.swap(newTables);
// 方法二:
HashTable<K, V, Hash> newHT(_tables.size() * 2);
// 遍历旧表,插入到新表
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
// 这里新表调用Insert()函数,并不会陷入死循环,因为空间*2倍之后,不会再次进入if判断条件了
// 直接复用线性探测的代码
}
}
_tables.swap(newHT._tables);// 交换两表,那么旧表出了作用域就会调用析构函数,旧表数据会被释放
}
Hash hs;
// 线性探测
size_t hashi = hs(kv.first) % _tables.size(); // 除和取模都不能除或取模0
// 这里要模取的是size,而不是capacity;假设表中的capacity和size是不一样的,
// 放值是需要[]的,[]会检查i < size,如果值放在模capacity的那块区间,超出size会越界;
// 所以只能放值在size区间处,放在size和capacity区间,则越界。
while (_tables[hashi]._state == EXIST) // 此位置状态为存在
{
++hashi;
hashi %= _tables.size();// 模上一个size,走到尾之后,从头再来
}
// 此位置状态为空或被删除
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n; // 实际数据个数+1
return true;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
_n--;
ret->_state = DELETE; // 直接改状态就相当于删除了
return true;
}
else
{
return false;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 实际存储的数据个数
};
思考:哈希表什么情况下进行扩容?如何扩容?
- 哈希冲突越多,效率就越低。
- 负载因子/载荷因子 = 实际存进去数据个数/表的大小。
- 闭散列(开放定址法):负载因子一般会控制在0.7左右。
线性探测优点:实现非常简单。
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法 为:$H_i$ = ($H_0$ + $i^2$ )% m, 或者:$H_i$ = ($H_0$ - $i^2$ )% m。其中:i = 1,2,3…, $H_0$是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表 的大小。
对于下图中如果要插入44,产生冲突,使用解决后的情况为:
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任 何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在 搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
开散列
开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
开散列实现并改造 + 迭代器的实现
template<class K>
struct HashFunc // 仿函数:将key转换成整型
{
size_t operator()(const K& key)
{
return (size_t)key;// 不传参数三,默认将key强转成整型
}
};
// 特化 ---> 在实践当中string经常做key,所以做特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto e : s)
{
hash += e;
hash *= 131;
}
return hash;
}
};
// 哈希桶
namespace hash_bucket
{
// T -> K
// T -> pair<K, V>
template<class T>
struct HashNode
{
HashNode<T>* _next;
T _data;
HashNode(const T& data)
:_next(nullptr)
, _data(data)
{}
};
// 编译器有一个原则:先定义或先声明,再使用。
// 在使用一个变量、类型、函数,要先定义或先声明,再使用。因为编译器为了提高编译速度,有一个原则,
// 比如:在使用一个变量、类型或函数时,编译器只会向上找,不会向下找,只向上找,编译速度会快很多。
// 下面__HTIterator类模板中使用了HashTable<K, T, KeyOfT, Hash>,在上面没有HashTable的定义,
// 所以编译器会报错,因为编译器不认识HashTable。
// 类里面是不受影响的,因为类里面的规则,是在整个类域里面进行查找,编译器把类域当成一个整体。
// 那我们如果把整个HashTable类模板放在__HTIterator类模板之前,也会有问题,
// 因为HashTable类模板中也使用了__HTIterator类型,这个地方就是一个经典的互相引用。
// 那么这时候就只能增加一个前置声明
// 前置声明(声明中不能有缺省值)
template<class K, class T, class KeyOfT, class Hash>
class HashTable;
template<class K, class T, class KeyOfT, class Hash>
struct __HTIterator
{
typedef HashNode<T> Node;
typedef HashTable<K, T, KeyOfT, Hash> HT;
typedef __HTIterator<K, T, KeyOfT, Hash> Self;
Node* _node;
HT* _ht;
__HTIterator(Node* node, HT* ht)
:_node(node)
, _ht(ht)
{}
T& operator*()
{
return _node->_data;
}
T* operator->()
{
return &_node->_data;
}
// 返回的是哈希表中对应的元素
Self& operator++()
{
// 当前哈希表所在位置的桶没有走完
if (_node->_next)
{
// 当前桶还是节点
_node = _node->_next;
}
else
{
// 当前桶走完了,找下一个桶
KeyOfT kot;
Hash hs;
// _tables是HashTable的私有,所以_tables无法使用。我们可以采用友元的方法
size_t hashi = hs(kot(_node->_data)) % _ht->_tables.size();
// 找下一个桶
hashi++;
while (hashi < _ht->_tables.size())
{
if (_ht->_tables[hashi])
{
_node = _ht->_tables[hashi];
break;
}
hashi++;
}
// 后面没有桶了
if (hashi == _ht->_tables.size())
{
_node = nullptr;
}
}
return *this;
}
bool operator!=(const Self& s)
{
return _node != s._node;
}
};
// 参数三:仿函数,对于set来说,返回key;对于map来说,返回pair<key,value>中的key
// 参数四:转换成整型的仿函数
template<class K, class T, class KeyOfT, class Hash>
class HashTable
{
// 迭代器想要使用哈希表,就得把迭代器变成哈希表的友元
template<class K, class T, class KeyOfT, class Hash>
friend struct __HTIterator;// 普通类的友元,只有这一行代码;类模板的友元,得把模板参数声明一下
typedef HashNode<T> Node;
public:
typedef __HTIterator<K, T, KeyOfT, Hash> iterator;
iterator begin()
{
for (size_t i = 0; i < _tables.size(); i++)
{
// 找到第一个桶的第一个节点
if (_tables[i])
{
// this就是哈希表对象的地址
return iterator(_tables[i], this);
}
}
// 找不到返回空
return end();
}
iterator end()
{
return iterator(nullptr, this);// 调用的是__HTIterator的构造函数
}
HashTable()
{
_tables.resize(10, nullptr);
_n = 0;
}
// 这里析构的是表中所挂的哈希桶中的节点;vector出了作用域之后会自己调用析构函数
// 哪怕我们自己显示写了析构函数,自定义类型出了作用域也会显示调用析构
~HashTable()
{
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;
}
}
pair<iterator, bool> Insert(const T& data)
{
KeyOfT kot;
// 此时Find()函数返回的是迭代器,不能转换成bool值,所以要拿迭代器进行比较
// 之前Find()函数返回的是节点的指针,可以隐式类型转换成bool值
/* if (Find(kot(data)) != end())
return false;*/
iterator it = Find(kot(data));
if (it != end())
return make_pair(it, false);
Hash hs;
// 负载因子到1就扩容
if (_n == _tables.size())
{
// 创建一个新表
vector<Node*> newTables(_tables.size() * 2, nullptr);// 调用HashTable的构造函数
for (size_t i = 0; i < _tables.size(); i++)
{
// 取出旧表中节点,重新计算挂到新表桶中
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;// 保存下一个节点
// 头插到新表
size_t hashi = hs(kot(cur->_data)) % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;// 查看下一个节点应该挂到那个桶中
}
_tables[i] = nullptr;// 将旧表置空
}
_tables.swap(newTables);// 交换两表之后,旧表出了作用域就被释放掉
}
size_t hashi = hs(kot(data)) % _tables.size();
Node* newnode = new Node(data);
// 头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return make_pair(iterator(newnode, this), true);
}
iterator Find(const K& key)
{
KeyOfT kot;
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
return iterator(cur, this);
}
cur = cur->_next;
}
return iterator(nullptr, this);
}
bool Erase(const K& key)
{
KeyOfT kot;
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
// 删除
if (prev) // 不是桶中的第一个节点
{
prev->_next = cur->_next;
}
else // 是桶中的第一个节点
{
_tables[hashi] = cur->_next;
}
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables; // 指针数组
size_t _n;
};
}
开散列增容
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点, 再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可 以给哈希表增容。
开散列与闭散列比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上: 由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
不同的类型转换成整型的操作
struct Date
{
int _year;
int _month;
int _day;
};
// 将日期类转换成整型
struct HashFuncDate
{
// 2024/6/3
// 2024/3/6
size_t operator()(const Date& d)
{
size_t hash = 0;
hash += d._year;
hash *= 131;
hash += d._month;
hash *= 131;
hash += d._day;
hash *= 131;
return hash;
}
};
struct Person
{
string _name;
string _id; // 身份证号码
string _tel;
int _age;
string _class;
string _address; //
//...
};
struct HashFuncPerson
{
// 2024/6/3
// 2024/3/6
size_t operator()(const Person& p)
{
size_t hash = 0;
for (auto e : p._id)
{
hash += e;
hash *= 131;
}
return hash;
}
};
MyOrderedMap.h
#include"HashTable.h"
namespace bit
{
template<class K, class V, class Hash = HashFunc<K>>
class unordered_map
{
struct MapKeyOfT
{
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
public:
typedef typename hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::iterator iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
pair<iterator, bool> insert(const pair<K, V>& kv)
{
return _ht.Insert(kv);
}
// Map要把[]实现出来,就得解决insert(),[]的本质就是insert()
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:
hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
};
void test_map1()
{
unordered_map<string, string> dict;
dict.insert(make_pair("sort", ""));
dict.insert(make_pair("left", ""));
dict.insert(make_pair("right", "?"));
for (auto& kv : dict)
{
//kv.first += 'x';
kv.second += 'y';
cout << kv.first << ":" << kv.second << endl;
}
}
}
MyOrderedSet.h
#include"HashTable.h"
namespace bit
{
template<class K, class Hash = HashFunc<K>>
class unordered_set
{
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
public:
typedef typename hash_bucket::HashTable<K, const K, SetKeyOfT, Hash>::iterator iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
bool insert(const K& key)
{
return _ht.Insert(key);
}
pair<iterator, bool> find(const K& key)
{
return _ht.Find(key);
}
bool erase(const K& key)
{
return _ht.Erase(key);
}
private:
hash_bucket::HashTable<K, const K, SetKeyOfT, Hash> _ht;
};
void test_set1()
{
unordered_set<int> us;
us.insert(3);
us.insert(1);
us.insert(5);
us.insert(15);
us.insert(45);
us.insert(7);
unordered_set<int>::iterator it = us.begin();
while (it != us.end())
{
//*it += 100;
cout << *it << " ";
++it;
}
cout << endl;
int x = 0;
cin >> x;
if (us.find(x) != us.end())
{
cout << "找到了" << endl;
}
else
{
cout << "没有找到" << endl;
}
for (auto e : us)
{
cout << e << " ";
}
cout << endl;
}
}
int a[10];// 静态数组
// 动态数组:malloc或new出来的数组是动态数组
哈希的应用
位图
位图概念
面试题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。【腾讯】
- 遍历,时间复杂度O(N)
- 排序(O(NlogN)),利用二分查找: logN
- 位图解决 数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0 代表不存在。比如:
位图概念
- 所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用 来判断某个数据存不存在的。
位图的实现
namespace bit
{
// 用一个非类型模板参数来控制位图要开多大(位图是存在于数组里面的)
template<size_t N>
class bitset
{
public:
bitset()
{
// 假如:N是50个比特位,50除以32是1个整型,还有18个比特位没有开出来,所以要向上取整
// 多开一个整型
_bits.resize(N / 32 + 1, 0);
//cout << N << endl;
}
// 把x映射的位标记成1
void set(size_t x)
{
assert(x <= N);// x不能超出N
size_t i = x / 32;// 计算x在第几个整型上
size_t j = x % 32;// 计算x在这个整型的第几个位上
_bits[i] |= (1 << j);
}
// 把x映射的位标记成0
void reset(size_t x)
{
assert(x <= N);
size_t i = x / 32;
size_t j = x % 32;
_bits[i] &= ~(1 << j);
}
// 检测x映射的标记位是1还是0
bool test(size_t x)
{
assert(x <= N);
size_t i = x / 32;
size_t j = x % 32;
return _bits[i] & (1 << j);
}
private:
vector<int> _bits;
};
void test_bitset()
{
bitset<100> bs1;
bs1.set(50);
bs1.set(30);
bs1.set(90);
for (size_t i = 0; i < 100; i++)
{
if (bs1.test(i))
{
cout << i << "->" << "在" << endl;
}
else
{
cout << i << "->" << "不在" << endl;
}
}
bs1.reset(90);
bs1.set(91);
cout << endl << endl;
for (size_t i = 0; i < 100; i++)
{
if (bs1.test(i))
{
cout << i << "->" << "在" << endl;
}
else
{
cout << i << "->" << "不在" << endl;
}
}
// 这三种方式都可以开42亿9千万个位图大小的空间
bitset<-1> bs2;
bitset<UINT_MAX> bs3;
bitset<0xffffffff> bs4;
}
位图应用
- 快速查找某个数据是否在一个集合中
- 排序 + 去重
- 求两个集合的交集、并集等
- 操作系统中磁盘块标记
给定100亿个整数,设计算法找到只出现一次的整数?
思路:出现1次和1次以上的整数需要两个比特位:00 ---> 0次;01 ---> 1次;10 ---> 2次及以上。
代码展示:
template<size_t N>
class two_bit_set
{
public:
void set(size_t x)
{
// 00 -> 01
if (_bs1.test(x) == false
&& _bs2.test(x) == false)
{
_bs2.set(x);
}
// 01 -> 10
else if (_bs1.test(x) == false
&& _bs2.test(x) == true)
{
_bs1.set(x);
_bs2.reset(x);
}
}
//int test(size_t x)
//{
// if (_bs1.test(x) == false
// && _bs2.test(x) == false)
// {
// return 0;
// }
// else if (_bs1.test(x) == false
// && _bs2.test(x) == true)
// {
// return 1;
// }
// else
// {
// return 2; // 2次及以上
// }
//}
bool test(size_t x)
{
if (_bs1.test(x) == false
&& _bs2.test(x) == true)
{
return true;
}
return false;
}
private:
bitset<N> _bs1;// 自定义类型的对象会去调用它的构造函数
bitset<N> _bs2;
};
void test_bitset2()
{
int a[] = { 5,7,9,2,5,99,5,5,7,5,3,9,2,55,1,5,6 };
two_bit_set<100> bs;
for (auto e : a)
{
bs.set(e);
}
for (size_t i = 0; i < 100; i++)
{
//cout << i << "->" << bs.test(i) << endl;
if (bs.test(i))
{
cout << i << endl;
}
}
}
给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
思路:分别set到两个位图,同时为1的就是交集。
1G内存是够的,100亿个整数,并不需要100亿个比特位,因为整数最多42亿9千万个,所以说映射的位图只需要42亿9千万个位,42亿9千万个比特位换算成1G,两个0.5G就是1G。
1GB是2的30次方,是10亿字节,100亿字节是10G,那么100亿个整型是40G。
代码展示:
void test_bitset3()
{
int a1[] = { 5,7,9,2,5,99,5,5,7,5,3,9,2,55,1,5,6 };
int a2[] = { 5,3,5,99,6,99,33,66 };
bitset<100> bs1;
bitset<100> bs2;
for (auto e : a1)
{
bs1.set(e);
}
for (auto e : a2)
{
bs2.set(e);
}
for (size_t i = 0; i < 100; i++)
{
// 寻找交集
if (bs1.test(i) && bs2.test(i))
{
cout << i << endl;
}
}
}
位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
内存当中一般是存不下这些值,这些值都是存在文件里面的。位图不是开40亿,而是按照范围来开的(42亿9千万),因为它的范围是无符号的整数,(0~2^32-1)。
采用两个比特位:00 ---> 0次;01 ---> 1次;10 ---> 2次;11 ---> 3次及以上
给定100亿个整数,只有512M,需要在512M内存中设计算法找到只出现一次的整数?
因为1G是10亿字节,1G是2^30,1G是42亿9千万个比特位,整数的范围最大才到42亿9千万,所以100亿个整数中有大量是重复的数字,所以要在512M内存中查找只出现一次的整数,可以让42亿9千万个整数分成两份,因为512M是是42亿9千万个比特位的一半。
先查找前一半,再查找后一半,映射的过程中就是去重的过程。
布隆过滤器
布隆过滤器提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉 那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用 户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那 些已经存在的记录。 如何快速查找呢?
- 用哈希表存储用户记录,缺点:浪费空间
- 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理 了。
- 将哈希与位图结合,即布隆过滤器
布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
布隆过滤器的插入
#pragma once
#include<bitset>
#include<string>
struct HashFuncBKDR
{
// BKDR
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
struct HashFuncAP
{
// AP
size_t operator()(const string& s)
{
size_t hash = 0;
for (size_t i = 0; i < s.size(); i++)
{
if ((i & 1) == 0) // 偶数位字符
{
hash ^= ((hash << 7) ^ (s[i]) ^ (hash >> 3));
}
else // 奇数位字符
{
hash ^= (~((hash << 11) ^ (s[i]) ^ (hash >> 5)));
}
}
return hash;
}
};
struct HashFuncDJB
{
// DJB
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash = hash * 33 ^ ch;
}
return hash;
}
};
// 参数三:三个哈希仿函数的个数,表示一个值能映射3个位
template<size_t N,
class K = string,
class Hash1 = HashFuncBKDR,
class Hash2 = HashFuncAP,
class Hash3 = HashFuncDJB>
class BloomFilter
{
public:
void Set(const K& key)
{
// 比如:插入第一个数,映射0~M-1的比特位区间
// 一个值要映射到三个比特位上,为了减少冲突
size_t hash1 = Hash1()(key) % M;
size_t hash2 = Hash2()(key) % M;
size_t hash3 = Hash3()(key) % M;
_bs->set(hash1);
_bs->set(hash2);
_bs->set(hash3);
}
// 这里不需要写reset()删除函数,因为删除百度,腾讯判断也可能不在了。因为百度和腾讯可能会映射到同一个位置
bool Test(const K& key)
{
// 值映射的三个比特位上,只要有一个比特位为0,就是该值不在哈希表中
size_t hash1 = Hash1()(key) % M;
if (_bs->test(hash1) == false)
return false;
size_t hash2 = Hash2()(key) % M;
if (_bs->test(hash2) == false)
return false;
size_t hash3 = Hash3()(key) % M;
if (_bs->test(hash3) == false)
return false;
return true; // 存在误判(有可能3个位都是跟别人冲突的,所以误判)
}
private:
// const size_t M = 10 * N;
// 我们不能用这种成员变量,因为这个成员变量是属于对象的,只是声明,没有空间,只在初始化列表才会初始化
// 加一个静态static就可以了,那么这个变量就在静态区,就不属于对象了,而是属于整个类
// N:比特位。插入一个整数,也就是一个整数映射一个比特位,比特位扩容10倍的N
static const size_t M = 10 * N; // 想降低误判率:可以增大比特位的空间
bit::bitset<M> _bs;
// 如果就是想要使用库里面的bitset,可以new在堆区开辟一个std::bitset<M>类型的空间,将空间的地址给_bs
//std::bitset<M>* _bs = new std::bitset<M>;
};
// 库里面的stl::bitset<M>类型所开辟的空间是开在对象里面的,这个对象是一个静态数组
// 我们自己用vector<>实现的bitset是调用resize()函数开辟空间是在堆上的
void TestBloomFilter1()
{
string strs[] = { "百度","字节","腾讯" };// 中文是由多个字符构成的
BloomFilter<10> bf;
for (auto& s : strs)
{
bf.Set(s);
}
for (auto& s : strs)
{
cout << bf.Test(s) << endl;
}
for (auto& s : strs)
{
cout << bf.Test(s + 'a') << endl;
}
cout << bf.Test("摆渡") << endl;
cout << bf.Test("百渡") << endl;
}
布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特 位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为 零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可 能存在,因为有些哈希函数存在一定的误判。
比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其 他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。
布隆过滤器删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
每个位置改成多个位的引用计数就可以支持。比如:一个映射位置给8个bit标记,但是这样空间的消耗就大了。
布隆过滤器优点
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无 关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺陷
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再 建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
布隆过滤器的面试题
给两个文件,分别有100亿个query(字符串),我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法?
小文件在找交集是没有误判的,因为已经读到内存当中了,不需要在使用布隆过滤器,直接将文件中的数据放到底层为哈希表或红黑树的容器中。
之前的算法要用布隆过滤器,因为数据在数据库中,都去数据库中查找太慢了,所以用布隆过滤,会效率高。
哈希切割
给一个超过100G大小的log file, log中存着IP地址,设计算法找到出现次数最多的IP地址? 与上题条件相同,如何找到top K的IP?如何直接用Linux系统命令实现?
如果是top K ,就自己建立一个小堆,默认是大堆,我们还得写一个仿函数,因为不能用pair<string,int>类型比,我们要用pair<string,int>类型中的second来进行比较,控制成一个K个数的小堆。
海量数据问题特征:数据量大,内存存不下。
- 先考虑具有特点的数据结构能否解决?比如:位图、堆、布隆过滤器等。
- 大事化小思路。哈希切分(不能平均切分),切小以后,放到内存中能处理。
总结
好了,本篇博客到这里就结束了,如果有更好的观点,请及时留言,我会认真观看并学习。
不积硅步,无以至千里;不积小流,无以成江海。