[本节目标]
1. unordered系列关联式容器
2. 底层结构
3. 模拟实现
4. 哈希的应用
5. 海量数据处理面试题
1. unordered系列关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到$log_2 N$,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好 的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个 unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是 其底层结构不同,本文中只对unordered_map和unordered_set进行介绍。
1.1 unordered_set
unordered_set和set的接口的使用方法都差不多,我们这里主要来介绍一下unordered_set和set的区别。
void test()
{
set<int> s;
s.insert(3);
s.insert(1);
s.insert(5);
s.insert(7);
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
unordered_set<int> us;
us.insert(3);
us.insert(1);
us.insert(5);
us.insert(7);
for (auto e : us)
{
cout << e << " ";
}
cout << endl;
}
我们来看一下运行结果:
然后我们再来看一下unordered_set和set插入,查找和删除的效率,利用重复值比较多、重复值比较少和有序的值分别测试。
int main()
{
const size_t N = 10000000;
unordered_set<int> us;
set<int> s;
vector<int> v;
v.reserve(N);
srand(time(0));
for (size_t i = 0; i < N; ++i)
{
v.push_back(rand()); // N比较大时,重复值比较多
//v.push_back(rand()+i); // 重复值相对少
//v.push_back(i); // 没有重复,有序
}
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set insert:" << end1 - begin1 << endl;
size_t begin2 = clock();
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "unordered_set insert:" << end2 - begin2 << endl;
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
cout << "set find:" << end3 - begin3 << endl;
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
cout << "unordered_set find:" << end4 - begin4 << endl << endl;
cout <<"插入数据个数:"<< s.size() << endl;
cout <<"插入数据个数:" << us.size() << endl << endl;
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
cout << "set erase:" << end5 - begin5 << endl;
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
return 0;
}
N比较大时,重复值比较多的结果:
重复值相对少的结果:
没有重复,有序的结果:
1.2 unordered_map
- 1. unordered_map是存储键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
- 2. 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此 键关联。键和映射值的类型可能不同。
- 3. 在内部,unordered_map没有对按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
- 4. unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭 代方面效率较低。
- 5. unordered_maps实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问 value。
- 6. 它的迭代器至少是前向迭代器。
1.2.1 unordered_map的接口说明
1. unordered_map的构造
2. unordered_map的容量
3. unordered_map的迭代器
4. unordered_map的元素访问
注意:该函数中实际调用哈希桶的插入操作,用参数key与V()构造一个默认值往底层哈希桶中插入,如果key不在哈希桶中,插入成功,返回V();插入失败,说明key已经在哈希桶中, 将key对应的value返回。
5. unordered_map的查询
注意:unordered_map中key是不能重复的,因此count函数的返回值最大为1
6. unordered_map的修改操作
7. unordered_map的桶操作
2. 底层结构
unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
2.1 哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素 时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O($log_2 N$),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
- 搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置 取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称 为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?hash(44) = 44%10 = 4,这样就出现了不同的值4和44都映射到相同位置的问题。
2.2 哈希冲突
对于两个数据元素的关键字key_i和key_j(key_i !=key_j),有key_i != key_j,但有:Hash(key_i) == Hash(key_j),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突 或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。 发生哈希冲突该如何处理呢?
2.3 哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有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种不同的符号在各位上出现的频率不一定 相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只 有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:
假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同 的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还 可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移 位、前两数与后两数叠加(如1234改成12+34=46)等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的 若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
2.4 哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列
2.4.1 闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有 空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
1. 线性探测
比如2.1中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4, 因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
我们先来搭建一下开放定址法的哈希表的结构
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _data;
State _state = EMPTY;//标记
};
template<class K, class V>
class HashTable
{
public:
private:
vector<HashData> _tables;
//这里不能用vector的size,size = _finish - _start
//和我们这里不符合
size_t _n = 0; //实际存储的数据个数
};
查找:
- i = key % 表的大小,如果i不是要查找的key就线性往后查找,直到找到或者遇到空
- 如果找到表尾位置,要往头回绕
HashData<K,V>* Find(const K& key)
{
size_t hashi = key % _tables.size();
//这里状态可能是存在,也可能是删除
while (_tables[hashi]._state != EMPTY)
{
if (key == _tables[hashi]._kv.first
&& _tables[hashi]._state == EXIST)
{
return &_tables[hashi];
}
++hashi;
// 如果走到尾,就要回到头
hashi %= _tables.size();
}
//没找到
return nullptr;
}
插入:
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突, 使用线性探测找到下一个空位置,插入新元素
现在我们就来直接写我们的插入逻辑了。
bool insert(const pair<K, V>& kv)
{
// 线性探测
// 线性探测
// 这里不能除_tables.capacity();
// 因为模出来的值会大于size,然后在摸出来的值处插入
// 会引发vector的插入错误
// 但是我们这里尽量控制size和capacity大小一致
size_t hashi = kv.first % _tables.size();
while (_tables[hashi]._state == EXIST)
{
++hashi;
// 如果走到尾,就要回到头
hashi %= _tables.size();
}
//走到这里状态要么为空,要么为删除
//开始进行插入
_tables[hashi]._data = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
然后我们来看看我们插入逻辑的问题,首先刚开始我们的表_tables.size()的大小是0,但是除和模是不能除0和模0的,这样会发生除0错误;同时如果我们的表满了,这里的程序会死循环,程序就会出问题,所以这里就涉及扩容的问题,那什么时候需要扩容呢?利用构造函数自动调用刚开始就给空间,后面当负载因子 = 实际存储的数据个数 / 表空间大小超过0.7的时候就需要扩容。
HashTable()//构造函数
{
_tables.resize(10);
}
if (_n / _tables.size() == 0.7)
{}
我们来看一下上面的代码有没有什么问题?在c语言章节的知识学到,两个整数相除的时候是不能获得浮点数的,而且浮点数再比较的时候会因为精度的影响负载因子的判断,我们可以设置负载因子大于0.7就进行扩容,至于两个整数相除的时候是不能获得浮点数的我们可以进行强转,或者我们也可以利用除数和被除数同时乘以一个数结果不变来进行判断。
//if ((double)_n / (double)_tables.size() >= 0.7)
if (_n * 10 / _tables.size() >= 7)
{}
现在我们就来看看扩容的代码该如何写?我们这里能不能直接resize成_tables.size()的2倍呢?不能,此时我们再插入一个值的映射关系就会发生改变,也不建议在原表上扩容进行映射,因为可能存在覆盖其他值的情况,我们这里的思路是建一个新表,将所有的值全部重新进行映射,所以我们这里就可以知道这里的缺点,这里的扩容的消耗是极大的。
if (_n * 10 / _tables.size() >= 7)
{
size_t newSize = _tables.size() * 2;
vector<HashData> newTables(newSize);
//遍历旧表,映射到新表
for (auto& e : _tables)
{
//按照下面的形式char
}
_tables.swap(newTables);
}
但是上面的代码的冗余太多了,所以我们这里换一种思路,复用insert。
HashTable(size_t size = 10)//全缺省值
{
_tables.resize(size);
}
if (_n * 10 / _tables.size() >= 7)
{
size_t newSize = _tables.size() * 2;
HashTable<K, V> newHT(newSize);//创建一个key和value的哈希表
//遍历旧表,插入到新表
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newHT.insert(e._data);
}
}
_tables.swap(newHT._tables);//现代写法
//交换之后,newHT._tables存储的是_tables的数据,是我们不想要的
// nweHT出完作用域调用析构函数销毁,旧数据就没有了
}
同时我们实现的哈希表中元素唯一,即key相同的元素不再进行插入。
HashTable(size_t size = 10)//全缺省值
{
_tables.resize(size);
}
bool insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 线性探测
// 这里不能除_tables.capacity();
// 因为模出来的值会大于size,然后在摸出来的值处插入
// 会引发vector的插入错误
// 但是我们这里尽量控制size和capacity大小一致
if (_n * 10 / _tables.size() >= 7)
{
size_t newSize = _tables.size() * 2;
HashTable<K, V> newHT(newSize);//创建一个key和value的哈希表
//遍历旧表,插入到新表
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newHT.insert(e._kv);
}
}
_tables.swap(newHT._tables);//现代写法
//交换之后,newHT._tables存储的是_tables的数据,是我们不想要的
// nweHT出完作用域销毁,符合我们的设计
}
size_t hashi = kv.first % _tables.size();
while (_tables[hashi]._state == EXIST)
{
++hashi;
// 如果走到尾,就要回到头
hashi %= _tables.size();
}
//走到这里状态要么为空,要么为删除
//开始进行插入
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
现在我们就可以来测试一下我们上面的逻辑。
void TestHT1()
{
HashTable<int, int> ht;
int arr[] = {1,4,24,34,7,44,17};
for (auto e : arr)
{
ht.insert(make_pair(e, e));
}
}
我们来看一下运行结果
我们此时再多插入一个值,此时就会因为负载因子的条件不满足而发生扩容从而所有的映射关系都会重新映射,我们来看一下结果。
我们会发现此时映射关系也都全部重新映射了,随后我们也来验证一下查找
void TestHT1()
{
HashTable<int, int> ht;
int arr[] = {1,4,24,34,7,44,17};
for (auto e : arr)
{
ht.insert(make_pair(e, e));
}
for (auto e : arr)
{
auto ret = ht.Find(e);
cout << ret->_kv.first << ":" << ret->_state << endl;
}
}
直接看运行结果:
删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素 会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State
{
EMPTY,
EXIST,
DELETE
};
删除的逻辑就是先查找该值是否存在,如果找到该值仅仅只需要将状态改为删除DELETE。
bool erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret != nullptr)
{
--_n;
ret->_state = DELETE;
return true;
}
return false;
}
我们来测试一下删除几个数据。
void TestHT1()
{
HashTable<int, int> ht;
int arr[] = {1,4,24,34,7,44,17};
for (auto e : arr)
{
ht.insert(make_pair(e, e));
}
for (auto e : arr)
{
auto ret = ht.Find(e);
cout << ret->_kv.first << ":" << ret->_state << endl;
}
ht.erase(34);
ht.erase(4);
for (auto e : arr)
{
auto ret = ht.Find(e);
cout << ret->_kv.first << ":" << ret->_state << endl;
}
}
此时我们来看一下运行结果:
我们会发现此时的程序崩溃了,因为我们上面的代码再删除之后依然去用数组arr的值进行查找,删除了4和34后,该值就不存在,就返回空,不能解引用操作。
void TestHT1()
{
HashTable<int, int> ht;
int arr[] = {1,4,24,34,7,44,17};
for (auto e : arr)
{
ht.insert(make_pair(e, e));
}
for (auto e : arr)
{
auto ret = ht.Find(e);
cout << ret->_kv.first << ":" << ret->_state << endl;
}
ht.erase(34);
ht.erase(4);
for (auto e : arr)
{
auto ret = ht.Find(e);
if (ret)
{
cout << ret->_kv.first << ":E" << endl;
}
else
{
cout << e << ":D" << endl;
}
}
}
我们再来看一下运行结果
此时我们也能发现我们成功删除了数据,如果我们的表存入的是string呢?
void TestHT2()
{
HashTable<string, string> dict;
dict.insert(make_pair("sort", "排序"));
dict.insert(make_pair("left", "左边"));
}
此时程序报错了,是因为我们在利用哈希函数求hashi的时候是利用的除模运算,而字符串是不支持除模运算的,那我们这里要怎么解决呢?我们可以对string的除模运算进行重载一下,但是这里对string库进行修改不建议,这里最好的办法都是使用仿函数,对于基本类型,可以直接进行强转成size_t,对于string类型,我们可以求每个字母ASCII码值,然后再相加,再去进行除模运算。
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return size_t(key);
}
};
struct HashFuncString
{
size_t operator()(const string& s)
{
//获取每个字母ASCII码值,然后相加
size_t hash = 0;
for (auto e : s)
{
hash += e;
}
return hash;
}
};
template<class K,class V,class Hash = HashFunc<K>>
void TestHT2()
{
HashTable<string, string,HashFuncString> dict;
dict.insert(make_pair("sort", "排序"));
dict.insert(make_pair("left", "左边"));
}
但是我们上面的对字符串转成整数的方法还是不太好,我们来随便举例"abcd"、"bcda"和"aadd",对于这三个不同的串,但是利用上面的方法求解的hash都是一样的。
由于hash都是一样的,那么就会映射相同的位置,也就会出现哈希冲突,我们这里可以看一下字符串哈希算法来解决问题,它里面是通过乘上一个权值,这里我们就不详解啦!如果是Person类呢?
struct Person
{
string _name;
string _id;//身份证
string _tel;
int _age;
};
此时Person类肯定是不支持除模运算的,所以我们这里肯定也要使用仿函数去解决,这里的常规思路就是将_name,_id和_tel拼接起来,然后转为size_t类型的,最后再加上_age去解决,但是我们这里可以取巧一下,我们知道每个人的身份证号码都是不同的,都是唯一的。
struct Person
{
string _name;
string _id;//身份证
string _tel;
int _age;
};
struct HashFuncPerson
{
size_t operator()(const Person& p)
{
size_t hash = 0;
for (auto e : p._id)
{
hash += e;
}
return hash;
}
};
这里再来提一个问题,我们在使用库里面的哈希的时候,此时我们不传入字符串转整型的仿函数程序就能运行,这是为什么呢?
void test_map1()
{
unordered_map<string, string> dict;
dict.insert(make_pair("sort", "排序"));
dict.insert(make_pair("left", "左边"));
}
因为string会经常当作key值,所以库里面对string类型的取模运算进行了特化。
// 特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
//获取每个字母ASCII码值,然后相加
size_t hash = 0;
for (auto e : s)
{
hash += e;
}
return hash;
}
};
总结:
线性探测优点:实现非常简单
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?
2. 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位 置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法 为:H_i = (H_0 + i^2 )% m, 或者:H_i = (H_0 - i^2 )% m。其中:i = 1,2,3…, H_0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表 的大小。
对于2.1中如果要插入44,产生冲突,使用解决后的情况为:
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
2.4.2 开散列
1. 开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地 址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。我们首先来看看链地址法的结构。
namespace hash_bucket
{
template<class K,class V>
class HashTable
{
private:
vector<list<pair<K, V>>> _table;
};
}
这里我们可以使用库里面的容器来设计这个结构,但是库里面的lish使用的双向链表,双向链表的优势是在任意位置插入效率高,在我们这里使用没有任何优势,我们这里仅仅只进行头插就行,是使用单链表即可,单链表头插的效率也高,而且对查找的时候也没有任何影响。
namespace hash_bucket
{
template<class K,class V>
struct HashNode
{
HashNode<K, V>* _next;
pair<K, V> _kv;
HashNode(const pair<K, V>& kv)
:_next(nullptr)
,_kv(kv)
{}
};
template<class K, class V>
class HashTable
{
typedef HashNode<K, V> Node;
private:
//vector<list<pair<K, V>>> _table;
vector<HashNode<K,V>*> _tables;//指针数组
size_t _n;//有效数据的个数
};
}
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
2. 开散列实现
首先我们来看查找的逻辑,首先我们需要找到当前元素所映射的指针,然后只需要遍历这条链表找值即可。
Node* find(const K& key)
{
size_t hashi = key % _tables.size();
Node* cur = _tables[hashi];//取第一个指针
while (cur != nullptr)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
对于插入的逻辑,由于我们设计的是单链表,直接头插数据即可。
bool insert(const pair<K, V>& kv)
{
if (find(kv.first))
return false;
size_t hashi = kv.first % _tables.size();
Node* newnode = new Node(kv);
//头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
现在我们要解决的就是扩容的问题,对于闭散列的开放定址法我们的负载因子要控制在0.7,如果到1就表示当前表已经满了,对于哈希桶这里的负载因子是可以控制的更大一点,可以超过1,因为它的数据不是存在表中,数据是挂在下面的结点上的,但是对于负载因子是有双向性的,负载因子越大利用率越高,但是冲突的概率就越大,负载因子越小空间利用率越低,但是冲突的概率就越小。那我们这里控制成多少呢?我们看一下库里面的负载因子是多少?
根据库的一致性,我们这里设置负载因子为1就需要扩容,那我们这里要怎么扩容呢?我们这里能不能和之前的闭散列的开发定制法的插入思路一样,创建一个key和value的哈希表hashTable,然后遍历旧表,插入到新表,最后再交换一下,这样是可以的,但是我们每次插入的时候都会new一个节点,再重新映射的时候要把原来的节点又要释放掉,那么这样的消耗是非常大的。
我们这里不需要再重新调用insert结构,不需要将原来的节点释放掉再重新new节点,直接将旧表的节点搬到新表上,然后再交换新表和旧表,旧表是我们需要的,新表再取调用析构函数释放即可。
HashTable()
{
_tables.resize(10, nullptr);
_n = 0;
}
Node* find(const K& key)
{
size_t hashi = key % _tables.size();
Node* cur = _tables[hashi];//取第一个指针
while (cur != nullptr)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool insert(const pair<K, V>& kv)
{
if (find(kv.first))
return false;
//负载因子为1就需要扩容
if (_n == _tables.size())
{
vector<Node*> newTables(_tables.size() * 2, nullptr);
// 遍历旧表
for (size_t i = 0; i < _tables.size(); i++)
{
//取出旧表中的节点,重新计算挂到新表中
Node* cur = _tables[i];
while (cur != nullptr)
{
//保存下一个
Node* next = cur->_next;
// 头插到新表
size_t hashi = cur->_kv.first % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
// 交换
_tables.swap(newTables);
}
size_t hashi = kv.first % _tables.size();
Node* newnode = new Node(kv);
//头插
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
这里我们还需要写一下析构函数,因为我们的结构是一个指针数组,对于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;
}
}
现在我们就来测试一下程序的结果
void TestHT1()
{
hash_bucket::HashTable<int, int> ht;
int arr[] = { 1,4,24,34,7,44,17,37 };
for (auto e : arr)
{
ht.insert(make_pair(e, e));
}
}
我们来看一下监视窗口
随后我们再多插入三个值,看看扩容之后的情况。
void TestHT1()
{
hash_bucket::HashTable<int, int> ht;
int arr[] = { 1,4,24,34,7,44,17,37 };
for (auto e : arr)
{
ht.insert(make_pair(e, e));
}
ht.insert(make_pair(5, 5));
ht.insert(make_pair(15, 15));
ht.insert(make_pair(25, 25));
}
我们来看一下监视窗口
现在我们再来一下erase,同样的我们这里能不能个闭散列的开发定址法一样查找到该值,然后直接删除可以嘛?这里是不行的,因为我们会丢失掉后面的节点,我们这里删除要保存前一个结点才可以。
bool erase(const K& key)
{
size_t hashi = key % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
//删除
if(prev)
{
prev->_next = cur->_next;
}
else
{
//只有一个结点
_tables[hashi] = cur->_next;
}
--_n;
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
现在我们就可以来测试一下了
我们来测试一下链地址法的性能,我们可以先对链地址法求一下负载因子、表大小、有数据的桶的个数、最大桶长度和平均桶长度。
void Some()
{
size_t bucketSize = 0;
size_t maxBucketLen = 0;
size_t sum = 0;
double averageBucketLen = 0;
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
if (cur)
{
++bucketSize;
}
size_t bucketLen = 0;
while (cur)
{
++bucketLen;
cur = cur->_next;
}
sum += bucketLen;
if (bucketLen > maxBucketLen)
{
maxBucketLen = bucketLen;
}
}
averageBucketLen = (double)sum / (double)bucketSize;
printf("load factor:%lf\n", (double)_n / _tables.size());
printf("all bucketSize:%d\n", _tables.size());
printf("bucketSize:%d\n", bucketSize);
printf("maxBucketLen:%d\n", maxBucketLen);
printf("averageBucketLen:%lf\n\n", averageBucketLen);
}
现在我们再来看一下我们的测试代码
void TestHT2()
{
const size_t N = 30000;
unordered_set<int> us;
set<int> s;
hash_bucket::HashTable<int, int> ht;
vector<int> v;
v.reserve(N);
srand(time(0));
for (size_t i = 0; i < N; ++i)
{
//v.push_back(rand()); // N比较大时,重复值比较多
v.push_back(rand() + i); // 重复值相对少
//v.push_back(i); // 没有重复,有序
}
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set insert:" << end1 - begin1 << endl;
size_t begin2 = clock();
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "unordered_set insert:" << end2 - begin2 << endl;
size_t begin10 = clock();
for (auto e : v)
{
ht.insert(make_pair(e, e));
}
size_t end10 = clock();
cout << "HashTbale insert:" << end10 - begin10 << endl << endl;
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
cout << "set find:" << end3 - begin3 << endl;
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
cout << "unordered_set find:" << end4 - begin4 << endl;
size_t begin11 = clock();
for (auto e : v)
{
ht.find(e);
}
size_t end11 = clock();
cout << "HashTable find:" << end11 - begin11 << endl << endl;
cout << "插入数据个数:" << us.size() << endl << endl;
ht.Some();
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
cout << "set erase:" << end5 - begin5 << endl;
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "unordered_set erase:" << end6 - begin6 << endl;
size_t begin12 = clock();
for (auto e : v)
{
ht.erase(e);
}
size_t end12 = clock();
cout << "HashTable Erase:" << end12 - begin12 << endl << endl;
}
我们来看一下测试的结果
这里我们就可以看出哈希结构比红黑树的效率还是高一点,但是如果我们出现了一个极端场景呢?单个桶的长度非常长呢?此时我们指针数组的指针是指向一个结构体,结构体里面分别是单链表和红黑树,默认插入的时候是单链表,当单个桶的长度非常长的时候,我们就可以将这些结点使用红黑叔的结构来降低长度解决,如果红黑树的结点个数变少了,我们再变会单链表的结构。
3. 模拟实现
3.1 哈希表的改造
1. 模板参数列表的改造
原本我们的第二个参数是V,但是由于我们既要封装unordered_set和unordered_map,所以我们模板的第二个参数改为T,unordered_set传入的依然是key,而unordered_map传入的是键值对pair<k,v>。
// K:关键码类型
// T:不同容器T的类型不同,如果是unordered_map,T代表一个键值对,如果是unordered_set,T 为 K
// KeyOfT: 因为T的类型不同,通过value取key的方式就不同,详细见unordered_map/set的实现
// Hash: 哈希函数仿函数对象类型,哈希函数使用除留余数法,需要将Key转换为整形数字才能取模
template<class K, class T, class KeyOfT, class Hash>
class HashTable
2. 增加迭代器操作
注意:因为哈希桶在底层是单链表结构,所以哈希桶的迭代器不需要--操作。
template<class K, class T, class KeyOfT, class Hash>
struct __HTIterator
{
typedef HashNode<T> Node;
typedef HashTable<K, T, KeyOfT, Hash> HT;//先声明后使用
//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 != nullptr)
{
_node = _node->_next;
}
else //当前桶已经走完,去下一个不为空的桶
{
//先算自己在那个桶
KeyOfT kot;
Hash hs;
// 这里的_tables是HashTable的私有成员,这里不能访问,友元解决
size_t hashi = hs(kot(_node->_data)) % _ht->_tables.size();
hashi++;//当前的桶已经走完,去下一个桶
while (hashi < _ht->_tables.size())
{
if (_ht->_tables[hashi] != nullptr)
{
break;//找到了不为空的桶
}
hashi++;
}
//这里有两种情况
//找到了不为空的桶
if (hashi < _ht->_tables.size())
{
_node = _ht->_tables[hashi];
}
else if(hashi == _ht->_tables.size())//没有桶了
{
_node = nullptr;
}
}
return *this;
}
bool operator!=(const Self& s)
{
return _node != s._node;
}
};
3. 整个哈希表结构实现
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)
{}
};
// 前置声明,注意声明不要带上缺省值
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;//先声明后使用
//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;
}
Self& operator++()
{
//当前桶还未走完
if (_node->_next != nullptr)
{
_node = _node->_next;
}
else //当前桶已经走完,去下一个不为空的桶
{
//先算自己在那个桶
KeyOfT kot;
Hash hs;
// 这里的_tables是HashTable的私有成员,这里不能访问,友元解决
size_t hashi = hs(kot(_node->_data)) % _ht->_tables.size();
hashi++;//当前的桶已经走完,去下一个桶
while (hashi < _ht->_tables.size())
{
if (_ht->_tables[hashi] != nullptr)
{
break;//找到了不为空的桶
}
hashi++;
}
//这里有两种情况
//找到了不为空的桶
if (hashi < _ht->_tables.size())
{
_node = _ht->_tables[hashi];
}
else if(hashi == _ht->_tables.size())//没有桶了
{
_node = nullptr;
}
}
return *this;
}
bool operator!=(const Self& s)
{
return _node != s._node;
}
};
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] != nullptr)
{
//this是当前表的指针
return iterator(_tables[i], this);
}
}
return end();
}
iterator end()
{
return iterator(nullptr, this);
}
public:
HashTable()
{
_tables.resize(10, nullptr);
_n = 0;
}
~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;
}
}
iterator find(const K& key)
{
KeyOfT kot;
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];//取第一个指针
while (cur != nullptr)
{
if (kot(cur->_data) == key)
{
return iterator(cur, this);
}
cur = cur->_next;
}
return iterator(nullptr, this);
}
bool insert(const T& data)
{
KeyOfT kot;
if (find(kot(data)) != end())
return false;
Hash hs;
//负载因子为1就需要扩容
if (_n == _tables.size())
{
vector<Node*> newTables(_tables.size() * 2, nullptr);
// 遍历旧表
for (size_t i = 0; i < _tables.size(); i++)
{
//取出旧表中的节点,重新计算挂到新表中
Node* cur = _tables[i];
while (cur != nullptr)
{
//保存下一个
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 true;
}
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<HashNode<T>*> _tables;//指针数组
size_t _n;//有效数据的个数
};
}
3.2 unordered_set的封装
unordered_set中存储的是key值对,K为key的类型,KeyOfT是获取key值,HF哈希函数类型。
#include "HashTable.h"
namespace yu
{
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, K, SetKeyOfT, Hash>::iterator iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
bool insert(const K& key)
{
return _ht.insert(key);
}
iterator find(const K& key)
{
return _ht.find(key);
}
bool erase(const K& key)
{
return _ht.erase(key);
}
private:
hash_bucket::HashTable<K, K, SetKeyOfT, Hash> _ht;
};
void test_set1()
{
unordered_set<int> us;
us.insert(3);
us.insert(1);
us.insert(5);
us.insert(7);
us.insert(15);
us.insert(37);
unordered_set<int>::iterator it = us.find(37);
if(it != us.end())
{
us.erase(37);
}
cout << endl;
for (auto e : us)
{
cout << e << " ";
}
cout << endl;
}
}
3.3 unordered_map的封装
对于map而言我们要实现operator[ ],所以我们要对insert函数单独处理一下
pair<iterator, bool> insert(const T& data)
{
KeyOfT kot;
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);
// 遍历旧表
for (size_t i = 0; i < _tables.size(); i++)
{
//取出旧表中的节点,重新计算挂到新表中
Node* cur = _tables[i];
while (cur != nullptr)
{
//保存下一个
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);
}
随后我们再来封装一下unordered_map结构,unordered_map中存储的是pair的键值对,K为key的类型,V为value的类型,KeyOfT是获取key值,HF哈希函数类型。
#include "HashTable.h"
namespace yu
{
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<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);
}
V& operator[](const K& key)
{
pair<iterator, bool> ret = insert(make_pair(key, V()));
return ret.first->second;
}
iterator find(const pair<K, V>& kv)
{
return _ht.find(kv);
}
bool erase(const pair<K, V>& kv)
{
return _ht.erase(kv);
}
private:
hash_bucket::HashTable<K, pair<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;
}
}
}
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在 搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。但是表长度为质数还是挺难控制的,我们来看源码里面是怎么解决的。
直接定一个数组,当前容量已经不满足负载因子的时候,将下一个值作为新的空间。
int GetNextPrimer(size_t val)
{
static const int __stl_num_primes = 28;
static const unsigned unsigned long __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_num_primes > val)
{
return __stl_prime_list[i];
}
}
//按照我们的逻辑程序不会走到这里,但是编译器不会理解
//编译器只会检查语法逻辑,如果if没进入,那么此时就没有返回
//编译器必须保证每条可控的路径都有返回值,否则编译器会报错
return __stl_prime_list[__stl_num_primes - 1];
}
HashTable()
{
//_tables.resize(10, nullptr);
//获取比1大的质数
_tables.resize(GetNextPrimer(1), nullptr);
_n = 0;
}
//负载因子扩容
//vector<Node*> newTables(_tables.size() * 2, nullptr);
vector<Node*> newTables(GetNextPrimer(_tables.size()), nullptr);
所以这样就能保证表的大小一直是一个质数,我们来测试一下这里的扩容。
4. 哈希的应用
4.1 位图
4.1.1 位图概念
1. 面试题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。【腾讯】
- 1. 遍历,时间复杂度O(N)
- 2. 排序(O(NlogN)),利用二分查找: logN
- 3. 位图解决
数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一 个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0 代表不存在。比如:
2. 位图概念
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用 来判断某个数据存不存在的。
4.1.2 位图的实现
在实现之前,我们先来看一下关于一些位运算的计算。
随后我们就来直接写我们的位图的框架
namespace yu
{
//非类型模板参数
template<size_t N>
class bitset
{
private:
vector<int> _bits;
};
}
然后我们在来实现关于位图的一些操作,首先是把x映射的位标记成1。
//把x映射的位标记成1
void set(size_t x)
{
assert(x < N);
size_t i = x / 32;
size_t j = x % 32;
_bits[i] |= (1 << j);
}
代码就写完了,我们来看看有没有什么问题?
根据上面的计算我们成功计算除了50在那个整型的第18个比特位上,这个第18位是从左往右数的第18位,还是从右往左数的第18位呢?按照我们上面的左移逻辑那不是从右向左数的吗?但是我们要的是从左向右数呀!解决这个问题,我们先来看看一个变量在内存中每个字节的存储形式。
这里是按照小端字节序存储的,1的二进制表示为:00 00 00 01,但是存储的时候是01 00 00 00
那么我们这里是不是就要关注大小端的结构呢?大小端左移的逻辑是不同的,我们不要关注,我们要理解移位操作符的含义,左移是往高位移动,右移是往低位移动,移位操作是不用关系大小端的影响的。所以我们上面就不需要关心从左到右还是从右到左,左移就是往高位移动,我们不需要关心,我们可以来看一下左移的特点。
我们再来完善把x映射的位标记成0。
//把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。
//判断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);
}
现在我们就可以来测试一下
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;
}
}
bs1.reset(90);
bs1.set(91);
cout << endl;
for (size_t i = 0; i < 100; i++)
{
if (bs1.test(i))
{
cout << i << "->" << "在" << endl;
}
}
}
测试结果:
如果我们这里有40亿个数据该怎么设置比特位总数呢?
//const size_t n = pow(2, 32);
//bitset<n> bs2;//<必须为常量>
bitset<-1> bs2;
bitset<UINT_MAX> bs3;
bitset<0xffffffff> bs4;
4.1.3 位图的应用
- 快速查找某个数据是否在一个集合中
- 排序 + 去重
- 求两个集合的交集、并集等
- 操作系统中磁盘块标记
4.2 布隆过滤器
4.2.1 布隆过滤器提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉 那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用 户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那 些已经存在的记录。 如何快速查找呢?
- 1. 用哈希表存储用户记录,缺点:浪费空间
- 2. 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理 了。
- 3. 将哈希与位图结合,即布隆过滤器
字符串变成整型,然后再在位图上进行映射,但是会存在误判。
4.2.2布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概 率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
4.2.3 布隆过滤器的插入
由于我们设计的是一个字符串映射三个位置,所以我们这里需要三个将字符串转为整型的仿函数,各种字符串Hash函数 - clq - 博客园 (cnblogs.com),我们可以参考这篇文章的算法。
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;
}
};
现在我们再来写一个这个布隆过滤器的框架,首先我们要解决的问题就是我们这里的布隆过滤器需要开多大空间呢?详解布隆过滤器的原理,使用场景和注意事项 - 知乎 (zhihu.com),我们可以看一个这篇文章,总结出:
所以我们就可以搭建布隆过滤器的框架啦!
#pragma once
#include <bitset>
#include <string>
#include <iostream>
#include <vector>
using namespace std;
template<size_t N,
class K = string,
class Hash1 = HashFuncBKDR,
class Hash2 = HashFuncAP,
class Hash3 = HashFuncDJB>
class BloomFilter
{
public:
private:
// N 是插入数据的个数
// M 是布隆过滤器的长度
// 我们这里取5倍关系
// 如果我们这里不加staict,那么就会报错
// const size_t M = 5 * N;只是声明,并没有开空间,初始化列表的时候才会初始化
// 加入static就是放在静态区,就不是属于对象,而是整个类
static const size_t M = 5 * N;
std::bitset<M>* _bs = new std::bitset<M>;
};
我们发现我们这里的框架的成员变量使用的bitset对象的指针,而不是对象本身,为什么呢?我们可以先来对比一下库和我们上面实现的位图哪里不一样。
库里面是通过静态数组来实现位图的,而静态数组是在栈上开辟空间的,一旦我们的数据量非常大的时候,我们的栈就会出现溢出的问题,我们可以new一个bitset对象的指针,这样就转到堆上了,所以我们上面使用的是对象的指针,而不是对象本身。然后我们向布隆过滤器中插入:"baidu"。
void Set(const K& key)
{
// Hash1仿函数,Hash1()匿名对象,再去调用operator()
size_t hash1 = Hash1()(key) % M;
size_t hash2 = Hash2()(key) % M;
size_t hash3 = Hash3()(key) % M;
// 把对应的位都标志为1
_bs->set(hash1);
_bs->set(hash2);
_bs->set(hash3);
}
4.2.4 布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特 位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为 零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可 能存在,因为有些哈希函数存在一定的误判。
比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其 他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。
bool Test(const K& key)
{
size_t hash1 = Hash1()(key) % M;
// 一个值映射三个位置,只要有一个不在,直接返回false
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个位都是跟别人冲突的,所以误判)
}
4.2.5 布隆过滤器删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu”元素也 被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
比如:删除百度就会对腾讯造成影响。
// 不支持删除,删除可能会影响其他值
// 有可能当前字符串和另一个字符串映射发生冲突
// 当前字符串删除就会影响另一个字符串发生冲突的比特位
void Reset(const K& key);
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计 数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储 空间的代价来增加删除操作。
缺陷: 1. 无法确认元素是否真正在布隆过滤器中 2. 存在计数回绕
根据上面的插入和查找,现在我们就来测试一下
void TestBloomFilter1()
{
string strs[] = { "百度","字节","腾讯" };
BloomFilter<10> bf;//10个元素
for (auto& s : strs)
{
bf.Set(s);
}
for (auto& s : strs)
{
cout << bf.Test(s) << endl;
}
cout << bf.Test("摆渡") << endl;
cout << bf.Test("百渡") << endl;
}
我们来看一下运行结果:
我们再来一组比较难的测试案例:
void TestBloomFilter2()
{
srand(time(0));
const size_t N = 1000;
BloomFilter<N> bf;
vector<string> v1;
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 + to_string(i));
}
for (auto& str : v1)
{
bf.Set(str);
}
// v2跟v1是相似字符串集(前缀一样),但是后缀不一样
vector<string> v2;
for (size_t i = 0; i < N; ++i)
{
string urlstr = url;
urlstr += to_string(9999999 + i);
v2.push_back(urlstr);
}
size_t n2 = 0;
// v2里面存放的串应该都是不在的
for (auto& str : v2)
{
if (bf.Test(str)) // 误判
{
++n2;
}
}
cout << "相似字符串误判率:" << (double)n2 / (double)N << endl;
// 不相似字符串集 前缀后缀都不一样
vector<string> v3;
for (size_t i = 0; i < N; ++i)
{
string url = "zhihu.com";
url += 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;
}
我们来看一下运行结果:
4.2.6 布隆过滤器优点
- 1. 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 2. 哈希函数相互之间没有关系,方便硬件并行运算
- 3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
4.2.7 布隆过滤器缺陷
- 1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 2. 不能获取元素本身
- 3. 一般情况下不能从布隆过滤器中删除元素
- 4. 如果采用计数方式删除,可能会存在计数回绕问题
应用:昵称注册
1.容忍误判
2.不容忍误判
5. 海量数据面试题
5.1 哈希切割
给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址? 与上题条件相同,如何找到top K的IP?如何直接用Linux系统命令实现?
5.2 位图应用
1. 给定100亿个整数,设计算法找到只出现一次的整数?这里就不是在不在的问题,而是一个典型的kv型的问题了,需要统计次数,但是这里数据量极大,map存储不下,我们这里可以利用两个位图解决问题。
我们这里可以直接使用上面的位图来实现双位图的操作。
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);
}
else if (_bs1.test(x) == false
&& _bs2.test(x) == true)
{
// 01 -> 10
_bs1.set(x);
_bs2.reset(x);
}
}
//int test(size_t x)
//{
// if (_bs1.test(x) == false
// && _bs2.test(x) == false)
// {
// return 0; //出现0次
// }
// else if (_bs1.test(x) == false
// && _bs2.test(x) == true)
// {
// return 1; //出现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;
}
}
}
我们来看一下运行结果:
如果这道题只有512MB空间怎么办?100亿个整型大概需要占用40G的内存,按照我们上面的逻辑,使用两个位图就需要1G的空间,那么我们该怎么处理呢?我们可以使用分范围访问。
2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?我们这里的思路还是使用两个位图,然后进行按位与的结果是1就表示是交集。
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;
}
}
我们来看一下运行结果:
我们上面实现的去重的,如果我们不想去重呢?
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 (auto i : a2)
{
if (bs1.test(i) && bs2.test(i))
{
cout << i << endl;
}
}
}
我们来看一下运行结果:
3. 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
咦,这里我们就有一个问题了,我们这里有100亿个int,那么按照常理也就有100亿个比特位需要映射,那么就需要100亿/8/1024/1024/1024 = 1.16GB,这样不是超过了1G内存吗?注意我们有100亿个int并不是说明我们需要100亿个比特位,整数最多只能映射到4,294,967,296,也就是差不多42亿个比特位,也就说明上面的100亿个int存在大量的重复值,我们实际开的位图也只有两个4,294,967,296/8/1024^3 = 0.5G,而这个题目我们要使用两个位图,刚好也就是1G内存,刚好满足题目的要求,所以我们上面开的是范围,就算只给我们两个整数,我们的位图也需要42亿个比特位,开多少个比特位和数据的个数无关,根我们的范围有关,因为我们这里使用的是直接定制法。
5.3 布隆过滤器
1. 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
精确算法:
平均切找交集是N^2
哈希切找交集是N
近似算法:
- 初始化布隆过滤器:
- 创建一个足够大的位数组作为布隆过滤器的底层数据结构。由于内存限制为1G,你需要根据query的数量和预期的错误率来计算合适的位数组大小。
- 选择几个合适的哈希函数,用于将query映射到位数组的不同位置。
- 构建第一个文件的布隆过滤器:
- 遍历第一个文件中的每个query。
- 对于每个query,使用哈希函数将其映射到位数组中的几个位置,并将这些位置上的位设置为1。
- 完成遍历后,你就得到了一个包含第一个文件中所有query信息的布隆过滤器。
- 查询第二个文件的交集:
- 遍历第二个文件中的每个query。
- 对于每个query,使用相同的哈希函数将其映射到位数组中的位置。
- 如果所有映射到的位置上的位都是1,则认为该query可能存在于第一个文件中(注意,这里存在误报的可能性,因为布隆过滤器可能会有假阳性)。
- 将这些可能存在于第一个文件中的query输出到一个新的文件或集合中。
2. 如何扩展BloomFilter使得它支持删除元素的操作
总结:海量数据问题特征:数据量大,内存存不下
- 1、先考虑具有特点的数据结构能否解决?比如:位图、堆、布隆过滤器等等。
- 2、大事化小思路。哈希切分(不能平均切分)切小以后,放到内存中能处理。