问题
面试时经常被问到的一个问题:几万亿的数据分布到几千台网络连接的计算机中,怎么最少的数据交换,最快的速度找到这些数据的中位数?(备注:看看候选人是否愿意澄清题意,数据是什么类型?计算机是怎么连接的?顺便考考网络。)
首先,看看什么是中位数。通用的定义是,给出一个排序好的列表,中间的那个元素就是中位数。比如,对有11个元素的排序好的列表[1 1 2 3 3 4 5 5 5 6 9],中位数就是离左右各5个元素位置的中间的那个元素,它的值就是列表中的4。(备注:如果候 选人不知道中位数,通过简单的例子,看看候选人是否能够快速理解和学习。这儿,可以考考候选人,如果是偶数个元素时,他会怎么做。)
那么这道题为什么值得作为一道面试题呢?
让我们来进行一些简单的估算,一万亿(10^12)个整数,按每个整数4个字节,4x10^12字节=4x10^9KB=4x10^6MB=4x10^3GB,如果每个机器内存4GB的话,需要存放到1000台机器上。所以,不是简单的排序就好解决的问题。。。(备注:这些看是简单的估算,就可以用来考察候选人的计算机基础知识。)
算法
分区和支点
只是为了求中位数而做一个完全排序,毫不奇怪的有点浪费。我们所需要的只是找到中间点的一个元素,离左右都有同等数量的元素。
解决这种问题,常用的操作是分区(partition),是著名的排序算法快排(quicksort)的基础。(备注:可以问问候选人有关排序的基础问题和算法复杂度什么的。)
快排算法的过程是这样的(备注:让候选人回忆一下。),选择一个元素,称作支点(pivot),然后通过移动(shuffling/洗牌)周围的元素,将支点元素移动到到正确的排序后的位置。所有比支点小的元素移动到列表的前面,其它的,等于或是大于的元素,保持在原位,也就是列表的后面。分区之后,支点元素就在正确的排序位置了,尽管整个列表还是无序的。(备注:可以让候选人写一个partition函数看看代码功底。)接下来,可以递归的使用同样的分区方法,对支点前的元素列表,和支点后的元素列表,最后完成排序。(备注:写一个quicksort函数。)
对于找中位数,我们可以使用类似的思路:采用第一个支点分区的思路,但不用做完整排序所要求的额外工作。
顺序统计量
对于一个排序好的列表,第n个顺序统计量就是列表中的第n个位置的元素(备注:考考概率知识。)。比如,对于一个9个元素的列表,第1顺序统计量就是最小元素;对于一个m个元素的列表,第m顺序统计量就是最大元素;对于一个2m个元素的列表,第m顺序统计量就是中位数。
支点选择
完美支点
让我们来看一个例子,假如有一个列表
[4 3 1 1 5 9 2 6 5 3 5]
对于一个2m个元素的列表,我们用下划线来表示第m顺序统计量。
我们的目标是找中位数,也就是第6顺序统计量。如果我们用列表中的第一个元素,即4,来分区,列表如下
[3 1 1 2 3 4 5 9 6 5 5]
我们用红色来指示那些小于支点的元素,绿色来指示等于或大于支点的。
因为4移动到了第6个位置,我们说4是第6顺序统计量。很幸运,支点元素正好是我们寻找的中位数。所以这个支点元素4,就是中位数。
这比快排(quicksort)少了很多的工作量,因为我们不用排序子列表[3 1 1 2 3]和[5 9 6 5 5]。但是,这只是由于4碰巧是一个好的选择。但是如果分区后,支点元素不是第6顺序工作量,该怎么办?
支点在中位数位置右边
考虑支点在中位数位置右边的情形
L1 = [6 3 1 1 5 9 2 4 5 3 7]
如果用第一个元素6来分区,我们得到
L1 = [3 1 1 5 5 2 4 3 6 9 7]
这次,6移到了第9顺序统计量。因为我们找寻的是第6顺序统计量,我们知道6不是中位数。更因为第9顺序统计量在第6顺序统计量的右边,所以我们知道中位数必须小于6。因此,我们不但知道6不是中位数,中位数也不可能是9或者7。这很重要,因为我们可以完全扔掉这些值。
L2 = L1 - [6 9 7]
L2 = [3 1 1 5 5 2 4 3]
L1的第6顺序统计量,也就是中位数,这时变成了L2的第6顺序统计量。因为我们扔掉了中间值之后的所有值,我们可以继续寻找同样的顺序统计量。注意了,L2的第6顺序统计量并不是L2的中位数。L2的中位数应该是第4顺序统计量。
支点在中位数位置的左边
考虑下面的情况,支点落到了中位数位置的左边。(备注:看看候选人是否能按照前面的分析来分析。)
L1 = [2 3 1 1 5 9 3 6 5 3 5]
同样,我们寻找中位数,第6顺序统计量。 围绕着第一个元素2来分区,我们得到
L1 = [1 1 2 3 5 9 3 6 5 3 5]
这时,支点元素是第3顺序统计量,但是我们需要的是第6顺序统计量。因为第3顺序统计量在第6顺序统计量的左边,同样,1也不是中位数,可以安全的扔掉所有小于支点的元素。
L2 = L1 - [1 1]
L2 = [2 3 5 9 3 6 5 3 5]
L1的第6顺序统计量,也就是中位数,到了L2,变成了第4顺序统计量。因为我们扔掉了中间值之前的元素,我们需要调整找寻的顺序统计量。
小结
如果我们找寻第k顺序统计量,且我们选择了一个支点正好就是,那么我们找到了中位数。否则我们扔掉一些值,调整我们寻找的顺序统计量,然后继续同样操作。
边角情形一
(备注:面试时,对于高级的,我们可以考察他们是否能发现这种情形。这同样也适用于测试,或是发现bugs。)
假如列表是
L1 = [2 3 1 1 5 9 3 6 5 3 5]
我们选择了第一个元素2,作为支点且扔掉了一些元素(所有的1),得到
L2 = [2 3 5 9 3 6 5 3 5]
现在继续,选择第一个元素2,作为支点,我们没有扔掉任何元素,又得到
L3 = [2 3 5 9 3 6 5 3 5]
我们被卡住了,不能有任何进展。所以,如果支点元素又是第一个元素,我们需要转动列表,也就是将第一个元素移到最后,然后继续。
边角情形二
(备注:候选人能发现这种情况吗?)
如果所有的元素都是一样的,转动列表也不能解决问题。比如,
L1 = [3 3 3 3 3]
对于这种情况,需要特殊处理。这也简单,看看列表的最大元素是不是和最小元素一样大。如果一样,第一个元素就是中位数。
几个快捷情形
如果是找寻列表的第1顺序统计量,只要扫描最小元素。如果是找寻k个元素列表的第k顺序统计量,只要扫描最大元素。
分布式
刚才设计的算法可以做些小的修改就能工作在分布式环境中。(备注:候选人怎么推广到分布式环境中?)
实际上,将列表中的元素移动到支点之前是不必要的,我们所感兴趣的是支点在分区后在列表中所在的位置。这个可以通过计数多少元素小于支点元素,然后加1,就得到最后的位置。
我们重新回顾一下之前的例子。
支点在中位数位置的右边
L1 = [6 3 1 1 5 9 2 4 5 3 7]
如果计数有多少个元素小于6,我们得到8。所以6是第9顺序统计量,又因为第9大于第6(中位数位置),我们需要扔掉所有值大于等于6(第一个元素)的元素。
L2 = [3 1 1 5 2 4 5 3]
然后继续寻找第6顺序统计量。
支点在中位数位置的左边
L1 = [2 3 1 1 5 9 3 6 5 3 5]
如果我们计数小于2的元素的个数,得到2。所以2是第3顺序统计量,又因第3小于第6,我们需要扔掉所有小于2的元素。
L2 = [2 3 5 9 3 6 5 3 5]
在这个情况下,支点元素在完成分区后仍然是第一个位置,我们需要轮转列表。
L2 = [3 5 9 3 6 5 3 5 2]
然后继续寻找第4(6 - 2)顺序统计量。
计数但没有移动
我们看到,算法不再要求移动任何元素。那么,我们到底对列表的元素做了什么?
计数总的元素个数;
确定最小和最大元素;
计数小于某个值的元素的个数;
扔掉小于某个值的所有元素;
扔掉大于或是等于某个值的所有元素;
轮换列表的头到尾
所有这些操作都能很容易的应用到分布式环境中,此时,我们不只是处理一个列表,二是一系列列表,而且是分布着很多的机器上。唯一不容易的是轮换列表,下面我们能看到也不是那么难。
多个列表/多处理器/多机网络下实现
列表的列表
在多处理器环境实现时,我们将一个列表分拆成多个子列表(类似于一台机器上一个列表),每个子列表可以由一个处理器处理,潜在可能跨越多台机器。注意,列表的大小可能不一样。
单个列表:[1 2 3 4 5 6 7 8 9]
多个列表:[[1 2 3 4],[5 6],[7 8 9]]
回忆一下在分布式环境下的那些操作,比如计数总的元素个数;确定最小和最大元素;等等。每个这种操作会先单独作用于每个子列表,然后将结果聚合。比如,确定总的元素个数。
单个列表:length([1 2 3 4 5 6 7 8 9])=9
多个列表:sum(length([1 2 3 4]),length([5 6]),length([7 8 9]))=sum([4 2 3])=9
再比如,
单个列表:min([1 2 3 4 5 6 7 8 9])=1
多个列表:min(min([1 2 3 4]),min([5 6]),min([7 8 9]))=min([1 5 7])=1
其它必要的改变
轮换列表
单个列表时,取第一个元素作为支点。多个列表时,取第一个子列表的第一个元素。
在多个列表时,我们还需要一些修改,为了保证所有可能的支点元素都探索了。所以,在有多个子列表时,要做两个层次的轮换:首先轮换第一个子列表,然后轮换列表的列表。
轮换前:[[1 2 3],[4 5 6],[7 8 9]]
轮换后:[[2 3 1],[4 5 6],[7 8 9]]-》[[4 5 6],[7 8 9][2 3 1]]
空子列表清理
算法需要扔掉值大于或是小于某个值的元素,在单个列表时,列表不会为空,但多个列表时就有可能了。
考虑单个列表情形
[3 1 2 2 2 4 5]
支点为3,小于3的个数为4,所以3是第5顺序统计量,需要扔掉大于3的元素
结果 [3 1 2 2 2]
现在考虑多个子列表的情形
[[3 1 2],[2 2],[4 5]]
支点为3,小于3的个数为4,所以3是第5顺序统计量,需要扔掉大于3的元素
结果 [[3 1 2],[2 2],[]]
这时,我嗯需要排除空列表接着处理。
面试时经常被问到的一个问题:几万亿的数据分布到几千台网络连接的计算机中,怎么最少的数据交换,最快的速度找到这些数据的中位数?(备注:看看候选人是否愿意澄清题意,数据是什么类型?计算机是怎么连接的?顺便考考网络。)
首先,看看什么是中位数。通用的定义是,给出一个排序好的列表,中间的那个元素就是中位数。比如,对有11个元素的排序好的列表[1 1 2 3 3 4 5 5 5 6 9],中位数就是离左右各5个元素位置的中间的那个元素,它的值就是列表中的4。(备注:如果候 选人不知道中位数,通过简单的例子,看看候选人是否能够快速理解和学习。这儿,可以考考候选人,如果是偶数个元素时,他会怎么做。)
那么这道题为什么值得作为一道面试题呢?
让我们来进行一些简单的估算,一万亿(10^12)个整数,按每个整数4个字节,4x10^12字节=4x10^9KB=4x10^6MB=4x10^3GB,如果每个机器内存4GB的话,需要存放到1000台机器上。所以,不是简单的排序就好解决的问题。。。(备注:这些看是简单的估算,就可以用来考察候选人的计算机基础知识。)
算法
分区和支点
只是为了求中位数而做一个完全排序,毫不奇怪的有点浪费。我们所需要的只是找到中间点的一个元素,离左右都有同等数量的元素。
解决这种问题,常用的操作是分区(partition),是著名的排序算法快排(quicksort)的基础。(备注:可以问问候选人有关排序的基础问题和算法复杂度什么的。)
快排算法的过程是这样的(备注:让候选人回忆一下。),选择一个元素,称作支点(pivot),然后通过移动(shuffling/洗牌)周围的元素,将支点元素移动到到正确的排序后的位置。所有比支点小的元素移动到列表的前面,其它的,等于或是大于的元素,保持在原位,也就是列表的后面。分区之后,支点元素就在正确的排序位置了,尽管整个列表还是无序的。(备注:可以让候选人写一个partition函数看看代码功底。)接下来,可以递归的使用同样的分区方法,对支点前的元素列表,和支点后的元素列表,最后完成排序。(备注:写一个quicksort函数。)
对于找中位数,我们可以使用类似的思路:采用第一个支点分区的思路,但不用做完整排序所要求的额外工作。
顺序统计量
对于一个排序好的列表,第n个顺序统计量就是列表中的第n个位置的元素(备注:考考概率知识。)。比如,对于一个9个元素的列表,第1顺序统计量就是最小元素;对于一个m个元素的列表,第m顺序统计量就是最大元素;对于一个2m个元素的列表,第m顺序统计量就是中位数。
支点选择
完美支点
让我们来看一个例子,假如有一个列表
[4 3 1 1 5 9 2 6 5 3 5]
对于一个2m个元素的列表,我们用下划线来表示第m顺序统计量。
我们的目标是找中位数,也就是第6顺序统计量。如果我们用列表中的第一个元素,即4,来分区,列表如下
[3 1 1 2 3 4 5 9 6 5 5]
我们用红色来指示那些小于支点的元素,绿色来指示等于或大于支点的。
因为4移动到了第6个位置,我们说4是第6顺序统计量。很幸运,支点元素正好是我们寻找的中位数。所以这个支点元素4,就是中位数。
这比快排(quicksort)少了很多的工作量,因为我们不用排序子列表[3 1 1 2 3]和[5 9 6 5 5]。但是,这只是由于4碰巧是一个好的选择。但是如果分区后,支点元素不是第6顺序工作量,该怎么办?
支点在中位数位置右边
考虑支点在中位数位置右边的情形
L1 = [6 3 1 1 5 9 2 4 5 3 7]
如果用第一个元素6来分区,我们得到
L1 = [3 1 1 5 5 2 4 3 6 9 7]
这次,6移到了第9顺序统计量。因为我们找寻的是第6顺序统计量,我们知道6不是中位数。更因为第9顺序统计量在第6顺序统计量的右边,所以我们知道中位数必须小于6。因此,我们不但知道6不是中位数,中位数也不可能是9或者7。这很重要,因为我们可以完全扔掉这些值。
L2 = L1 - [6 9 7]
L2 = [3 1 1 5 5 2 4 3]
L1的第6顺序统计量,也就是中位数,这时变成了L2的第6顺序统计量。因为我们扔掉了中间值之后的所有值,我们可以继续寻找同样的顺序统计量。注意了,L2的第6顺序统计量并不是L2的中位数。L2的中位数应该是第4顺序统计量。
支点在中位数位置的左边
考虑下面的情况,支点落到了中位数位置的左边。(备注:看看候选人是否能按照前面的分析来分析。)
L1 = [2 3 1 1 5 9 3 6 5 3 5]
同样,我们寻找中位数,第6顺序统计量。 围绕着第一个元素2来分区,我们得到
L1 = [1 1 2 3 5 9 3 6 5 3 5]
这时,支点元素是第3顺序统计量,但是我们需要的是第6顺序统计量。因为第3顺序统计量在第6顺序统计量的左边,同样,1也不是中位数,可以安全的扔掉所有小于支点的元素。
L2 = L1 - [1 1]
L2 = [2 3 5 9 3 6 5 3 5]
L1的第6顺序统计量,也就是中位数,到了L2,变成了第4顺序统计量。因为我们扔掉了中间值之前的元素,我们需要调整找寻的顺序统计量。
小结
如果我们找寻第k顺序统计量,且我们选择了一个支点正好就是,那么我们找到了中位数。否则我们扔掉一些值,调整我们寻找的顺序统计量,然后继续同样操作。
边角情形一
(备注:面试时,对于高级的,我们可以考察他们是否能发现这种情形。这同样也适用于测试,或是发现bugs。)
假如列表是
L1 = [2 3 1 1 5 9 3 6 5 3 5]
我们选择了第一个元素2,作为支点且扔掉了一些元素(所有的1),得到
L2 = [2 3 5 9 3 6 5 3 5]
现在继续,选择第一个元素2,作为支点,我们没有扔掉任何元素,又得到
L3 = [2 3 5 9 3 6 5 3 5]
我们被卡住了,不能有任何进展。所以,如果支点元素又是第一个元素,我们需要转动列表,也就是将第一个元素移到最后,然后继续。
边角情形二
(备注:候选人能发现这种情况吗?)
如果所有的元素都是一样的,转动列表也不能解决问题。比如,
L1 = [3 3 3 3 3]
对于这种情况,需要特殊处理。这也简单,看看列表的最大元素是不是和最小元素一样大。如果一样,第一个元素就是中位数。
几个快捷情形
如果是找寻列表的第1顺序统计量,只要扫描最小元素。如果是找寻k个元素列表的第k顺序统计量,只要扫描最大元素。
分布式
刚才设计的算法可以做些小的修改就能工作在分布式环境中。(备注:候选人怎么推广到分布式环境中?)
实际上,将列表中的元素移动到支点之前是不必要的,我们所感兴趣的是支点在分区后在列表中所在的位置。这个可以通过计数多少元素小于支点元素,然后加1,就得到最后的位置。
我们重新回顾一下之前的例子。
支点在中位数位置的右边
L1 = [6 3 1 1 5 9 2 4 5 3 7]
如果计数有多少个元素小于6,我们得到8。所以6是第9顺序统计量,又因为第9大于第6(中位数位置),我们需要扔掉所有值大于等于6(第一个元素)的元素。
L2 = [3 1 1 5 2 4 5 3]
然后继续寻找第6顺序统计量。
支点在中位数位置的左边
L1 = [2 3 1 1 5 9 3 6 5 3 5]
如果我们计数小于2的元素的个数,得到2。所以2是第3顺序统计量,又因第3小于第6,我们需要扔掉所有小于2的元素。
L2 = [2 3 5 9 3 6 5 3 5]
在这个情况下,支点元素在完成分区后仍然是第一个位置,我们需要轮转列表。
L2 = [3 5 9 3 6 5 3 5 2]
然后继续寻找第4(6 - 2)顺序统计量。
计数但没有移动
我们看到,算法不再要求移动任何元素。那么,我们到底对列表的元素做了什么?
计数总的元素个数;
确定最小和最大元素;
计数小于某个值的元素的个数;
扔掉小于某个值的所有元素;
扔掉大于或是等于某个值的所有元素;
轮换列表的头到尾
所有这些操作都能很容易的应用到分布式环境中,此时,我们不只是处理一个列表,二是一系列列表,而且是分布着很多的机器上。唯一不容易的是轮换列表,下面我们能看到也不是那么难。
多个列表/多处理器/多机网络下实现
列表的列表
在多处理器环境实现时,我们将一个列表分拆成多个子列表(类似于一台机器上一个列表),每个子列表可以由一个处理器处理,潜在可能跨越多台机器。注意,列表的大小可能不一样。
单个列表:[1 2 3 4 5 6 7 8 9]
多个列表:[[1 2 3 4],[5 6],[7 8 9]]
回忆一下在分布式环境下的那些操作,比如计数总的元素个数;确定最小和最大元素;等等。每个这种操作会先单独作用于每个子列表,然后将结果聚合。比如,确定总的元素个数。
单个列表:length([1 2 3 4 5 6 7 8 9])=9
多个列表:sum(length([1 2 3 4]),length([5 6]),length([7 8 9]))=sum([4 2 3])=9
再比如,
单个列表:min([1 2 3 4 5 6 7 8 9])=1
多个列表:min(min([1 2 3 4]),min([5 6]),min([7 8 9]))=min([1 5 7])=1
其它必要的改变
轮换列表
单个列表时,取第一个元素作为支点。多个列表时,取第一个子列表的第一个元素。
在多个列表时,我们还需要一些修改,为了保证所有可能的支点元素都探索了。所以,在有多个子列表时,要做两个层次的轮换:首先轮换第一个子列表,然后轮换列表的列表。
轮换前:[[1 2 3],[4 5 6],[7 8 9]]
轮换后:[[2 3 1],[4 5 6],[7 8 9]]-》[[4 5 6],[7 8 9][2 3 1]]
空子列表清理
算法需要扔掉值大于或是小于某个值的元素,在单个列表时,列表不会为空,但多个列表时就有可能了。
考虑单个列表情形
[3 1 2 2 2 4 5]
支点为3,小于3的个数为4,所以3是第5顺序统计量,需要扔掉大于3的元素
结果 [3 1 2 2 2]
现在考虑多个子列表的情形
[[3 1 2],[2 2],[4 5]]
支点为3,小于3的个数为4,所以3是第5顺序统计量,需要扔掉大于3的元素
结果 [[3 1 2],[2 2],[]]
这时,我嗯需要排除空列表接着处理。
到此,针对单个列表,多个列表,分布式环境,等各种情况,思路和算法都已经完善。从解决的过程,和中间我们给出的备注,我们明白了这个为什么是一个好的面试题。
本文转载自:http://www.vccoo.com/v/841287