Bootstrap

【数据结构/C++】位图



哈希思想的应用

哈希(也叫 散列) 是一种 映射 的思想
哈希表:解决问题的思想(算法思想):哈希表通过映射这种思想,实现了哈希表这种数据结构

还有其他的数据结构:位图、布隆过滤器。小众点的,基数树(多阶哈希)



位图

位图概念

经典面试题

给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。

  1. 排序+二分
  2. set + find

1G=1024MB;1MB=1024KB;1KB=1024byte 。( 1024=2^ 10. )
1G=1024 * 1024 * 1024=(2^ 10)^ 3 = 2^ 30 byte
由此得出,1G就能存放1,000,000,000 (10亿)个数据。每一个数据存放的单位是1byte。
42亿 (即 2^32 ) 则需 4G 的数据大小(基本单位为 byte)通过位图,一个bit位便能 映射表示一个数(1byte=8bit),因此 开一个 能够映射42亿数据位图 所需的内存空间大小为 4G / 8bit = 0.5G (512M)


位图所开的空间大小

位图 开的大小,是按范围去开。无符号整数 的范围是 0~4,294,967,295,因此我们需要开0~4,294,967,295大的范围 。即使是100亿个数据,不管开多大还是开多小,都开Unsigned_int大小的位置, 除这42亿9千万个数外,其他的都是重复的

INT_MAX 大小是不够的才 2,147,483,647(因为是有符号整型,还有另一半 INT_MIN,所以为 2,147,483,647
2*INT_MAX:默认给出INT_MAX的时候识别是整型(int) * 2 以后会溢出 。因此要用UINT_MAX (unsigned_int_max)范围就是从0~4,294,967,295

bitset<> bs;

(size_t)-1 将-1强转为无符号整形
UINT_MAX (unsigned_intMAX)
0xffffffff(16进制:8个f)
pow(2,32)-1


倘若是通过数据结构来存储的, 4G(个数据) X 一个整数4byte = 总共16G-的内存大小 。排序需要连续的物理地址空间大小。平时普通的电脑内存根本不够用。像红黑树一个节点中存放三指针和一个数据,其所消耗的内存大小就更不用说了。

对于这道题目而言,一个数据只有两种状态:在/不在。如果我们想要标识两种状态,其实 只需要一个比特位就够了,0表示不存在,1表示存在通过哈希的映射思想,我们可以把每一个数据映射到一个比特位中,这就是位图的概念

  1. 开2^32个bit位【 按范围去开:Unsigned_int 42亿9千万
    表示一个数在或者不在,用一个bit比特位来表示


STL库中的 bitset 位图

简单了解bitset的最常用接口,明白其的功能有哪些,后面实现 。
在这里插入图片描述
类模板:
非类型模板参数N,位图中要开多少个比特位。

接口功能
operator[]返回对应位置的引用
count计算所有比特位中1的个数
size返回比特位的个数
test检测某一个位,是1返回true,是0返回false
set把某一个位的值改为1
reset把某一个位的值改为0


位图实现

大框架

template<size_t N>
class bitSet
{
public:
    
   
private:
    vector<int> _bits;
};

模板参数N :用于传参,代表传过来的有多少个数需要判断N就是要开的bit位的数量

但位图的 底层结构是vector< int >int类型的数组 ),而 一个int有32个bit,一个位置能容下判断32个数。那么我们应开的空间为 N / 32int空间大小的vector<>

但由于C++的除法会向下取整,所以我们要额外+1避免开出来的位不够。如63/32=1,那剩下的31位怎么办?

这样我们就可以写一个构造函数 。

template<size_t N>
class bitSet
{
public:
    bitSet()
    {
        _bits.resize(N / 32 + 1, 0);
    }

private:
    vector<int> _bits;
};


位运算符<<左移 和 >>右移 移动的方位

左移还是右移,是由大小端来决定的吗?大小端在内存窗口中查看。小端(低位在地址低位,高位在地址高位),大端(高位在地址低位,低位位在地址高位)

