所谓海量数据处理,是指基于海量数据的存储、处理、和操作。正因为数据量太大,所以导致要么无
法在较短时间内迅速解决,要么无法一次性装入内存。
事实上,针对时间问题,可以采用巧妙的算法搭配合适的数据结构(如布隆过滤器、哈希、位图、堆、
数据库、倒排索引、Trie 树)来解决;而对于空间问题,可以采取分而治之(哈希映射)的方法,也就是
说,把规模大的数据转化为规模小的,从而各个击破。
此外,针对常说的单机及集群问题,通俗来讲,单机就是指处理装载数据的机器有限(只要考虑 CPU、
内存、和硬盘之间的数据交互),而集群的意思是指机器有多台,适合分布式处理或并行计算,更多考虑
节点与节点之间的数据交互。
一般说来,处理海量数据问题,有以下十种典型方法:
1. 哈希分治;
2. simhash 算法;
3. 外排序;
4. MapReduce;
5. 多层划分;
6. 位图;
7. 布隆过滤器;
8. Trie 树;
9. 数据库;
10.倒排索引。
受理论之限,本章将摒弃绝大部分的细节,只谈方法和模式论,注重用最通俗、最直白的语言阐述相
关问题。最后,有一点必须强调的是,全章行文是基于面试题的分析基础之上的,具体实践过程中,还得
视具体情况具体分析,且各个场景下需要考虑的细节也远比本章所描述的任何一种解决方案复杂得多。
关联式容器
一般来说,STL 容器分两种:序列式容器和关联式容器。
序列式容器包括 vector、list、deque、stack、queue、heap 等容器。而关联式容器,每笔数据或每
个元素都有一个键值(key)和一个实值(value),即所谓的键-值对(Key-Value)。当元素被插入到关联
式容器中时,容器的内部结构(可能是红黑树,也可能是哈希表)便依照其键值大小,以某种特定规则将
这个元素放置于适当位置。
在 C++ 11 标准之前,旧标准规定标准的关联式容器分为 set(集合)和 map(映射表)两大类,以及
这两大类的衍生体 multiset(多键集合)和 multimap(多键映射表),这些容器均基于红黑树(red-black tree)
实现。此外,还有另一类非标准的关联式容器,即 hashtable(哈希表,又称散列表),以及以 hashtable
为底层实现机制的 hash_set(散列集合)、hash_map(散列映射表)、hash_multiset(散列多键集合)、和
hash_multimap(散列多键映射表)。
也就是说,set、map、multiset、和 multimap 都内含一个 red-black tree,而 hash_set、hash_map、
hash_multiset、和 hash_multimap 都内含一个 hashtable2。
set/map/multiset/multimap
set,同 map 一样,所有元素都会根据元素的键值自动被排序,因为 set 和 map 两者的所有各种操作,
都只是转而调用 red-black tree 的操作行为。不过,值得注意的是,两者都不允许任意两个元素有相同的键
值。
不同的是:set 的元素不像 map 那样可以同时拥有实值(value)和键值(key),set 元素的键值就是实值,
实值就是键值,而 map 的所有元素都是 pair,同时拥有实值和键值,pair 的第一个元素被视为键值,第二
个元素被视为实值。
hash_set、hash_map、hash_multiset、和 hash_multimap
hash_set 和 hash_map,两者的一切操作都是基于 hashtable。不同的是,hash_set 同 set 一样,同时拥有
实值和键值,且实值就是键值,键值就是实值,而 hash_map 同 map 一样,每一个元素同时拥有一个实值
和一个键值,所以其使用方式和 map 基本相同。但由于 hash_set 和 hash_map 都是基于 hashtable 之上,所
以不具备自动排序功能。为什么?因为 hashtable 没有自动排序功能。
至于 hash_multiset 和 hash_multimap 的特性与 multiset 和 multimap 完全相同,唯一的差别就是
hash_multiset 和 hash_multimap 的底层实现机制是 hashtable(区别于 multiset 和 multimap 的底层实现机制
red-black tree),所以它们的元素都不会被自动排序,不过也都允许键值重复。
综上所述,什么样的结构决定其什么样的性质,因为 set、map、multiset、和 multimap 的实现都是
基于 red-black tree 的,所以有自动排序功能,而 hash_set、hash_map、hash_multiset、和 hash_multimap
的实现都是基于 hashtable 的,所以不含有自动排序功能,至于加个前缀 multi_无非就是允许键值重复而
已。
哈希分治
方法介绍
对于海量数据而言,由于无法一次性装进内存处理,不得不把海量的数据通过 hash 映射的方法分割成
相应的小块数据,然后再针对各个小块数据通过 hash_map 进行统计或其他操作。
那什么是 hash 映射呢?简单来说,就是为了便于计算机在有限的内存中处理大数据,我们通过一种映
射散列的方式让数据均匀分布在对应的内存位置(如大数据通过取余的方式映射成小数据存放在内存中,
或大文件映射成多个小文件),而这种映射散列的方式便是我们通常所说的 hash 函数,好的 hash 函数能
让数据均匀分布而减少冲突。
问题实例
1 寻找top IP
海量日志数据,提取出某日访问百度次数最多的那个 IP
分析:百度作为国内第一大搜索引擎,每天访问它的 IP 数量巨大,如果想一次性把所有 IP 数据装进
内存处理,则内存容量通常不够,故针对数据太大,内存受限的情况,可以把大文件转化成(取模映射)
小文件,从而大而化小,逐个处理。
换言之,先映射,而后统计,最后排序。
解法:具体分为以下 3 个步骤。
1. 分而治之/hash 映射。首先将该日访问百度的所有 IP 从访问日志中提取出来,然后逐个写入到一个
大文件中,接着采取 Hash 映射的方法(比如 hash(IP) % 10003),把整个大文件的数据映射到 1000 个小文
件中。
2. hash_map 统计。当大文件转化成了小文件,那么我们便可以采用 hash_map(ip, value)来分别对 1000
个小文件中的 IP 进行频率统计,找出每个小文件中出现频率最大的 IP,总共 1000 个 IP。
3. 堆/快速排序。统计出 1000 个频率最大的 IP 后,依据它们各自频率的大小进行排序(可采取堆排
序),找出那个出现频率最大的 IP,即为所求。
2 寻找热门查询
搜索引擎会通过日志文件把用户每次检索所使用的所有查询串都记录下来,每个查询串的长度为
1-255 字节。假设目前有 1000 万个查询记录(但因为这些查询串的重复度比较高,所以虽然总数是 1000
万,但如果除去重复后,不超过 300 万个查询字符串),请统计其中最热门的 10 个查询串,要求使用的
内存不能超过 1G。
一个查询串的重复度越高,说明查询它的用户越多,也就是越热门。
分析:如果是一亿个 IP 求 Top 10,可先%1000 将 IP 分到 1000 个小文件中去,并保证一种 IP 只出现
在一个文件中,再对每个小文件中的 IP 进行 hash_map 统计并按数量排序,最后用归并或者最小堆依次处
理每个小文件的 top 10 以得到最后的结果。
但对于本题,数据规模比较小,能一次性装入内存。因为根据题目描述,虽然有 1000 万个 Query, 但
是由于重复度比较高,故去除重复后,事实上只有 300 万的 Query,每个 Query 为 255Byte,因此我们可以
考虑把他们全部放进内存中去 (假设 300 万个字符串没有重复, 都是最大长度, 那么最多占用内存 3M 1K/4
= 0.75G,所以可以将所有字符串都存放在内存中进行处理)。
所以我们放弃分而治之/hash 映射的步骤,直接用 hash_map 统计,然后排序。事实上,针对此类典型
的 TOP K 问题,采取的对策一般都是:分而治之/hash 映射(如有必要) + hash_map + 堆。
解法:
1. hash_map 统计。先对这批海量数据进行预处理,用 hash_map 完成频率统计。具体做法是:维护一
个 Key 为 Query, Value 为该 Query 出现次数的 hash_map, 即 hash_map(Query, Value), 每次读取一个 Query,
如果该 Query 不在 hash_map 中, 那么将该 Query 放入 hash_map 中, 并将它的 Value 值设为 1; 如果该 Query
在 hash_map 中,那么将该 Query 的计数 Value 加 1 即可。最终我们用 hash_map 在 O(n)的时间复杂度内完
成了所有 Query 的频率统计。
2. 堆排序。借助堆这个数据结构,找出 Top K,时间复杂度为 N'O(logK)。即借助堆结构,我们可以
在 log 量级的时间内查找或调整移动。因此,维护一个 K(该题目中是 10)大小的小根堆,然后遍历 300 万
的 Query,分别和根元素进行比较。所以,最终的时间复杂度是:O(n) + N' O(logK),其中,N 为 1000
万,N'为 300 万。
关于第 2 步堆排序,进一步讲,可以维护 k 个元素的最小堆,即用容量为 k 的最小堆存储最先遍历到
的 k 个数,并假设它们即是最大的 k 个数,建堆费时 O(k),并调整堆(每次调整堆费时 O(logk))后,有 k1 >
k2 ... > kmin(kmin 设为最小堆中最小元素)。继续遍历数列,每次遍历一个元素 x,与堆顶元素比较,若
x > kmin,则更新堆(x 入堆,用时 O(logk)),否则不更新堆。这样下来,总费时 O( k + k logk + (n-k)logk )
= O(n logk)。此方法得益于在堆中,查找等各项操作的时间复杂度均为 O(logk)。
当然,你也可以采用 trie 树,关键字域存该查询串出现的次数,没有出现则为 0,最后用 10 个元素的
最小堆来对出现频率进行排序。
3 寻找频数最高的100个词
有一个 1G 大小的文件,里面每一行是一个词,词的大小不超过 16 字节,内存限制大小是 1M。返回
频数最高的 100 个词。
解法:
1.分而治之/hash 映射。按先后顺序读取文件,对于每个词 x,执行 hash(x)%5000,然后将该值存到 5000
个小文件(记为 x0, x1, ..., x4999)中。如此每个文件的大小大概是 200k 左右。当然,如果其中有的小文
件超过了 1M 大小,则可以按照类似的方法继续往下分,直到分解得到的小文件的大小都不超过 1M。
2.hash_map 统计。对每个小文件,采用 trie 树/hash_map 等统计每个文件中出现的词及相应的频率。
3.堆/归并排序。取出出现频率最大的 100 个词(可以用含 100 个结点的最小堆)后,再把 100 个词及
相应的频率存入文件,这样又得到了 5000 个文件。最后把这 5000 个文件进行归并(可以用归并排序)。
4 寻找Top10
海量数据分布在 100 台电脑中,请想个办法高效统计出这批数据的 TOP 10。
解法一:如果同一个数据元素只出现在某一台机器中,那么可以采取以下步骤统计出现次数为 TOP 10
的数据元素。
1.堆排序。在每台电脑上求出 TOP 10,可以采用包含 10 个元素的堆完成(求 TOP 10 小用最大堆,求
TOP 10 大用最小堆,比如求 TOP10 大,我们首先取前 10 个元素调整成最小堆,假设这 10 个元素就是 TOP
10 大,然后扫描后面的数据,并与堆顶元素比较,如果比堆顶元素大,那么用该元素替换堆顶,然后再调
整为最小堆,否则不调整。最后堆中的元素就是 TOP 10 大)。
2.组合归并。求出每台电脑上的 TOP 10 后,然后把这 100 台电脑上的 TOP 10 组合起来,共 1000 个
数据,再利用上面类似的方法求出 TOP 10 就可以了。
解法二:但如果同一个元素重复出现在不同的电脑中呢?举个例子,给定两台机器,第一台的数据及
各自出现频率为:a(50),b(50),c(49),d(49),e(0),f(0),第二台的数据及各自出现频率为:a(0),b(0),
c(49),d(49),e(50),f(50),求 TOP 2。其中,括号里的数字代表某个数据出现的频率,如 a(50)表示 a 出
现了 50 次。
这个时候,有两种方法可以解决:
● 要么遍历一遍所有数据,重新 hash 取模,如此使得同一个元素只出现在单独的一台电脑中,然后
采取上面所说的方法,统计每台电脑中各个元素的出现次数找出 TOP 10,继而组合 100 台电脑
上的 TOP 10,找出最终的 TOP 10。
● 要么暴力求解,直接统计每台电脑中各个元素的出现次数,然后把同一个元素在不同机器中的出
现次数相加,最终从所有数据中找出 TOP 10。
5 查询串的重新排序
有 10 个文件,每个文件 1G,每个文件的每一行存放的都是用户的 query,每个文件的 query 都可能重
复。要求你按照 query 的频度排序。
解法一:分为以下 3 个步骤,如下:
1.hash 映射。顺序读取 10 个文件,按照 hash(query)%10 的结果将 query 写入到另外 10 个文件(记为
a0,a1,..a9)中。这样新生成的每个文件的大小约为 1G(假设 hash 函数是随机的)。
2.hash_map 统计。找一台内存在 2G 左右的机器,依次对用 hash_map(query, query_count)来统计每个
query 出现的次数。注:hash_map(query, query_count)是用来统计每个 query 的出现次数,不是存储他们的
值,出现一次,则 count+1。
3.堆/快速/归并排序。利用快速/堆/归并排序按照出现次数进行排序,将排序好的 query 和对应的
query_cout 输出到文件中,这样得到了 10 个排好序的文件(记为 b0, b1, ..., b9)。最后,对这 10 个文件进
行归并排序(内排序与外排序相结合)。
解法二:一般 query 的总量是有限的,只是重复的次数比较多而已,可能对于所有的 query,一次性就
可以加入到内存了。这样,我们就可以采用 trie 树/hash_map 等直接来统计每个 query 出现的次数,然后按
出现次数做快速/堆/归并排序就可以了。
解法三:与解法 1 类似,但在做完 hash,分成多个文件后,可以交给多个文件来处理,采用分布式的
架构来处理(比如 MapReduce),最后再进行合并。
6.需找共同url
给定 a、b 两个文件,各存放 50 亿个 url,每个 url 各占 64 字节,内存限制是 4G,请找出 a、b 文件共
同的 url。
解法:
可以估计每个文件的大小为 5G 64=320G,远远大于内存限制的 4G。所以不可能将其完全加载到内
存中处理。考虑采取分而治之的方法。
simhash 算法以及后面的几种方法见文件 http://files.cnblogs.com/files/hlongch/%E6%B5%B7%E9%87%8F%E6%95%B0%E6%8D%AE%E5%A4%84%E7%90%86.pdf