1、位图
先看一个题目:
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个树是否在这40亿个数中?
这里可能会想到两种思路,排序+二分查找;放到哈希表或者红黑树。但是不要忽略一个重要的问题,40亿。实际换算一下,40亿个整数占用的空间四舍五入一下就是15G。15G,难道找这个数我需要先有个15G空间?这肯定不行。还可能想到一个方法,分成好几份查找,但是这并没有解决本质问题,占用空间大,假如要查找的数不止一个呢?
这个问题判断的是在不在的问题,所以没有必要将数字放进去某个结构,可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在,这也就是位图的操作。比如设置一个int类型的变量,占32个比特位,它的二进制位中所有可能的结果是2^32 - 1个,也就是大约42亿9千万多,那么就有可以存放足够的数据存在状态了。也可以把32个比特位分成4个char。
在之前Linux的博客中写到过,按位操作也就是位图。
我们写一个set函数用来设置为1,reset用来设置为0。传过来一个数后,我们如何判断在哪个char里?x / 8,在char中的第几个比特位? x % 8。找到就要开始设置。但是这是由几个char组成的,地址的存放方式还需要考虑,左移是低位向高位移,右移是高位向低位移动,不过不需要太担心这方面,编译器对此都有自己的做法。
定义一个vector< char >的_bits。
void set(size_t x)//设置1
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
void reset(size_t x)//设置0
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
另有一个test函数来看看标识过的值在不在。
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
测试代码
void test_bitset1()
{
bitset<100> bs;
bs.set(10);
bs.set(11);
bs.set(15);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
bs.reset(10);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
bs.reset(10);
bs.reset(15);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
}
如果要用的数字很大,比如42亿9千万这种最大的,就可以写zydset< -1 > zs或者zydset< 0xFFFFFFFF > zs,编译器就会实际开这些空间。
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N / 8 + 1, 0);//想象一下,100 / 8就是12,实际数值是12.5,+1就包含了这个多出来的值,也就是开辟了13个char,全都初始化为0,也就是每个char的比特位全为0。实际问题中,40亿算下来就是开辟476MB的空间,这样就大大减小了空间消耗。
}
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
private:
vector<char> _bits;
};
位图应用
100亿个数字,找其中只出现一次的数。
可以开两个位图,也可以改造一下位图,变成两位位图,两位位图就是N / 4了,原先的8变为4,一次性看两个比特位来检查状态,00就是0次,01就是1次,10就是1次以上。
借助上面写好的类,再写一个解决这个问题的类
template<size_t N>
class twobitset
{
public:
void set(size_t x)
{
// 00 -> 01
if (_bs1.test(x) == false
&& _bs2.test(x) == false)
{
_bs2.set(x);
}
// 01 -> 10
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;
};
给定两个文件,分别有100亿个整数,现在只有1G内存,如何找到两个文件交集?
可以创建两个位图,然后循环,将一个个char都&&一遍,留下来的就是出现一次的。
也可以将一个文件的值读到一个位图中,再读取另一个文件,判断在不在上面位图中,在就是交集,但这样得出来的结果需要再次去重,这里的改进办法就是每次找到交集值,都将上面位图对应的值设置为0.
如果数据量大,用第一个方法更好。比如100亿时,用第一个,1亿时用第二个就行,因为第一个创建的是固定数,第二个则是有多少创建多少。
100亿个整数,1G内存,找到出现次数不超过2次的整数。那么就可以用两位位图,00为0次,01为1次,10为2次,11为3次。
优缺点
优点:速度快,节省空间
缺点:只能映射整形,其他类型入浮点数,string等都不能存储映射
2、布隆过滤器
针对一个字符串,假设有10个字符,每个按照ANSII码表会发现有256个,所以就是256的10次方,这时候再用上面的位图就不行了。这时候一定有很多冲突。
布隆过滤器的思路是这样,要解决全部冲突很难,也没什么好办法。布隆的做法就是降低冲突,之前的方法是一对一,一个映射一个位置,那么布隆就一个映射多个位置就可以降低误判率了。这里就一对三。
template<size_t N, class K, class Hash1, class Hash2, class Hash3>
class BloomFilter
{
public:
void set(const K& key)
{
size_t hash1 = Hash1()(key) % N;
_bs.set(hash1);
size_t hash2 = Hash2()(key) % N;
_bs.set(hash2);
size_t hash3 = Hash3()(key) % N;
_bs.set(hash3);
}
bool test(const K& key)
{
size_t hash1 = Hash1()(key) % N;
if (!_bs.test(hash1))
{
return false;
}
size_t hash2 = Hash2()(key) % N;
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = Hash3()(key) % N;
if (!_bs.test(hash3))
{
return false;
}
}
private:
bitset<N> _bs;
};
还是会用到之前的位图类。
布隆过滤器如果判断不在是准确的,但是判断在是不准确的。因为不在的话位置上一定都是0,没有任何映射过的痕迹,所以不在很准确;但是在就不确定了,因为有可能有其他的字符串映射到了这个位置。
布隆过滤器被使用在能容忍误判的场景。比如:注册时,快速判断昵称是否被使用过,对于手机在不在,可以先用布隆过滤器来判断不在,如果在那就进入数据库查找。
优点:快,节省内存
缺点:存在误判
1、哈希函数
补充完整的字符串的哈希函数,这里用的是一些成型的函数,里面经过了一些数学计算。
struct BKDRHash
{
size_t operator()(const string& s)
{
register size_t hash = 0;
for (auto ch : s)
{
hash = hash * 131 + ch;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& s)
{
register size_t hash = 0;
size_t ch;
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)
{
register size_t hash = 5381;
for(auto ch : s)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
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 hash1 = Hash1()(key) % N;
_bs.set(hash1);
size_t hash2 = Hash2()(key) % N;
_bs.set(hash2);
size_t hash3 = Hash3()(key) % N;
_bs.set(hash3);
}
bool test(const K& key)
{
size_t hash1 = Hash1()(key) % N;
if (!_bs.test(hash1))
{
return false;
}
size_t hash2 = Hash2()(key) % N;
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = Hash3()(key) % N;
if (!_bs.test(hash3))
{
return false;
}
}
private:
bitset<N> _bs;
};
void test_bloomfilter()
{
BloomFilter<100>;
}
哈希函数个数,代表一个值映射几个位,函数越多,误判率越低,消耗空间越多。k和m的平衡看下图
按照我们的代码使用三个哈希函数,所以k == 3,算出m / n是4多点,所以修改一下代码,一次性开4倍的空间。
class BloomFilter
{
public:
void set(const K& key)
{
size_t len = N * _X;
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;
}
bool test(const K& key)
{
size_t len = N * _X;
size_t hash1 = Hash1()(key) % len;
if (!_bs.test(hash1))
{
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;
}
}
private:
static const size_t _X = 4;
bitset<N * _X> _bs;
};
测试代码
void test_bloomfilter()
{
BloomFilter<100> zs;
zs.set("sort");
zs.set("bloom");
zs.set("string");
zs.set("test");
zs.set("etst");
zs.set("estt");
cout << zs.test("sort") << endl;
cout << zs.test("bloom") << endl;
cout << zs.test("string") << endl;
cout << zs.test("test") << endl;
cout << zs.test("etst") << endl;
cout << zs.test("estt") << endl;
cout << zs.test("zyd") << endl;
cout << zs.test("int") << endl;
cout << zs.test("float") << endl;
}
//测试误判率
void test_bloomfilter2()
{
srand(time(0));
const size_t N = 10000;
BloomFilter<N> bf;
vector<string> v1;
string url = "https://blog.csdn.net/kongqizyd146?spm=1011.2415.3001.5343";
for (size_t i = 0; i < N; ++i)
{
v1.push_back(url + to_string(i));
}
for (auto& str : v1)
{
bf.set(str);
}
vector<string> v2;//v2和v1相似,但不一样
for (size_t i = 0; i < N; ++i)
{
string url = "https://blog.csdn.net/kongqi146?spm=1011.2415.3001.5343";
url += 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;
vector<string> v3;
for (size_t i = 0; i < N; ++i)
{
string url = "zhihu.com";
url += 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;
}
十万个数据
可以控制映射多少个位置来控制误判率。
2、删除
删除函数不能直接删除,可以用计数的思路,用比特位来当作计数,1个比特位代表2个,2个比特位代表4个,不过呢实际上布隆过滤器不支持删除,也就不用考虑删除了。
3、哈希切割
两个文件,分别有100亿个query,现在还有1G内存,如何找到文件交集?给出精确算法和近似算法。query看作字符串。
假设查找一个字符串需要50个字节,100亿就是5千亿字节,大概是466G,两个文件就是932G。
这里的解决思路就是一个哈希切分i = HashFunc(query) % 1000;HashFunc用一种哈希函数即可。每个query算出来的i是多少,就进入Ai号小文件,另一个文件则是放入Bi号小文件。这里的前提是把两个文件各自分成若干个小文件,然后A1和B1找交集,A2和B2找交集,最后得到的就是总体的交集。文件A和B中相同的query会进入编号相同的小文件。
这个方法还有点缺陷。因为每个字符串的长度不同,每个小文件的大小控制不了都相同,也就是出现了冲突,而且可能换哈希函数也不一定解决得了问题。
单个文件中,有某个大量重复的query
单个文件中,有大量不同的query
第一个情况,重复的那些不需要再存放,所以可以使用/unordered_set/set来依次读取文件query,插入set中。
如果读取整个小文件,都可以成功插入,则是情况1;如果插入过程中抛异常,则是情况2,换其他哈希函数,再次分割,再求交集。
set插入如果已经存在,返回false,如果没有内存,会抛bad_alloc异常,剩下的都会置空。
应用
给一个超过100G大小的log file,log中存着IP地址,设计算法找到出现次数最多的IP地址,以及top K的IP。
这里就是哈希切割,分成比如500个小文件,依次读取数据,i = HashFunc(ip) % 500,这个ip就是第i个小文件;依次插入小文件,使用map统计IP出现次数;统计过程中,出现抛内存异常,说明单个小文件过大,冲突太多,需要换哈希函数,再次哈希切割这个文件;如果没有抛异常,则正常统计,统计完一个就记录最大的。清理map,再去找下一个小文件。找top k,就把每个得到的最大值放到堆里。
相同的IP一定进入相同的小文件,读取单个小文件,就可以统计IP出现次数。
结束。