不是的,也没有说在小端机中右移是往高位移,左移往低位移这样的说法。
左移和右移并不是单纯的方向上的移动,而是统一 左移是往高位移,右移是往低位移 。而 硬件层 会单独判断处理数值是小端机还是大端机,(cpu)硬件体系中的指令体系 相当于 封装 了左移和右移,直接执行左移所对应的往高位移的操作,右移所对应的往低位移的操作



set():把x映射的位标记成1

大体逻辑:

  • 先定位( 一个元素有32个bit位 )
    • 确定是vector中的第i个元素
    • i个元素的第j个比特位
size_t i = x / 32; // vector的第i个元素
size_t j = x % 32; // 第i个元素的第j个比特位
  • 先将1左移<<(往高处移)到要标记成的位置( 除了该位置为1,其他位置都是0 )
  • 按位或等|= :有1则为1,( 除了该位置为1,其他位置都是0 )则能控制j位修改为1,而其他位置不受影响。

如把11001100的第4位变为1:

   11001100 	// 待修改数据
   00000001 	// 数字1
   00010000 	// 数字1左移4位
------------
   11001100
|= 00010000 	// 按位或等
 ------------
   11011100

set() 接口实现

	// 把x映射的位标记成1
	void set(size_t x)
	{
		assert(x <= N);
		
		size_t i = x / 32;
		size_t j = x % 32;
	
		_bits[i] |= (1 << j);
	}


reset:把x映射的位标记成0

将改为设置成0:

  • 定位
  • 将除了该位置设置成0,其他都设置成1
    这要怎么做到呢?对上面将该位置标记成1的( 1<<j )进行 取反操作~,就得到该位置是0,其他位置都为1 的底部
  • 然后 &= 按位与等:有0则为0,将该为修改为0。(其他位置都是1,则不会受到影响)
   11011100  // 待修改数据
   00000001  // 数字1
   00010000  // 数字1左移4位(1<<j)
-------------
   11011100
 ~ 00010000  // ~取反
-------------
   11011100
&= 11101111  // 按位与&=: 0来修改1
-------------
   11001100 

reset() 接口

	// 把x映射的位标记成0
	void reset(size_t x)
	{
		assert(x <= N);

		size_t i = x / 32;
		size_t j = x % 32;

		_bits[i] &= ~(1 << j);
	}


test():检测x位是1还是0

  • 左移1<<j( 让1与其按位与判断 )
  • & 按位与判断 就行了,不进行修改
    最后 return 该_bits[i]位的 整型值 转化成 bool值:0就是假,非0就是真。
bool test(size_t x)
{
    assert(x <= N);
    size_t i = x / 32;
    size_t j = x % 32;

    return _bits[i] & (1 << j);		// & 按位与判断,不进行修改
}


#include"BitSet.h" 展开在.cpp中,展开后BitSet.h里的内容也就只能向上查找
【链接】



效率

把数都遍历一遍,全都set到位图中。
剩下的查找就直接根据O(1),就能映射到要找的数据



海量面试题

  1. 给定100亿个整数,设计算法找到只出现一次的整数
  • 思路1:用map 内存存不下这些值
    10,000,000,000
    10亿多byte是1G,100亿多是10G,是整数因此还要x4byte,因此这里需要40G-的内存

  • 思路2:还是通过位图来映射找到对应数据出现的次数
    1次 和 2次及以上,需要2个比特位,只需要判断3中状态就可以了"00,01,10"(00 - 0次,01 - 1次,10 - 2次及以上)

    • 连开两个bit位,映射关系处理的有点麻烦
    • 直接开两个位图 ,两个位图组合起来就是其统计的次数
template<size_t N>
class two_bit_set
{
public:
	// 00 -> 01 1次
	// 01 -> 10 2次及以上(10以后就不变)
	void set(size_t x)
	{
		// 00 -> 01
		if (_bs1.test(x) == false
			&& _bs2.test(x) == false)
		{
			_bs2.set(x);
		}
		else if (_bs1.test(x) == false      
			&& _bs2.test(x) == true)   
		{
			// 01 -> 10
			_bs1.set(x);
			_bs2.reset(x);
		}
	}


