目录

一、位图

1.1 位图相关面试题

1.2 位图的设计和实现

1.2.1 位图的结构

1.2.2 位图的接口

1.2.3 测试

1.3 位图的代码

1.3.1 bitset.h

1.3.2 test.h

1.4 C++中的位图bitset

1.5 位图的优缺点

1.6 位图相关考察题目

二、布隆过滤器

2.1 什么是布隆过滤器

2.2 布隆过滤器误判率推导

2.3 布隆过滤器代码实现

2.3.1 bitset.h

2.3.2 BloomFilter.h

2.3.3 test.cpp

2.4 布隆过滤器删除问题

2.5 布隆过滤器的应用

三、海量数据处理

3.1 10亿个整数里面求最大的前100个

3.2 位图章节讲解的位图相关题目

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

3.4 给一个超过100G大小的logfile,log中存着ip地址,设计算法找到出现次数最多的ip地址?查找出现次数前10的ip地址


一、位图

1.1 位图相关面试题

给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。(本题为腾讯/百度等公司出过的一个面试题)

  1. 解题思路1:暴力遍历,时间复杂度O(N),太慢
  2. 解题思路2:排序+二分查找。时间复杂度消耗O(N*logN)+O(logN)

深入分析:解题思路2是否可行,我们先算算40亿个数据大概需要多少内存。

1G= 1024MB=1024*1024KB=1024*1024*1024Byte 约等于10亿多Byte

那么40亿个整数约等于16G,说明40亿个数是无法直接放到内存中的,只能放到硬盘文件中。而二分

查找只能对内存数组中的有序数据进行查找。

解题思路3:数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。那么我们设计一个用位表示数据是否存在的数据结构,这个数据结构就叫位图。

40亿个数据需要40亿个bit位来存储,就需要40亿bit/8=5亿B,差不多消耗内存500M左右

1.2 位图的设计和实现

位图本质是一个直接定址法的哈希表,每个整型值映射一个bit位,位图提供控制这个bit的相关接口。

1.2.1 位图的结构

位图的结构如下所示:

using namespace std;
namespace zx
{
	template<size_t N>
	class bitset
	{
	public:
		bitset()
		{
			_bs.resize(N / 32 + 1, 0);
		}
	private:
		vector<int> _bs;
	};
}

我们需要40亿个bit位来标记40亿个数据在不在。实现中需要注意的是,C/C++没有对应位的类型,只能看int/char这样整形类型,我们再通过位运算去控制对应的比特位。所以我们vector中存储int来表示。

1:如何判断某一个数x在哪一个vector中的哪里呢?

答:i=×/32;j=x %32;计算出x映射的值在vector的第i个整形数据的第j位。

2:为什么vector要预留(N/32+1)个空间呢?

答:现在有N个数据,判断一个数据在不在这N个数据中,我们使用vector<int>来表示数据在不在,一个int里面有4个字节,就能表示32个数据在不在,所以就需要开N/32个位置,但是由于C++中的除法是向下取整的,例如需要判断60个数据,vector里面就需要开2个int,但是60/32=1,只预留了一个int,所以需要+1,综上,vector就需要开N/32+1个数据。

解决给40亿个不重复的无符号整数,查找一个数据的问题,我们要给位图开2^32个位,注意不能开40亿个位,因为映射是按大小映射的,我们要按数据大小范围开空间,范围是是0-2^32-1,所以需要开2^32个位。然后从文件中依次读取每个set到位图中,然后就可以快速判断一个值是否在这40亿个数中了。

1.2.2 位图的接口

位图中主要有三个接口,将x对应的位标记成1,将x对应的位标记成0,测试x在不在位图中。如下所示:

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

//x映射的位标记成0
void reset(size_t x)
{
	size_t i = x / 32;
	size_t j = x % 32;
	_bs[i] &= ~(1 << j);
}

//x映射的位是1返回真
//x映射的位是0返回假
bool test(size_t x)
{
	size_t i = x / 32;
	size_t j = x % 32;
	return _bs[i] & (1 << j);
}

1.2.3 测试

#include"bitset.h"

