资源受限的海量数据计算:找到出现次数最多的数(求众数)

446 阅读4分钟

问题描述:有1T字节的数据,都是int32整型。现只有1G字节内存可以用于计算。求所有数据中出现次数最多的数。你可以假设这样的数只有一个。

思路

拆分

内存受限的计算,只有1G字节内存。为便于分析,先假定只用0.5G字节的内存做计算。1T字节的数据显然不能一次性装入内存,所以需要先做数据拆分。后续计算结果不求精确,只是展示思路。
1T/0.5G=20001T/0.5G=2000。将原数据集分成2000份,然后逐个处理。随便拆分成2000份就可以了吗?下面讲讲拆分策略。让每一份数据的取值范围与其它份没有交集,可以简化计算过程。将数据的取值范围也分成2000份,准确地说,单位是,整数数轴上的2000段,不同段之间没有交集。int32的取值范围是[231,2311][-2^{31}, 2^{31}-1],一共4G个数,4G/2000=2,000,0004G/2000=2,000,000,也就是每段有2百万个数。利用桶排序的思想,一段就是一个桶子。各个桶子的取值范围依次是 [231,231+size1],[231+size,231+2size1][-2^{31},-2^{31}+size-1], [-2^{31}+size,-2^{31}+2*size-1],...以此类推直到[231size,2311][2^{31}-size, 2^{31}-1]。假设1T数据放在一个大文件里,打开文件从中取数据。每个int32类型占4个字节,如果用0.5G字节内存加载数据,可以装0.5G/4=1,2500,00000.5G/4=1,2500,0000个int32。每次从大文件中加载1,2500,0000个数据到内存中。根据数据所在的段对应到一个桶子,每个桶子形成一个小文件。每个小文件开头都要记录数据个数,所在段的开始值和结束值,以便于后续计算。所有数都处理完成后就得到了2000个小文件。

计算

拆分完成后,就要逐个把小文件加载到内存中处理了。因为1T字节的数据在int32取值范围上的分布未必是均匀的,所以可能某些小文件中数据较多。小文件中数据个数超过1,2500,0000个,就不能一次性载入内存,只能分批加载了。每一批至多载入1,2500,0000个数。因为一个小文件中的数的取值范围在整数数轴上是2百万个相邻的点,所以可以利用计数排序的思想。因为数据总量是1T字节,某个数出现的次数可能大于uint32最大值,所以要用uint64记录。利用一个2百万长度的uint64类型的数组做数据计数。2,000,0008=16,000,0002,000,000 * 8 = 16,000,000,数组占用内存16M字节,内存依然绰绰有余(0.5G内存用于装数据,剩下的内存给计算使用)。因为文件开头记录了段的开始值,开始值加数组索引就是实际的数据值了。遍历小文件中的数,每个数都可以映射到数组的一个索引(数据值减去2百万),那个索引上的元素值+1(也就是数据个数+1),直到当前文件中的数都处理完。遍历数组找到当前文件的出现次数最多的数以及次数,与之前算出来的出现最多的数的次数(或者是初始次数0)比较,如果刚算出来的出现次数更多那就更新记录。继续处理下一个文件,直到所有小文件都处理完,就得到了需要的结果。

补充

上面分析过程假定只用0.5G内存,实际上我们可以使用更多的内存来加载数据,只要给进程和计算预留足够的内存即可。
注意看清题目是1T个数还是1T字节的数据量,要根据题目要求做分析。

优化

  • 优化拆分
    可以用双线程,一个线程C(消费者)负责计算,一个线程P(生产者)负责读文件。P维护一个队列,队列中每个item都是一份至多为0.2G字节的数据。P读大文件,每次读0.2G字节,然后放入队列尾部。队列最大长度限制为3,即最多保持0.2G3=0.6G0.2G*3=0.6G字节的数据在队列中。C每次从队列头拿走一个item去处理,P再加载生成一个item,装满队列。那么内存中最多保持0.2G(3+1)=0.8G0.2G*(3+1)=0.8G字节的数据。

  • 优化计算
    如果CPU有多核,在控制好内存占用的前提下,可以使用多线程。一个管理者线程M(生产者),多个工作线程W(消费者)。M不断地将未处理的文件分配给空闲的W,W处理完之后将结果通知给M。这样做,多个文件就可以并行处理了。