	void test(size_t x)
	{
		// 00 
		if (_bs1.test(x) == false
			&& _bs2.test(x) == false)
		{
			return 0;
		}
		else if (_bs1.test(x) == false
			&& _bs2.test(x) == true)
		{
			// 01
			return 1;
		}
		else
		{
			return 2;   // 2次及以上
		}
	}

private:
	bitSet<N> _bs1;
	bitSet<N> _bs2;
};

也可以按照自己的需求自己造轮子:

	// 根据需求自己造轮子:只需判断出只出现一次的
	bool test(size_t x)
	{
		if (_bs1.test(x) == false
			&& _bs2.test(x) == true)
		{
			// 01
			return true;
		}
		return false;
	}


  1. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?

思路:分别set到两个位图,两个位图同时为1,按位与& 后为1,则为交集

void test_bitset2()
{
	int a1[] = { 4,9,5 };
	int a2[] = { 9,4,9,8,4 };

	bitSet<100> bs1;
	bitSet<100> bs2;

	for (auto e : a1)
	{
		bs1.set(e);
	}

	for (auto e : a2)
	{
		bs2.set(e);
	}

	for (size_t i=0;i<100;i++)
	{
		if (bs1.test(i) && bs2.test(i))    // 都存在
		{
			cout << i << endl;
		}
	}
}


  1. 位图应用变形:1个文件有100亿个int,只有512M内存,设计算法找到出现次数不超过2次的所有整数

    处理 Unsigned_int_max无符号整型(范围为0~42亿9千万),所需开的位图大小为 0.5G
    这里有100亿个int整型,则大概需要1G多的内存大小来处理数据,内存不够,怎么办?

    位图所开的空间大小,是取决于需要映射的数的范围 。 若只有512M的内存,则每次只映射一半范围的数据,因此则能一次只使用256M的内存(直接映射) 。第二次值统计,则统计后半部分(通过间接映射 x-2^31 )。

    将分开遍历的两组值,一个位图对这分开的两组值,分别都进行遍历 。



布隆过滤器

布隆过滤器提出

我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查找呢?

数据在内存中查找

  • 排序 + 二分查找:
    排序只需要排一次,后面的查找。
    不足:主要的问题是在 数组 上,插入在头部、中间都要挪动数据,消耗很大

  • 搜索树
    按照搜索树规则,搜索的时间复杂度为O(log_2 N)
    不足: 该数据结构 在内存大小上的消耗很大,一个节点存放三指针一数据。

  • 哈希表
    映射规则,
    不足:浪费空间,一个空间存储数据的大小根据要存储的数据通过 Hash哈希函数 转化成整型来进行哈希映射存储。整型大小的位为int

  • 位图(应用了哈希映射思想 + 位运算0 1 标记,仅需bit位的大小)
    局限:只是要简单判断这个值在不在 -> 只需要 位映射 来进行0 1标记,仅需bit位的大小

    • 特点:1. 快 ; 2. 节省空间

    • 不足只能解决整型

      只是要简单判断这个值在不在,就能用位图 位映射标记,直接查找该位置标记的值是0还是1来直接判断是否存在 的思想,来解决问题。



布隆过滤器的引入

若想将位图这样,只是想简单判断这个值在不在 的思想,同样应用在其他自定义类型上。

要是是其他类型,(如自定义类型,string类型,怎么办?)通过字符串来判断

若仍采用位图来解决string类 有可能会存在哈希冲突:
不同的字符串(无限) —> 整型(有限 42亿9千万) —> 映射存储位置 ,字符串的多少是无限多(假设开的位图大小是10个位置,这能组合出来的字符串是已经256^10个了,这远超 有限个整型个数),即一定会存在整型重复,而造成误判的情况存在。


误判:

  • 在:有误判的可能(本来不在,映射位置冲突了,误判成在了)
  • 不在:准确的


存在误判,但如果将要将这样的映射位置冲突的概率降低 。

布隆过滤器

一个数据插入多个位置,这多个位置,可能会存在映射和其他位置存在冲突,只要遍历到的该位置 不存在,则进行映射插入,则大大降低了映射冲突的概率。

在这里插入图片描述



布隆过滤器 实现

大框架

