题干: 给40亿个不重复的无符号整数, 没排过序. 给一个无符号整数, 如何快速判断一个数是否在
这40亿个数中.
看到这个问题可能会想到这样的思路:
1. 遍历, 时间复杂度O(N)
2. 排序 + 二分查找
3. 利用哈希表或红黑树, 就是放到set或unordered_set里面进行查找.
上面这些方法有没有什么问题?
我们注意到它这里给了40亿个整数,而1G=1024MB=1024*1024KB=1024*1024*1024byte, 1G约等于10亿byte, 40亿个整数约等于16G.
上面那些方法最关键的问题是16G的数据可能都不能一次全部放到到内存中, 内存可能都不够用.
二分查找的话排序不是问题, 如果要排序那就是用归并排序了, 分开放到一个个的小文件里面, 进行归并, 但是关键是内存开不出这么大的连续重进, 不能支持下标访问, 无法用二分查找.
放到set或unordered_set里面查找也是一样, 内存可能不够, 哈希表或红黑树还有额外的消耗, 因为还要存一些其它的成员变量, 可以分开每次处理一小部分, 但这样效率就不太行了。
所以上面这些思路都不太合适,而且我们这里只是要判断在不在,其实没必要把它们全部存起来。
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用
来判断某个数据存不存在的。
数据是否在给定的整形数据中, 结果是在或者不在, 刚好是两种状态, 那么可以使用一个二进制比特位来代表数据是否存在的信息, 如果二进制比特位为1代表存在, 为0代表不存在.
比如:
回到题目, 题目说的是40亿个不重复的无符号整数, 无符号整数的最大值是2^32-1,即4294967295, 所以需要2^32次方个比特位, 需要的内存就是上面的16G/32 = 0.5G, 0.5G内存就可以开辟出来了.
所以我们开这样一个数组. 每个元素的大小是1个比特位, 因为我们用1个比特位就可以来标识当前位置下标所对应的值存不存在, 所以它其实就是一个直接定址法的思想.
没有类型的大小是1个bit, 所以我们可以开成int类型或者char类型的数组, 类型无所谓, 只要能取到对应的比特位即可.这里0到31映射到第一个int, 32到63映射到第二个int, 以此类推.
模板参数可以用一个非类型模板参数作为位图所表示的数据个数.
内部的vector应该开辟多大的空间?
这里的N是需要表示的数据个数, 在位图中就是N个比特位, N/8*4就是对应的整型个数, 但是可能不是整除会有剩余, 所以还需要+1. 而且初始的时候要把vector里面全放成0.
位图中的两个核心操作是set和reset:
set就是把x映射的那个位置的比特位设置成1,表示这个数存在, reset就是把它设置成0, 表示不存在.
我们现在开的是int数组, 里面是一个个的int(32bit), 所以我们首先要找到数据x映射到第几个int, 然后找数据x映射到这个int的哪一位.
设i是映射的那个int, j是int里的位数, i = x/32, j = x%32, 最终x = i*32 + j.
我们找到了这个比特位, 如何把它设置成1或者0呢?
先来看set, 把x映射的比特位设置成1, 怎么做呢?
其实就是第j位设置为1, 其它位全为0, 假如j是3, 我们可以给这个位置按位或
这样改变这个位置的同时还没有影响其它位置,因为一个数或0还是它本身.
所以我们让x | (1<
那reset就是把x映射的比特位设置成0
假设j还是3, 给这个位置按位与1111 1111 1111 1011就可以.
所以我们只需让x & ~(1<这样x映射的比特位变成0, 其它位置也不受影响.
除了这两个还有一个比较核心的接口——test, 它是去判断某个值存不存在(它映射的位置是否被设置成了1)
让x映射的这个比特位 和 1<
简单测试一下:
void TestBS()
{
test::bitset<100> bs;
bs.set(34);
if (bs.test(34))
cout << "34存在" << endl;
else
cout << "34不存在" << endl;
bs.reset(34);
if (bs.test(34))
cout << "34存在" << endl;
else
cout << "34不存在" << endl;
}
执行set后:
执行reset后:
回到最开始的那道题, 40亿个无符号整数, 我们的位图应该给多大?
开40亿个可以吗, 不可以.
要注意我们不能按个数去开, 而是要按照范围去开.
就算现在变成10亿个无符号整数, 我们也应该开4294967295(即2^32-1,无符号整型最大值)个, 因为我们不知道这10亿个整数的取值范围, 它可能就包含了最大值, 所以我们要确保不论它多大, 就可以映射到位图中一个确定的位置上.
test::bitset<-1> bs;
位图其实C++STL库里面有提供的现成的:
下面来看几个位图相关的题:
1. 给定100亿个整数, 设计算法找到只出现一次的整数?
首先这里是100亿个整数, 我们还开0xFFFFFFFF这么多空间吗?
虽然有100亿个, 但它的范围还是不变的, 不会超过整型最大值, 只能说明有很多重复值. 那我们还是用位图来解决, 找出只出现一次的整数, 1个比特位只能找出是否存在, 2个比特位就可以判断出现的次数了:
我们只看两位, 00就是0次, 01就是一次, 10是1次以上, 不一定就是两次, 因为我们set的时候如果是10就可以不进行操作了, 因为找的是只出现一次的整数, 只要证明出现不是1次就行.
1.我们可以给上面实现的位图改造一下, 改造成每个位置占两个比特位的位图.
2. 也可以不改造, 我们还是用上面的位图, 但是我们开两个位图.
所以我们可以封装一个Twobitset:
两个位图中映射位置的值:
如果是00, 就变成01;
如果是01, 就变成10;
如果是10, 已经超过1次了, 就可以不处理了,因为已经能判断出来出现不止1次.
template
class Twobitset
{
public:
void set(size_t x)
{
if (!bs1.test(x) && !bs2.test(x))
bs2.set(x);
else if (!bs1.test(x) && bs2.test(x))
{
bs2.reset(x);
bs1.set(x);
}
//其它的情况不需要再处理了, 因为已经能判断出来出现不止1次
}
void PrintOnce()
{
for (size_t i = 0; i < N; i++)
{
//打印只出现一次的
if (!bs1.test(i) && bs2.test(i))
cout << i << " ";
}
}
private:
bitset bs1;
bitset bs2;
};
测试:
void TestBS3()
{
int a[] = { 1,4,7,9,44,88,1,4,88,99,78,5,7 ,7,7,7 };
test::Twobitset<100> bs; //假设数据范围只有100
for (auto e : a)
{
bs.set(e);
}
bs.PrintOnce();
}
2. 给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
把两个文件的数据分别映射到两个位图里面, 然后遍历其中一个文件依次取值, 如果一个值在两个文件里都存在, 就是交集.
void TestBS4()
{
size_t N = 100; //假设数据范围是0~100
int a[] = { 1,4,7,9,44,88,1,4,88,99,78,5,7 ,7,7,7 };
int b[] = { 1,4,7,10,44,88,1,4,88,100,60,5,7 ,7,7,7 };
test::bitset<100> bs1;
test::bitset<100> bs2;
for (auto e : a)
{
bs1.set(e);
}
for (auto e : b)
{
bs2.set(e);
}
for (size_t i = 0; i< N;i++)
{
if (bs1.test(i) && bs2.test(i))
cout << i <<" ";
}
}
3. 位图应用变形: 1个文件有100亿个int, 我们只有1G内存, 设计算法找到出现次数不超过2次的所有整数.
只需要把第一个题的双位图改一改即可, 最后打印出01和10即可:
template
class Twobitset
{
public:
void set(size_t x)
{
if (!bs1.test(x) && !bs2.test(x))
bs2.set(x);
else if (!bs1.test(x) && bs2.test(x))
{
bs2.reset(x);
bs1.set(x);
}
else if (bs1.test(x) && !bs2.test(x))
{
bs2.set(x);
}
//11就是出现3次及以上了
}
void PrintOnce()
{
for (size_t i = 0; i < N; i++)
{
if (!bs1.test(i) && bs2.test(i) || bs1.test(i) && !bs2.test(i))
cout << i << " ";
}
}
private:
bitset bs1;
bitset bs2;
};
void TestBS5()
{
int a[] = { 1,4,7,9,44,88,1,4,88,99,78,5,7 ,7,7,7 };
test::Twobitset<100> bs;
for (auto e : a)
{
bs.set(e);
}
bs.PrintOnce();
}
1.暴力查找: 数据量大了, 效率就低.
2.排序 + 二分查找
问题a: 排序有代价
问题b: 数组不方便增删
3. 搜索树: 引申出->ALV树和红黑树, 性能整体比较稳定, 插入不会有太大波动.
4. 哈希: 搜索比较快, 但是整体不稳定, 插入是有波动的, 某次的插入可能需要扩容, 扩容代价比较高
还有极端场景下某个桶的数量可能很高, 但可以改挂红黑树解决.
以上数据结构, 空间消耗很高.
对于数量很大的数据的场景?
5、[整形]的是否存在及其扩展问题--位图及变形节省空间, 但是位图的局限是只能处理整型.
6、[其他类型]的存在问题呢?--布隆过滤器, 下面会介绍.