目录
3.模拟实现unordered_map和unordered_set
1.unordered系列关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到logN,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,本文中只对unordered_map和unordered_set进行介绍,unordered_multimap和unordered_multiset可查看文档介绍
1.1unordered_map
unordered_map的文档介绍
1. unordered_map是存储<key, value>键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
2. 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
3. 在内部,unordered_map没有对<kye, value>按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
4. unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
5. unordered_maps实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问value。
6. 它的迭代器是一个单向迭代器。
注:set\map底层是红黑树,它的迭代器遍历是有序的,是一个双向迭代器,unordered_set/unordered_map底层是哈希表,它的迭代器遍历是无序的,是一个单向迭代器
在C++的迭代器分类中,有一个常见的细分是单向迭代器(Forward lterator)和双向迭代器(Bidirectional lterator)。
单向迭代器︰单向迭代器是一种最基本的迭代器,它只能向前迭代,即只能通过递增操作(++)来访问容器中的下一个元素。单向迭代器适用于只需要单向遍历容器元素的场景。
双向迭代器:双向迭代器可以向前迭代和向后迭代,即可以通过递增操作(++)和递减操作(--)来访问容器中的元素。双向迭代器适用于需要双向遍历容器元素的场景,例如需要反向遍历容器。
在C++STL中,大多数容器都提供双向迭代器,可以使用迭代器在容器中进行正向和反向遍历。例如,list、deque和set等容器都提供双向迭代器。而数组和向量(vector)等容器,则提供随机访问迭代器,它们支持从任意位置随机跳转访问元素。
需要注意的是,双向迭代器是对于单向迭代器的一种扩展,双向迭代器可以执行单向迭代器的所有操作,但是单向迭代器不一定能执行双向迭代器的所有操作。
https://cplusplus.com/reference/unordered_map/unordered_map/
unordered_map的在线文档,通过在线文档可以查看unordered_map的说明以及各种函数接口的使用。
1.2unordered_set
https://cplusplus.com/reference/unordered_set/unordered_set/
unordered_set的在线文档,通过在线文档可以查看unordered_set的说明以及各种函数接口的使用。
注:除了少部分接口之外,大部分接口和set和map的用法还是一样的没有多大的区别。
1.3unordered_map 和 unordered_set的作用
unordered_map 和 unordered_set 是 C++ 标准模板库(STL)中的关联容器,它们用于存储键值对(unordered_map)和无重复元素集合(unordered_set)。这两种容器都是基于哈希表实现的,提供了平均常数时间复杂度的性能特性,这意味着对于大多数操作,如插入、查找和删除,它们的运行时间与容器中的元素数量大致成正比。
unordered_map 是一种映射容器,它存储键值对。每个键映射到一个值。键必须是可哈希的,因此它们必须定义一个有效的哈希函数。unordered_map 适用于需要快速通过键检索值的情况。例如,如果您正在开发一个程序,需要根据用户ID快速查找用户的个人信息,那么unordered_map将是一个很好的选择。
unordered_set 是一种集合容器,它存储唯一的元素。unordered_set 适用于需要确保元素唯一性并快速检索这些元素的场景。例如,如果您想维护一个活跃用户的集合,并且需要快速检查某个用户是否在该集合中,那么unordered_set将是合适的容器。
两者都提供了高效的迭代器,但迭代顺序并不保证,因为元素是根据哈希值存储在不同的桶中的。此外,由于它们不是有序的,所以不能使用像std::sort这样的算法来排序元素;如果需要有序性,应该考虑使用std::map或std::set。
总的来说,unordered_map 和 unordered_set 提供了快速的元素访问速度,特别是在元素数量较多的情况下。然而,它们的内存使用效率可能不如顺序容器,如std::vector和std::deque,因为它们需要额外的空间来支持哈希表结构和可能的冲突链表。
1.4在使用时什么时候选择unordered_set和unordered_map,什么时候选择红黑树实现的map和set
选择unordered_set/unordered_map和基于红黑树的map/set主要取决于以下因素:
- 性能需求:
-
- 如果需要平均常数时间复杂度(O(1))的插入、查找和删除操作,并且元素数量较大,那么unordered_set/unordered_map是更好的选择。
- 如果元素数量较小或者需要有序集合/映射,那么基于红黑树的map/set可能更合适,因为它们提供稳定的对数时间复杂度(O(log n))。
- 内存使用:
-
- unordered_set/unordered_map由于其哈希表实现,可能会占用更多内存,因为它们需要额外的空间来维护哈希表的负载因子和处理冲突。
- 红黑树实现的map/set通常更加紧凑,因为它们不需要额外的空间来处理哈希冲突。
- 元素分布:
-
- 如果元素分布均匀,unordered_set/unordered_map能提供更好的性能。
- 如果元素分布极端不均,导致哈希冲突严重,红黑树实现的map/set可能会提供更稳定的性能。
- 迭代顺序:
-
- 如果需要按照特定顺序(通常是元素的构造顺序或根据比较函数排序)迭代容器,那么应该选择基于红黑树的map/set。
- unordered_set/unordered_map迭代顺序不保证,元素是根据哈希值存储的。
- 哈希函数:
-
- 如果使用的键类型没有合适的哈希函数,或者自定义哈希函数可能导致性能问题,那么基于红黑树的map/set可能是更好的选择。
- 并发编程:
-
- 如果需要在多线程环境中使用容器,并且关心线程安全性,应该知道C++17引入了并行算法,这些算法可以与unordered_map和unordered_set一起使用,以提高并发操作的性能。
总结来说,选择哪种容器取决于具体的应用场景和对性能、内存使用、元素分布和迭代顺序的需求。通常,如果没有特别的需求,unordered_set/unordered_map因其平均常数时间的操作效率而成为默认选择,但在需要有序性、稳定性或者内存使用优化时,红黑树实现的map/set则更为适合。
2.底层结构
unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
2.1哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
1.插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
2.搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表),通过key根存储位置建立关联关系。
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为: hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
问题:按照上述哈希方式,向集合中插入元素44,11,1009,会出现什么问题?因为这几个数字 % 10之后 等于4,1,9,但是这几个位置已经存储了元素,导致这几个位置就会出现冲突,称为哈希冲突,
2.2哈希冲突
对于两个数据元素的关键字k_i和 k_j(i != j),有k_i != k_j,但有:Hash(k_i) == Hash(k_j),即:不同关键字通过相同哈希哈数计算出相同的哈希地址(不同的值,映射到相同的位置),该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?
2.3哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
哈希函数的定义域必须包括需要存储的全部关键码,如果散列表允许有m个地址时,其值域必须在0到m-1之间
哈希函数计算出来的地址能均匀分布在整个空间中。
哈希函数应该比较简单。
常见哈希函数:
1.直接定址法 -- (常用)
取关键字的某个线性函数为散列地址: Hash (Key) = A*Key + B
注:A和B的值是由我们自己定义的常数。在直接定址法中,我们可以根据具体的需求和问题的特点来选择A和B的值。
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
注:范围比较集中,每个数据分配一个唯一位置
下面给出一个例子来说明直接定址法的散列函数的使用。
假设我们有一个存储学生信息的哈希表,每个学生的学号作为关键字。我们的散列地址空间大小为1000,即哈希表有1000个位置。
我们可以选择散列函数为 Hash(Key) = (2 * Key + 3) % 1000。
假设学生的学号有以下几个:101, 205, 312, 418。
我们可以通过散列函数计算它们的散列地址:
Hash(101) = (2 * 101 + 3) % 1000 = 205 Hash(205) = (2 * 205 + 3) % 1000 = 413 Hash(312) = (2 * 312 + 3) % 1000 = 627 Hash(418) = (2 * 418 + 3) % 1000 = 839
因此,学号101会被映射到散列地址205,学号205会被映射到散列地址413,学号312会被映射到散列地址627,学号418会被映射到散列地址839。这样,我们可以将学生信息存储在相应的散列地址上。
需要注意的是,选择合适的A和B值非常重要,以确保散列函数能够使散列地址均匀分布,并尽量避免冲突。在实际应用中,通常需要进行分析和测试来选择最佳的A和B值
2. 除留余数法 -- (常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,m是指哈希表的最大长度,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
注:范围不集中,分布分散
以下是一个使用除留余数法的示例:
假设我们有一个散列表,允许的地址数为 1000,我们需要将关键字映射为散列地址。
我们可以选择质数 p = 997,将关键字通过除以 p 来计算散列地址。
假设关键字有以下几个:101, 205, 312, 418。
我们可以通过除留余数法计算它们的散列地址:
Hash(101) = 101 % 997 = 101 Hash(205) = 205 % 997 = 205 Hash(312) = 312 % 997 = 312 Hash(418) = 418 % 997 = 418
因此,关键字101会被映射到散列地址101,关键字205会被映射到散列地址205,关键字312会被映射到散列地址312,关键字418会被映射到散列地址418。这样,我们可以将关键字存储在相应的散列地址上。
需要注意的是,选择合适的质数 p 是至关重要的,它应该不大于散列表中允许的地址数 m,并且最接近或等于 m。在实际应用中,可以通过一些方法来选择合适的质数 p,以获得更好的散列性能和避免冲突
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.线性探测
比如图中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hash(key)为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入:
通过哈希函数获取待插入元素在哈希表中的位置。
如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
线性探测优点:实现非常简单
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,比如图中5, 6, 7这三个位置也不为空,直到位置8,导致搜索效率降低。如何缓解呢?
2. 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:H_i = (H_0 + i^2) % m, 或者:H_i = (H_0 - i^2 )% m。其中:i =1,2,3…, H_0是通过散列函数Hash(key)对元素的关键码 key 进行计算得到的位置,m是表的大小。
注:意思就是当前计算的位置(H_0) + i,这个i < m,原来的线性探测是一个一个的位置去查找,而二次探测就是一次隔i个位置进行操作,能一定程度的缓解搜索效率,但是二次探测有可能出现死循环的问题每次探测刚好都跳过空的位置,导致一直找不到空位置。
对于图中如果要插入44,产生冲突,使用解决后的情况为:
问题:哈希表什么情况下进行扩容?如何扩容?
载荷因子也叫负载因子,其实指的是表的存储数据量的百分比
研究表明:当表的长度为质数且表装载因子a不超过0.5(百分之五十)时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
为什么标记的伪删除法来删除一个元素,而不是直接设置为空?因为在进行查找时要从映射位置开始查找,直到为空才停止,如果直接删除4然后置为空就会查找不到后面的44,所以每个存储的位置都要设置它们的状态标识:空、存在、删除。
enum State
{
EMPTY,//空
EXIST,//存在
DELETE//删除
};
闭散列的实现
namespace OpenAddress
{
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)//const pair<K, V>& kv 赋值给 vector<HashDate<K, V>> _tables 就像字符串赋值给string一样,支持单参数类型转换
{
if (Find(kv.first))
return false;
//负载因子超过0.7就扩容
//if((double)_n / (double)_tables.size() >= 0.7)
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) //判断是否需要扩容
{
//注:哈希扩容不能在原来的空间上进行扩容,需要重新开辟一块新的空间,因为扩容后映射关系变了
// 原来冲突的值可能不冲突了
// 原来不冲突的值可能冲突了
//扩容方法1
//size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
//vector<HashDate<K, V>> newtables(newsize);
遍历旧表,重新映射到新表
//for (auto& data : _tables)
//{
// if (data._state == EXIST)
// {
// //重新算在新表的位置
// size_t i = 1;
// size_t index = hashi;
// while (newtables[index]._state == EXIST)
// {
// index = hashi + i;
// index %= newtables.size();
// ++i;
// }
// newtables[index]._kv = date._kv;
// newtables[index]._state = EXIST;
// }
//}
//_tables.swap(newtables);
//扩容方法2
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht;
newht._tables.resize(newsize);
//遍历旧表,重新映射到新表
for (auto& data : _tables)//每一个data都是HashData自定义类型
{
if (data._state == EXIST)
{
newht.Insert(data._kv);//扩容之后自己调用自己进行插入操作
}
}
_tables.swap(newht._tables);
}
//注,为什么取模和扩容的时候都使用size而不是使用capacity的原因:
//在哈希表中,"size"通常表示当前表中存储的元素数量,而"capacity"表示哈希表的容量大小。在进行哈希表的扩容和取模操作时,通常涉及到重新分配
//内存空间,并重新计算元素的哈希值和插入位置。
//在扩容时,哈希表需要将当前的数据重新分布到新的内存空间中,因此需要对原有的元素重新进行哈希计算。扩容时,一般会先将哈希表的容量扩大一
//定倍数(如2倍),然后将原有的元素重新插入到新的哈希表中。这时使用的是哈希表的"size",即当前的元素数量,而不是"capacity",因为元素数量
//是确定的,而不是哈希表的容量。
//在取模时,哈希表通常使用一个取模运算(如对一个质数取模)来确定元素在哈希表中的插入位置。当哈希表的容量改变时,一般需要重新计算取模的
//除数。这时同样使用的是哈希表的"size",即当前的元素数量,而不是"capacity",因为元素数量反映了哈希表实际存储的元素,而容量只是表示哈希
//表的潜在空间大小。
//综上所述,在哈希表的扩容和取模操作中使用"size"而不是"capacity",是因为这两个操作需要根据当前实际存储的元素数量来重新计算插入位置,并
//重新分配内存空间。
//为什么不是取模capacity?因为在顺序表(vector)中只能依次按顺序存储元素,如果取模capacity就会越界,除非不使用顺序表
//线性探测
size_t hashi = kv.first % _tables.size();
size_t i = 1;//i是用来二次线性探测下一个空位置的
size_t index = hashi;
while (_tables[index]._state == EXIST)//探测当前位置是否存在元素,如果存在再去找下一个直到为空
{
index = hashi + i;
index %= _tables.size();//防止越界
++i;
}
_tables[index]._kv = kv;
_tables[index]._state = EXIST;
_n++;
return true;
}
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
//线性探测
size_t hashi = key % _tables.size();
size_t i = 1;
size_t index = hashi;
//因为闭散列哈希表存储数据是分散的,所以在查找遍历时,至少要循环一圈,如果一圈之后没有查找到该元素就跳出循环,说明元素不存在
while (_tables[index]._state == EMPTY)
{
if (_tables[index]._state == EXIST && _tables[index]._kv.first == key)
{
return &_tables[index];
}
index = hashi + i;
index %= _tables.size();
++i;
//如果已经查找一圈,那么说明全是存在 + 删除
if (index == hashi)
{
break;
}
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
private:
//可以使用vector来充当容器,可以自动进行扩容
vector<HashData<K, V>> _tables;
size_t _n = 0;//存储数据的个数
//HashDate* tables;
//size_t _size;
//size_t _capacity;
};
//测试
void TestHashTable1()
{
int a[] = { 3,33,2,13,5,12,1002 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
}
}
2.4.2开散列
1.开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
意思就是通过一个哈希表,里面存储着一个个的指针,指针负责把所有的元素或者冲突的元素挂起来。
2.开散列的实现
namespace HashBucket//避免名字冲突
{
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>
struct HashFunc//返回键值对的key,用于计算哈希值
{
size_t operator()(const K& key)
{
return key;
}
};
//如果使用string做key就会调用这个特化模版,如果是其他类型做key就是调用上面的普通模版
//特化模版
template<>
struct HashFunc<string>//用于把字符串的key转化成整形,然后用于计算哈希值 -- 字符串哈希函数
{//BKDR
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;//
hash *= 31;
}
return hash;
}
};
//方法二:开散列
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
//闭散列会自动释放空间,但是开散列不会,因为开散列里面是一个个的指针,所以
//我们要显示实现析构函数,释放指针里面的每一个节点的空间
~HashTable()
{
for (auto cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
cur = nullptr;
}
}
Node* Find(const K& key)
{
if (_tables.size() == 0)
return nullptr;
Hash hash;
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)//开散列的删除其实就是单链表的删除
{
Hash hash;
size_t hashi = hash(key) % _tables.size();//计算在哪一个桶中
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
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];
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
Hash hash;
//负载因子 == 1时扩容,指的是数据个数等于哈希桶的个数,
//负载因子越大,冲突的概率越高,查找效率越低,空间利用率越高
//负载因子越小,冲突的概率越低,查找效率越高,空间利用率越低
//比如说:负载因子很大,冲突的概率越高,一个桶中就挂有成百上千的元素负载因子越小,每个
// 桶中的元素越少,最少的情况每个桶中都有一个元素,最坏的情况也只有一个桶中挂满了所有
// 元素,不过这个场景的概率非常小,就算一个桶中挂满了,但是进行扩容之后桶里每一个元素
//就会重新映射,所以从整体来看它的时间复杂度是O(1)
if (_n == _tables.size())//因为指的是数据个数等于哈希桶的个数所以直接使用等于即可,不用进行计算
{
//方法1
/*size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht;
newht.resize(newsize);
for (auot cur : _tables)
{
while (cur)
{
newht.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newht._tables);*/
//方法2 -- 优化
//size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
//方法3 -- 优化
size_t newsize = GetNextPrime(_tables.size());
vector<Node*> newtables(newsize, nullptr);
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
size_t hashi = hash(cur->_kv.first) % newtables.size();
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
}
_tables.swap(newtables);
}
//注:每一个桶都是不带头单向链表
size_t hashi = hash(kv.first) % _tables.size();
//头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;//元素+1
return true;
}
private:
vector<Node*> _tables;//指针数组 -- 存储哈希桶的个数
size_t _n = 0;//存储数据有效个数,统计所有桶中元素的个数
};
void TestHashTable1()
{
int a[] = { 3,33,2,13,5,12,1002 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(15, 15));
ht.Insert(make_pair(25, 25));
ht.Insert(make_pair(35, 35));
ht.Insert(make_pair(45, 45));
}
void TestHashTable2()
{
int a[] = { 3,33,2,13,5,12,1002 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Erase(12);
ht.Erase(3);
ht.Erase(33);
}
struct HashStr
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
return hash;
}
};
void TestHashTable3()
{
HashTable<string, string> ht;
ht.Insert(make_pair("sort", "排序"));
ht.Insert(make_pair("left", "左边"));
ht.Insert(make_pair("right", "右边"));
ht.Insert(make_pair("", "右边"));
}
}
3.开散列的增容
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
//负载因子 == 1时扩容,指的是数据个数等于哈希桶的个数,
//负载因子越大,冲突的概率越高,查找效率越低,空间利用率越高
//负载因子越小,冲突的概率越低,查找效率越高,空间利用率越低
//比如说:负载因子很大,冲突的概率越高,一个桶中就挂有成百上千的元素负载因子越小,每个
// 桶中的元素越少,最少的情况每个桶中都有一个元素,最坏的情况也只有一个桶中挂满了所有
// 元素,不过这个场景的概率非常小,就算一个桶中挂满了,但是进行扩容之后桶里每一个元素
//就会重新映射,所以从整体来看它的时间复杂度是O(1)
//假设;如果就是出现极端场景怎么办?一个桶里面挂满了所有元素如何解决?
//方法一:控制负载因子,缩写负载因子使用空间换时间的方法,如缩小到0.5
//方法二:改为挂红黑树,如果一个桶里面超过一定的元素就改为挂红黑树。
if (_n == _tables.size())//因为指的是数据个数等于哈希桶的个数所以直接使用等于即可,不用进行计算
{
//方法1
/*size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht;
newht.resize(newsize);
for (auot cur : _tables)
{
while (cur)
{
newht.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newht._tables);*/
//方法2 -- 优化
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
//size_t newsize = GetNextPrime(_tables.size());
vector<Node*> newtables(newsize, nullptr);
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
size_t hashi = hash(cur->_kv.first) % newtables.size();
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
}
_tables.swap(newtables);
}
4.开散列的思考
1.只能存储key为整形的元素,其他类型怎么解决?
template<class K>
struct HashFunc//返回键值对的key,用于计算哈希值
{
size_t operator()(const K& key)
{
return key;
}
};
//如果使用string做key就会调用这个特化模版,如果是其他类型做key就是调用上面的普通模版
//特化模版
template<>
struct HashFunc<string>//用于把字符串的key转化成整形,然后用于计算哈希值 -- 字符串哈希函数
{//BKDR
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;//
hash *= 31;
}
return hash;
}
};
给哈希表的类模版设置一个仿函数,当key是整形时自动调用普通模版,当key是string时自动调用特化模版。
https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html -- 字符串哈希函数算法
哈希函数算法指的就是如闭散列的线性探测,哈希算法是一种用来计算键(key)在散列表中的位置(索引)的算法。它将键(key)作为输入,经过计算转化为一个散列值(hash value),然后将散列值映射到散列表中的一个位置上。
散列表(哈希表)是一种数据结构,它由若干个位置(桶或槽)组成,每个位置可以存储一个或多个键值对。通过哈希算法,将键映射为散列值,再通过取模运算或其他映射方法,将散列值转化为散列表中的位置。这样,就可以通过键来快速访问到对应的位置,从而实现高效的查找、插入和删除操作。
哈希算法的设计要尽量使得散列值分布均匀,减少冲突(多个键映射到同一个位置)的概率,以提高散列表的性能。常用的哈希算法包括除法散列法、乘法散列法、平方取中法、折叠法等。不同的哈希算法适用于不同的应用场景,根据键的特点和散列表的大小选择适合的哈希算法可以提高散列表的效率。
5.开散列与闭散列比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于放开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开放地址法节省存储空间。
3.模拟实现unordered_map和unordered_set
//HashTable.h
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include <vector>
#include <utility>
#include <string>
#include <iostream>
using namespace std;
using std::pair;
using std::vector;
using std::string;
using std::make_pair;
namespace OpenAddress
{
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;
//负载因子超过0.7就扩容
//if((double)_n / (double)_tables.size() >= 0.7)
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
//size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
//vector<HashDate<K, V>> newtables(newsize);
遍历旧表,重新映射到新表
//for (auto& data : _tables)
//{
// if (data._state == EXIST)
// {
// //重新算在新表的位置
// size_t i = 1;
// size_t index = hashi;
// while (newtables[index]._state == EXIST)
// {
// index = hashi + i;
// index %= newtables.size();
// ++i;
// }
// newtables[index]._kv = date._kv;
// newtables[index]._state = EXIST;
// }
//}
//_tables.swap(newtables);
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht;
newht._tables.resize(newsize);
//遍历旧表,重新映射到新表
for (auto& data : _tables)
{
if (data._state == EXIST)
{
newht.Insert(data._kv);
}
}
_tables.swap(newht._tables);
}
size_t hashi = kv.first % _tables.size();//为什么不是取模capacity?因为在顺序表中只能依次按顺序存储元素,如果取模capacity就会越界
//线性探测
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state == EXIST)
{
index = hashi + i;
index %= _tables.size();
++i;
}
_tables[index]._kv = kv;
_tables[index]._state = EXIST;
_n++;
return true;
}
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t hashi = key % _tables.size();
//线性探测
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state == EMPTY)
{
if (_tables[index]._state == EXIST && _tables[index]._kv.first == key)
{
return &_tables[index];
}
index = hashi + i;
index %= _tables.size();
++i;
//如果已经查找一圈,那么说明全是存在 + 删除
//比如这种情况:插入数据后,在扩容前,删除一部分数据,在插入数据,并且数据正好
//占据其他空位,导致表里面,除了存在就是删除
if (index == hashi)
{
break;
}
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
private:
//可以使用vector来充当容器,可以自动进行扩容
vector<HashData<K, V>> _tables;
size_t _n = 0;//存储数据的个数
//HashDate* tables;
//size_t _size;
//size_t _capacity;
};
void TestHashTable1()
{
int a[] = { 3,33,2,13,5,12,1002 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
}
}
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
//特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
return hash;
}
};
//对开散列进行改造
namespace HashBucket//避免名字冲突
{
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 Ref, class Ptr, class KeyOfT, class Hash>
struct __HashIterator
{
typedef HashNode<T> Node;
typedef HashTable<K, T, KeyOfT, Hash> HT;
typedef __HashIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;
typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> Iterator;
Node* _node;
const HT* _ht;//哈希表的指针,用来寻找下一个桶
__HashIterator(Node* node, const HT * ht)
:_node(node)
,_ht(ht)
{}
__HashIterator(const Iterator& it)
:_node(it._node)
, _ht(it._ht)
{}
Ref operator*()
{
return _node->_data;
}
Ptr operator->()
{
return &_node->_data;
}
bool operator!=(const Self& s)
{
return _node != s._node;
}
Self& operator++()
{
if (_node->_next != nullptr)
{
_node = _node->_next;
}
else
{
//找下一个不为空的桶
KeyOfT kot;
Hash hash;
//算出当前桶的位置
size_t hashi = hash(kot(_node->_data)) % _ht->_tables.size();
++hashi;//下一个位置
while (hashi < _ht->_tables.size())
{
if (_ht->_tables[hashi])
{
_node = _ht->_tables[hashi];
break;
}
else
{
++hashi;
}
}
//没有找到不为空的桶
if (hashi == _ht->_tables.size())
{
_node = nullptr;
}
}
return *this;
}
};
//方法二:开散列
template<class K, class T, class KeyOfT, class Hash>
class HashTable
{
//友元
template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>
friend struct __HashIterator;
typedef HashNode<T> Node;
public:
typedef __HashIterator<K, T, T&, T*, KeyOfT, Hash> iterator;
typedef __HashIterator<K, T, const T&, const T*, KeyOfT, Hash> const_iterator;
iterator begin()
{
Node* cur = nullptr;
for (size_t i = 0; i < _tables.size(); ++i)
{
cur = _tables[i];
if (cur)
{
break;
}
}
return iterator(cur, this);//传this本身过去,也就是哈希表的地址,因为迭代器里面还要调用哈希表的成员
}
iterator end()
{
return iterator(nullptr, this);
}
const_iterator begin() const
{
Node* cur = nullptr;
for (size_t i = 0; i < _tables.size(); ++i)
{
cur = _tables[i];
if (cur)
{
break;
}
}
return const_iterator(cur, this);
}
const_iterator end() const
{
return const_iterator(nullptr, this);
}
~HashTable()
{
for (auto cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
cur = nullptr;
}
}
iterator Find(const K& key)
{
if (_tables.size() == 0)
return end();
KeyOfT kot;
Hash hash;
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)
{
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)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
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];
}
pair<iterator, bool> Insert(const T& data)
{
KeyOfT kot;
iterator it = Find(kot(data));
if (it != end())
{
return make_pair(it, false);
}
Hash hash;
//负载因子 == 1时扩容
if (_n == _tables.size())
{
/*size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht;
newht.resize(newsize);
for (auot cur : _tables)
{
while (cur)
{
newht.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newht._tables);*/
//size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
size_t newsize = GetNextPrime(_tables.size());
vector<Node*> newtables(newsize, nullptr);
for (auto& cur : _tables)
{
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.swap(newtables);
}
size_t hashi = hash(kot(data)) % _tables.size();
//头插
Node* newnode = new Node(data);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return make_pair(iterator(newnode, this), false);
}
private:
vector<Node*> _tables;//指针数组
size_t _n = 0;//存储数据有效个数
};
}
//unordered_map.h
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include "HashTable.h"
namespace bit
{
template <class K, class V, class Hash = HashFunc<K>>
class unordered_map
{
public:
struct MapKeyOfT
{
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
typedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::iterator iterator;
typedef typename HashBucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::const_iterator const_iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
const_iterator begin() const
{
return _ht.begin();
}
const_iterator end() const
{
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 K& key)
{
return _ht.Find(key);
}
bool erase(const K& key)
{
return _ht.Erase();
}
private:
HashBucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;
};
void test_unordered_map1()
{
unordered_map<int, int> m;
m.insert(make_pair(1, 1));
m.insert(make_pair(3, 3));
m.insert(make_pair(2, 2));
unordered_map<int, int>::iterator it = m.begin();
while (it != m.end())
{
//它的key不能修改,但是val可以修改
//it->first = 1;
//it->second = 1;
cout << it->first << ":" << it->second << endl;
++it;
}
cout << endl;
}
void test_unordered_map2()
{
string arr[] = { "西瓜", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉", "梨" };
unordered_map<string, int> countMap;
for (auto& e : arr)
{
countMap[e]++;
}
for (auto& kv : countMap)
{
cout << kv.first << ":" << kv.second << endl;
}
}
class Date
{
friend struct HashDate;
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
bool operator<(const Date& d)const
{
return (_year < d._year) ||
(_year == d._year && _month < d._month) ||
(_year == d._year && _month == d._month && _day < d._day);
}
bool operator>(const Date& d)const
{
return (_year > d._year) ||
(_year == d._year && _month > d._month) ||
(_year == d._year && _month == d._month && _day > d._day);
}
bool operator==(const Date& d) const
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
friend ostream& operator<<(ostream& _cout, const Date& d);
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& _cout, const Date& d)
{
_cout << d._year << "-" << d._month << "-" << d._day;
return _cout;
}
//计算时间key的哈希函数
struct HashDate
{
size_t operator()(const Date& d)
{
size_t hash = 0;
hash += d._year;
hash *= 31;
hash += d._month;
hash *= 31;
hash += d._day;
hash *= 31;
return hash;
}
};
//一个类型要做unordered_set/unordered_map的key,要满足支持转换成取模的整形 + 、== 比较
//一个类型要做set/map的key,要满足支持 < 的比较
void test_unordered_map3()
{
Date d1(2023, 3, 13);
Date d2(2023, 3, 13);
Date d3(2023, 3, 12);
Date d4(2023, 3, 11);
Date d5(2023, 3, 12);
Date d6(2023, 3, 13);
Date a[] = { d1, d2, d3, d4, d5, d6 };
unordered_map<Date, int, HashDate> countMap;
for (auto e : a)
{
countMap[e]++;
}
for (auto& kv : countMap)
{
cout << kv.first << ":" << kv.second << endl;
}
}
}
//unordered_set.h
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include "HashTable.h"
namespace bit
{
template <class K, class Hash = HashFunc<K>>
class unordered_set
{
public:
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
//关键字typename用于告诉编译器一个标识符是一个类型名而不是一个变量名或者静态成员。
typedef typename HashBucket::HashTable<K, K, SetKeyOfT, Hash>::const_iterator iterator;
typedef typename HashBucket::HashTable<K, K, SetKeyOfT, Hash>::const_iterator const_iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
const_iterator begin() const
{
return _ht.begin();
}
const_iterator end() const
{
return _ht.end();
}
pair<iterator, 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();
}
private:
HashBucket::HashTable<K, K, SetKeyOfT, Hash> _ht;
};
void print(const unordered_set<int>& s)
{
unordered_set<int>::const_iterator it = s.begin();
while (it != s.end())
{
//*it = 1;
cout << *it << " ";
++it;
}
cout << endl;
}
void test_unordered_set1()
{
int a[] = { 3,33,2,13,5,12,1002 };
unordered_set<int> s;
for (auto e : a)
{
s.insert(e);
}
s.insert(103);
unordered_set<int>::iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
cout << endl;
print(s);
}
}
//main.cpp
#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include "UnorderedSet.h"
#include "UnorderedMap.h"
#include "HashTable.h"
int main()
{
//bit::test_unordered_set1();
bit::test_unordered_map1();
bit::test_unordered_map2();
bit::test_unordered_map3();
return 0;
}
//int main()
//{
// //OpenAddress::TestHashTable1();
// HashBucket::TestHashTable1();
// //HashBucket::TestHashTable2();
//
// return 0;
//}
4.哈希的应用
4.1位图
4.1.1位图概念
问题一:
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中?
1.遍历,时间复杂度O(N)
2.放到哈希表或者红黑树
首先分析40亿个整数占用多少空间?
1G == 1024MB == 1024*1024KB == 1024*1024*1024Byte(字节),一个字节(1Byte)等于8个比特位(8bit),
1024*1024*1024Byte约等于10亿Byte,而一个整数(整形)占用4个字节,那么就是约等于16G,很明显使用第一种方法和第二种方法是行不通的光数据就要16G,更何况还有其他开销,比如指针。但是根据题目我们只需要标识一个数字在不在而已,这个时候每一个值就可以用一个位去标识它,一个字节等于8个bit位,40亿 / 8 = 5亿,5亿于等于512MB,这个方法就叫位图。
3.位图解决:
数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。比如:
位图概念:
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
4.1.2位图的实现
//bitset.h
#pragma once
#include <vector>
#include <iostream>
using namespace::std;
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N / 8 + 1, 0);//(N / 8 + 1)多开一个字节,为什么?因为如果是8的整数倍肯定刚刚好,如果不是呢?肯定会少几个比特位,所以要多开一个字节
}
void set(size_t x)//把映射在该位置上的比特位设置为1
{
size_t i = x / 8;//计算x映射的位char在第i个数组位置
size_t j = x % 8;//计算x映射的位在这个char的第j个比特位
_bits[i] |= (1 << j);// 通过 | 按位或,把该比特位的0变为1
}
void reset(size_t x)//把x映射在该位置上的比特位设置为0
{
size_t i = x / 8;//计算x映射的位char在第i个数组位置
size_t j = x % 8;//计算x映射的位在这个char的第j个比特位
_bits[i] &= ~(1 << j);// 通过 & 按位与,把该比特位的1变为0
}
bool test(size_t x)//判断一个值在不在
{
size_t i = x / 8;//计算x映射的位char在第i个数组位置
size_t j = x % 8;//计算x映射的位在这个char的第j个比特位
return _bits[i] & (1 << j);//存在返回true,否则返回false
}
//打印地址
void getaddress()
{
printf("%p\n", &_bits[0]);
}
private:
vector<char> _bits;
};
void test_bitest1()
{
bitset<100> bs;
bs.getaddress();
bs.set(10);
bs.set(11);
bs.set(15);
cout << bs.test(10) << endl;
cout << bs.test(11) << endl;
cout << bs.test(15) << endl;
bs.reset(11);
bs.reset(10);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
}
void test_bitest2()
{
//使用这两种方法可以快速把空间开到最大
//bitset<-1> bs1;//使用-1是因为接受的形参类型是size_t是无符号整形,会被转换成最大值
bitset<0xffffffff> bs2;
}
//main.cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include "bitset.h"
int main()
{
test_bitest1();
test_bitest2();
return 0;
}
问题二:
给定100亿个无符号整数,设计算法找到只出现一次的整数?
我们需要记录三种状态,00、01、10,分别表示出现0次,出现1次,出现1次以上,只需要和刚才一样,之前是开一个位图,那现在开两个位图即可。
#pragma once
#include <vector>
#include <iostream>
using namespace::std;
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N / 8 + 1, 0);//(N / 8 + 1)多开一个字节,为什么?因为如果是8的整数倍肯定刚刚好,如果不是呢?肯定会少几个比特位,所以要多开一个字节
}
void set(size_t x)//把映射在该位置上的比特位设置为1
{
size_t i = x / 8;//计算x映射的位char在第i个数组位置
size_t j = x % 8;//计算x映射的位在这个char的第j个比特位
_bits[i] |= (1 << j);// 通过 | 按位或,把该比特位的0变为1
}
void reset(size_t x)//把x映射在该位置上的比特位设置为0
{
size_t i = x / 8;//计算x映射的位char在第i个数组位置
size_t j = x % 8;//计算x映射的位在这个char的第j个比特位
_bits[i] &= ~(1 << j);// 通过 & 按位与,把该比特位的1变为0
}
bool test(size_t x)//判断一个值在不在
{
size_t i = x / 8;//计算x映射的位char在第i个数组位置
size_t j = x % 8;//计算x映射的位在这个char的第j个比特位
return _bits[i] & (1 << j);//存在返回true,否则返回false
}
//打印地址
void getaddress()
{
printf("%p\n", &_bits[0]);
}
private:
vector<char> _bits;
};
template<size_t N>
class towbitset
{
public:
void set(size_t x)
{
//00 -> 01 -- 出现1次
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
_bs2.set(x);
}
//01 -> 10 -- 出现2次
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
_bs1.set(x);
_bs2.reset(x);
}
//10 之后代表出现一次以上的数字,不包含一次,所以后面可以不用实现
}
void Print()
{
for (size_t i = 0; i < N; i++)
{
if (_bs2.test(i))
{
cout << i << endl;//输出出现一次的数字
}
}
}
public:
//定义两个位图(对之前的位图进行封装)
bitset<N> _bs1;
bitset<N> _bs2;
};
void test_towbitset1()
{
int a[] = { 3,45,53,32,32,43,3,2,5,2,32,7,8,1,55,5,53 };
towbitset<100> bs;
for (auto e : a)
{
bs.set(e);
}
bs.Print();
}
问题三:
给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
方法1:其中一个文件的值,读到内存的一个位图中,再读取另一个文件,判断在不在上面文图中,在就是交集,但是找出的交集存在重复的值,还需要再次去重才可以,改进方法就是把每次找到的交集值,都将位图上面对应的值设置为0,可以解决找到交集有重复值的问题。
方法2:开两个位图,读取文件1的数据映射到位图1,读取文件2的数据映射到位图2。
for(int i = 0; i < N; i++)
{
if(bs1.test(i) && bs2.test(i))
{
//交集
}
}
这两个方法根据情况使用。
问题四:
位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数?
- 将整型数据范围映射到位图:根据整型数据范围(假设为[min, max]),创建两个位图,每个整数对应位图中的一个位。如果整数出现,则对应位设置为1,否则为0。
- 逐个读取文件中的整数:使用适当的读取方式,逐个从文件中读取整数。
- 判断并设置位图中的位:对于每个读取到的整数,在位图中进行判断。如果对应位的值为0,表示该整数第一次出现,将该位设置为1;如果对应位的值为1,表示该整数已经出现过一次,将该位设置为2。
- 统计出现次数不超过2次的整数:遍历位图,记录位图中值为1或2的位对应的整数。
需要注意的是,根据题目中给出的限制条件,1G内存不足以一次性将100亿个整数全部加载到内存中。因此,需要采用逐个读取的方式,利用位图来记录整数的出现情况,从而找到出现次数不超过2次的所有整数。这种方法可以有效地利用有限的内存空间进行处理。
注:和问题二完全类似,稍微变化一下即可
4.1.3位图的应用
1.快速查找某个数据是否在一个集合中
2.排序+去重
3.求两个集合的交集、并集等
4.操作系统中磁盘块标记
位图的优点和缺点:
优点:速度快、节省空间
缺点:只能映射整形,其他类型如:浮点数、string等等不能存储映射
但是可以通过布隆过滤器来解决位图的一些缺点。
4.2布隆过滤器
4.2.1布隆过滤器的提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的?用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。如何快速查找呢?
1.用哈希表存储用户记录,缺点:浪费空间
⒉.用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。
3.将哈希与位图结合,即布隆过滤器
4.2.2布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你“某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
布隆过滤器(Bloom Filter)是一种数据结构,用于高效地判断一个元素是否存在于集合中,以及对于不存在的元素,能够进行快速的判断。它通常用于在大规模数据集中进行快速的查找操作,以避免昂贵的磁盘或网络访问。
布隆过滤器基于一组哈希函数和一个位数组构建而成。该位数组通常是一个二进制位(bit)数组,每个位可以被设置为0或1。初始时,所有位都被初始化为0。
布隆过滤器的基本原理是将要存储的元素通过多个哈希函数映射到位数组中的多个位置上,然后将这些位置的位设置为1。当需要查询一个元素是否存在时,将要查询的元素通过同样的哈希函数映射到位数组中的多个位置上,然后检查这些位置的位是否都为1。如果至少有一个位置的位为0,则可以确定该元素一定不存在于集合中,不存在误判;如果所有位置的位都为1,则表示可能存在于集合中,但可能存在误判,但是布隆过滤器是允许误判的。
布隆过滤器允许一定的误判率。误判指的是布隆过滤器判断一个元素存在于集合中,但实际上该元素并不存在于集合中。
由于布隆过滤器使用多个哈希函数和位数组来表示集合中的元素,误判率的存在是不可避免的。当进行元素查询时,如果所有对应位置的位都被标记为1,那么布隆过滤器将会认为该元素可能存在于集合中。然而,由于哈希函数的碰撞和位数组的有限大小,可能会导致不同元素映射到同一个位置上,从而造成误判。
布隆过滤器的误判率主要取决于以下几个因素:
- 哈希函数的数量和质量:使用更多、更散列的哈希函数可以降低误判率。
- 位数组的大小:较小的位数组可能导致更多的位碰撞,增加误判率。
- 存在的元素数量:随着元素数量的增加,位数组中的1的数量也会增加,进一步增加误判率。
通常情况下,可以根据应用的需求来选择合适的哈希函数数量和位数组大小,哈希函数个数,代表一个值映射几个位,哈希函数越多,误判率越低,但是哈希函数越多,平均的空间越多,从而控制误判率。可能会导致较高的存储空间占用。需要权衡操作成本和误判率,在实际应用中进行调优。
布隆过滤器具有高效的查询和插入操作,且占用的空间相对较小。但它也存在一定的误判率(即可能判断某个元素存在于集合中,但实际上不存在),且在删除元素时比较困难。
布隆过滤器的使用场景,要能容忍误判,比如注册时,快速判断昵称是否使用过以及常用于缓存系统、网络爬虫去重、垃圾邮件过滤等场景,用于快速判断某个元素是否已经存在,避免重复操作。
详解布隆过滤器的原理,使用场景和注意事项 - 知乎,这篇文章有详细的了解过程。
4.2.3布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判。
比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为: 1、3、7,刚好和其他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。
4.2.4布隆过滤器删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu"元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作,但是存在计数回绕问题。
布隆过滤器存在的计数回绕问题指的是在使用计数型布隆过滤器时,如果某个元素的计数超过了计数存储的最大值,计数将回绕到零,并且可能导致误判。
具体来说,计数型布隆过滤器通常使用一个固定长度的计数器数组来存储每个元素的计数值。当一个元素被插入时,对应的计数值会加1。然而,由于计数器数组的长度是有限的,当一个元素的计数值达到了最大值时,再次对其进行计数时,计数值会回绕到零。
这会导致两个问题:
- 计数误判:当一个元素的计数值达到最大值并回绕到零后,布隆过滤器无法准确地判断该元素是否存在。因为其他元素的计数值可能也回绕到零,并且会与该元素的计数值产生冲突,导致误判。
- 计数溢出:在计数器数组长度较小的情况下,如果有大量元素的计数值累积达到最大值并回绕,会导致计数溢出的问题。计数器数组不能再准确地表示这些元素的真实计数值,从而影响布隆过滤器的准确性。
为了解决计数回绕问题,在设计计数型布隆过滤器时,可以采取以下措施:
- 增加计数器数组的长度:通过增加计数器数组的长度,可以延迟回绕的发生,减少计数回绕的可能性。但是,这会增加内存消耗和计算开销。
- 定期重置计数值:定期重置计数器数组中的计数值,将所有元素的计数值归零,确保计数值不会回绕。但是,这会导致误判的可能性增加。
在实际应用中,需要权衡计数回绕问题对布隆过滤器准确性和性能的影响,选择适当的布隆过滤器参数和策略来满足具体需求。
缺陷:
1.无法确认元素是否真正在布隆过滤器中
2.存在计数回绕
4.2.5代码
//bitset.h
#pragma once
#include <vector>
#include <string>
#include <iostream>
#include <time.h>
using namespace::std;
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N / 8 + 1, 0);//(N / 8 + 1)多开一个字节,为什么?因为如果是8的整数倍肯定刚刚好,如果不是呢?肯定会少几个比特位,所以要多开一个字节
}
void set(size_t x)//把映射在该位置上的比特位设置为1
{
size_t i = x / 8;//计算x映射的位char在第i个数组位置
size_t j = x % 8;//计算x映射的位在这个char的第j个比特位
_bits[i] |= (1 << j);// 通过 | 按位或,把该比特位的0变为1
}
void reset(size_t x)//把x映射在该位置上的比特位设置为0
{
size_t i = x / 8;//计算x映射的位char在第i个数组位置
size_t j = x % 8;//计算x映射的位在这个char的第j个比特位
_bits[i] &= ~(1 << j);// 通过 & 按位与,把该比特位的1变为0
}
bool test(size_t x)//判断一个值在不在
{
size_t i = x / 8;//计算x映射的位char在第i个数组位置
size_t j = x % 8;//计算x映射的位在这个char的第j个比特位
return _bits[i] & (1 << j);//存在返回true,否则返回false
}
//打印地址
void getaddress()
{
printf("%p\n", &_bits[0]);
}
private:
vector<char> _bits;
};
void test_bitest1()
{
bitset<100> bs;
bs.getaddress();
bs.set(10);
bs.set(11);
bs.set(15);
cout << bs.test(10) << endl;
cout << bs.test(11) << endl;
cout << bs.test(15) << endl;
bs.reset(11);
bs.reset(10);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
}
void test_bitest2()
{
//使用这两种方法可以快速把空间开到最大
//bitset<-1> bs1;//使用-1是因为接受的形参类型是size_t是无符号整形,会被转换成最大值
bitset<0xffffffff> bs2;
}
template<size_t N>
class towbitset
{
public:
void set(size_t x)
{
//00 -> 01 -- 出现1次
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
_bs2.set(x);
}
//01 -> 10 -- 出现2次
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
_bs1.set(x);
_bs2.reset(x);
}
//10 之后代表出现一次以上的数字,不包含一次,所以后面可以不用实现
}
void Print()
{
for (size_t i = 0; i < N; i++)
{
if (_bs2.test(i))
{
cout << i << endl;//输出出现一次的数字
}
}
}
public:
bitset<N> _bs1;
bitset<N> _bs2;
};
void test_towbitset1()
{
int a[] = { 3,45,53,32,32,43,3,2,5,2,32,7,8,1,55,5,53 };
towbitset<100> bs;
for (auto e : a)
{
bs.set(e);
}
bs.Print();
}
struct BKDRHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (long i = 0; i < s.size(); i++)
{
size_t ch = s[i];
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));
}
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch :s)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
//N代表最多会插入key
template<size_t N, class K = string,
class Hash1= BKDRHash,
class Hash2 = APHash,
class Hash3 = DJBHash>//哈希函数根据情况添加,有几个哈希函数就映射几个位置
class BloomFilter
{
public:
void set(const K& key)//插入
{
size_t len = N * _X;//len是开空间的个数
size_t hash1 = Hash1()(key) % len;//这是一个仿函数的匿名对象调用方法,通过产生临时对象调用重载()运算符
_bs.set(hash1);
size_t hash2 = Hash2()(key) % len;
_bs.set(hash2);
size_t hash3 = Hash3()(key) % len;
_bs.set(hash3);
//cout << hash1 << " " << hash2 << " " << hash3 << endl << endl;
}
bool test(const K& key)//查找
{
//只要以下有一个位置不在,则说明这个元素不存在,如果全部位置都在,则这个元素存在,但是可能存在误判
//在 -- 不准确
//不在 -- 准确
size_t len = N * _X;
size_t hash1 = Hash1()(key) % len;//这是一个仿函数的匿名对象调用方法,通过产生临时对象调用重载()运算符
if (!_bs.test(hash1))//这三个if都是判断不存在的情况
{
return false;
}
size_t hash2 = Hash2()(key) % len;//这是一个仿函数的匿名对象调用方法,通过产生临时对象调用重载()运算符
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = Hash3()(key) % len;//这是一个仿函数的匿名对象调用方法,通过产生临时对象调用重载()运算符
if (!_bs.test(hash3))
{
return false;
}
return true;//在,返回true。
}
private:
static const size_t _X = 8;
bitset<N * _X> _bs;
};
void test_bloomfilter()
{
BloomFilter<100> bs;
bs.set("sort");
bs.set("bloom");
bs.set("hello wordl");
bs.set("hello");
bs.set("world");
bs.set("test");
bs.set("etst");
bs.set("estt");
cout << bs.test("sort") << endl;
cout << bs.test("bloom") << endl;
cout << bs.test("hello world") << endl;
cout << bs.test("etst") << endl;
cout << bs.test("test") << endl;
cout << bs.test("estt") << endl;
cout << bs.test("ssort") << endl;
cout << bs.test("tors") << endl;
cout << bs.test("ttes") << endl;
}
void test_bloomfilter2()
{
srand(time(0));
const size_t N = 10000;
BloomFilter<N> bf;
std::vector<std::string> v1;
std::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 + std::to_string(i));
}
for (auto& str : v1)
{
bf.set(str);
}
// v2跟v1是相似字符串集,但是不一样
std::vector<std::string> v2;
for (size_t i = 0; i < N; ++i)
{
std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
url += std::to_string(999999 + i);
v2.push_back(url);
}
size_t n2 = 0;
for (auto& str : v2)
{
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";
//string url = "https://www.cctalk.com/m/statistics/live/16845432622875";
url += std::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;
}
//main.cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include "bitset.h"
int main()
{
//test_bitest1();
//test_bitest2();
//test_towbitset1();
//test_bloomfilter();
test_bloomfilter2();
return 0;
}
4.2.6布隆过滤器优点
1.增加和查询元素的时间复杂度为:O(K),(K为哈希函数的个数,一般比较小),与数据量大小无关
2.哈希函数相互之间没有关系,方便硬件并行运算
3.布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
4.在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
5.数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
6.使用同一组散列函数的布隆过滤器可以进行交、并、差运算
使用同一组散列函数的布隆过滤器可以进行交、并、差运算的基本思路是将两个布隆过滤器的位向量进行相应的位操作。
假设我们有两个布隆过滤器,分别为BloomFilter1和BloomFilter2,它们使用相同的一组散列函数。
- 交集(Intersection)运算: 对于交集运算,我们可以直接将两个布隆过滤器的位向量进行逐位的与(AND)操作。最终得到的布隆过滤器的位向量即为两个布隆过滤器的交集。
BloomFilter_Intersection = BloomFilter1 & BloomFilter2
- 并集(Union)运算: 对于并集运算,我们可以直接将两个布隆过滤器的位向量进行逐位的或(OR)操作。最终得到的布隆过滤器的位向量即为两个布隆过滤器的并集。
BloomFilter_Union = BloomFilter1 | BloomFilter2
- 差集(Difference)运算: 对于差集运算,我们可以将BloomFilter1的位向量与BloomFilter2的位向量按位取反(NOT)后再进行与(AND)操作。最终得到的布隆过滤器的位向量即为两个布隆过滤器的差集。
BloomFilter_Difference = BloomFilter1 & (~BloomFilter2)
需要注意的是,进行交、并、差运算的两个布隆过滤器必须使用相同的一组散列函数,否则结果可能不准确。
4.2.7布隆过滤器缺陷
1.有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
2.不能获取元素本身
3.一般情况下不能从布隆过滤器中删除元素
4.如果采用计数方式删除,可能会存在计数回绕问题
问题一,哈希切割:
给一个超过100G大小的log file, log中存着IP地址,设计算法找到出现次数最多的IP地址?与上题条件相同,如何找到top K的IP?如何直接用Linux系统命令实现?
找到出现次数最多的IP地址: 可以使用哈希切割(Hash Cut)的思想来解决这个问题。哈希切割将大文件切割成多个小文件,并使用哈希函数将同样的IP地址映射到相同的小文件中。然后,对每个小文件分别统计其中IP地址的出现次数,找到每个小文件中出现次数最多的IP地址。最后,合并所有小文件的统计结果,找到整个log文件中出现次数最多的IP地址。
具体步骤如下:
- 将log文件按照哈希函数的结果切割成多个小文件(可以使用IP地址的哈希值作为切割依据)。
- 对每个小文件进行统计,记录每个小文件中出现次数最多的IP地址及其出现次数。
- 合并所有小文件的统计结果,找到出现次数最多的IP地址及其出现次数。
算法找到top K的IP地址: 同样地,使用哈希切割的思想可以找到top K的IP地址。首先,将log文件按照哈希函数切割成多个小文件。然后,对每个小文件进行统计,记录每个小文件中出现次数最多的K个IP地址及其出现次数。最后,合并所有小文件的统计结果,并选取其中出现次数最多的K个IP地址。
具体步骤如下:
- 将log文件按照哈希函数的结果切割成多个小文件。
- 对每个小文件进行统计,记录每个小文件中出现次数最多的K个IP地址及其出现次数。
- 合并所有小文件的统计结果,并选取其中出现次数最多的K个IP地址。
使用Linux系统命令实现: 可以使用一系列的Linux命令来实现。首先,使用grep命令从log文件中筛选出所有的IP地址,并使用sort命令对IP地址进行排序。然后,使用uniq -c命令统计每个IP地址的出现次数,并再次使用sort命令对结果进行排序。最后,使用head -n 1命令获取出现次数最多的IP地址,或者使用head -n K命令获取出现次数最多的top K的IP地址。
命令示例:
grep -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' log_file | sort | uniq -c | sort -nr | head -n 1
或者
grep -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' log_file | sort | uniq -c | sort -nr | head -n K
其中,log_file为log文件的路径,K为要获取的top K的IP地址的数量。
问题二,布隆过滤器:
给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
当两个文件都很大且内存有限时,可以使用以下方法找到两个文件的交集。
- 精确算法: a. 将两个文件分别按照一定规则进行排序,例如按照字典序排序。 b. 使用双指针的方法,同时遍历两个排序后的文件。 c. 如果两个指针指向的元素相等,则将该元素添加到交集结果中,并同时将两个指针向后移动。 d. 如果两个指针指向的元素不相等,则将较小的元素的指针向后移动。 e. 重复步骤c和d,直到遍历完任意一个文件。 f. 返回得到的交集结果。
这种方法需要将两个文件分别排序,因此可能需要额外的磁盘空间来存储排序后的文件。但由于内存有限,可能需要使用外部排序等技术来进行排序。
- 近似算法: a. 将两个文件分别分割成小块,每个块可以放入内存。 b. 逐个加载两个文件的块到内存中,分别构建布隆过滤器 (Bloom Filter)。 c. 对于一个文件的块,使用布隆过滤器检查另一个文件的块中的元素是否可能存在于该文件中。 d. 如果布隆过滤器返回可能存在,则将该块中的元素全部加载到内存中。 e. 对于两个文件块中的元素进行比较,找到交集。 f. 重复步骤c至e,直到遍历完所有的文件块。 g. 返回得到的交集结果。
近似算法利用了布隆过滤器的高效查找特性,在内存有限的情况下,可以快速地过滤掉一部分不可能存在于交集中的元素,从而减少了内存的使用和快速查找的时间。但近似算法由于使用了布隆过滤器,可能会存在一定的误判概率。