template<size_t N,       // 要插入数据的个数
	class K= string,	    // 常用于布隆过滤器的key值是string
	class Hash1	= HashFuncBKDR,       // Hash1,Hash2,Hash3 通过不同的Hash哈希,分别映射到不同的三个位置
	class Hash2 = HashFuncAP, 
	class Hash3 = HashFuncDJB>
class  BloomFilter
{
public:

private:
	bitSet<5*N> _bs;	   
};

模板参数

class K = String 缺省参数传string,因为最常传的类型是string 。K是指需要位图进行标识是否存在的数据的类型: 传 自定义类型(如 日期类),只要对应传对应自定义类型的仿函数(将其转化成整型) 就行

size_t N 需要进行存储在位图中,需要进行Hash哈希函数映射处理的数据个数:想要通过建立映射关系来达到O(1)的查找时间复杂度 。


布隆过滤器 的之所以能实现不同字符串之间不冲突,来实现自定义类型也能使用位图来达到O(1)查找的时间复杂度的直接映射,是通过实行多个哈希函数在位图中进行多个位置同时映射,来降低哈希冲突的概率从而让非整型类型也能采用哈希思想O(1)的时间复杂度直接判断出该数据是否存在。

但这样的实行 多个哈希函数 进行的 多个位置同时映射,来判断该数据是否存在的做法,对空间消耗非常大,因此 对于因为要建立这样多个映射关系,要开多大的布隆过滤器 也是有讲究的,下面让我们来进行一下讨论分析。



讨论1:哈希函数个数 的选择 —— 开3个哈希函数(一个值映射3个位)


哈希函数的个数(一个值映射几个位 的) 也需要权衡:

  • 个数越多 则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低
  • 但是如果太少的话,那我们的误报率会变高

在这里插入图片描述
k 为哈希函数个数(一个值映射几个位)m 为布隆过滤器长度(有多少个位),n 为插入的元素个数(数据个数),p 为误报率

由图可看出,当k==3时,就已经把 误报率p 由原来的1降低到接近0.01了。则映射位置再拉大,对误报率的降低也没差多少了。得出结论:一个值的映射位数,一般取3位,因此需三个映射函数。



讨论2:布隆过滤器长度 —— 开要进行插入元素个数的5倍大小

x为哈希函数的个数,m是布隆过滤器的长度,n是插入元素的个数。经过研究发现,三者满足以下关系式时,布隆过滤器的误判率最低:

x = m / n ∗ l n 2 x=m/n * ln2 x=m/nln2

由上得出的 哈希函数个数x 的 最优数量大小是3,那么我们所开的布隆过滤器的长度大小m 大约是 插入元素的个数n4.3 。因此我们自己模拟实现布隆过滤器我们取整数5倍:bitset<5 * N> _bs;

template<size_t N,       // 要插入数据的个数
	class K= string,	    // 常用于布隆过滤器的key值是string
	class Hash1	= HashFuncBKDR,       // Hash1,Hash2,Hash3 通过不同的Hash哈希,分别映射到不同的三个位置
	class Hash2 = HashFuncAP, 
	class Hash3 = HashFuncDJB>
class  BloomFilter
{
public:

private:
	const size_t M = 5 * N;		// 【★】经研究得:布隆过滤器的长度最好是 开 要插入数据大小N 的 5倍
	bitSet<M> _bs;	    // 后面扩容,直接将bitSet的容器大小设置成M,方便后面扩容变动
};


reset() 删除 不能实现

在这里插入图片描述
删除了百度,则腾讯有一个位被reset()为0,则一个位为0,则直接判断腾讯不存在,即出现了误判的情况 。



布隆过滤器 码源实现

#pragma once
#include<set>
#include<string>

using namespace std;
#include"bitset.h"


struct HashFuncBKDR
{
	// BKDR
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			ch*=131;
			hash += ch;
		}
		return hash;
	}
};

struct HashFuncAP
{
	// AP
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (size_t i=0;i<s.size();i++)
		{
			if ((i & 1) == 0)	   // 偶数位字符
			{
				hash ^= ((hash << 7) ^ (s[i]) ^ (hash >> 3));
			}
			else       // 奇数位字符
			{
				hash ^= (~(hash << 11) ^ (s[i]++) ^ (hash >> 5));
			}
		}
		return hash;
	}

};