int main()
{
	zx::bitset<100> bs1;
	bs1.set(50);
	bs1.set(30);
	bs1.set(90);
	for (size_t i = 0; i < 100; i++)
	{
		if (bs1.test(i))
		{
			cout << i << "->" << "在" << endl;
		}
	}
	bs1.reset(90);
	bs1.set(91);
	cout << endl << endl;
	for (size_t i = 0; i < 100; i++)
	{
		if (bs1.test(i))
		{
			cout << i << "->" << "在" << endl;
		}

	}
	return 0;
}

运行结果如下:

如何开42亿个比特位的空间,如下所示:

zx::bitset<0xffffffff> bs2;
zx::bitset<-1> bs3;
zx::bitset<UINT_MAX> bs4;

1.3 位图的代码

1.3.1 bitset.h

#pragma once
#include<vector>
#include<iostream>

using namespace std;
namespace zx
{
	template<size_t N>
	class bitset
	{
	public:
		bitset()
		{
			_bs.resize(N / 32 + 1);
		}

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

		//x映射的位标记成0
		void reset(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;
			_bs[i] &= ~(1 << j);
		}

		//x映射的位是1返回真
		//x映射的位是0返回假
		bool test(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;
			return _bs[i] & (1 << j);
		}
	private:
		vector<int> _bs;
	};
}

1.3.2 test.h

#include"bitset.h"

int main()
{
	zx::bitset<100> bs1;
	bs1.set(50);
	bs1.set(30);
	bs1.set(90);
	for (size_t i = 0; i < 100; i++)
	{
		if (bs1.test(i))
		{
			cout << i << "->" << "在" << endl;
		}
	}
	bs1.reset(90);
	bs1.set(91);
	cout << endl << endl;
	for (size_t i = 0; i < 100; i++)
	{
		if (bs1.test(i))
		{
			cout << i << "->" << "在" << endl;
		}

	}
	return 0;
}

1.4 C++中的位图bitset

bitset - C++ Reference

可以看到核心接口还是set/reset/和test,当然后面还实现了一些其他接口,如to_string将位图按位转成01字符串,再包括operator[]等支持像数组一样控制一个位的实现。

库里面的bitset有一个小bug,我们使用库里面的bitset是开不出0xffffffff个位的,为什么我们模拟实现的bitset是可以开出来的呢?

这是因为我们模拟实现的bitset里面是一个vector,vector里面的数据是在堆里面的,不再栈上,而库里面的bitset里面可能是一个静态数组,是在栈上的,我们使用sizeof来看一下空间大小。

我们可以看到:我们模拟实现的bitset无论判断多少个数在不在,大小始终是32B,数据在堆里面。而库里面的bitset数据越多,占用的空间越大,所以库里面的bitset中的数据在对象里面,如果我们想要开0xffffffff个bit位,栈上的空间就会溢出,就会报错,那使用库里面的bitset如何开0xffffffff个bit位呢?

我们可以这样写:std::bitset<0xffffffff>* ptr = new std::bitset<0xffffffff>();

在堆上new一个bitset<0xffffffff>对象,这样所有的数据就在堆里面了。

1.5 位图的优缺点

优点:增删查改快,节省空间
缺点:只适用于整形,不适应于其他类型

1.6 位图相关考察题目

1:给定100亿个整数,设计算法找到只出现一次的整数?

虽然是100亿个数,但是还是按范围开空间,所以还是开2^32个位,跟前面的题目是一样的

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

把数据读出来,分别放到两个位图,依次遍历,同时在两个位图的值就是交集

3:一个文件有100亿个整数,1G内存,设计算法找到出现次数不超过2次的所有整数

之前我们是标记在不在,只需要一个位即可,这里要统计出现次数不超过2次的,可以每个值用两个位标记即可,00代表出现0次,01代表出现1次,10代表出现2次,11代表出现2次以上。最后统计出所有01和10标记的值即可。

代码如下所示:

bitset.h:

#pragma once
#include<vector>
#include<iostream>

using namespace std;
namespace zx
{
	template<size_t N>
	class bitset
	{
	public:
		bitset()
		{
			_bs.resize(N / 32 + 1);
		}

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

		//x映射的位标记成0
		void reset(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;
			_bs[i] &= ~(1 << j);
		}

		//x映射的位是1返回真
		//x映射的位是0返回假
		bool test(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;
			return _bs[i] & (1 << j);
		}
	private:
		vector<int> _bs;
	};

