在讨论咱们是否真的须要Map-Reduce这一分布式计算技术以前,咱们先面对一个问题,这能够为咱们讨论这个问题提供一个直观的背景。php
咱们先从最直接和直观的方式出发,来尝试解决这个问题:
先伪一下这个问题:java
SELECT COUNT(DISTINCT surname) FROM big_name_file
咱们用一个指针来关联这个文件.c++
接着考察每一行的数据,解析出里面的姓氏,这里咱们可能须要一个姓氏字典或者对照表,而后咱们能够利用最长前缀匹配来解析出姓氏。这很像命名实体识别所干的事。算法
拿到了姓氏,咱们还须要一个链表L,这个链表的每一个元素存储两个信息,一个是姓氏或者姓氏的编号,另外一个是这个姓氏出现的次数。sql
在考察每一行的数据时,咱们解析出姓氏,而后在链表L中查找这个姓氏对应的元素是否存在,若是存在就将这个元素的姓氏出现次数加一,不然就新增一个元素,而后置这个元素的姓氏出现次数为1。markdown
当全部的行都遍历完毕,链表L的长度就是不一样的姓氏的个数出现的次数。分布式
/** * 直接法伪代码 */
int distinctCount(file) {
//将磁盘文件file关联到一个内存中的指针f上
f <- file;
//初始化一个链表
L <- new LinkedList();
while(true) {
line <- f.readline();
if(line == null)
break;
//解析出此行的姓氏
surname <- parse(line);
//若是链表中没有这个姓氏,就新增一个,若是有,就将这个姓氏的出现次数+1
L.addOrUpdate(surname,1);
}
//链表的长度就是文件中不一样姓氏的个数
return L.size();
}
ok,这个方法在不关心效率和内存空间的状况下是个解决办法。
可是却有一些值得注意的问题:ui
在进行addOrUpdate操做时,咱们须要进行一个find的操做来找到元素是否已在链表中了。对于无序链表来讲,咱们必须采起逐一比较的方式来实现这个find的语义。编码
对于上面的考虑,显然咱们知道若是能按下标直接找出元素就最好不过了,咱们能够在常量时间找出元素并更新姓氏出现的次数。atom
对于这一点,咱们能够采起哈希表来作,采起这个结构,咱们能够用常量时间来找到元素并更新。
int distinctCountWithHashTable(file) {
//将磁盘文件file关联到一个内存中的指针f上
f <- file;
//初始化一个哈希表
T <- new HashTable();
while(true) {
line <- f.readline();
if(line == null)
break;
//解析出此行的姓氏
surname <- parse(line);
//若是哈希表中没有这个姓氏,就新增一个,若是有,就将这个姓氏的出现次数+1
T.addOrUpdate(surname,1);
}
//哈希表中实际存储的元素个数就是文件中不一样姓氏的个数
return T.size();
}
哈希表法看起来很美,但仍是有潜在的问题,若是内存不够大怎么办,哈希表在内存中放不下。这个问题一样存在于直接法中。
想一想看,若是这个文件是个排好序的文件,那该多好。
全部重复的姓氏都会连着出现,这样咱们只须要标记一个计数器,每次读取一行文本,若是解析出的姓氏和上一行的不一样,计数器就增1.
那么代码就像下面这样:
int distinctCountWithSortedFile(file) {
//将磁盘文件file关联到一个内存中的指针f上
f <- file;
//不一样姓氏的计数器,初始为0
C <- 0;
//上一行的姓氏
last_surname <- empty;
while(true) {
line <- f.readline();
if(line == null)
break;
//解析出此行的姓氏
surname <- parse(line);
//若是和上一行的姓氏不一样,计数器加1
if(!last_surname.equals(surname))
C++;
last_surname <- surname;
}
return C;
}
遗憾的是,咱们并不能保证给定的文件是有序的。但上面方法的优势是能够破除内存空间的限制,对内存的需求很小很小。
那么能不能先排个序呢?
确定是能够的,那么多排序算法在。可是有了内存空间的限制,能用到的排序算法大概只有位图法和外排了吧。
假设13亿/32 + 1个int(这里设32位)的内存空间仍是有的,那么咱们用位图法来作。
位图法很简单,基本上须要两个操做:
/** * 将i编码 */
void encode(M,i) {
(M[i >> 5]) |= (1 << (i & 0x1F));
}
/** *将i解码 */
int decode(M,i) {
return (M[i >> 5]) & (1 << (i & 0x1F));
}
假设咱们采起和姓氏字典同样的编号,咱们作一个天然升序,那么这个方法就像下面这样:
int distinctCountWithBitMap(file) {
//将磁盘文件file关联到一个内存中的指针f上
f <- file;
//初始化一个位图结构M,长度为13亿/32 + 1
M <- new Array();
//不一样姓氏的个数,初始为0
C <- 0;
while(true) {
line <- f.readline();
if(line == null)
break;
//解析出此行的姓氏编号
surname_index <- parse(line);
//将姓氏编号编码到位图对应的位上
encode(M,surname_index);
}
//找出位图中二进制1的个数
C <- findCountOfOneBits(M);
return C;
}
ok,一切看起来很完美,但如何有效地找出位图中的二进制1的个数呢?上面使用了一个findCountOfOneBits方法,找出二进制1的个数,好吧,这是另一个问题,但咱们为了完整,能够给出它的一些算法:
int findCountOfOneBits_1(int[] array) {
int c = 0;
for(int i = 0 ; i < array.length; i++)
c += __popcnt(array[i]);
return c;
}
int findCountOfOneBits_2(int[] array) {
int c = 0;
for(int i = 0 ; i < array.length; i++) {
while(array[i]) {
array[i] &= array[i] - 1;
c++;
}
}
return c;
}
int findCountOfOneBits_3(int[] array) {
int c = 0;
unsigned int t;
int e = 0;
for(int i = 0 ; i < array.length; i++) {
e = array[i];
t = e
- ((e >> 1) & 033333333333)
- ((e >> 2) & 011111111111);
t = (t + (t >> 3)) & 030707070707
c += (t%63);
}
return c;
}
上面的算法哪一种效率最高呢?老三。
ok,位图法看起来破除了内存的限制,的确如此吗?若是内存小到连位图都放不下怎么办?
不解决这个问题了!开玩笑~
既然内存严重不足,那么咱们只能每次处理一小部分数据,而后对这部分数据进行不一样姓氏的个数的统计,用一个{key,count}的结构去维护这个统计,其中key就表明了咱们的姓氏,count表明了它出现的次数。
处理完毕一小批数据后,咱们须要将统计结果持久化到硬盘,以备最后累计,这牵扯到一个合并的问题。
如何进行有效地合并也值得思索,由于一开始文件内的姓名是无序的,因此不能在最后时刻进行简单合并,由于同一种姓氏可能出如今不一样的统计结果分组中,这会使得统计结果出现重复。
因此咱们必须对每批统计结果维护一个group结构或者以下的结构:
统计结果1:{{key=赵,count=631}...}
统计结果2:{{key=赵,count=3124}...}
…
统计结果N : {{key=赵,count=9956}...}
这样,咱们在最后能够按key进行合并,得出以下的结构:
汇总结果1:{{key=赵,count=20234520}...}
汇总结果2:{{key=王,count=33000091...}
…
汇总结果M:{{key=钱,count=20009323}...}
BTW,数据是瞎编的,我我的并不知道到底哪一个姓氏最多。
这样M就是咱们不一样姓氏的个数。
合并的过程以下图:
因为不断地将部分的统计结果合并到硬盘中,这种方式很是相似LSM算法,不一样的是,咱们对硬盘上中间文件的合并是on-line的,不是off-line的。
合并法中,显然须要屡次的访问硬盘,这有点问题:
若是是机械硬盘,那么磁盘的寻道时间使人头痛。
而且,合并的算法是串行的,咱们没法下降摊还寻道代价。
面对内存容量有限的假设,咱们能够推广到单机的计算资源有限的场景中来,设想一下,上面所列举的算法中,若是文档是有序的,那么咱们仅仅使用极小的内存就能够解决问题,那么咱们不须要分布式,也不须要Map-Reduce。
固然,若是咱们不只须要统计不一样姓氏的个数,还想知道不一样姓氏出现的频率,以研究到底姓王的多仍是姓张的多,那么咱们须要一些新思路。
若是咱们能将姓名数据仔细分组,使得一样的姓氏会出如今同一组中.
而后将这些组分派到不一样的计算节点上,由这些节点并行计算出若干个数C1、C2、...、Cn,最终咱们的答案就是:n.
而每一个姓氏的频率能够表示为:
frequencyi=Ci∑ni=1Ci,其中i是姓氏的编号,Ci表示第i个姓氏的出现的个数 。
而对应这种分布式计算模型的,就是Map-Reduce.
一个典型的Map-Reduce模型,大概像下图这样:
注:上图来自Search Engines:Information Retrieval In Practice.
对应咱们这个问题,伪代码以下:
function Map(file) {
while(true) {
line <- file.readline();
if(line == null)
break;
surname <- parse(line);
count <- 1;
Emit(surname,count);
}
}
function Reduce(key,values) {
C <- 0;
surname <- key;
while(!values.empty()) {
C <- C + values.next();
}
Emit(surname,C);
}
使用Map-Reduce技术,不只能够并行处理姓氏频率,同时也能够应对big、big、big-data(好比全银河系的“人”的姓名)。前提是你有足够的计算节点或者机器。
这里还有一个问题须要注意,就是上面的Reduce算法默认了数据已经按姓氏分组了,这个目标咱们依靠Shuffle来完成。
在Shuffle阶段,依靠哈希表来完成group by surname.
在这里,将全部数据按姓氏分组并将每一组分派到一个计算节点上显得有些奢侈,因此若是在机器不足的状况下,能够将分组的粒度变大,好比100个姓氏为一组,而后经过屡次的Map-Reduce来得到最终结果。
最后,但愿我说明白了为何咱们须要Map-Reduce技术。 同时,不得不认可这个问题的设定是比较尴尬的= _ =,由于在对姓氏的parse阶段,咱们用到了一个全姓氏字典,显然这个字典自己(Trie or Hash)能够告诉咱们不一样姓氏的个数。但若是问题的设定不是所有的姓氏都出如今文件中,或许这篇文章就能起到抛砖引玉的效果,那么其中的过程也值得书写下来。