struct HashFuncDJB
{
	// DJB
	size_t operator()(const string& s)
	{
		size_t hash = 5381;
		for (auto ch : s)
		{
			hash = hash * 33 ^ ch;
		}

		return hash;
	}
};



template<size_t N,       // 要插入数据的个数
	class K= string,	    // 常用于布隆过滤器的key值是string
	class Hash1	= HashFuncBKDR,       // Hash1,Hash2,Hash3 通过不同的Hash哈希,分别映射到不同的三个位置
	class Hash2 = HashFuncAP, 
	class Hash3 = HashFuncDJB>
class  BloomFilter
{
public:
	bool Set(const K& key)
	{
		size_t hash1 = Hash1()(key) % M;
		size_t hash2 = Hash2()(key) % M;
		size_t hash3 = Hash3()(key) % M;

		_bs.set(hash1);
		_bs.set(hash2);
		_bs.set(hash3);
	}

	bool Test(const K& key)
	{
		size_t hash1 = Hash1()(key) % M;
		if (_bs.test(hash1) == false)		// 只要判断出一个位不在,那就是(准确的)不在
			return false;

		size_t hash2 = Hash2()(key) % M;
		if (_bs.test(hash2) == false)		
			return false;

		size_t hash3 = Hash3()(key) % M;
		if (_bs.test(hash3) == false)		
			return false;

		return true;	// 存在误判(有可能判断到这3个位都是在的,但可能这3个位都冲突了)
	}

	// 布隆过滤器不好实现reset(),

private:
	static const size_t M = 5 * N;		// 经研究得:布隆过滤器的长度最好是 开 要插入数据大小N 的 5倍	   // static是被放到静态区,不独属于对象,而是属于整个类
	bitSet<M> _bs;	    // 后面扩容,直接将bitSet的容器大小设置成M,方便后面扩容变动
};


在这里插入图片描述


在这里插入图片描述

为避免误判,先经过布隆过滤器判断:

  • 在,有可能昵称未注册过,位置被其他数据映射到,而发生误判
  • 不在,昵称没注册过 布隆过滤器的价值:(准确的,直接返回结果,提速,减少了数据库的访问压力)【以空间换时间】

误判的,再到数据库中进行验证 。



经典面试题

  1. 给两个文件,分别有100亿个query(查询),我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
  • 近似算法:把一个文件放到布隆过滤器里面,另外一个文件 再去看,在就是交集,不在就不是交集
    【为什么说是近似的:因为布隆过滤器筛选出来的值,存在误判,】


  • 精确算法:

哈希切分:都放到内存(数据结构set)里面,去重,找交集

  1. 内存大小
    假设一个query 平均50byte,100亿个query占用多少空间?1G 约等于 10亿字节,那么一共需要500G。

  2. 将数据存储到一个一个文件(细分一千份)中(文件的存储,就是单纯的字节流的存储):
    依次每一个query,i = HashFunc(query) % 1000这个query就进入Ai文件中
    存放的是这个query本身,而HashFunc计算出来的hash值,只是用来得出query内容要放入的文件的编号

    A和B相同的query 一定会进入 编号相同的AiBi小文件只需要 遍历编号相同的文件 找交集 就可以了。
    平均切 找交集 O(N^2)(平均切:每个Ai文件都去遍历一遍Bi的每个文件),哈希切 找交集 O(N)


对于 切分的文件数量多少 的讨论

按理说,1G内存找切500份,这里切1000份,平均每份文件就是500M因为哈希切分是有的文件是冲突的有点多,有的文件大,有的文件小,避免出现因单个文件的内存存储的空间不够了,而存到下一个文件中,则不能做到哈希切分,找到对应标号的文件。



  1. 而后 将文件中的内容读到内存当中,运用内存当中的 哈希表unordered_set红黑树 进行处理,处理完了再把它清掉再读下一组文件:
    Ai的query放 哈希表set<string> seta,Bi的query放 哈希表set<string> setbsetasetb找交集即可