	template<size_t N>
	class twobitset
	{
	public:
		void set(size_t x)
		{
			bool flag1 = _bs1.test(x);
			bool flag2 = _bs2.test(x);
			if (!flag1 && !flag2) //00->01
			{
				_bs2.set(x);
			}
			else if (!flag1 && flag2)  //01->10
			{
				_bs1.set(x);
				_bs2.reset(x);
			}  
			else if(flag1 && !flag2)  //10>11
			{
				_bs2.set(x);
			}
		}

		// 返回0 出现0次数
		// 返回1 出现1次数
		// 返回2 出现2次数
		// 返回3 出现2次及以上
		size_t get_count(size_t x)
		{
			bool flag1 = _bs1.test(x);
			bool flag2 = _bs2.test(x);
			if (!flag1 && !flag2)
			{
				return 0;
			}
			else if (!flag1 && flag2)
			{
				return 1;
			}
			else if (flag1 && !flag2)
			{
				return 2;
			}
			else
			{
				return 3;
			}
		}
	private:
		bitset<N> _bs1;
		bitset<N> _bs2;
	};
}

测试代码:test.cpp

#include"bitset.h"

int main()
{
	zx::twobitset<100> tbs;
	int a[] = { 5,7,9,2,5,99,5,5,7,5,3,9,2,55,1,5,6,6,6,6,7,9 };
	for (auto e : a)
	{
		tbs.set(e);
	}
	for (size_t i = 0; i < 100; ++i)
	{
		//cout << i << "->" << tbs.get_count(i) << endl;
		if (tbs.get_count(i) == 1 || tbs.get_count(i) == 2)
		{
			cout << i << endl;
		}
	}
	return 0;
}

二、布隆过滤器

2.1 什么是布隆过滤器

  • 有一些场景下面,有大量数据需要判断是否存在,而这些数据不是整形,那么位图就不能使用了,使用红黑树/哈希表等内存空间可能不够。这些场景就需要布隆过滤器来解决。
  • 布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你“某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
  • 布隆过滤器的思路就是把key先映射转成哈希整型值,再映射一个位,如果只映射一个位的话,冲突率会比较多,所以可以通过多个哈希函数映射多个位,降低冲突率。
  • 布隆过滤器这里跟哈希表不一样,它无法解决哈希冲突的,因为他压根就不存储这个值,只标记映射的位。它的思路是尽可能降低哈希冲突。判断一个值key在是不准确的,判断一个值key不在是准确的。

2.2 布隆过滤器误判率推导

结论:
布隆过滤器的误判率为:

2.3 布隆过滤器代码实现

2.3.1 bitset.h

#pragma once
#include<vector>
#include<iostream>

using namespace std;
namespace zx
{
	template<size_t N>
	class bitset
	{
	public:
		bitset()
		{
			_bs.resize(N / 32 + 1);
		}

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

		//x映射的位标记成0
		void reset(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;
			_bs[i] &= ~(1 << j);
		}

		//x映射的位是1返回真
		//x映射的位是0返回假
		bool test(size_t x)
		{
			size_t i = x / 32;
			size_t j = x % 32;
			return _bs[i] & (1 << j);
		}
	private:
		vector<int> _bs;
	};
}

2.3.2 BloomFilter.h

#pragma once
#include<string>
#include"bitset.h"

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

struct HashFuncAP
{
	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
{
	size_t operator()(const string& s)
	{
		size_t hash = 5381;
		for (auto ch : s)
		{
			hash = hash * 33 ^ ch;
		}
		return hash;
	}
};
template<size_t N,
	size_t X=5,
	class K=string,
	class Hash1= HashFuncBKDR,
	class Hash2= HashFuncAP,
	class Hash3= HashFuncDJB>
class BloomFilter
{
public:
	void 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))
		{
			return false;
		}
		size_t hash2 = Hash2()(key) % M;
		if (!_bs.test(hash2))
		{
			return false;
		}
		size_t hash3 = Hash3()(key) % M;
		if (!_bs.test(hash2))
		{
			return false;
		}

		return true; // 存在误判(有可能3个位都是跟别人冲突的,所以误判)
	}

	// 获取公式计算出的误判率
	double getFalseProbability()
	{
		double p = pow((1.0 - pow(2.71, -3.0 / X)), 3.0);
		return p;
	}
