目录
2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
3. 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
一、两个相关的容器
假如从底层实现上去取名字的话,map和set的实现可以分为hash实现和tree的实现
hash_map/hash_set
tree_map/tree_set
最大的区别是unordered系列提供的是单向的迭代器
unordered_map
unordered_set
对比map/set区别
1、map和set遍历是有序的,unordered系列是无序的
2、map和set是双向双迭代器,unordered系列是单向
基于上面比较,相比而言map/set更强大,为什么还需要提供unordered系列呢?
因为unordered系列在大量数据插入的时候更具有优势。
简单使用unordered_map
给你一个整数数组 nums ,该数组具有以下属性:
nums.length == 2 * n.
nums 包含 n + 1 个 不同的 元素
nums 中恰有一个元素重复 n 次
找出并返回重复了 n 次的那个元素。示例 1:
输入:nums = [1,2,3,3]
输出:3
示例 2:输入:nums = [2,1,2,5,3,2]
输出:2
示例 3:输入:nums = [5,1,5,2,5,3,5,4]
输出:5来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/n-repeated-element-in-size-2n-array
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
class Solution {
public:
int repeatedNTimes(vector<int>& nums) {
unordered_map<int,int> countMap;
for(auto e:nums)
countMap[e]++;
for(auto&kv:countMap)
{
if(kv.second==nums.size()/2)
return kv.first;
}
return 0;
}
};
验证是无序的
#include <iostream>
#include <unordered_map>
#include <unordered_set>
using namespace std;
void test_set()
{
unordered_set<int> s;
//set<int> s;
s.insert(2);
s.insert(3);
s.insert(1);
s.insert(2);
s.insert(5);
//unordered_set<int>::iterator it = s.begin();
auto it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
cout << endl;
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
}
int main() {
test_set();
return 0;
}
查看性能
#include <iostream>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <set>
#include <time.h>
using namespace std;
void test_op()
{
int n = 10000000;
vector<int> v;
v.reserve(n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
//v.push_back(i);
//v.push_back(rand()+i); // 重复少
v.push_back(rand()); // 重复多
}
size_t begin1 = clock();
set<int> s;
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
size_t begin2 = clock();
unordered_set<int> us;
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << s.size() << endl;
cout << "set insert:" << end1 - begin1 << endl;
cout << "unordered_set insert:" << end2 - begin2 << endl;
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 find:" << end3 - begin3 << endl;
cout << "unordered_set find:" << end4 - begin4 << endl;
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "set erase:" << end5 - begin5 << endl;
cout << "unordered_set erase:" << end6 - begin6 << endl;
}
int main() {
test_op();
return 0;
}
我们看到我们的unordered的系列所用的时间明显比set更加快速
(时间单位是毫秒)
大量数据时,增删查改unordered系列增删查改效率更优,尤其是查找
二、底层结构
hash和我们的树结构不同,它是依靠映射关系来实现查找的
哈希/散列 --值跟存储位置建立映射的关联关系来实现查找。
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放搜索元素对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
1. 直接定址法--(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况 比方说我们之前的计数排序,统计字母的个数,我们就是将字母的asc码值作为我们的映射函数,然后统计每一个字母的个数
但是如果我们插入的数据间隔非常大呢?范围又没有那么集中?
比方说3,7,9,300,70000
我们不可能开辟70000个空间,然后再第3,7,9,300,70000的位置标记有这个元素,其他的数据全部标记为0
我们就需要将我们的数据进行映射。
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种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
哈希冲突
对于两个数据元素的关键字k_i和 k_j(i != j),有k_i != k_j,但有:Hash(k_i) ==
Hash(k_j),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?(冲突越多,哈希的效率就越低下)
假设300%10->0
70000%10->0
它们两个都想要存储在0的位置
1.闭散列--开方定址法
我当前这个hash的位置已经有值了,那我就往后去找一找还有没有没有被占用的位置
那怎么去找这个hash位置呢?
a.线性探测
当前这个位置被占用了,它就往后去探测有没有没有被占用的空位置。
我们的300先插入到了0的位置,那我们的70000来的时候我们就从300的存储的位置往后查看,如果有空的位置,我们就将70000放入。
但是我们如果将这里的20删除了,那么我们的13就查找不到了,因为我们查找到空我们就停止查找了。我们可以设置一个标志位,将其标志为删除。
哈希表在什么情况下进行删除?
这里我们引入负载因子的概念。
负载因子越大的话,冲突的概率越大,负载因子越小,冲突的概率越小
什么时候扩容,负载因子到一个基准值就扩容。
基准值越大,冲突概率越大,效率越低,但是空间利用率越高
基准值越小,冲突概率越小,效率越高,空间利用率越低
但是我们hash一旦扩容了,我们原来的映射关系全部都乱掉了。
哈希表的扩容的代价是很大的。
#ifndef HASH_TEST_HASHTABLE_H
#define HASH_TEST_HASHTABLE_H
#pragma once
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K,class V>
struct HashData
{
pair<K,V> _kv;
State _state=EMPTY;
};
template<class K,class V>
class HashTable
{
public:
bool Insert(const pair<K,V>&kv)
{
//如果已经有了就不插入了
if(Find(kv.first))
return false;
//负载因子超出了某一个阈值就进行扩容
if(_tables.size()==0||10*_size/_tables.size()>=7)//扩容
{
size_t newSize=_tables.size()==0?10:_tables.size()*2;
//创建一个新的哈希表对象
HashTable<K,V> newHT;
newHT._tables.resize(newSize);
//将旧表的数据映射到新表
for(auto e:_tables)
{
if(e._state==EXIST)
{
//复用自己,如果自己当前这个位置的数据是存在的,就将其插入到新表中。
newHT.Insert(e._kv);
}
}
//用交换,将新表替换掉旧表,然后旧表自动被销毁掉
//这里的析构汉式我们没有写,但是编译器会去调用vector的析构函数,来将旧表析构掉
_tables.swap(newHT._tables);
}
size_t hashi=kv.first % _tables.size();
//线性探测
//如果这个位置已经有值了
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;
}
size_t hashi=key% _tables.size();
while(_tables[hashi]._state!=EMPTY)
{
if(_tables[hashi]._state!=DELETE&&_tables[hashi]._kv.first==key)
{
//找到就返回data的地址
return &_tables[hashi];
}
hashi++;
hashi %=_tables.size();
}
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<pair<K,V>> _table;
vector<HashData<K,V>> _tables;
size_t _size=0;//存储多少个有效数据
};
#endif //HASH_TEST_HASHTABLE_H
测试代码
void TestHT1()
{
int a[]={1,11,4,15,26,7,44,9};
HashTable<int,int> ht;
for(auto e:a)
{
ht.Insert(make_pair(e,e));
}
ht.Erase(4);
cout<<ht.Find(44)<<endl;
cout<<ht.Find(4)<<endl;
//负数不会有bug
//当操作符两边的数据不是相同类型的时候,(无符号和无符号)
//有符号会被提升为无符号,所以我们的-2就提升成了无符号,然后进行了取模运算
ht.Insert(make_pair(-2,-2));
ht.Print();
ht.Find(-2);
}
但是我们要是想要统计词频呢?我们的string类型没有办法取模怎么办?
使用仿函数,为我们的string类定制比较的规则
#ifndef HASH_TEST_HASHTABLE_H
#define HASH_TEST_HASHTABLE_H
#pragma once
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;
}
};
struct HashFuncString
{
//将key转换成无符号的整型
size_t operator()(const string& key)
{
size_t val=0;
//将asc码全部加起来
for(auto ch:key)
{
val+=ch;
}
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(_tables.size()==0||10*_size/_tables.size()>=7)//扩容
{
size_t newSize=_tables.size()==0?10:_tables.size()*2;
//创建一个新的哈希表对象
HashTable<K,V,Hash> newHT;
newHT._tables.resize(newSize);
//将旧表的数据映射到新表
for(auto e:_tables)
{
if(e._state==EXIST)
{
//复用自己,如果自己当前这个位置的数据是存在的,就将其插入到新表中。
newHT.Insert(e._kv);
}
}
//用交换,将新表替换掉旧表,然后旧表自动被销毁掉
//这里的析构汉式我们没有写,但是编译器会去调用vector的析构函数,来将旧表析构掉
_tables.swap(newHT._tables);
}
Hash hash;
size_t hashi=hash(kv.first) % _tables.size();
//线性探测
//如果这个位置已经有值了
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)
{
//找到就返回data的地址
return &_tables[hashi];
}
hashi++;
hashi %=_tables.size();
if(hashi==start)
{
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<pair<K,V>> _table;
vector<HashData<K,V>> _tables;
size_t _size=0;//存储多少个有效数据
};
#endif //HASH_TEST_HASHTABLE_H
测试代码
void TestHT2()
{
string arr[]={"苹果","西瓜","香蕉","草莓","西瓜","香蕉","草莓","西瓜","香蕉","草莓","苹果","西瓜","香蕉","草莓","西瓜","香蕉","草莓","西瓜"};
HashTable<string,int,HashFuncString> countHT;
for(auto &str:arr)
{
auto ptr=countHT.Find(str);
if(ptr)
{
ptr->_kv.second++;
}
else
{
countHT.Insert(make_pair(str,1));
}
}
}
但是为什么unordered_map为什么不用传string的仿函数,直接就可以插入?
因为它使用了特化,也就是直接特化成string类型,那么当传入的key是string类型的时候,就可以直接匹配上string额理性对应的转换方式。
template<class K>
struct HashFunc
{
//将key转换成无符号的整型
size_t operator()(const K& key)
{
//显式类型转换
return (size_t)key;
}
};
//特化成string类型
template<>
struct HashFunc<string>
{
//将key转换成无符号的整型
size_t operator()(const string& key)
{
size_t val=0;
//将asc码全部加起来
for(auto ch:key)
{
val+=ch;
}
return val;
}
};
测试代码
void TestHT2()
{
string arr[]={"苹果","西瓜","香蕉","草莓","西瓜","香蕉","草莓","西瓜","香蕉","草莓","苹果","西瓜","香蕉","草莓","西瓜","香蕉","草莓","西瓜"};
//这样的话,我们这里就不用传string类的仿函数了。
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));
}
}
}
但是将string中每一个字符的asc码值加起来真的能避免冲突吗?
不能!
void TestHT3() {
HashFunc<string> hash;
cout<<hash("abcd")<<endl;
cout<<hash("bcad")<<endl;
cout<<hash("eat")<<endl;
cout<<hash("ate")<<endl;
cout<<hash("aadd")<<endl;
}
这样我们的hash表就会产生很多的冲突,从而降低我们的哈希表的效率。
采用BKDR的思想,每一次计算asc码的时候*131
template<>
struct HashFunc<string>
{
//将key转换成无符号的整型
//BKDR
size_t operator()(const string& key)
{
size_t val=0;
//将asc码全部加起来
for(auto ch:key)
{
val*=131;
val+=ch;
}
return val;
}
};
再次测试我们的代码
void TestHT3() {
HashFunc<string> hash;
cout<<hash("abcd")<<endl;
cout<<hash("bcad")<<endl;
cout<<hash("eat")<<endl;
cout<<hash("ate")<<endl;
cout<<hash("aadd")<<endl;
}
我们发现现在我们的数字不同了。
b.二次探测
线性探测中存在的问题
给定
1 11 21 31 2 12
这几个数字,会产生下面这种情况
某个位置冲突很多的情况下,相互占用,冲突一片。
这里我们就不妨使用二次探测来解决这个问题。
线性探测是按照
hash+i (i>=0)进行插入
二次探测是按照
hash+i^2(i>=0)
从而让我们插入的数据相对而言更加稀疏一点,从而缓解上面线性探测中的问题
#ifndef HASH_TEST_HASHTABLE_H
#define HASH_TEST_HASHTABLE_H
#pragma once
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;
}
};
template<>
struct HashFunc<string>
{
//将key转换成无符号的整型
//BKDR
size_t operator()(const string& key)
{
size_t val=0;
//将asc码全部加起来
for(auto ch:key)
{
val*=131;
val+=ch;
}
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(_tables.size()==0||10*_size/_tables.size()>=7)//扩容
{
size_t newSize=_tables.size()==0?10:_tables.size()*2;
//创建一个新的哈希表对象
HashTable<K,V,Hash> newHT;
newHT._tables.resize(newSize);
//将旧表的数据映射到新表
for(auto e:_tables)
{
if(e._state==EXIST)
{
//复用自己,如果自己当前这个位置的数据是存在的,就将其插入到新表中。
newHT.Insert(e._kv);
}
}
//用交换,将新表替换掉旧表,然后旧表自动被销毁掉
//这里的析构汉式我们没有写,但是编译器会去调用vector的析构函数,来将旧表析构掉
_tables.swap(newHT._tables);
}
Hash hash;
//start是起始的位置
size_t start=hash(kv.first) % _tables.size();
size_t i=0;
size_t hashi=start;
//二次探测
//如果这个位置已经有值了
while(_tables[hashi]._state==EXIST)
{
++i;
hashi=start+i*i;
//如果超出了范围就回到起点的位置
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)
{
//找到就返回data的地址
return &_tables[hashi];
}
hashi++;
hashi %=_tables.size();
if(hashi==start)
{
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<pair<K,V>> _table;
vector<HashData<K,V>> _tables;
size_t _size=0;//存储多少个有效数据
};
#endif //HASH_TEST_HASHTABLE_H
但是二次探测还是占用式的,没有从根本上解决问题。
2.开散列--拉链法(哈希桶)
也就是说,如果有冲突的话,我们就在那个冲突的位置用一张链表,将冲突位置的数据全部都连接起来
所以冲突的数据不会影响别的桶,从而彻底解决了我们上面所提出来的问题。
(哈希表里面存储的是指针数组)
这里我们其实挂单链表比较好。
因为我们并不知道每一个桶中的链表的顺序,所以我们都是要从上到下遍历一遍的,所以还不如用单链表,毕竟节省了一个指针的空间位置。
查找中间的结点的话,双向链表和单链表没啥区别。
按时间复杂度来说,我们当前的哈希桶的时间复杂度是O(n),因为最坏的时间复杂度就是我们将全部的数据都挂在了同一个桶上。
但是哈希表里面的全部数据都是冲突的概率并不大,并且在扩容之后,原本冲突的数据也可能变成不冲突。
那么哈希桶的平均时间复杂度,每个哈希桶下面一般会挂常数个数据。
那当然挂得太多了,就可以将桶改成红黑树
往桶中插入的话,头插法比较好,因为这样我们就不用遍历整个桶,就可以直接插入,更加方便。
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 hashTable{
typedef HashNode<K,V> Node;
public:
//因为默认析构的话,编译器只会帮助我们将这一张散列表给析构掉
//但是我们的散列表的中的每一个哈希桶中的单链表并不会被析构
//所以我们需要自己写一个析构函数,来将我们每一个哈希桶中链表的空间全部都释放掉。
~hashTable()
{
for(size_t i=0;i<_tables.size();++i)
{
Node* cur=_tables[i];
while(cur)
{
Node* next=cur->_next;
free(cur);
cur=next;
}
_tables[i]=nullptr;
}
}
bool Insert(const pair<K,V> &kv)
{
//去重
//如果已经存在了,就不要插入了
if(Find(kv.first))
{
return false;
}
//负载因子到1就扩容
//负载因子如何控制?
if(_size==_tables.size())
{
size_t newSize=_tables.size()==0?10:_tables.size()*2;
vector<Node*> newTables;
newTables.resize(newSize, nullptr);
//旧表中的结点映射到新表
for(size_t i=0;i<_tables.size();++i)
{
Node*cur=_tables[i];
while(cur)
{
Node* newxt=cur->_next;
size_t hashi=cur->_kv.first% newTables.size();
cur->_next=newTables[hashi];
newTables[hashi]=cur;
cur=newxt;
}
_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;
++_size;
return true;
}
//去重
Node *Find(const K&key)
{
if(_tables.size()== 0)
{
return nullptr;
}
size_t hashi=key%_tables.size();
Node * cur=_tables[hashi];
while(cur)
{
if(cur->_kv.first==key)
{
return cur;
}
cur=cur->_next;
}
return nullptr;
}
bool Erase(const K&key)
{
//找到这个位置,将这个节点给去掉
return true;
}
private:
vector<Node*> _tables;
size_t _size=0;//存储有效数据个数
};
测试代码
void TestHT1() {
int a[] = {1, 11, 4, 15, 26,7,44,9,99,77,22,55,12,16,28,39,12,13,32,21,32};
hashTable<int, int> ht;
for (auto e: a) {
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(22,22));
}
哈希桶的删除
由于我们采用了链表结构的哈希桶,其实哦我们删除的时候就跟删除链表中某个节点的元素是一样的。
bool Erase(const K&key)
{
//找到这个位置,将这个节点给去掉
if(_tables.size()==0)
{
return false;
}
size_t hashi=key%_tables.size();
//prev是我们cur的前置结点,当cur是我们要删除的结点的时候,我们就需要用到我们的前置结点
Node* prev= nullptr;
Node* cur=_tables[hashi];
while(cur)
{
//找到了要删除的结点
if(cur->_kv.first==key)
{
//1、头删(删除的刚好就是我们哈希映射表中的那个结点)
if(prev== nullptr)
{
_tables[hashi]=cur->_next;
}
//2、中间删
else
{
prev->_next=cur->_next;
}
delete cur;
return true;
}
//更新我们的前置结点
prev=cur;
cur=cur->_next;
}
//我们想要删除的值根本不在我们的当前的这个桶里面,我们就删除失败了。
return false;
}
void TestHT1() {
int a[] = {1, 11, 4, 15, 26,7,44,9,99,77,22,55,12,16,28,39,12,13,32,21,32};
hashTable<int, int> ht;
for (auto e: a) {
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(22,22));
ht.Erase(4);
ht.Erase(44);
}
三、哈希的优化
1.仿函数键将传入的内容转换成key值
//创建仿函数,用于将key值转换成无符号的整型进行比较
template<class K>
struct HashFunc {
//将key转换成无符号的整型
size_t operator()(const K &key) {
//显式类型转换
return (size_t) key;
}
};
//特化string类型的模板匹配
template<>
struct HashFunc<string> {
//将key转换成无符号的整型
//BKDR
size_t operator()(const string &key) {
size_t val = 0;
//将asc码全部加起来
for (auto ch: key) {
val *= 131;
val += ch;
}
return val;
}
};
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()
{
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;
}
}
bool Insert(const pair<K,V> &kv)
{
//去重
//如果已经存在了,就不要插入了
if(Find(kv.first))
{
return false;
}
//负载因子到1就扩容
//负载因子如何控制?
Hash hash;
if(_size==_tables.size())
{
size_t newSize=_tables.size()==0?10:_tables.size()*2;
vector<Node*> newTables;
newTables.resize(newSize, nullptr);
//旧表中的结点映射到新表
for(size_t i=0;i<_tables.size();++i)
{
Node*cur=_tables[i];
while(cur)
{
Node* next=cur->_next;
//创建仿函数,然后调用仿函数来为我们求出key值
size_t hashi=hash(cur->_kv.first)% newTables.size();
cur->_next=newTables[hashi];
newTables[hashi]=cur;
cur=next;
}
_tables[i]= nullptr;
}
_tables.swap(newTables);
}
//创建仿函数,然后调用仿函数来为我们求出key值
size_t hashi=hash(kv.first)%_tables.size();
//头插
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;
//创建仿函数,然后调用仿函数来为我们求出key值
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;
}
bool Erase(const K&key)
{
//找到这个位置,将这个节点给去掉
if(_tables.size()==0)
{
return false;
}
Hash hash;
//创建仿函数,然后调用仿函数来为我们求出key值
size_t hashi=hash(key)%_tables.size();
//prev是我们cur的前置结点,当cur是我们要删除的结点的时候,我们就需要用到我们的前置结点
Node* prev= nullptr;
Node* cur=_tables[hashi];
while(cur)
{
//找到了要删除的结点
if(cur->_kv.first==key)
{
//1、头删(删除的刚好就是我们哈希映射表中的那个结点)
if(prev== nullptr)
{
_tables[hashi]=cur->_next;
}
//2、中间删
else
{
prev->_next=cur->_next;
}
delete cur;
--_size;
return true;
}
//更新我们的前置结点
prev=cur;
cur=cur->_next;
}
//我们想要删除的值根本不在我们的当前的这个桶里面,我们就删除失败了。
return false;
}
private:
vector<Node*> _tables;
size_t _size=0;//存储有效数据个数
};
void TestHT1() {
int a[] = {1, 11, 4, 15, 26, 7, 44, 9,99,77,22,55,12,16,28,39,12,13,32,21,32};
HashTable<int, int> ht;
for (auto e: a) {
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(22,22));
ht.Erase(4);
ht.Erase(44);
cout<<"hello world"<<endl;
}
void TestHT2()
{
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
//HashTable<string, int, HashFuncString> 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));
}
}
}
}
2.将桶的大小调整为素数
因为素数只有素数本身和1能够将其整除
//加上inline,将其变成局部静态
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
};
//寻找第一个大于n的值,也就是找到我们应该需要扩容到的大小
for (size_t i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
//为了满足语法的执行逻辑,我们在这里返回一个-1
return -1;
}
bool Insert(const pair<K,V> &kv)
{
//去重
//如果已经存在了,就不要插入了
if(Find(kv.first))
{
return false;
}
//负载因子到1就扩容
//负载因子如何控制?
Hash hash;
if(_size==_tables.size())
{
//使用我们的刚刚的素数的大小容量进行扩容
// size_t newSize=_tables.size()==0?10:_tables.size()*2;
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];
while(cur)
{
Node* next=cur->_next;
//创建仿函数,然后调用仿函数来为我们求出key值
size_t hashi=hash(cur->_kv.first)% newTables.size();
cur->_next=newTables[hashi];
newTables[hashi]=cur;
cur=next;
}
_tables[i]= nullptr;
}
_tables.swap(newTables);
}
//创建仿函数,然后调用仿函数来为我们求出key值
size_t hashi=hash(kv.first)%_tables.size();
//头插
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;
//创建仿函数,然后调用仿函数来为我们求出key值
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;
}
3.查看一下我们哈希桶的性能
size_t Size()
{
return _size;
}
//有多少个桶放入了数据
size_t BucketNum()
{
size_t num=0;
for(size_t i=0;i<_tables.size();++i)
{
if(_tables[i])
{
++num;
}
}
return num;
}
//表的长度
size_t TablesSize()
{
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 > 0)
// printf("[%d]号桶长度:%d\n", i, len);
if (len > maxLen)
{
maxLen = len;
}
}
return maxLen;
}
测试代码
void TestHT3()
{
int n = 19000000;
vector<int> v;
v.reserve(n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
//v.push_back(i);
v.push_back(rand() + i); // 重复少
//v.push_back(rand()); // 重复多
}
size_t begin1 = clock();
HashTable<int, int> ht;
for (auto e : v)
{
ht.Insert(make_pair(e, e));
}
size_t end1 = clock();
cout << "数据个数:" << ht.Size() << endl;
cout << "表的长度:" << ht.TablesSize() << endl;
cout << "桶的个数:" << ht.BucketNum() << endl;
cout << "平均每个桶的长度:" << (double)ht.Size() / (double)ht.BucketNum() << endl;
cout << "最长的桶的长度:" << ht.MaxBucketLenth() << endl;
cout << "负载因子:" << (double)ht.Size() / (double)ht.TablesSize() << endl;
}
四、哈希表的封装
UnorderedMap.h的封装
#include "HashTable1.h"
namespace Bucket
{
//配置一个仿函数
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 Bucket::HashTable<K, pair<K, V>, Hash, MapKeyOfT>::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 = _ht.Insert(make_pair(key, V()));
return ret.first->second;
}
private:
Bucket::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", ""));
unordered_map<string, string>::iterator it = dict.begin();
while (it != dict.end())
{
cout << it->first << ":" << it->second << endl;
++it;
}
cout << endl;
unordered_map<string, int> countMap;
string arr[] = { "ƻ", "", "ƻ", "", "ƻ", "ƻ", "", "ƻ", "㽶", "ƻ", "㽶" };
for (auto e : arr)
{
countMap[e]++;
}
for (auto& kv : countMap)
{
cout << kv.first << ":" << kv.second << endl;
}
}
}
UnorderedSet.h的封装
#include "HashTable1.h"
namespace Bucket
{
template<class K, class Hash = HashFunc<K>>
class unordered_set
{
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
public:
//告诉编译器这是一个类型,不是静态成员变量
typedef typename Bucket::HashTable<K, K, Hash, SetKeyOfT>::iterator iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
pair<iterator, bool> insert(const K& key)
{
return _ht.Insert(key);
}
private:
Bucket::HashTable<K, K, Hash, SetKeyOfT> _ht;
};
void test_set()
{
unordered_set<int> s;
s.insert(2);
s.insert(3);
s.insert(1);
s.insert(2);
s.insert(5);
unordered_set<int>::iterator it = s.begin();
//auto it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
}
HashTable.h
#pragma once
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//对于字符串类型的特化取出key值
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
namespace Bucket
{
template<class T>
struct HashNode
{
T _data;
HashNode<T>* _next;
HashNode(const T& data)
:_data(data)
, _next(nullptr)
{}
};
// 前置声明
//这里的T是我们传入的pair,
//这里的K是pair中的key值的类型
//然后Hash是我们的仿函数,用来将输入的key转换成我们的哈希映射
//KeyOfT取出T中的K值
template<class K, class T, class Hash, class KeyOfT>
class HashTable;
//哈希表的迭代器中的顺序不一定是有序的,因为我们这里的unordered_map和unordered_set天生就是无序的。
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)
{
// 当前桶中迭代
_node = _node->_next;
}
else
{
// 找下一个桶
Hash hash;
KeyOfT kot;
//取出data中的key,然后将其转化成合法的哈希映射
//先计算我当前在哪一个桶
size_t i = hash(kot(_node->_data)) % _pht->_tables.size();
//然后寻找下一个桶
++i;
//往后不断遍历,寻找第一个不为空的桶
for (; i < _pht->_tables.size(); ++i)
{
//如果找到了一个不为空的桶,就开始将该桶的第一个结点返回给_node
if (_pht->_tables[i])
{
_node = _pht->_tables[i];
break;
}
}
// 说明后面没有有数据的桶了
if (i == _pht->_tables.size())
{
_node = nullptr;
}
}
return *this;
}
bool operator!=(Self& s) const
{
return _node != s._node;
}
bool operator==(Self& s) const
{
return _node == s._node;
}
};
template<class K, class T, class Hash, class KeyOfT>
class HashTable
{
typedef HashNode<T> Node;
//模板的友元需要加上模板参数的声明
template<class K, class T, class Hash, class KeyOfT>
friend struct __HashIterator;
public:
typedef __HashIterator<K, T, Hash, KeyOfT> iterator;
iterator begin()
{
//寻找第一个不为空的桶的第一个指针
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i])
{
return iterator(_tables[i], this);
}
}
return end();
}
iterator end()
{
return iterator(nullptr, this);
}
~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;
}
}
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;
}
pair<iterator, bool> Insert(const T& data)
{
Hash hash;
KeyOfT kot;
// 去重
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;
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];
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();
// 头插
Node* newnode = new Node(data);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_size;
return make_pair(iterator(newnode, this), true);
}
iterator Find(const K& key)
{
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 iterator(cur, this);
}
cur = cur->_next;
}
return end();
}
bool Erase(const K& key)
{
if (_tables.size() == 0)
{
return false;
}
Hash hash;
KeyOfT kot;
size_t hashi = hash(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
// 1、头删
// 2、中间删
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_size;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
size_t Size()
{
return _size;
}
// 表的长度
size_t TablesSize()
{
return _tables.size();
}
// 桶的个数
size_t BucketNum()
{
size_t num = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i])
{
++num;
}
}
return num;
}
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; // 存储有效数据个数
};
}
一个类型K去做set和unorder_set他的模板参数有什么要求?
set
a:要求支持小于比较,或者显式提供比较的仿函数
unordered_set
a:K类型对象可以转换整形取模 或者 提供转成整形的仿函数
b:K类型的对象可以支持等于比较 或者提供等于比较的仿函数
五、常见的哈希函数
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种不同的符号在各位上出现的频率不一定
相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只
有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散
列地址。
六、位图
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
1G=1024MB
1024MB=1024*1024KB
1024*1024KB=1024*1024*1024Byte
2^30约等于10亿
一个整数是4个字节,40亿大概是160亿字节,也就是16G空间左右
1. 遍历,时间复杂度O(N)
2. 排序(O(NlogN)),利用二分查找: logN
1.搜索树和哈希表是不行的,因为搜索树还要存储大量的相关结点关系,辅助空间还要好几个G,非常麻烦,内存中存不下
2.排序+二分查找:数据太大,只能放在磁盘文件上,不好支持二分查找。
外排序,二分查找效率慢,磁盘上的外排序非常慢
3. 位图解决
(直接定值法)数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。
也就是说我们需要2的32次方的个数的比特位,一个字节是8个比特位,也就是4G/8也就是512兆,存储效果非常好
这里我们用开辟char的方式开辟我们的位图空间,第一个char记录的是第0-7个位,第二个char记录的是8-15个位,以此类推。
template<size_t N>
class bitset
{
public:
//开辟位图的空间
bitset()
{
//一个char是1个字节,是8个比特位,随意除以8,
//万一我们的N是10的话,10/8,也就是对于8不能整除的话,我们需要多开辟一个字节。
//这里我们永远多开辟一个比特位,虽然存在一点点浪费,但是无伤大雅
_bits.resize(N/8+1, 0);
}
//将对应的比特位变成1
void set(size_t x)
{
//计算我们放入的数据在第一个char中的第几个位置的地方
//比方说20/8=2,也就是在第2号char中
//20%8->4也就是在第二个char的第四个比特位中
size_t i = x / 8;
size_t j = x % 8;
//第i号char中的第j位设置为1,这里我们用或运算
_bits[i] |= (1 << j);
}
//将x映射的那个位重置为0
void reset(size_t x)
{
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;
//按位与,取出第i个char的第j位
return _bits[i] & (1 << j);
}
private:
//用char控制我们的位图的大小
vector<char> _bits;
};
测试代码
void test_bit_set1()
{
bitset<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;
bs1.reset(8);
bs1.reset(9);
bs1.reset(20);
cout << bs1.test(8) << endl;
cout << bs1.test(9) << endl;
cout << bs1.test(20) << endl;
}
如何表示大数
void test_bit_set2()
{
bitset<(unsigned int)-1> bs1;
bitset<0xffffffff> bs2;
}
位图的问题
1. 给定100亿个整数,设计算法找到只出现一次的整数?
key_value的统计次数的模型
这些整数有出现0次,1次和两次即以上
我们可以用两个位表示出这三种情况
0次 00
1次 01
2次即以上 10
两个位表示一个数是否存在
一个char有8个比特位,我们这里用两个比特位表示一个数的存在的次数,所以我们一个char表示了4个数的存在与否
如果我们不想改动之前的代码,我们就用两张位图,两张位图的第一个位用于表示第一个数据的出现次数,两张位图的第二个位用来保存第二个数的出现次数,以此类推。
代码实现
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
bool inset1 = _bs1.test(x);
bool inset2 = _bs2.test(x);
// 00
//如果是00的话就变成10
if (inset1 == false && inset2 == false)
{
// -> 01
_bs2.set(x);
}
//如果我们一开始是01,我们将其变成10
else if (inset1 == false && inset2 == true)
{
// ->10
_bs1.set(x);
_bs2.reset(x);
}
//如果一开始是10,我们将其变成11
else if (inset1 == true && inset2 == false)
{
// ->11
_bs1.set(x);
_bs2.set(x);
}
}
void print_once_num()
{
//打印出仅出现过一次的数字
for (size_t i = 0; i < N; ++i)
{
//如果我们的查找的数在我们的两张表中的位图的映射为01,我们就将其打印出来
if (_bs1.test(i) == false && _bs2.test(i) == true)
{
cout << i << endl;
}
}
}
private:
//按照我们上面的说法,创建两张位图
bitset<N> _bs1;
bitset<N> _bs2;
};
测试代码
void test_bit_set3()
{
int a[] = { 3, 4, 5, 2, 3, 4, 4, 4, 4, 12, 77, 65, 44, 4, 44, 99, 33, 33, 33, 6, 5, 34, 12 };
twobitset<100> bs;
//将我们的数据放入位图中
for (auto e : a)
{
bs.set(e);
}
bs.print_once_num();
}
2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
映射位都是1的,就是我们的交集
3. 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
00 0次
01 1次
10 2次
11 3次及以上
类似问题1
位图的优缺点
位图的缺点:只能处理我们的整数,别的数据类型没办法处理。
位图的特点:快,节省空间 ,直接定值法,不存在冲突
位图的应用
1. 快速查找某个数据是否在一个集合中
2. 排序 + 去重
3. 求两个集合的交集、并集等
4. 操作系统中磁盘块标记
位图是标准库中的容器
七、布隆过滤器
上面的位图只能处理整数,但是要是我们传入的是大量的字符串,或者是别的类型的数据呢?
字符串哈希算法转成整形去映射一个位置进行标记。
但是万一我们两个字符串映射的是同一个位置呢?
这个时候我们就会对我们的字符串是否存在产生误判。
在:不准确的,存在误判的(比方说“苹果”映射到了1位置,然后“香蕉”也映射到了1位置,当1位置为1的时候,原本是苹果存在,但是我们误判成了香蕉存在。)
不在:准确的,不存在误判
不可能完全去掉误判,但是我们可以尝试降低误判率。
每个值多映射几个位。
两个字符串映射一个位冲突的话,可能性比较大,但是要是两个字符串映射的三个位都相同的话,就不太可能了。
只要有一个位不冲突,就能够防止误判。
理论而言:一个值映射的位越多,误判的概率就越低,但是也不能够映射过多的位置,因为映射的位阅读,那么空间消耗就越多。
布隆过滤器的使用场景
比方说我们的数据库中有一个黑名单,而我们的布隆过滤器中也映射了黑名单。
布隆过滤器没有数据库中准确,但是布隆过滤器比我们查数据库要快得多。
这个时候如果我们的布隆过滤器发现是在黑名单中的话,再去数据库中寻找(防止误判),然后如果不在黑名单的话,不用去数据库中找,直接返回不在黑名单的结果。因为不在的情况按照我们上述的说法一定是准确的。
如果我们要防止昵称重复,我们可以使用布隆过滤器。如果昵称不在的话,我们能一定保证这个“不在”一定是准确的
布隆过滤器可以提高查找效率,适用于允许误判的场景。
首先我们需要三个不同的哈希映射,来满足我们上述的将字符串映射到不同的三个位置
//第一个哈希映射的方法
struct HashBKDR
{
// BKDR
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
//第二个哈希映射的方法
struct HashAP
{
// BKDR
size_t operator()(const string& key)
{
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ key[i] ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ key[i] ^ (hash >> 5)));
}
}
return hash;
}
};
//第三个哈希映射的方法
struct HashDJB
{
// BKDR
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)
{
//调用对应的哈希函数,将我们的key转换成哈希函数,并使用除留余数法映射到我们的空间中
//Hash1()是一个仿函数
//下面我们用了三个不同的仿函数,将我们同一个字符串映射到三个不同的位置上
size_t hash1 = Hash1()(key) % (_ratio*N);
//cout << hash1 << endl;
_bits->set(hash1);
size_t hash2 = Hash2()(key) % (_ratio*N);
//cout << hash2 << endl;
_bits->set(hash2);
size_t hash3 = Hash3()(key) % (_ratio*N);
//cout << hash3 << endl;
_bits->set(hash3);
}
//判断是不是已经存在
bool Test(const K& key)
{
//在的话可能存在误判,但是不存在话不会误判
//用第一个哈希仿函数计算第一个映射到的位置
size_t hash1 = Hash1()(key) % (_ratio*N);
//cout << hash1 << endl;
if (!_bits->test(hash1))
return false; // 准确的
//用第二个哈希仿函数计算第一个映射到的位置
size_t hash2 = Hash2()(key) % (_ratio*N);
//cout << hash2 << endl;
if (!_bits->test(hash2))
return false; // 准确的
//用第三个哈希仿函数计算第一个映射到的位置
size_t hash3 = Hash3()(key) % (_ratio*N);
//cout << hash3 << endl;
if (!_bits->test(hash3))
return false; // 准确的
return true; // 可能存在误判
}
private:
const static size_t _ratio = 5;
//调用我们库中的位图
//由于我们库中的位图是静态的,是创建在堆上面的,我们这里直接用一个指针
//然后使用new方法,在堆上创建我们的位图
std::bitset<_ratio*N>* _bits = new std::bitset<_ratio*N>;
};
测试代码
void TestBloomFilter1()
{
//大部分情况下,布隆过滤器都是用来处理字符串的
BloomFilter<10> bf;
string arr1[] = { "苹果", "西瓜", "阿里", "美团", "苹果", "字节", "西瓜", "苹果", "香蕉", "苹果", "腾讯" };
for (auto& str : arr1)
{
//将数据插入到我们的布隆过滤器中
bf.Set(str);
}
for (auto& str : arr1)
{
cout << bf.Test(str) << endl;
}
cout << endl << endl;
string arr2[] = { "苹果111", "西瓜", "阿里2222", "美团", "苹果dadcaddxadx", "字节", "西瓜sSSSX", "苹果 ", "香蕉", "苹果$", "腾讯" };
for (auto& str : arr2)
{
cout <<str<<":"<<bf.Test(str) << endl;
}
}
简单测试一下相似字符的误判率和不相似字符的误判率
void TestBloomFilter2()
{
srand(time(0));
const size_t N = 1000000;
BloomFilter<N> bf;
cout << sizeof(bf) << endl;
std::vector<std::string> v1;
std::string url = "https://mp.csdn.net/mp_blog/creation/editor/127701012";
for (size_t i = 0; i < N; ++i)
{
v1.push_back(url + std::to_string(1234 + i));
}
//将n1的数据插入到我们的布隆过滤器当中
for (auto& str : v1)
{
bf.Set(str);
}
// 相似的字符串
std::vector<std::string> v2;
for (size_t i = 0; i < N; ++i)
{
std::string url = "https://mp.csdn.net/mp_blog/creation/editor/127701012";
url += std::to_string(rand() + i);
v2.push_back(url);
}
size_t n2 = 0;
for (auto& str : v2)
{
//如果本来是不在的误判成是在的,我们就将n2++
if (bf.Test(str))
{
++n2;
}
}
cout << "相似字符串误判率:" << (double)n2 / (double)N << endl;
std::vector<std::string> v3;
for (size_t i = 0; i < N; ++i)
{
string url = "zhihu.com";
url += std::to_string(rand()+i);
v3.push_back(url);
}
//将不相似的字符进行查看是否存在,如果存在的话,就将我们误判的元素个数++
size_t n3 = 0;
for (auto& str : v3)
{
if (bf.Test(str))
{
++n3;
}
}
cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl;
}
如果我们想把误判率降低,除了可以扩大我们的位图的大小,还可以传入更多的哈希映射。但是需要衡量多少才是最合适的。
布隆过滤器的相关问题
1. 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
近似算法就是允许一些误判。在这里,我们可以考虑将一个文件中的内容放入我们的布隆过滤器中,再用另一个文件中的内容在这个布隆过滤器中查找是否存在。
哈希切分
1.假设每个query 30byte,100亿个query需要多少空间
1G是10亿字节,3000亿字节大概是300G的空间。
我们假设我们上面想要比较的两个文件是A和B,我们依次读取文件A中的query,i=Hash(query)%1000,我们将其切成1000份
这个query进去Ai小文件中查找
依次读取文件B中的query,i=Hash(query)%1000
这个query进去Bi小文件中查找。
相同的query,一定进入了相同编号的小文件。
放到内存的两个set中,找编号相同的Ai和Bi的小文件找交集即可
我们将A0和B0放入内存中,比较是否相同,然后将A1和B1放入内存中,查找是否相同,以此类推。
那如果某一个小文件没有办法读取到内存中呢?
再对这个小文件进行哈希切分,就相当于是递归调用。
2. 如何扩展BloomFilter使得它支持删除元素的操作
我们上面写的布隆过滤器不支持删除操作,因为我们如果两个字符串的映射存在一部分的相同的位,那么其中一个删除了之后,那么另一个字符串的映射也会查不到,这就会导致删除之后影响其他的值的问题。
这里我们可以尝试用多个位表示一个位置,做计数。当删除掉一个元素的映射位置的时候,我们就将其映射的位置的计数--,从而支持删除。
但是布隆过滤器入股偶支持删除的话,空间消耗更多了,布隆过滤器的优势削弱了。
3.给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?与上题条件相同,如何找到top K的IP?如何直接用Linux系统命令实现?
读取每个ip,i=hash(ip)%500,这个ip进入第i个小文件
关键点:相同的ip一定进入的是同一个小文件。
如果两个哈希的值算出来的是相同的话,不同的ip会进入到同一个小文件中,或者两个ip的哈希值不同,但是%500之后就变成相同的了。
一次使用map<string,int>对每个小文件统计次数。
查找topK的ip建立的一定是K个值为<ip,count>的小堆, 然后使用仿函数,将我们的比较方法变成比较count。
(为什么使用小根堆,因为我们先搭建一个大小为k的小根堆(为了取出topK的元素),然后我们堆定的元素就是最小的元素,然后我们新插入的元素如果比我们堆定的元素大,我们就用新的元素替换掉我们堆定的元素,然后再向下调整。这样,我们的堆中最小的元素又会被换到堆定。以此方式迭代,我们就能够找到topK元素了)
Linux的话可以对文件进行排序然后对文件中的ip进行计数