在这里插入图片描述


极端情况:某个文件冲突很多,导致Ai或者Bi太大了,比如超过1G,

那就 再走一层递归就可以了,再换一个哈希函数,再进行一次哈希切分
还是解决不了,某个文件还是特别特别的大,那就是 因为文件中存放的大部分 都是 相同的值
但 文件存入到 哈希表unordered_set中,有去重功能,重复的值不会再次插入

因此不会出现 文件中冲突的值很多是因为 重复的很多 而出现的 文件过大 的情况在存放进内存数据结构set中,就已经解决了这个问题
+ 重复出现的值很多的情况,set就已经解决了
+ 因此,只可能是因为其本身经过Hash函数后,哈希冲突还是很多 而造成的 。

query 插入set里面时,倘若抛异常,则代表query太多,且重复不多 。
在这里插入图片描述
解决办法:进行二次处理:换个哈希函数,对Ai和Bi文件再进行哈希切分 。



  1. 如何扩展BloomFilter使得它支持删除元素的操作
    引用计数:每个位置 改成 多个位的引用计数 就可以支持 。比如一个映射位置给8个bit位标记,但是这样空间消耗太大了。
    以前位图只需要标记0,1,只需要标记 或者 不在;而启用计数后,在位图中,最起码得 额外开八个bit位( 八个位能表示出255个数 ) (在原来位图的基础上开 )来进行映射 。

Counting Bloom Filter 的原理和实现



i=HashFunc(query)%100哈希切分出各Ai文件 + map<string, int>统计次数 + topK priority_queue小堆 :统计出topK的IP地址

给一个超过100G大小的log file, log中存着IP地址,设计算法 找到出现次数最多的IP地址
与上题条件相同,如何找到 top K的IP ?如何直接用Linux系统命令实现

分析:

  • 用map和set:100G大小的数据 内存占用太大了,内存搞不定。
  • 平均切分:这是个IP地址,平均切会*被分到不同的文件中。

哈希切分:

  1. 依次读取每个ipi = HashFunc(ip) % 100(一个文件开多大,取决于你设备的性能,最好是一个文件能放下 差不多两三百个IP地址 就好)
  2. 每个ip就 进入对应算出的Ai小文件:能进入同一个Ai小文件的,要么是相同的IP,要么是哈希冲突的IP 。
  3. 依次使用 map<string,int> countMap:统计每个文件ip的出现次数
    ( 如果map抛异常,则说明冲突很多,小文件很大 => 必须得换 哈希函数,二次切分处理;相同的文件ip就只是++int并不会对内存上开辟新的内存空间占用 )
  4. 然后 用 pair<string, int> maxIP来依次遍历每个文件找出 出现次数最大的文件ip ,比其大就进行更新,直到每个文件都遍历完了为止 。
  5. topK 就建立一个 小堆priority_queue< pair<string, int>, __?__ > minHeappriority_queue默认是大堆,因此此处若要实现 小堆 就需要自己实现 仿函数根据需求 “取pair<string, int>中的second 进行比较”


i=HashFunc(query)%100哈希切分成各Ai文件 + map<int, int> 逐一遍历哈希切分出的各Ai文件:统计出现的次数

重新看回【 海量面试题 】:给定100亿个整数,设计算法找到只出现一次的整数

原本采取的措施:

  • 开双位图:来记录出现的次数00 - 0次01 - 1次10 - 2次即以上
  • 哈希切分:这里的类型是 整型,直接用 整型 去 %取模 就好了

海量数据处理 问题的特征

  1. 数据量大,内存存不下
    如 哈希表unordered_map、红黑树 这样的数据结构
  2. 先考虑具有特点的数据结构能否解决?
    位图、堆、布隆过滤器(节省空间)
  3. 大事化小 思路
    文件指针,磁盘中的一个磁头来定位,真的效率太慢了
    哈希切分 [ 不能平均切分 ]:相同的值进入相同的文件
    找交集、统计次数
  4. 文件当中有很多数据,这个时候只是想找这个数据 单纯的在不在
    B树/B+树
;