5TB的硬盘上放满了数据,请写一个算法将这些数据进行排重。如果这些数据是一些32bit大小的数据该如何解决?如果是64bit的呢?
在面试时遇到的问题,问题的解决方案十分典型,但对于海量数据处理接触少的同学可能一时也想不到什么好方案。介绍两个算法,对于空间的利用到达了一种极致,那就是Bitmap和布隆过滤器(Bloom Filter)。
在网上并没有找到Bitmap算法的中文翻译,在《编程珠玑》中有提及。与其说是算法,不如说是一种紧凑的数据存储结构。其实如果并非如此大量的数据,有很多排重方案可以使用,典型的就是哈希表。
public int[] removeDuplicates(int[] array) {
int index = 0;
int[] newArray = new int[array.length];
Map<Integer, Boolean> maps = new LinkedHashMap<Integer, Boolean>();
for(int num : array) {
if(!maps.contains(num)) {
newArray[index++] = num;
maps.put(num, true);
}
}
return newArray;
}
实际上,哈希表实际上为每一个可能出现的数字提供了一个一一映射的关系,每个元素都相当于有了自己的独享的一份空间,这个映射由散列函数来提供(这里我们先不考虑碰撞)。实际上哈希表甚至还能记录每个元素出现的次数,这样的数据结构完成这个任务有点“大材小用”了。
我们拆解一下我们的需求:
int
)有一个独享的空间这个空间要多大?对于我们的问题来说,一个boolean就够了,或者说,1个bit就够了,我们只想知道某个元素出现过没有。如果为每个所有可能的值分配1个bit,32bit的int
所有可能取值需要内存空间为:
2 32 b i t = 2 29 B y t e = 512 M B 2^{32} bit = 2^{29} Byte = 512 MB 232bit=229Byte=512MB
那怎么样完成这个映射呢?其实就是Bitmap所要完成的工作了。如果我们把整型0x01、0x02、…、0x08的空间依次映射到一个Byte上,每个bit就代表这个int
值是否出现过,初值为0(false)。
若扩展到整个int
取值域,申请一个byte[]
即可,示例代码如下:
public static final int _1MB = 1024 * 1024;
//每个byte记录8bit信息,也就是8个数是否存在于数组中
public static byte[] flags = new byte[ 512 * _1MB ];
public static void main(String[] args) {
//待判重数据
int[] array = {255, 1024, 0, 65536, 255};
int index = 0;
for(int num : array) {
if(!getFlag(num)) {
//未出现的元素
array[index] = num;
index = index + 1;
//设置标志位
setFlag(num);
System.out.println("set " + num);
} else {
System.out.println(num + " already exist");
}
}
}
public static void setFlag(int num) {
//使用每个数的低三位作为byte内的映射
//例如: 255 = (11111111)
//低三位(也就是num & (0x07))为(111) = 7, 则byte的第7位为1, 表示255已存在
flags[num >> 3] |= 0x01 << (num & (0x07));
}
public static boolean getFlag(int num) {
return (flags[num >> 3] >> (num & (0x07)) & 0x01) == 0x01;
}
其实,就是按int
从小到大的顺序依次摆放到byte[]
中,仅涉及到一些除以2的整次幂和对2的整次幂取余的位操作小技巧。鉴于评论区对位操作有疑问,下面基于java.util.BitSet
重写了一下上面的代码的关键函数setFlag
和getFlag
。
static BitSet seen = new BitSet((1 << 31) - 1);
public static void setFlag(int num) {
seen.set(num);
}
public static boolean getFlag(int num) {
return set.get(num);
}
很显然,对于小数据量、数据取值很稀疏,上面的方法并没有什么优势,但对于海量的、取值分布很均匀的集合进行去重,Bitmap极大地压缩了所需要的内存空间。于此同时,还额外地完成了对原始数组的排序工作。缺点是,Bitmap对于每个元素只能记录1bit信息,如果还想完成额外的功能,恐怕只能靠牺牲更多的空间、时间来完成了。
然而Bitmap不是万能的,如果数据量大到一定程度,如开头写的64bit类型的数据,还能不能用Bitmap?我们来算一算:
2 64 b i t = 2 61 B y t e = 2048 P B = 2 E B 2^{64} bit = 2^{61} Byte = 2048 PB = 2 EB 264bit=261Byte=2048PB=2EB
EB(Exabyte,艾字节)这个计算机科学中统计数据量的单位有多大,有兴趣的小伙伴可以查阅下资料。这个量级的Bitmap,已经不是人类硬件所能承担的了。我相信谁也不会想用集群去计算这么一个问题吧1?所以Bitmap的好处在于空间复杂度不随原始集合内元素的个数增加而增加,而它的坏处也源于这一点——空间复杂度随集合内最大元素增大而线性增大。
所以接下来,我们要引入另一个著名的工业实现——布隆过滤器(Bloom Filter)。如果说Bitmap对于每一个可能的整型值,通过直接寻址的方式进行映射,相当于使用了一个哈希函数,那布隆过滤器就是引入了 k ( k > 1 ) k(k>1) k(k>1)个相互独立的哈希函数,保证在给定的空间、误判率下,完成元素判重的过程。下图中是 k = 3 k=3 k=3时的布隆过滤器。
x , y , z x,y,z x,y,z经由哈希函数映射将各自在Bitmap中的3个位置置为1,当 w w w出现时,仅当3个标志位都为1时,才表示 w w w在集合中。图中所示的情况,布隆过滤器将判定 w w w不在集合中。
那么布隆过滤器的误差有多少?我们假设所有哈希函数散列足够均匀,散列后落到Bitmap每个位置的概率均等。Bitmap的大小为 m m m、原始数集大小为 n n n、哈希函数个数为 k k k:
回到我们的问题中,有趣的是由于硬盘空间是限制死的,集合元素个数 n n n的大小反而与单个数据的比特数成反比,数据长度为64bit时,
n = 5 T B 64 b i t = 5 × 2 40 B y t e 8 B y t e ≈ 2 39 n = \dfrac{5TB }{ 64bit} = \dfrac{5 \times 2 ^{40}Byte}{8Byte} \approx 2^{39} n=64bit5TB=8Byte5×240Byte≈239
若以 m = 8 n , k = 0.7 m n = 5.6 m=8n,k=0.7\frac{m}{n}=5.6 m=8n,k=0.7nm=5.6计算,Bitmap集合的大小为 2 42 b i t = 2 39 B y t e = 512 G B 2^{42}bit=2^{39}Byte = 512GB 242bit=239Byte=512GB,此时的 ε = 0. 5 5.6 ≈ 0.02 \varepsilon = 0.5 ^{5.6} \approx 0.02 ε=0.55.6≈0.02。并且要知道,以上计算的都是误差的上限。当输入元素个数逼近集合总元素 n n n时,误差率便逐渐逼近这个上界。
布隆过滤器通过引入一定错误率,使得海量数据判重在可以接受的内存代价中得以实现。从上面的公式可以看出,随着集合中的元素不断输入过滤器中( n n n增大),误差将越来越大。但是,当Bitmap的大小 m m m(指bit数)足够大时,比如比所有可能出现的不重复元素个数还要大10倍以上时,错误概率是可以接受的。相比于单纯的bitmap,这个算法跳出了空间复杂度对待判元素值域的依赖,转而依赖总元素个数,这是一个更加工程可实现的算法——前者不管你的数集有多大,所需要的内存空间是一定的;后者,数集越大,想要达到相同误判率,所需要的内存空间就越大。
最后我们所要做的,就是实现一个布隆过滤器,然后利用它对硬盘上的5TB数据一一判重,并写回硬盘中。这其中可能涉及到利用读写的buffer,待有时间补上。
这里有一个google实现的布隆过滤器,我们来看看它的误判率:
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.util.HashSet;
import java.util.Random;
public class testBloomFilter {
static int sizeOfNumberSet = Integer.MAX_VALUE >> 4;
static Random generator = new Random();
public static void main(String[] args) {
int error = 0;
HashSet<Integer> hashSet = new HashSet<Integer>();
BloomFilter<Integer> filter = BloomFilter.create(Funnels.integerFunnel(), sizeOfNumberSet);
for(int i = 0; i < sizeOfNumberSet; i++) {
int number = generator.nextInt();
if(filter.mightContain(number) != hashSet.contains(number)) {
error++;
}
filter.put(number);
hashSet.add(number);
}
System.out.println("Error count: " + error + ", error rate = " + String.format("%f", (float)error/(float)sizeOfNumberSet));
}
}
在这个实现中,Bitmap的集合 m m m、输入的原始数集合 n n n、哈希函数 k k k的取值都是按照上面最优的方案选取的,默认情况下保证误判率 ε = 0. 5 k < 0.03 ≈ 0. 5 5 \varepsilon=0.5^k<0.03\approx0.5^5 ε=0.5k<0.03≈0.55,因而此时 k = 5 k=5 k=5。
/**
* Creates a {@link BloomFilter BloomFilter} with the expected number of
* insertions and a default expected false positive probability of 3%.
*/
public static <T> BloomFilter<T> create(Funnel<T> funnel, int expectedInsertions /* n */) {
return create(funnel, expectedInsertions, 0.03); // FYI, for 3%, we always get 5 hash functions
}
而还有一个很有趣的地方是,实际使用的却并不是5个哈希函数。实际进行映射时,而是分别使用了一个64bit哈希函数的高、低32bit进行循环移位。注释中包含着这个算法的论文“Less Hashing, Same Performance: Building a Better Bloom Filter”,论文中指明其对过滤器性能没有明显影响。很明显这个实现对于 m > 2 32 m > 2^{32} m>232时的支持并不好,因为当大于 2 31 − 1 2^{31}-1 231−1的下标在算法中并不能被映射到。
enum BloomFilterStrategies implements BloomFilter.Strategy {
/**
* See "Less Hashing, Same Performance: Building a Better Bloom Filter" by Adam Kirsch and
* Michael Mitzenmacher. The paper argues that this trick doesn't significantly deteriorate the
* performance of a Bloom filter (yet only needs two 32bit hash functions).
*/
MURMUR128_MITZ_32() {
@Override public <T> boolean put(T object, Funnel<? super T> funnel,
int numHashFunctions, BitArray bits) {
long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong();
int hash1 = (int) hash64;
int hash2 = (int) (hash64 >>> 32);
boolean bitsChanged = false;
for (int i = 1; i <= numHashFunctions; i++) {
int nextHash = hash1 + i * hash2;
if (nextHash < 0) {
nextHash = ~nextHash;
}
bitsChanged |= bits.set(nextHash % bits.bitSize());
}
return bitsChanged;
}
@Override public <T> boolean mightContain(T object, Funnel<? super T> funnel,
int numHashFunctions, BitArray bits) {
long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong();
int hash1 = (int) hash64;
int hash2 = (int) (hash64 >>> 32);
for (int i = 1; i <= numHashFunctions; i++) {
int nextHash = hash1 + i * hash2;
if (nextHash < 0) {
nextHash = ~nextHash;
}
if (!bits.get(nextHash % bits.bitSize())) {
return false;
}
}
return true;
}
};
...
}
勘误:原谅我当年的孤陋寡闻,工业还有另一个著名实现是Map Reduce,正是用集群方案来解决“海量数据处理”的问题。 ↩︎
WikiPedia - Bloom Filter ↩︎