在
海量数据
中,此时普通的数组、链表、Hash、树等等结构有无效了 ,因为内存空间放不下了。而常规的递归、排序,回溯、贪心和动态规划等思想也无效了,因为执行都会超时,必须另外想办法。这类问题该如何下手呢?这里介绍三种非常典型的思路:
使用位存储
,使用位存储最大的好处是占用的空间是简单存整数的1/8。例如一个40亿的整数数组,如果用整数存储需要16GB左右的空间,而如果使用位存储,就可以用2GB的空间,这样很多问题就能够解决了。如果文件实在太大 ,无法在内存中放下,则需要考虑将大文件分成若干小块,先处理每个块,最后再逐步得到想要的结果,这种方式也叫做
外部排序
。这样需要遍历全部序列至少两次,是典型的用时间换空间的方法。
堆
,如果在超大数据中找第K大、第K小,K个最大、K个最小,则特别适合使用堆来做。而且将超大数据换成流数据也可以,而且几乎是唯一的方式,口诀就是“查小用大堆,查大用小堆”。
对于40亿数据的存储所需内存分析:
//直接存储 需要15GB,不满足要求
4000000000*4/1024/1024/1024 ≈ 15GB
//使用位图存储 需要0.5GB,满足要求
4000000000/8/1024/1024/1024 ≈ 0.5GB
所以,跟上一节使用的位存储
方案一样即可,话不多说,直接上代码
源码地址: GitHub-golang版本(含单元测试代码)
func FindNoExistNumBy1G(arr []int) int {
N := 4000000000
bitmap := make([]int, N/32+1)
for _, num := range arr {
num0 := num - 1
index := num0 / 32
offset := num0 % 32
mark := 1 << offset
bitmap[index] |= mark
}
for index, v := range bitmap {
for i := 0; i < 32; i++ {
mark := 1 << i
if v&mark == 0 {
return index*32 + i + 1
}
}
}
return -1
}
10*1024*1024*8 = 83886080(bit)
对40亿数据,使用分块处理,如果每块用10MB内存使用位图存储
,只需 4000000000/83886080 ≈ 48 块
即可。
而一般来说,我们划分都是使用2的整数倍,因此这里划分成64块是更合理的。
2^2=4
2^3=8
2^4=16
2^5=32
2^6=64
2^26=67108864
2^32=4294967296
接上面,这里40亿数据,我们分成了64块来处理,即 countArr := make([]int, 64)
。
遍历40亿数据,明确每个数据对应的块,统计落在每个块上的总数量,然后判断哪个块的总数<平均数
,则不存在的整数在哪个块中。(这里一定会有至少一个块的总数<平均数)
那每块的平均数是多少?
4000000000 / 64 = 62500000
4294967296 / 64 = 67108864
举例使用第二种方法:将 4294967296 / 64 = 67108864
得到每块平均存放的个数,即
第 0 块 :67108864 * 0 ~ (67108864 * (0 + 1) - 1) <==> 0 ~ 67108863
第 1 块 :67108864 * 1 ~ (67108864 * (1 + 1) - 1) <==> 67108864 ~ 134217727
第 x 块 :67108864 * x ~ (67108864 * (x + 1) - 1)
第 63 块 :67108864 * 63 ~ (67108864 * (63+ 1) - 1) <==> 4227858432 ~ 4294967295
举例如何求出每个数据对应的块:比如当前数是 3422552090
对应块的索引:3422552090 / 67108864 = 51
然后让该块的总数量+1,即:countArr[51]++
最后遍历countArr
,判断哪个块的总数量 < 67108864,就找到了不存在的整数在这个块中。
假设找到第37块上的总数 < 67108864,那就对40亿数据进行第二次遍历,过滤掉不在37块上的数。
对于在37块上的数据,继续使用上一节的位存储
方案处理。将每个数据投射在位图中对应位置,设置值为1。
最后遍历位图,当找到位置值为0,根据此时的位偏移
、位图索引
就能算出这个不存在的值了
举例:不存在的值=位偏移 + 位图索引*32 + 37*67108864
。
源码地址: GitHub-golang版本(含单元测试代码)
func FindTargetIndex(arr []int) (targerIndex int) {
N := 67108864
countArr := make([]int, 64) //大约占用 0.25K 内存空间
// 遍历40亿数据,明确每个数据对应的块,统计落在每个块上的总数量
for _, num := range arr {
countArr[num/N]++
}
for i, count := range countArr {
if count < N {
return i
}
}
return -1
}
func FindNoExistNumBy10M(arr []int) (res int) {
N := 67108864
targetIndex := FindTargetIndex(arr)
bitmap := make([]int, N/32+1) //大约占用 8M 内存空间
for _, num := range arr {
// 如果数据是属于目标块的
if num/N == targetIndex {
val := num - targetIndex*N
val0 := val - 1
index := val0 / 32
offset := val0 % 32
mark := 1 << offset
bitmap[index] |= mark
}
}
for index, intval := range bitmap {
for i := 0; i < 32; i++ {
mark := 1 << i
// 找到不存在的值
if intval&mark == 0 {
res = i + index*32 + targetIndex*N + 1
return res
}
}
}
return -1
}
要求:有一个包含 20 亿个全是 32 位整数的大文件,在其中找到出现次数最多的数。
要求,内存限制为 2GB。
在找出现次数最多的数时,一般做法是使用哈希表,其中键是整数,值是对应整数的出现次数。
int的最大值是:2^31-1 = 2147483647
2147483647 > 20亿,所以可以使用int类型表示key和val(极端情况:如果有整数出现20亿次,或者有20亿个不同的整数,都不会有溢出问题)
所以,哈希map的结构为:map[int]int
。
由于每个整数是32位,所以一个键需要4字节,一个值也需要4字节,所以每个键值对需要8字节。
2GB = 210241024*1024 = 2147483648(byte)
2147483648 / 8 = 268435456
2亿 < 268435456 < 3亿
所以,将20亿分为每块2亿进行处理,共10块,这样处理每块的内存 < 2GB 内存,满足题意。
但一般来说,我们划分都是使用2的整数倍,因此这里划分成16块是更合理的。
哈希函数的作用是将输入的数据映射到一个较小的范围,例如0到15。这样,相同的整数会被映射到同一个范围,从而将整个大文件分割成了16个小文件。
对每个小文件,使用哈希表来统计其中每种数出现的次数。这样,每个小文件都包含了它自己的键值对,键是整数,值是对应整数的出现次数。
对每个小文件,找出出现次数最多的数(第一名),并记录其出现次数。这样,就得到了16个小文件中各自的第一名和对应的次数。
最后,从这16个小文件的第一名中选出谁出现的次数最多,就是全局的出现次数最多的数。
解答:原问题的解法使用解决大数据问题的一种常规方法:把大文件通过哈希函数分配到机器, 或者通过哈希函数把大文件拆成小文件,一直进行这种划分,直到划分的结果满足资源限制的要求。首先,你要向面试官询问在资源上的限制有哪些,包括内存、计算时间等要求。在明确了限制要求之后,可以将每条 URL 通过哈希函数分配到若干台机器或者拆分成若干个小文件, 这里的“若干”由具体的资源限制来计算出精确的数量。
例如,将 100 亿字节的大文件通过哈希函数分配到 100 台机器上,然后每一台机器分别统计分给自己的 URL 中是否有重复的 URL,同时哈希函数的性质决定了同一条 URL 不可能分给不同的机器;或者在单机上将大文件通过哈希函数拆成 1000 个小文件,对每一个小文件再利用哈希表遍历,找出重复的 URL;还可以在分给机器或拆完文件之后进行排序,排序过后再看是否有重复的 URL 出现。总之,牢记一点,很多大数据问题都离不开分流,要么是用哈希函数把大文件的内容分配给不同的机器,要么是用哈希函数把大文件拆成小文件,然后处理每一个小数量的集合。
补充问题最开始还是用哈希分流的思路来处理,把包含百亿数据量的词汇文件分流到不同的机器上,具体多少台机器由面试官规定或者由更多的限制来决定。对每一台机器来说,如果分到的数据量依然很大,比如,内存不够或存在其他问题,可以再用哈希函数把每台机器的分流文件拆成更小的文件处理。处理每一个小文件的时候,通过哈希表统计每种词及其词频,哈希表记录建立完成后,再遍历哈希表,遍历哈希表的过程中使用大小为 100 的小根堆来选出每一个小文件的 Top 100(整体未排序的 Top 100)。每一个小文件都有自己词频的小根堆(整体未排序的 Top 100),将小根堆里的词按照词频排序,就得到了每个小文件的排序后 Top 100。然后把各个小文件排序后的 Top 100 进行外排序或者继续利用小根堆,就可以选出每台机器上的 Top100。不同机器之间的 Top 100 再进行外排序或者继续利用小根堆,最终求出整个百亿数据量中的 Top 100。对于 Top K 的问题,除用哈希函数分流和用哈希表做词频统计之外,还经常用堆结构和外排序的手段进行处理。
本题可以看做第一题的进阶问题,这里将出现次数限制在了两次。
func findDuplicates(arr []uint32) []uint32 {
bitArrLen := uint64(4294967295) * 2
bitArr := make([]byte, (bitArrLen/8)+1)
duplicates := []uint32{}
for _, num := range arr {
index := num * 2
if (bitArr[index/8] & (1 << (index % 8))) == 0 {
bitArr[index/8] |= 1 << (index % 8)
} else if (bitArr[(index+1)/8] & (1 << ((index + 1) % 8))) == 0 {
bitArr[(index+1)/8] |= 1 << ((index + 1) % 8)
duplicates = append(duplicates, num)
}
}
return duplicates
}