目录
1. 哈希的概念
1.1. 哈希冲突
1.2. 哈希函数:
1. 直接定址法
2. 除留余数法
1.3. 闭散列实现哈希
1.4. 开散列实现哈希
2. 哈希的应用
2.1 位图的概念
2.1.1. 问题:
2.2.1. set
编辑 2.2.2. reset
2.2.3. test()
2.2. 位图的实现
2.3. 位图的应用:
1.给定100亿个整数,设计算法找到只出现一次的整数。
2. 给两个文件,分别有100亿个整数,只有1G内存,如何找到两个文件的交集?
3. 一个文件有100亿个int,1G内存,设计算法找到出现次数不超过两次的整数
2.4. 位图特点
2.5. 布隆过滤器
2.5.1. 布隆过滤器的应用
2.5.2. 布隆过滤器的实现
2.5.3. 布隆过滤器的拓展
1. 哈希的概念
哈希也叫做散列,本质是一种映射关系,key 和 存储位置建立映射(关联)关系,哈希or散列 是一种思想(映射)。
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O(logN),搜索的效率取决于搜索过程中元素的比较次数理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(HashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:插入元素根据待插入元素的关键码,以此函数(hashFunc)计算出该元素的存储位置并按此位置进行存放搜索元素对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功该方式即为哈希 ( 散列 ) 方法, 哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数,构造出来的结构称 为哈希表 (Hash Table)( 或者称散列表)
例如:现在有一个数组{23,45,11,57,36,10};
哈希函数设置为: hash(key) = key % size ; size 为存储元素底层空间总的大小。
我们发现,上面的数据都可以对应到一个独特的位置,因此查找的时候,我们可以根据同样的方式查找这个数是不是存在。
但是,如果我现在还要继续插入25这个元素,会发生什么问题呢?
我们发现,hash(25) % 10 == 5,可是5这个位置已经被占用了啊,那该怎么办呢?
首先,我们将这种情况称之为哈希冲突,即不同的关键字映射到了哈希表的同一个位置。
1.1. 哈希冲突
哈希冲突(Hash Collision)或者称之为哈希碰撞,它是指不同的键(Key)被哈希函数映射到相同的哈希值(Hash Value)或哈希表(Hash Table)的同一个位置的情况。
在哈希结构中,哈希函数将键映射到固定长度的哈希值或索引位置。由于哈希函数的输出空间通常要比键的输入空间小得多,因此不同的键可能会产生相同的哈希值。
解决哈希冲突的常见方法包括:
1. 开放寻址法(Open Addressing):开放寻址法也称之为闭散列 ,在哈希表的冲突位置寻找下一个可用的空槽来存储键值对。常见的开放寻址方法包括线性探测、二次探索和双重哈希等。
2. 拉链法(哈希桶):在哈希表的每个索引位置上维护一个单链表,将具有相同哈希值的键值对存储在链表中。在插入、查找或删除时,根据哈希值找到对应的链表,然后在链表中进行操作。
3. 增加哈希函数的复杂度:通过改变哈希函数的设计,可以尽量减少哈希冲突的发生。例如,使用更复杂的哈希函数算法、增加哈希表的大小等。
最佳的解决方法取决于具体的应用场景和数据特征。在选择哈希函数和冲突解决策略时,需要考虑数据分布情况、哈希表的负载因子、时间复杂度和空间复杂度等因素,以平衡性能和存储效率。
1.2. 哈希函数:
哈希函数(Hash Function)是一种将任意长度的输入数据(也称为消息、键或原始数据)转换为固定长度的输出(哈希值或摘要)的算法。常见哈希函数:
1. 直接定址法
直接定址法(Direct Addressing)也被称为确定性哈希函数(Deterministic Hash Function)。
在哈希表中,直接定址法是一种简单的哈希函数形式,它使用键的某个特定值作为其在哈希表中的索引位置。具体来说,直接定址法将键直接映射到索引值上,不需要进行任何复杂的计算或处理。
直接定址法的基本思想是,将键的某个属性或组合作为索引来直接访问哈希表的特定位置。例如,如果键是整数类型,可以直接使用它作为哈希表的索引位置。这样一来,每个键都会与唯一的索引位置相对应。
直接定址法的优点是简单高效,插入和查找操作的时间复杂度为常量,即 O(1)。
缺点:需要事先知道关键字的分布情况使用场景:适合查找比较小且连续的情况需要注意的是,直接定址法只适用于键的范围是有限且确定的情况,且每个Key会映射到一个唯一位置,也就是说,直接定址法是不存在哈希冲突的。
例如,统计二十六个英文字母出现的次数
对于该问题,由于二十六个英文字母是唯一且确定的。因此我们可以分配26个空间存储它们,而这些字母(Key),经过哈希函数会映射到唯一位置,即不会出现哈希冲突。
2. 除留余数法
除留余数法(Division Method)是一种常见的哈希函数处理方法,用于将输入键映射到哈希表中的索引位置。
除留余数法的基本思想是,将输入键除以一个特定的数(通常是哈希表的大小),然后取余数作为最终的哈希值或索引位置。
具体来说,除留余数法的步骤如下:
1. 选择一个用于除法计算的常数,通常为一个较大的素数,例如哈希表的大小。
2. 对于给定的输入键,使用除法运算将其除以选择的常数。
3. 取得到的余数作为最终的哈希值或索引位置。
除留余数法的优点是简单易实现,计算过程简洁,可用于将各种类型的键映射到哈希表中。但是,在使用除留余数法时,需要注意以下几点:
1. 选择一个合适的常数:选择一个适当的常数对哈希函数的性能和键的均匀分布至关重要。通常,常数应该是一个较大的素数,以减小冲突的概率。
2. 处理冲突:除留余数法可能会导致一些键映射到相同的哈希值或索引位置,即哈希冲突。为了解决哈希冲突,可以使用其他的冲突解决策略,例如开链法或开放地址法。
3. 哈希表的大小:哈希表的大小与选择的除数相关。选择合适的哈希表大小可以平衡存储空间和性能。通常,哈希表大小应为一个较大的质数,以便更均匀地分布键。
除留余数法在实际应用中被广泛使用,特别适用于键的范围有限且已知的情况。通过合理选择常数和哈希表大小,可以实现较低的冲突概率和较高的哈希性能。
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
1.3. 闭散列实现哈希
相关概念的解释
负载因子:
负载因子(load factor)是指哈希表中已经存储的有效元素数量与哈希表总大小之间的比率。它可以用来衡量哈希表的装填程度或密度。
通常,负载因子的计算公式是:
负载因子 = 已存储元素数量 / 哈希表容量
在哈希表中,负载因子的数值范围通常为 0 到 1 之间。负载因子越接近 1,表示哈希表中的存储元素越多,装填程度越高。而负载因子越接近 0,表示哈希表中的存储元素较少,装填程度较低。
影响负载因子的因素包括哈希表的容量和已存储的元素数量。一般来说,当负载因子超过某个阈值(例如 0.75 或 0.8)时,我们通常会考虑对哈希表进行扩容操作,以保持合理的负载因子,避免哈希冲突的发生频率过大,影响哈希表的性能。
哈希表的负载因子是一个基本的设计参数,需要根据具体应用场景和数据模式进行权衡和调整。一般情况下,较低的负载因子可以提供较好的性能,但会占用更多的内存空间;较高的负载因子则可以节省内存空间,但可能会带来更多的哈希冲突和性能下降。
线性探测:
线性探测(Linear Probing)是一种常见的解决哈希冲突的方法,用于处理哈希表中的元素冲突问题。
当发生哈希冲突时,线性探测会尝试在哈希表中找到下一个可用的位置来存储冲突的元素。具体的操作是,如果哈希表中的某个槽位已经被占用,则线性探测会依次检查下一个槽位,直到找到一个空闲的槽位,然后将元素存储在该位置。
当需要查找或删除特定元素时,也需要使用线性探测来定位目标元素所在的位置。如果目标元素不在哈希表的初始位置上,线性探测会按照相同的方式,依次检查下一个槽位,直到找到目标元素或遇到空槽位。
线性探测的优点是实现简单,不需要维护额外的数据结构。然而,线性探测也有一些限制。当装填因子较高时,线性探测容易引发聚集现象,即一些相邻聚集位置连续冲突,可能形成”踩踏“ ,导致哈希表的性能下降。此外,线性探测也可能导致元素的聚集在表的一侧,造成不均匀的分布。
为了克服线性探测的缺点,还有其他的解决冲突方法,如二次探测等,二次探测,缓解线性探测的“踩踏” ,可以根据具体的场景和需求选择适合的解决方案。
总而言之,闭散列整体上效率是不好的。
namespace open_address
{
// 用三种状态标记哈希表的每个空间的情况
enum state
{
EXIST,
EMPTY,
DELETE
};
template<class K,class V>
struct hash_data
{
std::pair<K, V> _kv;
state _st;
hash_data(const std::pair<K, V>& kv = std::pair<K, V>())
:_kv(kv)
, _st(EMPTY)
{}
};
template<class K,class V>
class hash_table
{
private:
typedef hash_data<K, V> node;
public:
hash_table() :_size(0){}
bool insert(const std::pair<K, V>& kv)
{
// 去重
if (find(kv.first)) return false;
// 处理扩容
// 空表或者负载因子大于等于0.7进行扩容
// 扩容不可以将数据直接拷贝下来,因为扩容了,原来的映射关系会受到影响
// 需要重新映射。将旧表的数据重新映射到新表。因此,
// 哈希表的扩容代价是很大的,比vecor的扩容代价还大
if (_table.size() == 0 || _size * 10 / _table.size() >= 7)
{
size_t new_size = _table.size() == 0 ? 10 : 2 * _table.size();
// 在这里重新构造一个哈希表,复用insert
hash_table<K, V> new_table;
new_table._table.resize(new_size);
for (size_t i = 0; i < _table.size(); ++i)
{
if (_table[i]._st == EXIST)
{
new_table.insert(_table[i]._kv);
}
}
//更新完数据后,交换新表和旧表
std::swap(new_table._table, _table);
}
// 直接插入
// 注意:这里不能模capacity,因为vector的operator[]会强制见检查pos < size
// 因此实际中,最好让size == capacity,即开空间or扩容用resize即可
size_t pos = kv.first % _table.size();
// 如果这个位置已经有值了,说明出现了哈希冲突,在这里采用线性探测
// 线性探测: 当发生哈希冲突的位置开始,依次向后探测,
// 直到寻找到下一个空位置(没有被占用的位置)
while (_table[pos]._st == EXIST)
{
++pos;
if (pos == _table.size())
pos = 0;
}
_table[pos]._kv = kv;
_table[pos]._st = EXIST;
++_size;
return true;
}
bool find(const K& key)
{
// 如果没有数据,直接返回false
if (_size == 0) return false;
size_t pos = key % _table.size();
size_t start = pos;
// 如果走到空,说明没有这个值
while (_table[pos]._st != EMPTY)
{
if (_table[pos]._kv.first == key)
{
return true;
}
++pos;
if (pos == _table.size())
pos = 0;
// 遍历了一圈也没找到,说明不存在,避免死循环
if (pos == start)
return false;
}
return false;
}
// 删除:
// 如果目标存在,只需要将目标位置的状态置为DELETE即可
// 如果不存在,返回false即可
bool erase(const K& key)
{
if (_size == 0 || !find(key)) return false;
size_t pos = key % _table.size();
//由于我们存储元素是线性探测的方式存储的,因此删除也需要按照线性探测的方式查找
while (_table[pos]._kv.first != key)
{
++pos;
if (pos == _table.size())
pos = 0;
}
_table[pos]._st = DELETE;
--_size;
return true;
}
private:
std::vector<node> _table;
size_t _size; // 有效元素个数
};
}
1.4. 开散列实现哈希
初始版本:这是我们以开散列的方式实现的哈希表
#pragma once
#include <iostream>
#include <utility>
#include <vector>
namespace Xq
{
template<class K, class V>
struct hash_table_node
{
struct hash_table_node<K, V>* _next;
std::pair<K, V> _kv;
hash_table_node(const std::pair<K, V>& kv = std::pair<K, V>())
:_kv(kv)
, _next(nullptr)
{}
};
template <class K, class V>
class hash_table
{
private:
typedef hash_table_node<K, V> node;
public:
static const size_t _table_size = 28; // 静态数组的大小
static const size_t _table_count_arr[_table_size]; // 哈希表的大小(每个都是素数)
hash_table() :_size(0){}
// 用来获取下一次扩容后的表的大小
size_t get_prime_size(size_t size)
{
for (size_t i = 0; i < _table_size; ++i)
{
if (i == 28) break;
if (_table_count_arr[i] > size)
return _table_count_arr[i];
}
return -1;
}
bool insert(const std::pair<K, V>& kv)
{
// 去重
if (find(kv.first)) return false;
// 扩容
// 空表或者负载因子>=1 进行扩容
if (_table.size() == 0 || _size * 10 / _table.size() >= 10)
{
std::vector<node*> new_table;
new_table.resize(get_prime_size(_table.size()), nullptr);
// 将旧表的有效节点摘下来,头插到新表
for (size_t i = 0; i < _table.size(); ++i)
{
while(_table[i])
{
node* next = _table[i]->_next;
size_t pos = _table[i]->_kv.first % new_table.size();
_table[i]->_next = new_table[pos];
new_table[pos] = _table[i];
_table[i] = next;
}
}
// 交换两个表,扩容结束
std::swap(_table, new_table);
}
// 直接插入
size_t pos = kv.first % _table.size();
node* newnode = new node(kv);
newnode->_next = _table[pos];
_table[pos] = newnode;
++_size;
return true;
}
node* find(const K& key)
{
// 空表,直接返回空
if (_size == 0) return nullptr;
size_t obj_pos = key % _table.size();
node* cur = _table[obj_pos];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
bool erase(const K& key)
{
if (!find(key) || _size == 0) return false;
size_t pos = key % _table.size();
//头删
node* cur = _table[pos];
if (cur->_kv.first == key)
{
node* next = cur->_next;
delete cur;
_table[pos] = next;
}
// !头删
else
{
while (cur->_next->_kv.first != key)
{
cur = cur->_next;
}
node* next = cur->_next->_next;
delete cur->_next;
cur->_next = next;
}
--_size;
return true;
}
private:
std::vector<node*> _table;
size_t _size; // 存储有效数据的个数
};
template<class K,class V>
const size_t hash_table<K,V>::_table_count_arr[hash_table<K,V>::_table_size] = // 哈希表的大小(每个都是素数)
{
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
};
}
上面的代码,存在问题,假如此时的这个K是一个string,那么会带来什么样的问题呢?
void Test3(void)
{
std::string str[] = { "老虎", "狮子", "大熊猫", "长颈鹿", "孔雀" };
srand((unsigned int)time(nullptr));
Xq::hash_table<std::string, int> my_hash;
for (size_t i = 0; i < 10; ++i)
{
std::string tmp = str[rand() % 5];
Xq::hash_table_node<std::string,int>* ret = my_hash.find(tmp);
// 如果该动物没存在,就插入map中,并将Value赋值为1
if (!ret)
{
my_hash.insert(std::make_pair(tmp, 1));
}
// 如果该动物存在,将Value值++即可
else
{
++ret->_kv.second;
}
}
}
可以看到,string是不支持取模运算的。那么我们的哈希表的除留余数法的取模操作就有了限制,为了解开这个限制,我们需要对像string这种类型需要进行听特殊处理。
如何特殊处理?
我们利用仿函数和特化,针对string这种类型,进行特殊处理
具体如下:
#pragma once
#include <iostream>
#include <utility>
#include <vector>
#include <time.h>
#include <string>
namespace Xq
{
template<class K, class V>
struct hash_table_node
{
struct hash_table_node<K, V>* _next;
std::pair<K, V> _kv;
hash_table_node(const std::pair<K, V>& kv = std::pair<K, V>())
:_kv(kv)
, _next(nullptr)
{}
};
//hash_func这个仿函数的主要目的:将不能转化为size_t类型用特殊方式转化为size_t,以便于支持取模操作
template<class K>
struct hash_func
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 例如在这里,string默认是不可以进行取模运算的
// 因此在这里利用类模板的特化,针对string特殊处理
template<>
struct hash_func<std::string>
{
size_t operator()(const std::string& str)
{
size_t ret = 0;
// 具体这里为什么要乘于131,请看解释(1)
for (auto ch : str)
{
ret *= 131;
ret += ch;
}
return ret;
}
};
template <class K, class V,class Hash = hash_func<K>>
class hash_table
{
private:
typedef hash_table_node<K, V> node;
public:
static const size_t _table_size = 28; // 静态数组的大小
static const size_t _table_count_arr[_table_size]; // 哈希表的大小(每个都是素数)
hash_table() :_size(0){}
size_t get_prime_size(size_t size)
{
for (size_t i = 0; i < _table_size; ++i)
{
if (i == 28) break;
if (_table_count_arr[i] > size)
return _table_count_arr[i];
}
return -1;
}
bool insert(const std::pair<K, V>& kv)
{
Hash hash_func;
// 去重
if (find(kv.first)) return false;
// 扩容
// 空表或者负载因子>=1 进行扩容
if (_table.size() == 0 || _size * 10 / _table.size() >= 10)
{
//在这里我们选择建立新的vector,将旧表的vector中的数据导入新vector,在交换这两个vector即可
std::vector<node*> new_table;
new_table.resize(get_prime_size(_table.size()), nullptr);
// 将旧表的有效节点摘下来,头插到新表
for (size_t i = 0; i < _table.size(); ++i)
{
// 如果当前节点不为空,说明有数据
// 在这里我们选择用头插
while(_table[i])
{
// 提前保存下一个节点的位置
node* next = _table[i]->_next;
size_t pos = hash_func(_table[i]->_kv.first) % new_table.size();
_table[i]->_next = new_table[pos];
new_table[pos] = _table[i];
_table[i] = next;
}
}
// 交换两个表,扩容结束
std::swap(_table, new_table);
}
// 直接以头插的方式插入
size_t pos = hash_func(kv.first) % _table.size();
node* newnode = new node(kv);
newnode->_next = _table[pos];
_table[pos] = newnode;
++_size;
return true;
}
node* find(const K& key)
{
Hash hash_func;
// 空表,直接返回空
if (_size == 0) return nullptr;
size_t obj_pos = hash_func(key) % _table.size();
node* cur = _table[obj_pos];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
bool erase(const K& key)
{
Hash hash_func;
if (!find(key) || _size == 0) return false;
size_t pos = hash_func(key) % _table.size();
//头删
node* cur = _table[pos];
if (cur->_kv.first == key)
{
node* next = cur->_next;
delete cur;
_table[pos] = next;
}
// !头删
else
{
while (cur->_next->_kv.first != key)
{
cur = cur->_next;
}
node* next = cur->_next->_next;
delete cur->_next;
cur->_next = next;
}
--_size;
return true;
}
private:
std::vector<node*> _table;
size_t _size; // 存储有效数据的个数
};
template<class K,class V,class Hash = hash_func<K>>
const size_t hash_table<K,V,Hash>::_table_count_arr[hash_table<K,V,Hash>::_table_size] = // 哈希表的大小(每个都是素数)
{
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
};
}
解释(1)
可以看到,上面的代码,当我们需要将一个string类转为整形的时候,我们会让其每个字符乘等于131,这是为什么呢?
为什么:
首先,我们知道,我们将string类转化为整形的目的是:为了让其可以进行取模。但是如果是以下场景:
string str1 = "ate";
string str2 = "eat";
我们可以发现,如果我们让其的每个字符直接进行相加求和,那么带来的问题就是它们最后结果是一致的,那么就会带来增大哈希冲突的可能性。所以为了减少哈希冲突的情况:
人们经过大量的实验证实,将字符串转换为size_t类型时,乘以131是一种常见的哈希算法的实现方式,用于将字符串映射到一个数值。这个具体的常数(131)是为了在哈希算法中获得较好的散列效果,提高哈希值的唯一性和均匀性。
哈希算法的目的是将输入的字符串转化为一个唯一的哈希值,这个哈希值可以作为字符串的标识或索引。在字符串转换为哈希值的过程中,通过乘以一个常数(如131)来对每个字符进行加权。
乘以131的选择是基于经验和实践得出的,具体原因如下:
- 131是一个较大的质数,质数具有较好的散列性质,可以减少哈希冲突的概率。
- 131的选择是为了保持乘法的效率。在计算机中,乘法运算通常比除法运算更高效,因此选择一个较大的质数可以保持计算的效率。
需要注意的是,乘以131这种具体数字在实际应用中可能会有所变化。不同的哈希算法和实现方式可能选择不同的常数来实现字符串到哈希值的转换。关键是选择一个合适的常数,以获得较好的散列效果和哈希值的均匀性。
扩容机制的优化
人们在不断地实践中发现,如果哈希表的大小是一个素数,可以减少哈希冲突
降低碰撞:在散列过程中,我们希望不同的键值对能够均匀地映射到数组的不同位置,减少碰撞的概率。如果选择一个合数作为数组的大小,它可能有多个因子,这可能导致哈希码的低位数字对取模后的结果有较大的影响,使得元素在数组中的分布不均匀。而素数没有其他除了1和自身的因子,选择素数作为数组大小可以减少这种不均匀性,降低碰撞的概率。
优化哈希函数:选择素数作为数组的大小可以简化哈希函数的设计。通常,我们可以使用键的哈希码对数组大小取模,而选择素数作为数组大小可以使得取模的结果不容易受到哈希码的低位数字分布的影响,降低生成哈希码的复杂度。
提高性能:素数作为数组的大小可以提高哈希表的性能。素数通常可以提供更好的分布性,减少了碰撞的发生,从而提高了查找、插入和删除等操作的性能。
那么如何控制,每次扩容后的哈希表的大小呢?
在SGI-STL版本中,定义了一个const的静态数组,具体如下:
也就是说,开散列的哈希表的初始大小就是53,扩容后哈希表的大小就是97,以此类推。
template <class K, class V,class Hash = hash_func<K>>
class hash_table
{
private:
typedef hash_table_node<K, V> node;
public:
static const size_t _table_size = 28; // 静态数组的大小
static const size_t _table_count_arr[_table_size]; // 哈希表的大小(每个都是素数)
hash_table() :_size(0){}
size_t get_prime_size(size_t size)
{
for (size_t i = 0; i < _table_size; ++i)
{
if (i == 28) break;
if (_table_count_arr[i] > size)
return _table_count_arr[i];
}
return -1;
}
// ... 省略
}
template<class K,class V,class Hash = hash_func<K>>
const size_t hash_table<K,V,Hash>::_table_count_arr[hash_table<K,V,Hash>::_table_size] = // 哈希表的大小(每个都是素数)
{
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
};
unordered_map和unordered_set的封装
unordered_set底层用的哈希表的第二个模板参数是一个Key
unordered_map底层用的哈希表的第二个模板参数是一个pair
因此在哈希表这一层,它并不知道它的第二个模板参数究竟是什么?也许是pair,也许是一个K。但是对于unordered_set和unordered_map这一层来说,它们是知道自己的第二个模板参数是什么的,如果是unordered_map,那么第二个参数就是pair,如果是unordered_set,那么第二个参数就是K,因此我们可以在unordered_set和unordered_map这一层将类型显示告诉给哈希表,如何告诉呢?我们通过仿函数
具体如下:
#pragma once
#include "hush_bucket.h"
namespace Xq
{
template<class K,class Hash = hash_func<K>>
class unordered_set
{
public:
// unordered_map_key_of_data这个仿函数的目的
// 告诉哈希表第二个模板参数是什么类型
struct unordered_set_key_of_data
{
const K& operator()(const K& key)
{
return key;
}
};
private:
// 第二个模板参数确定哈希表里面存放的数据类型
typedef Xq::hash_table<K, K, Hash, unordered_set_key_of_data> hash_table;
private:
hash_table _table;
};
}
#pragma once
#include "hush_bucket.h"
namespace Xq
{
template<class K, class V, class Hash = hash_func<K>>
class unordered_map
{
public:
// unordered_map_key_of_data这个仿函数的目的
// 告诉哈希表第二个模板参数是什么类型
struct unordered_map_key_of_data
{
const K& operator()(const std::pair<K,V>& kv)
{
return kv.first;
}
};
private:
// 第二个模板参数确定哈希表里面存放的数据类型
typedef Xq::hash_table<K, std::pair<K, V>, Hash, unordered_map_key_of_data> hash_table;
private:
hash_table _table;
};
}
因此,此时的哈希表的第二个类型就不能是Key也不能是pair了,因为它不知道第二个参数究竟是什么,那么我们的代码就要更改了:
#pragma once
#include <iostream>
#include <utility>
#include <vector>
#include <time.h>
#include <string>
namespace Xq
{
//hash_func这个仿函数的主要目的:将不能转化为size_t类型用特殊方式转化为size_t,以便于支持取模操作
template<class K>
struct hash_func
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 例如在这里,string默认是不可以进行取模运算的
// 因此在这里利用类模板的特化,针对string特殊处理
template<>
struct hash_func<std::string>
{
size_t operator()(const std::string& str)
{
size_t ret = 0;
for (auto ch : str)
{
ret *= 131;
ret += ch;
}
return ret;
}
};
// 节点里面存放的数据(_data)究竟是什么类型,我不知道,但我可以通过unordered_map
// 或者unordered_set传递过来的第二个模板参数推出它是什么类型
template<class D>
struct hash_table_node
{
struct hash_table_node<D>* _next;
D _data;
hash_table_node(const D& data = D())
:_data(data)
, _next(nullptr)
{}
};
// 第三个模板参数作用:将一些数据类型(不能取模操作的类型)转化为可以进行取模操作的类型
// 第四个模板参数作用:推出第二个模板参数究竟是什么类型
template <class K, class D,class Hash,class Key_Of_Data>
class hash_table
{
private:
typedef hash_table_node<D> node;
public:
static const size_t _table_size = 28; // 静态数组的大小
static const size_t _table_count_arr[_table_size]; // 哈希表的大小(每个都是素数)
hash_table() :_size(0){}
size_t get_prime_size(size_t size)
{
for (size_t i = 0; i < _table_size; ++i)
{
if (i == 28) break;
if (_table_count_arr[i] > size)
return _table_count_arr[i];
}
return -1;
}
bool insert(const D& data)
{
Hash hash_func;
Key_Of_Data kod;
// 去重
if (find(kod(data))) return false;
// 扩容
// 空表或者负载因子>=1 进行扩容
if (_table.size() == 0 || _size * 10 / _table.size() >= 10)
{
//在这里我们选择建立新的vector,将旧表的vector中的数据导入新vector,在交换这两个vector即可
std::vector<node*> new_table;
new_table.resize(get_prime_size(_table.size()), nullptr);
// 将旧表的有效节点摘下来,头插到新表
for (size_t i = 0; i < _table.size(); ++i)
{
// 如果当前节点不为空,说明有数据
// 在这里我们选择用头插
while(_table[i])
{
// 提前保存下一个节点的位置
node* next = _table[i]->_next;
size_t pos = hash_func(kod(_table[i]->_data)) % new_table.size();
_table[i]->_next = new_table[pos];
new_table[pos] = _table[i];
_table[i] = next;
}
}
// 交换两个表,扩容结束
std::swap(_table, new_table);
}
// 直接以头插的方式插入
size_t pos = hash_func(kod(data)) % _table.size();
node* newnode = new node(data);
newnode->_next = _table[pos];
_table[pos] = newnode;
++_size;
return true;
}
node* find(const K& key)
{
Hash hash_func;
Key_Of_Data kod;
// 空表,直接返回空
if (_size == 0) return nullptr;
size_t obj_pos = hash_func(key) % _table.size();
node* cur = _table[obj_pos];
while (cur)
{
if (kod(cur->_data) == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
bool erase(const K& key)
{
Hash hash_func;
Key_Of_Data kod;
if (!find(key) || _size == 0) return false;
size_t pos = hash_func(key) % _table.size();
//头删
node* cur = _table[pos];
if (kod(cur->_data) == key)
{
node* next = cur->_next;
delete cur;
_table[pos] = next;
}
// !头删
else
{
while (kod(cur->_next->_data) != key)
{
cur = cur->_next;
}
node* next = cur->_next->_next;
delete cur->_next;
cur->_next = next;
}
--_size;
return true;
}
private:
std::vector<node*> _table;
size_t _size; // 存储有效数据的个数
};
template<class K, class V, class Hash = hash_func<K>, class Key_Of_Data>
const size_t hash_table<K, V, Hash, Key_Of_Data>::_table_count_arr[hash_table<K, V, Hash, Key_Of_Data>::_table_size] = // 哈希表的大小(每个都是素数)
{
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
};
}
第一步我们完成了,接下来应该实现我们的普通迭代器
// 编译器只会向上查找,因此需要在这里声明
template <class K, class D, class Hash, class Key_Of_Data>
class hash_table;
template <class K, class D, class Hash, class Key_Of_Data>
struct _hash_table_iterator
{
typedef hash_table<K, D, Hash, Key_Of_Data> hash_table;
typedef hash_table_node<D> node;
typedef _hash_table_iterator<K, D, Hash, Key_Of_Data> Self;
_hash_table_iterator(hash_table* hpt, node* node) :_table_ptr(hpt), _node(node){}
// 返回数据的引用
D& operator*()
{
return _node->_data;
}
// 返回数据的地址
D* operator->()
{
return &(operator*());
}
bool operator!=(Self& s)
{
return _node != s._node;
}
bool operator==(Self& s)
{
return _node == s._node;
}
// 前置++
Self& operator++()
{
// 提前保存当前位置
node* old = _node;
_node = _node->_next;
// 如果当前桶走到空了,去找下一个非空的桶
if (!_node)
{
Hash hash_func;
Key_Of_Data kod;
// 需要找到下一个非空的哈希桶
size_t cur_pos = hash_func(kod(old->_data)) % _table_ptr->_table.size();
cur_pos++;
while (cur_pos < _table_ptr->_table.size() && !_table_ptr->_table[cur_pos])
{
++cur_pos;
}
if (cur_pos == _table_ptr->_table.size())
_node = nullptr;
else
_node = _table_ptr->_table[cur_pos];
}
return *this;
}
// 需要一个节点以及哈希表的指针
hash_table* _table_ptr;
node* _node;
};
unordered_set的完整实现
#pragma once
#include "hush_bucket.h"
namespace Xq
{
template<class K,class Hash = hash_func<K>>
class unordered_set
{
public:
struct unordered_set_key_of_data
{
const K& operator()(const K& key)
{
return key;
}
};
typedef typename Xq::_hash_table_iterator<K, K, Hash, unordered_set_key_of_data> iterator;
std::pair<iterator,bool> insert(const K& key)
{
return _table.insert(key);
}
iterator find(const K& key)
{
return _table.find();
}
iterator erase(const K& key)
{
return _table.erase();
}
iterator begin()
{
return _table.begin();
}
iterator end()
{
return _table.end();
}
private:
// 第二个模板参数确定哈希表里面存放的数据类型
typedef Xq::hash_table<K, K, Hash, unordered_set_key_of_data> hash_table;
private:
hash_table _table;
};
}
unordered_map的完整实现
#pragma once
#include "hush_bucket.h"
namespace Xq
{
template<class K, class V, class Hash = hash_func<K>>
class unordered_map
{
public:
struct unordered_map_key_of_data
{
const K& operator()(const std::pair<K,V>& kv)
{
return kv.first;
}
};
typedef typename Xq::_hash_table_iterator<K, std::pair<K, V>, Hash, unordered_map_key_of_data> iterator;
std::pair<iterator,bool> insert(const std::pair<K, V>& kv)
{
return _table.insert(kv);
}
V& operator[](const K& key)
{
return _table.insert(std::make_pair(key, V())).first->second;
}
iterator find(const K& key)
{
return _table.find(key);
}
iterator erase(const K& key)
{
return _table.erase(key);
}
iterator begin()
{
return _table.begin();
}
iterator end()
{
return _table.end();
}
private:
// 第二个模板参数确定哈希表里面存放的数据类型
typedef Xq::hash_table<K, std::pair<K, V>, Hash, unordered_map_key_of_data> hash_table;
private:
hash_table _table;
};
}
哈希表的开散列的实现
#pragma once
#include <iostream>
#include <utility>
#include <vector>
#include <time.h>
#include <string>
namespace Xq
{
//hash_func这个仿函数的主要目的:将不能转化为size_t类型用特殊方式转化为size_t,以便于支持取模操作
template<class K>
struct hash_func
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 例如在这里,string默认是不可以进行取模运算的
// 因此在这里利用类模板的特化,针对string特殊处理
template<>
struct hash_func<std::string>
{
size_t operator()(const std::string& str)
{
size_t ret = 0;
// 具体这里为什么要乘于131,请看解释(1)
for (auto ch : str)
{
ret *= 131;
ret += ch;
}
return ret;
}
};
// 节点里面存放的数据(_data)究竟是什么类型,我不知道,但我可以通过unordered_map
// 或者unordered_set传递过来的第二个模板参数推出它是什么类型
template<class D>
struct hash_table_node
{
struct hash_table_node<D>* _next;
D _data;
hash_table_node(const D& data = D())
:_data(data)
, _next(nullptr)
{}
};
// 编译器只会向上查找,因此需要在这里声明
template <class K, class D, class Hash, class Key_Of_Data>
class hash_table;
template <class K, class D, class Hash, class Key_Of_Data>
struct _hash_table_iterator
{
typedef hash_table<K, D, Hash, Key_Of_Data> hash_table;
typedef hash_table_node<D> node;
typedef _hash_table_iterator<K, D, Hash, Key_Of_Data> Self;
_hash_table_iterator(hash_table* hpt, node* node) :_table_ptr(hpt), _node(node){}
// 返回数据的引用
D& operator*()
{
return _node->_data;
}
// 返回数据的地址
D* operator->()
{
return &(operator*());
}
bool operator!=(Self& s)
{
return _node != s._node;
}
bool operator==(Self& s)
{
return _node == s._node;
}
// 前置++
Self& operator++()
{
// 提前保存当前位置
node* old = _node;
_node = _node->_next;
// 如果当前桶走到空了,去找下一个非空的桶
if (!_node)
{
Hash hash_func;
Key_Of_Data kod;
// 需要找到下一个非空的哈希桶
size_t cur_pos = hash_func(kod(old->_data)) % _table_ptr->_table.size();
cur_pos++;
while (cur_pos < _table_ptr->_table.size() && !_table_ptr->_table[cur_pos])
{
++cur_pos;
}
if (cur_pos == _table_ptr->_table.size())
_node = nullptr;
else
_node = _table_ptr->_table[cur_pos];
}
return *this;
}
// 需要一个节点以及哈希表的指针
hash_table* _table_ptr;
node* _node;
};
// 第三个模板参数作用:将一些数据类型(不能取模操作的类型)转化为可以进行取模操作的类型
// 第四个模板参数作用:推出第二个模板参数究竟是什么类型
template <class K, class D,class Hash,class Key_Of_Data>
class hash_table
{
private:
typedef hash_table_node<D> node;
friend struct _hash_table_iterator<K, D, Hash, Key_Of_Data>;
typedef _hash_table_iterator<K, D, Hash, Key_Of_Data> iterator;
public:
static const size_t _table_size = 28; // 静态数组的大小
static const size_t _table_count_arr[_table_size]; // 哈希表的大小(每个都是素数)
hash_table() :_size(0){}
~hash_table()
{
for (auto& ptr : _table)
{
while (ptr)
{
node* next = ptr->_next;
delete ptr;
ptr = next;
}
}
}
iterator begin()
{
size_t i = 0;
for (; i < _table.size(); ++i)
{
if (_table[i])
break;
}
if (i == _table.size()) return iterator(this, nullptr);
else return iterator(this, _table[i]);
}
iterator end()
{
return iterator(this, nullptr);
}
size_t get_prime_size(size_t size)
{
for (size_t i = 0; i < _table_size; ++i)
{
if (i == 28) break;
if (_table_count_arr[i] > size)
return _table_count_arr[i];
}
return -1;
}
std::pair<iterator,bool> insert(const D& data)
{
Hash hash_func;
Key_Of_Data kod;
// 去重
iterator obj = find(kod(data));
if (obj._node) return std::make_pair(obj,false);
// 扩容
// 空表或者负载因子>=1 进行扩容
if (_table.size() == 0 || _size * 10 / _table.size() >= 10)
{
//在这里我们选择建立新的vector,将旧表的vector中的数据导入新vector,在交换这两个vector即可
std::vector<node*> new_table;
new_table.resize(get_prime_size(_table.size()), nullptr);
// 将旧表的有效节点摘下来,头插到新表
for (size_t i = 0; i < _table.size(); ++i)
{
// 如果当前节点不为空,说明有数据
// 在这里我们选择用头插
while(_table[i])
{
// 提前保存下一个节点的位置
node* next = _table[i]->_next;
size_t pos = hash_func(kod(_table[i]->_data)) % new_table.size();
_table[i]->_next = new_table[pos];
new_table[pos] = _table[i];
_table[i] = next;
}
}
// 交换两个表,扩容结束
std::swap(_table, new_table);
}
// 直接以头插的方式插入
size_t pos = hash_func(kod(data)) % _table.size();
node* newnode = new node(data);
newnode->_next = _table[pos];
_table[pos] = newnode;
++_size;
return std::make_pair(iterator(this,newnode),true);
}
iterator find(const K& key)
{
Hash hash_func;
Key_Of_Data kod;
// 空表,直接返回空
if (_size == 0) return iterator(this, nullptr);
size_t obj_pos = hash_func(key) % _table.size();
node* cur = _table[obj_pos];
while (cur)
{
if (kod(cur->_data) == key)
return iterator(this, cur);
cur = cur->_next;
}
return iterator(this, nullptr);
}
size_t get_effective_next_node(size_t cur_pos)
{
++cur_pos;
while (cur_pos < _table.size() && !_table[cur_pos])
{
++cur_pos;
}
if (cur_pos == _table.size())
return -1;
else
return cur_pos;
}
// 返回被删除节点的下一个有效节点
iterator erase(const K& key)
{
Hash hash_func;
Key_Of_Data kod;
if (!(find(key)._node) || _size == 0) return iterator(this,nullptr);
size_t pos = hash_func(key) % _table.size();
//头删
node* cur = _table[pos];
node* tmp = nullptr;
if (kod(cur->_data) == key)
{
node* next = cur->_next;
delete _table[pos];
_table[pos] = next;
tmp = _table[pos];
if (!tmp)
{
// 去找下一个有效位置
size_t ret = get_effective_next_node(pos);
if (ret == -1)
tmp = nullptr;
else
tmp = _table[ret];
}
}
// !头删
else
{
while (kod(cur->_next->_data) != key)
{
cur = cur->_next;
}
node* next = cur->_next->_next;
delete cur->_next;
cur->_next = next;
tmp = cur->_next;
if (!tmp)
{
size_t ret = get_effective_next_node(pos);
if (ret == -1)
tmp = nullptr;
else
tmp = _table[ret];
}
}
--_size;
return iterator(this,tmp);
}
private:
std::vector<node*> _table;
size_t _size; // 存储有效数据的个数
};
template<class K, class V, class Hash = hash_func<K>, class Key_Of_Data>
const size_t hash_table<K, V, Hash, Key_Of_Data>::_table_count_arr[hash_table<K, V, Hash, Key_Of_Data>::_table_size] = // 哈希表的大小(每个都是素数)
{
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
};
}
2. 哈希的应用
2.1 位图的概念
位图(Bitset)是一种数据结构,用来表示一个固定大小的位序列。它将每一个位(0 或 1)映射到一个特定的索引位置,如果位为1,那么说明映射到这个位的Key是存在的,反之如果位为0,那么说明这个Key是不存在的。位图用的是直接定址法,没有了哈希冲突 ,并可以进行高效的位操作。
位图通常用于解决一些需要高效存储和查询大量布尔类型数据的场景。它可以以较小的内存消耗存储大量的布尔值信息。
位图的基本操作包括设置位(set),清除位(reset),和查找位(test)。通过这些操作,可以对位图中的特定位进行设置或清除,并查找特定位是否存在。
常见的应用场景包括:
1. 压缩存储:位图可以将大量的布尔类型数据以很小的内存占用进行存储,节省存储空间。
2. 集合操作:位图可以被用于表示和操作集合。每个位可以代表某个元素是否属于集合,例如在数据库中进行条件过滤和查询等操作。
3. 布隆过滤器:布隆过滤器是一种基于位图的数据结构,用于快速判断一个元素是否属于一个集合,具有高效的查找和内存占用优势。
需要注意的是,位图适用于数据集较大、数据分布较稀疏的情况下。对于数据集较小或数据分布较密集的情况,位图可能会导致较大的内存消耗。因此,在选择使用位图时,需要根据具体的需求和数据特点进行权衡和评估。
2.1.1. 问题:
1.给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
分析:首先,我们可以知道 ,这是40亿个不重复的无符号整数,那么也就是160亿个字节。而我们知道1024^3也就是1GB近似于10亿字节,那么也就是说光这些数据就近似需要16GB的空间,而我们的32位系统,其内存就只有4GB,内存都存不下这些数据,思路一:搜索树 + 哈希表经过上面的分析 ,我们的搜索树,以及哈希表都不可以支持,内存存不下这些数据思路二:外排序 + 二分查找我们的归并排序可以支持,但是这么大的数据,内存是存不下的,那么这些数据只能存储与磁盘中,而磁盘是不好支持二分查找的,效率太低。
思路三:位图
位图只是用一个位标识Key是否存在(0意味着不在,1意味着在) ,空间消耗小,且位图是直接定址法,其映射位置具有唯一性,效率高。
对于无符号整型来讲,其范围是0至2^32-1,那么我们也就需要2^32个位即可,也就是2^29个字节,而我们知道2^30是1GB,那么2^29个字节也就是512MB,相较于上面的思路,节省了很大的空间。并且,不论此时有多少个无符号整型的数据,哪怕你有50亿个、100亿个数据,我都只开这么大的空间(512MB)就可以判断某个特殊值是否存在,因为这里的空间不是多少个数据,而是代表这该数据的范围。
而我们知道,vector所开的空间最小单位是一个字节即8个bit位,例如vector<char>,假设我现在已经知道了,某个数组的最大数据是27,那么我应该开多少个空间呢?
27 / 8 等于3余 4,显然3个char是不够的,我们应该开4个char空间,也就是说,我们开的空间应该是所需要映射的Key中的 最大值 / 8 + 1
例如:有这样一个集合{12,6,18,27}
由于Key的最大值为27,因此我们需要 27 / 8 + 1个char,也就是4个char
2.2.1. set
set() ,设置操作,即将Key映射的特定位置的bit位 置为1
step1 : Key先除8,得到在第几个char
step2 : Key在模8,确定在这个char第几个bit位
step3 : 用这个char |= (将1左移模8的结果)
就完成了set的操作
0 | 任何bit位 == 任何bit(没有影响)
1 | 任何bit位 == 1
2.2.2. reset
reset(),清除操作,即将Key映射的特定位置的bit位 置为0
其他位不受影响,将目标bit位置为0
step1 : Key先除8,得到在第几个char
step2 : Key在模8,确定在这个char第几个bit位
step3 : 用这个char &= ~(将1 左移 (Key模8的结果))
用这个char &= ~ (将1左移模8的结果)
~ 按位取反
1 &= 任何bit位 还是任何bit位
2.2.3. test()
reset(),查找操作,即将Key映射的特定bit位是否存在(0/1)
step1 : Key先除8,得到在第几个char
step2 : Key在模8,确定在这个char第几个bit位
step3 : return 这个char & (将1 左移 (Key模8的结果))
由于是&,而不是&=,因此不会对位图产生影响。
2.2. 位图的实现
#include <iostream>
#include <vector>
namespace Xq
{
// 非类型模板参数,如果已经确定了最大值,那么范围就是0 - 最大值
// 注意:这里开多少空间,是由数据范围决定的,而不是由数据的多少决定的
// 如果没有明确范围,哪怕只有10个数据,你也得给我开2^32个bit
template<size_t N>
class bit_set
{
public:
bit_set()
{
// 根据上面的分析,空间 = Key(max) / 8 + 1
_bit_set.resize(N / 8 + 1, 0);
}
void set(int key)
{
// pos_size 得出是第几个char
size_t pos_size = key / 8;
// pos_count 得出在这个char的第几个bit位
size_t pos_count = key % 8;
// 这个bit位 |= (左移1)
_bit_set[pos_size] |= (1 << pos_count);
}
void reset(int key)
{
// pos_size 得出是第几个char
size_t pos_size = key / 8;
// pos_count 得出在这个char的第几个bit位
size_t pos_count = key % 8;
// ~按位取反
_bit_set[pos_size] &= ~(1 << pos_count);
}
bool test(int key)
{
// pos_size 得出是第几个char
size_t pos_size = key / 8;
// pos_count 得出在这个char的第几个bit位
size_t pos_count = key % 8;
// 注意这里是 &,不是&= 不会影响位图
return _bit_set[pos_size] & (1 << pos_count);
}
private:
std::vector<char> _bit_set;
};
}
2.3. 位图的应用:
1. 快速查找某个数据是否在一个集合中2. 排序 + 去重3. 求两个集合的交集、并集等4. 操作系统中磁盘块标记
1.给定100亿个整数,设计算法找到只出现一次的整数。
思路:我们可以用两个位图标识每个数据的的出现次数 。
如果某个数字出现了0次,那么这个数字映射到的两个位图的位分别是0,0;
如果某个数字出现了1次,那么这个数字映射两个位图的位分别是0,1;
如果某个数字出现了2次及以上,那么这个数字映射两个位图的位分别是1,0;
#include <iostream>
#include <vector>
namespace Xq
{
template<size_t N>
class bit_set
{
public:
bit_set(size_t num = N)
{
_table.resize(num / 8 + 1, 0);
}
void set(size_t key)
{
size_t pos_size = key / 8;
size_t pos_count = key % 8;
_table[pos_size] |= (1 << pos_count);
}
void reset(size_t key)
{
size_t pos_size = key / 8;
size_t pos_count = key % 8;
_table[pos_size] &= ~(1 << pos_count);
}
bool test(size_t key)
{
size_t pos_size = key / 8;
size_t pos_count = key % 8;
return _table[pos_size] & (1 << pos_count); // 000 1 000
}
private:
std::vector<char> _table;
};
template<size_t N>
class two_bit_set
{
public:
void set(size_t key)
{
bool ret1 = bs1.test(key);
bool ret2 = bs2.test(key);
// 如果第一个位图映射的位 == 1,说明这个数已经出现了两次,直接返回即可
if (ret1) return;
// 如果第一个位图映射的位 == 0且第二个位图映射位 == 0,说明这个数已经出现了零次,直接在位图2中set
if (!ret1 && !ret2)
{
// 0 0 -> 0 1
bs2.set(key);
return;
}
// 如果第一个位图映射的位 == 0且第二个位图映射位 == 1,说明这个数已经出现了1次,直接在位图1中set
if (!ret1 && ret2)
{
// 0 1 -> 1 0
bs1.set(key);
bs2.reset(key);
}
}
bool test(size_t key)
{
// 如果第一个位图映射的位 == 0且第二个位图映射位 == 1,说明这个数只出现了1次
if (!bs1.test(key) && bs2.test(key))
return true;
else
return false;
}
private:
bit_set<N> bs1;
bit_set<N> bs2;
};
void Test1()
{
two_bit_set<9> tbs;
std::vector<int> v{ 3, 4, 5, 3, 4, 2, 1, 1, 7, 8, 7, 0, 9 }; // 5 2 8 0 9
for (auto e : v)
{
tbs.set(e);
}
std::cout << "出现一次的数字:> ";
for (auto e : v)
{
if (1 == tbs.test(e))
std::cout << e << " ";
}
std::cout << "\n";
}
}
2. 给两个文件,分别有100亿个整数,只有1G内存,如何找到两个文件的交集?
与上面的思路一致,同样用两个位图(去重),将文件的数据set到两个位图中,遍历两个位图,如果相同位置的位 == 1,则是交集
template<size_t N>
class Intersection_bit_set
{
public:
void set_arr1(size_t key)
{
_bs1.set(key);
}
void set_arr2(size_t key)
{
_bs2.set(key);
}
bool test(size_t key)
{
if (_bs1.test(key) && _bs2.test(key))
return true;
else
return false;
}
private:
bit_set<N> _bs1;
bit_set<N> _bs2;
};
3. 一个文件有100亿个int,1G内存,设计算法找到出现次数不超过两次的整数
与第一个问题稍有差异,只不过第一个问题两个位图记录了三种状态,而这里我们需要用两个位图记录四种状态。
第一种状态:Key没有出现过,对应的两个位图对应的映射位置分别为0,0
第二种状态:Key出现过一次,对应的两个位图对应的映射位置分别为0,1
第三种状态:Key出现过两次,对应的两个位图对应的映射位置分别为1,0
第四种状态:Key出现过两次以上,对应的两个位图对应的映射位置分别为1,1
template<size_t N>
class two_bit_set_plus
{
public:
void set(size_t key)
{
bool ret1 = bs1.test(key);
bool ret2 = bs2.test(key);
// 如果第一个位图映射的位 == 1且第二个位图映射的位 == 1,说明这个数已经出现了两次以上,直接返回即可
if (ret1 && ret2) return;
// 如果第一个位图映射的位 == 0且第二个位图映射位 == 0,说明这个数已经出现了零次,直接在位图2中set
else if (!ret1 && !ret2)
{
// 0 0 -> 0 1
bs2.set(key);
}
// 如果第一个位图映射的位 == 0且第二个位图映射位 == 1,说明这个数已经出现了1次,直接在位图1中set
else if (!ret1 && ret2)
{
// 0 1 -> 1 0
bs1.set(key);
bs2.reset(key);
}
// 如果第一个位图映射的位 == 1且第二个位图映射位 == 0,说明这个数已经出现了2次,直接在位图2中set
else (ret1 && !ret2)
{
// 1 0 -> 1 1
bs2.set(key);
}
}
bool test(size_t key)
{
// 如果第一个位图映射的位 == 1且第二个位图映射位 == 1,说明这个数已经出现了两次以上
if (bs1.test(key) && bs2.test(key))
return false;
else
return true;
}
private:
bit_set<N> bs1;
bit_set<N> bs2;
};
2.4. 位图特点
1、效率快、节省空间
2、相对局限,只能映射处理整形
3、直接定址法,不存在哈希冲突
2.5. 布隆过滤器
布隆过滤器是由布隆( Burton Howard Bloom )在 1970 年提出的 一种紧凑型的、比较巧妙的 概 率型数据结构 ,特点是 高效地插入和查询,可以用来告诉你 “ 某样东西一定不存在或者可能存 在 ” ,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式 不仅可以提升查询效率,也 可以节省大量的内存空间。
布隆过滤器的设计思路:
我们发现,上面的映射关系可能不具备唯一性。也就是说,当我们去判断某一个值存不存在的时候,可能具有误判,假设上面没有"野牛"这个字符串,但是当我们去判断"野牛"存不存在的时候,如果此时"大熊猫"是存在的,那么我们也会得到"野牛"是存在的结果,而如果此时"大熊猫"也不存在,那么我们会得到"野牛"是一定不存在的。
因此,对于在的判断 :是不准确的,存在误判
但是对于不在的判断 :是准确的,具有唯一性
而对于这种误判,我们是不能做到完全杜绝的,但是我们却可以降低它的误判率。
那么如何降低误判率呢?
我们可以让每个Key多映射几个位。
如下图:
虽然此时,"野牛"和"大熊猫"有一个位置冲突了,但是它们剩余的位置却是不冲突的,即只有两个字符串所有映射的位置都冲突了,才会导致误判,因此,在一定程度上降低了误判率。
理论而言:
一个值映射的位越多,误判概率越低。但是也不敢映射太多,映射位太多,那么空间消耗就越大,优势就会被削弱。
而上面的方式,也就是布隆过滤器的大致实现思路。
2.5.1. 布隆过滤器的应用
布隆过滤器(Bloom Filter)是一种空间效率非常高的概率数据结构,用于判断一个元素是否属于一个集合。它可以用于快速过滤掉不属于集合中的元素,具有高效的查询速度和较小的内存占用。
布隆过滤器的应用场景包括但不限于以下几个方面:
1. 缓存:在缓存系统中,布隆过滤器可以用来判断一个待查询的数据是否存在于缓存中,从而避免对底层数据存储系统的查询操作,提高缓存的命中率。
2. 数据库查询优化:在数据库系统中,布隆过滤器可以用于减少对磁盘或网络的查询压力。例如,在查询之前,可以先使用布隆过滤器判断某个查询条件是否存在对应的记录,如果不存在,可以快速返回查询结果为空,避免不必要的查询操作。
3. 网络爬虫去重:在网络爬虫系统中,布隆过滤器可以用于去重操作。爬虫在爬取网页时,可以使用布隆过滤器来过滤已经抓取过的网页链接,避免重复抓取相同的内容。
4. 防止缓存穿透:在分布式系统中,布隆过滤器可以用于防止缓存穿透。当一个请求的查询结果不存在于缓存中时,可以先通过布隆过滤器进行快速判断,如果查询结果不存在于布隆过滤器中,可以直接拒绝该请求,避免对底层存储系统的过度查询负载。
需要注意的是,布隆过滤器在判断元素是否存在时,可能会存在一定的误判率(False Positive)。因此,在使用布隆过滤器时需要权衡误判率和内存占用,根据实际需求选择合适的参数配置和误判率控制策略。
由于黑名单的Key是整体的一小部分,而大多数的Key都不在黑名单,因此布隆过滤器可以快速过滤掉黑名单中不存在的Key,避免过多的操作,进而提高效率。
2.5.2. 布隆过滤器的实现
在开始实现之前,我们应该解决一个问题: 如何选择哈希函数个数 和 布隆过滤器长度,人们经过分析和实践,得出下面的结论:
在这里我们粗略计算,当k = 3时,插入1个元素,大概需要5个空间
那么我们的实现如下:
class hash1_string //BKDRHash
{
public:
size_t operator()(const std::string& str)
{
size_t ret = 0;
for (auto ch : str)
{
ret *= 131;
ret += ch;
}
return ret;
}
};
class hash2_string //APHash
{
public:
size_t operator()(const std::string& str)
{
size_t hash = 0;
size_t ch = 0;
for (size_t i = 0;i < str.size(); i++)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));
}
}
return hash;
}
};
class hash3_string //DJBHash
{
public:
size_t operator()(const std::string& str)
{
size_t hash = 5381;
for (auto ch : str)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
// 布隆过滤器大部分情况Key都是string
template<size_t N,class K = std::string,class Hash1 = hash1_string,class Hash2 = hash2_string,class Hash3 = hash3_string>
class bloom_filter
{
public:
void set(const K& key)
{
// 第一个哈希函数映射的位置
size_t hash_index1 = Hash1()(key) % (_ratio * 5);
_table.set(hash_index1);
// 第二个哈希函数映射的位置
size_t hash_index2 = Hash2()(key) % (_ratio * 5);
_table.set(hash_index2);
// 第三个哈希函数映射的位置
size_t hash_index3 = Hash3()(key) % (_ratio * 5);
_table.set(hash_index3);
}
bool test(const K& key)
{
size_t hash_index1 = Hash1()(key) % (_ratio * 5);
if (!_table.test(hash_index1)) // 不存在是确定的
return false;
size_t hash_index2 = Hash2()(key) % (_ratio * 5);
if (!_table.test(hash_index2)) // 不存在是确定的
return false;
size_t hash_index3 = Hash3()(key) % (_ratio * 5);
if (!_table.test(hash_index3)) // 不存在是确定的
return false;
return true; // 走到这里,说明可能存在,但不确定,可能误判
}
private:
const static size_t _ratio = 5; // 当哈希函数个数为3,插入一个数据,需要5个空间
bit_set<N*_ratio> _table;
};
布隆过滤器不能直接支持删除,因为某些Key可能会映射到同一个位置,删除会影响其他Key。
一种支持删除的方法:采用引用计数的思想 ,将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
缺陷:1. 无法确认元素是否真正在布隆过滤器中2. 存在计数回绕
布隆过滤器优点:1. 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关2. 哈希函数相互之间没有关系,方便硬件并行运算3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算布隆过滤器缺点:1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)2. 不能获取元素本身3. 一般情况下不能从布隆过滤器中删除元素4. 如果采用计数方式删除,可能会存在计数回绕问题
2.5.3. 布隆过滤器的拓展
1. 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法
在这里我们采用一种思想:哈希切分,大概思路如下
注意:这里的分割并不是平均切分,而是根据Key取模后得到不同的值进入不同的文件
给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
与上题条件相同,如何找到top K的IP?关键点:虽然一个文件中可能有不同的ip,但是相同的ip一定在同一个文件