哈希
概念 (略 参考了其他博客)
哈希:
- 不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素
插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
哈希函数设置为:hash(key) = key % capacity;
capacity为存储元素底层空间总的大小
当向其中插入11时会产生哈希冲突:
哈希冲突
对于两个数据元素的关键字ki 和kj (i != j),有ki !=kj ,但有:Hash(ki ) == Hash(kj ),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”
哈希函数
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数
直接定制法--(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况 面试题:字符串中第一个只出现一次字符除留余数法--(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址平方取中法--(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况折叠法--(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况随机数法--(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。通常应用于关键字长度不等时采用此法数学分析法--(了解)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址
例如
:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
解决哈希冲突
闭散列 (线性和二次 模拟实现)
闭散列:
- 也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去
线性探测:
- 从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
- 插入:
通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
- 删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索
。比如删除元素1,如果直接删除掉,11查找起来可能会受影响。因此线性探测采用标记的伪删除法
来删除一个元素闭散列线性探测模拟实现:
- 结构:
enum Status//标识状态 { EMPTY, EXITS, DELETE };
//注意这里K V都是一个值,这样做是为方便unordered_map封装和方便编写,参考MAP SET实现的时候的Ketofmap和Ketofset template<class K, class V> struct HashData//哈希数据(包含一个_status记录状态), { pair<K, V> _kv; Status _status = EMPTY; }; template<class K> struct HashFunc//hashfunc获取下标 {}; //hashfunc的string特化 template<> struct HashFunc<string> {}; template<class K, class V, class Hash = HashFunc<K>> class HashTable { public: bool Erase(const K& key)//删 HashData<K, V>* Find(const K& key)//查 bool Insert(const pair<K, V>& kv)//插入 private: vector<HashData<K, V>> _tables;//用于hashdata存储 size_t _n = 0; // 存储有效数据的个数 };
插入:
关于扩容:
散列表的载荷因子定义为: α =填入表中的元素个数/散列表的长度
,
α是散列表装满程度的标志因子由于表长是定值,α与“填入表中的元素个数”成正比,所以,α越大,表明填入表中的元素越多,产生冲突的可能性就越大:反之,α越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子a的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下
。超过0.8,查表时的CPU缓存不命中( cache missing)按照指数曲线上升。因此,一些采用开放定 址法的hash库,如Java的 系统库限制了荷载因子为0.75,超过此值将resize散列表。- 注意:不允许重复元素,其他见注释
bool Insert(const pair<K, V>& kv) { //不允许重复元素 HashData<K, V>* ret = Find(kv.first); if (ret) return false; //当table大小为0 或存储有效数据的个数和table大小比值(载荷因子)大于等于0.7(0.8也可)扩容 if (_tables.size() == 0 || (_n / _tables.size()) >= 0.7) { size_t newsize = (_tables.size() == 0) ? 10 : _tables.size() * 2; HashTable<K,V> NewHashtable; NewHashtable._tables.resize(newsize); for (auto& e : _tables) { if (e._status == EXIST)//由于初始化的时候HashData里的Status _status = EMPTY;有数据的为EXIST { NewHashtable.Insert(e._kv); } } swap(_tables, NewHashtable._tables);//类似与拷贝构造的现代写法,局部Newtable出作用域被销毁 } Hash hash; size_t start = hash(kv.first) % _tables.size();//hash(key) = key % capacity size_t i = 0;//用于线性探测 size_t index = i + start; while (_tables[index]._status == EXIST) { i++;//如果存在,每次向后1 index = start + i;//线性 //index = start+i*i;//二次 index %= _tables.size();//hash(key) = key % capacity } _tables[index]._kv = kv;//存入kv _tables[index]._status = EXIST;//标志存在 _n++; return true; }
查找:
- 注意:见注释
当为_tables大小为0查找出错,加一层判断
因为伪删除,需同时判断状态HashData<K, V>* Find(const K& key) { //当为_tables大小为0查找出错,加一层判断 if (_n == 0) return nullptr; Hash hash; size_t start = hash(key) % _tables.size(); size_t i = 0; size_t index = start + i; while (_tables[index]._status != EMPTY) //从HashFunc得出的键向后查找,如果是避免哈希冲突,其后必无EMPTY,遇EMPTY即无此key { if (_tables[index]._status == EXIST && _tables[index]._kv.first == key) //注意:由于是伪删除,需同时判断状态 return &_tables[index]; else { i++;//如果存在,每次向后1 index = start + i;//线性 //index = start+i*i;//二次 index %= _tables.size(); } } return nullptr; }
删除:
- 注意:见注释
直接复用Find
使用伪删除bool Erase(const K& key) { HashData<K, V>* ret = Find(key); if (ret) { ret->_status = DELETE;//伪删除 _n--; return true; } else //nullptr return false; }
关于HashFunc的string特化:
- 参考了:字符哈希函数
使用的BKDR Hash Function(累乘因子为31(也可以乘以31、131、1313、13131、131313.. ))
template<class K> struct HashFunc { size_t operator()(const K& key) { return key; } }; template<> struct HashFunc<string> { size_t operator()(const string& key) { //BKDR Hash size_t hash = 0; for (auto e : key) { hash *= 131; hash += e; } return hash; } };
二次探测:
- 代码已经在上面注释中展示,逻辑为
//index = start + i;//线性 index = start+i*i;//二次
注意:
- 研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容
线性与二次的对比:
- 二次:
- 线性:
线性探测缺点
:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”
,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低
除此之外:基于开放寻址的其他冲突解决技术还有 合并散列,杜鹃散列,跳房子哈希,罗宾汉哈希
开散列(模拟实现)
闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷
开散列:
- 开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中
开散列中每个桶中放的都是发生哈希冲突的元素:hash(key)=key%capacity
结构:
//每个哈希桶里面的节点 template<class K, class V> struct HashNode { pair<K, V> _kv; HashNode<K, V>* _next; }; template<class K, class V, class Hash = HashFunc<K>> class HashTable { typedef HashNode<K, V> Node; public: ~HashTable()//由于开辟节点,需要手动释放节点,vector会自动释放 bool Erase(const K& key) Node* Find(const K& key) bool Insert(const pair<K, V>& kv) private: vector<Node*> _tables; size_t _n;//存储的有效数据 };
对于Insert:
- 开散列的增容:
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容哈希桶- 控制负载因子负载因子为1时 进行扩容(库里面的unordered_map::load_factor可以查看负载因子unordered_map::max_load_factor查看最大负载因子)
- 对于K为string的情况,同闭散列
注意
:使用现代写法进行_tables交换:原来的_tables处e仍指向原来指针,应置空
- 除留余数法,最好模一个素数(每次扩容时选一个最近的素数),如何每次快速取一个类似两倍关系的素数:
在ST sgi版本中有素数表(用于增容) ul: unsigned long
const int PRIMECOUNT = 28; size_t Getnextprime(size_t prime) { const int PRIMECOUNT = 28; static const size_t primeList[PRIMECOUNT] = { 53ul, 97ul, 193ul, 389ul, 769ul, 1543ul, 3079ul, 6151ul, 12289ul, 24593ul, 49157ul, 98317ul, 196613ul, 393241ul, 786433ul, 1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul, 50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul, 1610612741ul, 3221225473ul, 4294967291ul }; size_t i = 0; for (; i < PRIMECOUNT; ++i) { if (primeList[i] > prime) return primeList[i]; } return primeList[i]; }
Insert:
bool Insert(const pair<K, V>& kv) { //哈希桶控制负载因子负载因子为1时 进行扩容 //库里面的unordered_map::load_factor可以查看负载因子,unordered_map::max_load_factor查看最大负载因子 if (_n == _tables.size()) { //size_t newsize = (_tables.size() == 0) ? 10 : _tables.size() * 2; //unordered_map::bucket_count查看桶的数量 size_t newsize = GetNextPrime(_tables.size());//使用除留余数法,最好模一个素数(没有明确规定,vs版本下就没有) vector<Node*> newtables(newsize,nullptr); for (auto& e : _tables) { Node* cur = e; while (cur) { Node* next = cur->_next;//下面要更改cur->_next这里记录一下 Hash hash; size_t index = hash(cur->_kv.first) % newtables.size(); //头插 cur->_next = newtables[index]; newtables[index] = cur; cur = next; } e = nullptr;//注意,原来的_tables处e仍指向原来指针,应置空 } newtables.swap(_tables);//现代写法 } //插入逻辑 Hash hash; size_t index = hash(kv.first) % _tables.size(); Node* cur = _tables[index]; while (cur) { if (cur->_kv.first == kv.first) return false; else cur = cur->_next; } //采用头插更方便(尾插也可以) Node* newnode = new Node(kv); newnode->_next = _tables[index]; _tables[index] = newnode; _n++; return true; }
对于Erase
:
- 注意这里不是双向链表不能借用Find()
- 需要一个prev记录cur上一个位置,防止删除要删的时cur
bool Erase(const K& key) { if (_tables.size() == 0) return false; //注意这里不是双向链表不能借用Find() Hash hash; size_t index = hash(key) % _tables.size(); Node* prev = nullptr;//需要一个prev记录cur上一个位置,防止删除要删的时cur Node* cur = _tables[index]; while (cur) { if (cur->_kv.first == key) { if (prev == nullptr) _tables[index] = cur->_next; else prev->_next = cur->_next; delete cur;//释放cur指针 _n--; return true; } else { prev = cur; cur = cur->_next; } } return false; }
Find:
- 注意除零错误
Node* Find(const K& key) { //防止除零错误 if (_tables.size() == 0) return nullptr; Hash hash; size_t index = hash(key) % _tables.size(); Node* cur = _tables[index]; while (cur) { if (cur->_kv.first == key) return cur; else cur = cur->_next; } return nullptr; }
比较
开散列与闭散列比较
- 应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上: 由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间
哈希应用
位图
引入
:给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数
位图在库中有实现:
位图概念:
- 所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的
模拟实现
- bitset类私有成员:
vector<char> _bits
,注意也可以为int型的vector,但是就不是/8而是32了- 构造函数:
_bits.resize(N / 8+1 , 0)
_bits为1字节即8bit,相当于每个char标识8个数(+1防止出现(例如N=20,20/8=2, 不能表示所有的情况))- *含有公有成员函数
test(x)判断此位是否为0
set(x)设置此位为1
reset(x)重设此位为0*
- x/8计算此在第几个char,x%8计算在此char的第几位
bitset() { _bits.resize(N / 8+1, 0); } bool test(size_t x) { size_t i = x / 8;//在_bits中第几个char size_t j = x % 8;//在这个char的第几位 return (_bits[i] & (1 << j)); } void set(size_t x) { size_t i = x / 8;//在_bits中第几个char size_t j = x % 8;//在这个char的第几位 _bits[i] |= (1 << j); } void reset(size_t x) { size_t i = x / 8;//在_bits中第几个char size_t j = x % 8;//在这个char的第几位 _bits[i] &= (~(1 << j)); }
测试:对于初始化bitset,可以:
bitset<-1> bs;
bitset<0xffffffff> bs;
位图题目
1.
100亿个整数,设计算法找到只出现一次的整数
- 00:未出现 01:出现一次 11:出现多次
- 可以用两个位来标识,但是为了复用bitset可以创建两个位图来记录这两位
另创一类
public: FindOneVal() {} //复用bitset使用两个位图:00:未出现 01:出现一次 11:出现多次 void SetMarkBits(size_t x) { if ((_bits1.test(x) == false) && (_bits2.test(x) == false)) _bits2.set(x); else if (_bits1.test(x) == false && _bits2.test(x) == true) _bits1.set(x); } void PrintOneVal(vector<int>& arry) { for (auto& e:arry) { if (_bits1.test(e) == false && _bits2.test(e) == true) cout << e << endl; } } private: my_bitset::bitset<N> _bits1; my_bitset::bitset<N> _bits2;
2.
给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集
- 链接:来源:牛客网
把1000个文件记为 a1,a2,a3…a1000,用同样的hash函数映射第二个文件到1000个文件中,这1000个文件记为b1,b2,b3…b1000,由于使用的是相同的hash函数,所以两个文件中一样的数字会被分配到文件下标一致的文件中,分别对a1和b1求交集,a2和b2求交集,ai和bi求交集,最后将结果汇总,即为两个文件的交集
3.
位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
- 参考问题1:这里采用四种不同的情况标识即可:
00表示无
,01表示1个
,11表示2个
,10表示2个以上
布隆过滤器
布隆过滤器:
- 布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间
哈希+位图
选择三种不同的hash函数:字符串Hash函数
注意:Hash函数越多误判率越低
模拟实现
模拟实现:
- 采用的三个Hash函数:
BKDR哈希
,SDBM哈希
,RS哈希
struct HashFunc1 { size_t operator()(const string& s) { size_t hash = 0; // BKDR for (size_t i = 0; i < s.size(); ++i) { hash *= 131; hash += s[i]; } return hash; } }; struct HashFunc2 { size_t operator()(const string& s) { size_t hash = 0; // SDBMHash for (size_t i = 0; i < s.size(); ++i) { hash *= 65599; hash += s[i]; } return hash; } }; struct HashFunc3 { size_t operator()(const string& s) { size_t hash = 0; // RSHash size_t magic = 63689; for (size_t i = 0; i < s.size(); ++i) { hash *= magic; hash += s[i]; magic *= 378551; } return hash; } };
类BloomFilter实现
:布隆过滤器大多数使用场景为字符串,K默认模板参数设为string,传入三个Hash函数,N表示最多插入N个值template<size_t N, class K=string ,class Hash1=HashFunc1, class Hash2=HashFunc2, class Hash3 = HashFunc3> class BloomFilter { public: bool Test(const K& key) { size_t index1 = Hash1()(key) % len;//这里Hash用的匿名函数 if (_bitset.test(index1) == false) return false; size_t index2 = Hash2()(key) % len;//这里Hash用的匿名函数 if (_bitset.test(index2) == false) return false; size_t index3 = Hash3()(key) % len; if (_bitset.test(index3) == false)//这里Hash用的匿名函数 return false; return true; //布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判 //判断一个值在不准确的,可能存在误判, 判断一个值不在是准确的。 //误报匹配是可能的,但漏报不是 } void Set(const K& key) { size_t index1 = Hash1()(key) % len;//这里Hash用的匿名函数 size_t index2 = Hash2()(key) % len; size_t index3 = Hash3()(key) % len; _bitset.set(index1); _bitset.set(index2); _bitset.set(index3); } void Delete() {} private: my_bitset::bitset<6*N> _bitset; size_t len = 6 * N;//注意这里的误判率和6有关系 };
测试:
布隆过滤器的误判
关于误判率:
误判率测试:
先向布隆过滤器插入一系列以url为基础的构造字符串
相似字符串判断
: 用于判断:一系列以新url(相似老url)为基础构造的字符串,bf.Test()返回true代表已有此string,即误判
不相似字符串判断
: 用于判断:一系列以新url(不相似老url)为基础构造的字符串,bf.Test()返回true代表已有此string,即误判
误判率
:false_alarm/Nvoid Test_BloomFiler() { size_t N = 100; my_bloom_filter:: BloomFilter<100> bf; vector<string> vctr1;//先插入一系列字符串(以url为基础构造字符串) for (size_t i = 0; i < N; i++) { string url = "https://blog.youkuaiyun.com/qq_41420788/article/details/126751475"; url += to_string(32694 + i); vctr1.push_back(url); } for (auto& e : vctr1) { bf.Set(e); } for (auto& str : vctr1) { cout << bf.Test(str) << endl; } cout << endl << endl; // vector<string> vctr2;//用于判断:一系列以新url(相似老url)为基础构造的字符串 for (size_t i = 0; i < N; i++) { string url = "https://blog.youkuaiyun.com/qq_41420788/article/details/126677148"; url += to_string(32694 + i); vctr2.push_back(url); } size_t false_alarm1 = 0; for (auto& e : vctr2) { if (bf.Test(e))//出现返回true代表已有此string,即误判 false_alarm1++; } cout << "false_alarm rate(similar to the old url): " << (double)false_alarm1 / (double)N << endl;//false_alarm/N:误判率 // vector<string> vctr3;//用于判断:一系列以新url(不相似老url)为基础构造的字符串 for (size_t i = 0; i < N; i++) { string url = "https://www.baidu.com/s?wd=ln2&rsv_pt=1&rsv_iqid=0xc1c7784f000040b1&issp=1&f=8&rsv_bp=1&rsv_idx=2&ie=utf-8&tn=baiduhome_pg&rsv_dl=tb&rsv_enter=1&rsv_sug3=8&rsv_sug1=7&rsv_sug71=10&rsv_sug2=0rsv_btype=i&prefixsug=ln2&rsp=5&inputT=4576&rsv_sug4=5211"; url += to_string(32694 + i); vctr3.push_back(url); } size_t false_alarm2 = 0; for (auto& e : vctr3) { if (bf.Test(e))//出现返回true代表已有此string,即误判 false_alarm2++; } cout << "false_alarm rate(not similar to the old url): " << (double)false_alarm2 / (double)N << endl;//false_alarm/N:误判率 }
- 测试:当BloomFilter的私有成员bitset<mN> _bitset的
m设为1
时:*
- 测试:当BloomFilter的私有成员bitset<m*N> _bitset的
m设为2
时:
- 测试:当BloomFilter的私有成员bitset<m*N> _bitset的
m设为3
时:
- 测试:当BloomFilter的私有成员bitset<m*N> _bitset的
m设为4
时:
- 测试:当BloomFilter的私有成员bitset<m*N> _bitset的
m设为5
时:
注意到这里的不相似误判率从0变为0.2,所以误判率和m没有绝对关系,只是可以减小误判的概率而已
- 测试:当BloomFilter的私有成员bitset<m*N> _bitset的
m设为6
时:- 测试:当BloomFilter的私有成员bitset<m*N> _bitset的
m设为7
时:- 测试:当BloomFilter的私有成员bitset<m*N> _bitset的
m设为8
时:- 测试:当BloomFilter的私有成员bitset<m*N> _bitset的
m设为9
时:- 测试:当BloomFilter的私有成员bitset<m*N> _bitset的
m设为10
时:
可见当m为8或9时为最佳误报率
参考:wiki-Bloom filter误报的概率推导
假设 Hash 函数以等概率条件选择并设置 Bit Array 中的某一位,假定由每个 Hash 计算出需要设置的位(bit) 的位置是相互独立, m 是该位数组的大小,k 是 Hash 函数的个数
- 使得错误率最小,对于给定的m和n,当
k=(m/n)ln2
的时候取值最小
对于布隆过滤器的删除
一般情况不支持删除,因为多个值可能会标记一个位,删除可能会影响其他key,如果非要支持删除的话,标记不再使用一个比特位,可以使用多个比特位,进行计数多少个值映射的这个比特位
一种支持删除的方法:
- 将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作
wiki中这样说的:
- 从这个简单的布隆过滤器中删除一个元素是不可能的,因为没有办法知道它映射到的k位中的哪一个应该被清除。尽管将这些k位中的任何一个设置为零就足以删除该元素,但它也会删除碰巧映射到该位上的任何其他元素。由于简单算法无法确定是否添加了影响要删除元素的位的任何其他元素,因此清除任何位将引入假阴性的可能性
总结
应用场景:在一些允许误判的地方:
论坛系统
注册的时候需要每个用户取一个昵称,要求昵称不能重复。注册时候,在输入一个昵称以后,就要判断一下这个呢称是否被注册。可以使用一个布隆过滤器存储所有昵称,快速判断某个昵称是否使用过 (容许误判,因为一个昵称可以使用,判断不在布隆过滤器,是准确的)注意
:不可判断手机号是否注册
BloomFilter优点:
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
BloomFilter缺点:
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
拓展问题(利用哈希切分)
给两个文件,分别有100亿个query(SQL语句),我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
- 假设一个query平均20byte100亿个query大概是2000亿Byte大概200G
- 依次读取A的文件中query,然后使用字符串哈希算法转成整形,
size_t val = HashStr(query);
size_t i= val%200;
这个query进入Ai.txt号小文件.- 依次读取B的文件中query,然后使用字符串哈希算法转成整形,
size_t val = HashStr(query);
size_t i= val%200;
这个query进入Bi.txt号小文件.- A和B中,相同的query进入角标编号相同的小文件,只需要角标编号相同的小文件找交集即可
- Ai.txt读进一个setA,Bi.txt读一 个setB, setA和setB相同的query就是交集,i= [0,199]
其他
哈希切割
给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址? 与上题条件相同,如何找到top K的IP?如何直接用Linux系统命令实现
- 这里大文件不能统计次数,要想办法切分成小文件,但是不能平均切分,平均切分统计不出次数
- 先创建100个小文件,分别叫0.txt 1.txt … 99.txt。然后读取100G long file, 依次获取每个ip,用一个字符串哈希算法,把ip转换成整形 size_ t num = BKDRHash(ip) % 100,这个ip就进如num.txt号小文件,依次对所有ip,进行处理,进入对应的小文件(
相同的ip,一定会进入编号相同的小文件
)- 依次读取每个小文件,比如先读取0.txt中ip, map <string, int>统计次数。这里ip的次数就是他最终次数,然后在clear掉map中值,在读取1.txt,继续统计次数, 不断走下去(同时记录ip出现次数最多的)
- 对于top K的IP,建一个小堆即可
对于极端情况
:如果某个小文件过大,可以考虑再次对他切分,可以换一个字符串哈希算法- Linux:假设top 10:sort log_file | uniq -c | sort -nr k1,1 | head -10
参考:牛客网