在讨论我们是否真的需要Map-Reduce这一分布式计算技术之前,我们先面对一个问题,这可以为我们讨论这个问题提供一个直观的背景。
问题
我们先从最直接和直观的方式出发,来尝试解决这个问题:
先伪一下这个问题:
SELECT COUNT(DISTINCT surname)
FROM big_name_file
我们用一个指针来关联这个文件.
接着考察每一行的数据,解析出里面的姓氏,这里我们可能需要一个姓氏字典或者对照表,然后我们可以利用最长前缀匹配来解析出姓氏。这很像命名实体识别所干的事。
拿到了姓氏,我们还需要一个链表L,这个链表的每个元素存储两个信息,一个是姓氏或者姓氏的编号,另一个是这个姓氏出现的次数。
在考察每一行的数据时,我们解析出姓氏,然后在链表L中查找这个姓氏对应的元素是否存在,如果存在就将这个元素的姓氏出现次数加一,否则就新增一个元素,然后置这个元素的姓氏出现次数为1。
当所有的行都遍历完毕,链表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,这个方法在不关心效率和内存空间的情况下是个解决办法。
但是却有一些值得注意的问题:
在进行addOrUpdate操作时,我们需要进行一个find的操作来找到元素是否已在链表中了。对于无序链表来说,我们必须采取逐一比较的方式来实现这个find的语义。
对于上面的考虑,显然我们知道如果能按下标直接找出元素就最好不过了,我们可以在常量时间找出元素并更新姓氏出现的次数。
哈希表法
对于这一点,我们可以采取哈希表来做,采取这个结构,我们可以用常量时间来找到元素并更新。
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