private:
	static const size_t M = N * X;
	// 我们实现位图是用vector,也就是堆上开的空间
	zx::bitset<M> _bs;

	//std::bitset<M>_bs;
	//vs下std的位图是开的静态数组,M太大会存在崩的问题
	//解决方案就是bitset对象整体new一下,空间就开到堆上了
	//std::bitset<M>*_bs= new std::bitset<M>;
};

2.3.3 test.cpp

#include<time.h>
#include"BloomFilter.h"

void TestBloomFilter1()
{
	string strs[] = { "百度","字节","腾讯" };
	BloomFilter<10> bf;
	for (auto& s : strs)
	{
		bf.set(s);
	}
	for (auto& s : strs)
	{
		cout << bf.test(s) << endl;
	}
	cout << "-------------" << endl;
	for (auto& s : strs)
	{
		cout << bf.test(s + 'a') << endl;
	}
	cout << "-------------" << endl;
	cout << bf.test("摆渡") << endl;
	cout << "-------------" << endl;
	cout << bf.test("百渡") << endl;
}

void TestBloomFilter2()
{
	srand(time(0));
	const size_t N = 1000000;
	//BloomFilter<N> bf;
	//BloomFilter<N, 3> bf;
	BloomFilter<N, 10> bf;
	std::vector<std::string> v1;
	std::string url = "https://www.cnblogs.com/-clq / archive / 2012 / 05 / 31 / 2528153.html";
	//std::string url = "https://www.baidu.com/s?ie=utf-8 & f = 8 & rsv_bp = 1 & rsv_idx = 1 & tn = 65081411_1_oem_dg & wd = ln2 & fenlei = 256 & rsv_pq = 0x8d9962630072789f & rsv_t = ceda1rulSdBxDLjBdX4484KaopD % 2BzBFgV1uZn4271RV0PonRFJm0i5xAJ % 2FDo & rqlang = en & rsv_enter = 1 & rsv_dl = ib & rsv_sug3 = 3 & rsv_sug1 = 2 & rsv_sug7 = 100 & rsv_sug2 =0 & rsv_btype = i & inputT = 330 & rsv_sug4 = 2535";
	//std::string url = "猪八戒";
	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是相似字符串集(前缀一样),但是后缀不一样
	v1.clear();
	for (size_t i = 0; i < N; ++i)
	{
		std::string urlstr = url;
		urlstr += std::to_string(9999999 + i);
		v1.push_back(urlstr);
	}
	size_t n2 = 0;
	for (auto& str : v1)
	{
		if (bf.test(str)) // 误判
		{
			++n2;
		}
	}
	cout << "相似字符串误判率:" << (double)n2 / (double)N << endl;
	// 不相似字符串集,前缀后缀都不一样
	v1.clear();
	for (size_t i = 0; i < N; ++i)
	{
		//string url = "zhihu.com";
		string url = "孙悟空";
		url += std::to_string(i + rand());
		v1.push_back(url);
	}
	size_t n3 = 0;
	for (auto& str : v1)
	{
		if (bf.test(str))
		{
			++n3;
		}
	}
	cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl;
	cout << "公式计算出的误判率:" << bf.getFalseProbability() << endl;
}
int main()
{
	//TestBloomFilter1();
	TestBloomFilter2();
	return 0;
}

2.4 布隆过滤器删除问题

  • 布隆过滤器默认是不支持删除的(删除一个值可能会影响另一个值),因为比如”猪八戒“和”孙悟空“都映射在布隆过滤器中,他们映射的位有一个位是共同映射的(冲突的),如果我们把孙悟空删掉,那么再去查找“猪八戒”会查找不到,因为那么“猪八戒”间接被删掉了。
  • 解决方案:可以考虑计数标记的方式,一个位置用多个位标记,记录映射这个位的计数值,删除时,仅仅减减计数,那么就可以某种程度支持删除。但是这个方案也有缺陷,如果一个值不在布隆过滤器中,我们去删除,减减了映射位的计数,那么会影响已存在的值,也就是说,一个确定存在的值,可能会变成不存在,这里就很坑。当然也有人提出,我们可以考虑计数方式支持删除,但是定期重建一下布隆过滤器,这样也是一种思路。

2.5 布隆过滤器的应用

首先我们分析一下布隆过滤器的优缺点:
优点:效率高,节省空间,相比位图,可以适用于各种类型的标记过滤
缺点:存在误判(在是不准确的,不在是准确的),不好支持删除


布隆过滤器在实际中的一些应用:

1:爬虫系统中URL去重:

在爬虫系统中,为了避免重复爬取相同的URL,可以使用布隆过滤器来进行URL去重。爬取到的URL可以通过布隆过滤器进行判断,已经存在的URL则可以直接忽略,避免重复的网络请求和数据处理。

2:垃圾邮件过滤:

在垃圾邮件过滤系统中,布隆过滤器可以用来判断邮件是否是垃圾邮件。系统可以将已知的垃圾邮件的特征信息存储在布隆过滤器中,当新的邮件到达时,可以通过布隆过滤器快速判断是否为垃圾邮件,从而提高过滤的效率。

3:预防缓存穿透:

在分布式缓存系统中,布隆过滤器可以用来解决缓存穿透的问题。缓存穿透是指恶意用户请求一个不存在的数据,导致请求直接访问数据库,造成数据库压力过大。布隆过滤器可以先判断请求的数据是否存在于布隆过滤器中,如果不存在,直接返回不存在,避免对数据库的无效查询。

4:对数据库查询提效:

在数据库中,布隆过滤器可以用来加速查询操作。例如:一个app要快速判断一个电话号码是否注册过,可以使用布隆过滤器来判断一个用户电话号码是否存在于表中,如果不存在,可以直接返回不存在,避免对数据库进行无用的查询操作。如果在,再去数据库查询进行二次确认。

三、海量数据处理

3.1 10亿个整数里面求最大的前100个

经典topk问题,⽤堆解决,用前100个数据建立一个小根堆,然后遍历后面的数据,如果当前元素大于堆顶元素,就入堆。

3.2 位图章节讲解的位图相关题目

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

分析:假设平均每个query字符串50byte,100亿个query就是5000亿byte,约等于500G(1G约等于10亿多Byte)

哈希表/红黑树等数据结构肯定是无能为力的。

解决方案1:

这个首先可以用布隆过滤器解决,一个文件中的query放进布隆过滤器,另一个文件依次查找,在的就是交集,问题就是找到交集不够准确,因为在的值可能是误判的,但是交集一定被找到了。

解决方案2:

  • 哈希切分,首先内存的访问速度远大于硬盘,大文件放到内存搞不定,那么我们可以考虑切分为小文件,再放进内存处理。
  • 但是不要平均切分,因为平均切分以后,每个小文件都需要依次暴力处理,效率还是太低了。
  • 可以利用哈希切分,依次读取文件中query,i=HashFunc(query)%N,N为准备切分多少分小文件,N取决于切成多少份,内存能放下,query放进第i号小文件,这样A和B中相同的query算出的hash值i是一样的,相同的query就进入的编号相同的小文件就可以编号相同的文件直接找交集,不用交叉找,效率就提升了。
  • 本质是相同的query在哈希切分过程中,一定进入的同一个小文件Ai和Bi,不可能出现A中的query进入Ai,但是B中的相同query进入了和Bj的情况,所以对Ai和Bi进行求交集即可,不需要Ai和Bj求交集。(本段表述中i和j是不同的整数)
  • 哈希切分的问题就是每个小文件不是均匀切分的,可能会导致某个小文件很大内存放不下。我们细细分析一下某个小文件很大有两种情况:
    • 1.这个小文件中大部分是同一个query。
    • 2.这个小文件是有很多的不同query构成,本质是这些query冲突了。
    • 针对情况1,其实放到内存的set中是可以放下的,因为set是去重的。
    • 针对情况2,需要换个哈希函数继续二次哈希切分。所以本体我们遇到大于1G小文件,可以继续读到set中找交集,若set insert时抛出了异常(set插入数据抛异常只可能是申请内存失败了,不会有其他情况),那么就说明内存放不下是情况2,换个哈希函数进行二次哈希切分后再对应找交集。

3.4 给一个超过100G大小的logfile,log中存着ip地址,设计算法找到出现次数最多的ip地址?查找出现次数前10的ip地址

本题的思路跟上题完全类似,依次读取文件A中query,i=HashFunc(query)%500,query放进Ai号小文件,然后依次用map<string,int>对每个Ai小文件统计ip次数,同时求出现次数最多的ip或者topk ip。本质是相同的ip在哈希切分过程中,一定进入的同一个小文件Ai,不可能出现同一个ip进入Ai和Aj的情况,所以对Ai进行统计次数就是准确的ip次数。

更多推荐