背景
海量数据的处理主要包括三个方面:
- 数据排序
- 数据统计
- 数据计算
我们可以简单的来算算 5亿个数在内存中的占用:
(64bit)*5*1e8
约为(按1000换算) 4Gbyte
,所以内存一次一般装不下。对于这类大数的处理,本文会总结一些方法。
数据排序
分治
- 根据数据存在文件中的位置分裂文件到批量小文件中
这里我们的做法是每次读取待排序文件的1e4
个数据,把这1e4
个数据进行快速排序,再写到一个小文件bigdata.part.i.sorted
中。这样我们就得到了5*1e4
个已排序好的小文件了。
在有已排序小文件的基础上,我只要每次拿到这些文件中当前位置的最小值就OK了(小顶堆
)。再把这些值依次写入bigdata.sorted
中。
- 根据数据自身大小分裂文件到批量小文件中
按照数据位置进行分裂大文件也可以。不过这样就导致了一个问题,在把小文件合并成大文件的时候并不那么高效。那么,这里我们就有了另一种思路:我们先把文件中的数据按照大小把到不同的文件中。再对这些不同的文件进行排序。这样我们可以直接按文件的字典序输出即可。
当然这种做法也会有缺陷,比如如果数据是稠密的(在某一个文件里很多),就不知道把某一个数据放到哪了。
上面的做法需要频繁地读写磁盘,可以设置输入缓存和输出缓存来解决这个问题。为每个拆分节点都设置一个输入缓存,每次将一部分数据读入输入缓存中,只有当输入缓存数据为空时才再从磁盘读入数据。并设置一个输出缓存,只有输出缓存满时才将数据写出磁盘中。
字典树
Trie 树又叫字典树、前缀树和单词查找树,它是一颗多叉查找树,键不是直接保存在节点中,而是由节点在树中的位置决定。
如果海量数据是字符串,可以使用 Trie 树来完成排序操作。先读入海量字符串数据构建一个 Trie 树,最后按字典序先序遍历 Trie 树就能得到已排序的数据。为了处理数据重复问题,可以使用 Trie 树的节点存储计数信息。
数据去重
哈希
考虑到海量数据,需要使用拆分的方式将数据拆分到多台机器。拆分过程可以用哈希取模实现。
压缩存储空间
- BitSet
BitSet存储。如果海量数据是整数且范围不大时,可以使用BitSet存储,通过构建一定大小的比特数组就可以判断某个整数是否出现。 - 布隆过滤器
参考:Redis缓存击穿解决方案 - 字典树
同理。
面试题汇总
以下的题均是海量数据下的解法,后文不做赘述。
1. TopK
问题:
一亿个数查找最大的K个。
解法:
使用大小为K的小顶堆。分为建堆的过程和之后不断reBuildHeap的过程,总的来说复杂度是O(N),前面建堆的复杂度可以忽略不计。
2. 查找中位数
方法一:
分治法的思想是把一个大的问题逐渐转换为规模较小的问题来求解。
对于这道题,顺序读取这 5 亿个数字,对于读取到的数字 num,如果它对应的二进制中最高位为 1,则把这个数字写到 f1 中,否则写入 f0 中。通过这一步,可以把这 5 亿个数划分为两部分,而且 f0 中的数都大于 f1 中的数(最高位是符号位)。
划分之后,可以非常容易地知道中位数是在 f0 还是 f1 中。假设 f1 中有 1 亿个数,那么中位数一定在 f0 中,且是在 f0 中,从小到大排列的第 1.5 亿个数与它后面的一个数的平均值。
提示,5 亿数的中位数是第 2.5 亿与右边相邻一个数求平均值。若 f1 有一亿个数,那么中位数就是 f0 中从第 1.5 亿个数开始的两个数求得的平均值。
对于 f0 可以用次高位的二进制继续将文件一分为二,如此划分下去,直到划分后的文件可以被加载到内存中,把数据加载到内存中以后直接排序,找出中位数。
注意,当数据总数为偶数,如果划分后两个文件中的数据有相同个数,那么中位数就是数据较小的文件中的最大值与数据较大的文件中的最小值的平均值。
方法二:
排序后找。
3. 随机选择K个数
问题:
“给出一个数据流,这个数据流的长度很大或者未知。并且对该数据流中数据只能访问一次。请写出一个随机选择算法,使得数据流中所有数据被选中的概率相等。”
解法:
蓄水池采样(Reservoir Sampling)算法。
介绍该算法之前,我们首先从最简单的例子出发(只在数据流中取一个数据):假设数据流只有一个数据。我们接收数据,发现数据流结束了,直接返回该数据,该数据返回的概率为1。看来很简单,那么我们试试难一点的情况:假设数据流里有两个数据。
我们读到了第一个数据,这次我们不能直接返回该数据,因为数据流没有结束。我们继续读取第二个数据,发现数据流结束了。因此我们只要保证以相同的概率返回第一个或者第二个数据就可以满足题目要求。因此我们生成一个0到1的随机数R,如果R小于0.5,我们就返回第一个数据,如果R大于0.5,返回第二个数据。
接着我们继续分析有三个数据的数据流的情况。为了方便,我们按顺序给流中的数据命名为1、2、3。我们陆续收到了数据1、2。和前面的例子一样,我们只能保存一个数据,所以必须淘汰1和2中的一个。应该如何淘汰呢?不妨和上面例子一样,我们按照二分之一的概率淘汰一个,例如我们淘汰了2。继续读取流中的数据3,发现数据流结束了,我们知道在长度为3的数据流中,如果返回数据3的概率为1/3,那么才有可能保证选择的正确性。也就是说,目前我们手里有1、3两个数据,我们通过一次随机选择,以1/3的概率留下数据3,以2/3的概率留下数据1。那么数据1被最终留下的概率是多少呢?
数据1被留下概率:(1/2)* (2/3) = 1/3
数据2被留下概率:(1/2)*(2/3) = 1/3
数据3被留下概率:1/3
这个方法可以满足题目要求,所有数据被留下返回的概率一样。
因此,循着这个思路,我们可以总结算法的过程:
- 假设需要采样的数量为K。
- 首先构建一个可容纳 K 个元素的数组,将序列的前 K 个元素放入数组中。
然后对于第J( j > k j>k j>k)个元素开始,以 k j \frac{k}{j} jk 的概率来决定该元素是否被替换到数组中(数组中的 K个元素被替换的概率是相同的)。 当遍历完所有元素之后,数组中剩下的元素即为所需采取的样本。
代码如下:
private int[] sampling(int K) {
int[] result = new int[K];
for (int i = 0; i < K; i++) { // 前 K 个元素直接放入数组中
result[i] = pool[i];
}
for (int i = K; i < N; i++) { // K + 1 个元素开始进行概率采样
int r = random.nextInt(i + 1);
// 这里其实就是k/j的体现
if (r < K) {
result[r] = pool[i];
}
}
return result;
}
4. 找出出现次数最多的IP
可以考虑采用“分而治之”的思想,按照IP地址的Hash(IP)%1024值,把海量IP日志分别存储到1024个小文件中。