一百亿个人,按高矮排序。
这个问题乍一看是算法题,其实是工程题,更是对数据理解深度的考察。
咱们今天不聊那些虚头巴脑的时间复杂度公式,也不整那些花里胡哨的PPT架构图,就实打实地聊聊,如果明天老板真把这活儿扔给你,这100亿条数据拍在你脸上,你该怎么把它们给捋顺了。
咱们先盘盘这个数据规模。
100亿。目前地球上大概也就80亿人。这题里的100亿,明显是虚拟数据,或者是把历史上的死人活人都算进来了。但这不重要,重要的是量级。
如果是单纯的整型数据,我们来算笔账。假设每个人的身高数据存成一个整数,int类型占4个字节。100亿乘以4字节,大概是37GB或者40GB的样子。
这就有意思了。
现在随便一台商用服务器,内存整个64GB或者128GB都不是事儿。如果仅仅是排序身高这一个数值,都不用搞什么分布式,直接把这40GB数据加载到内存里排,完全放得下。
但是,这里有个大坑。
题目说的是把人按高矮排序。人是一个对象,包含身高,肯定还包含名字、身份证号、家庭住址这些杂七杂八的信息。如果是一百亿个对象,那数据量就不是40GB了,起码是TB级别的。一般的单机内存肯定爆掉。
所以这事儿得拆成两个维度看:一个是纯算法维度,一个是工程落地维度。
咱们先说最核心的算法选择。
很多刚毕业的小孩,一听到排序,脑子里的反应就是快排QuickSort,或者是归并排序MergeSort。他们会告诉你,这俩的时间复杂度是O(N log N)。
如果你在面试里这么跟我说,我大概率会让你回去等通知。
为什么?因为你没看数据特征。
这是身高排序。身高的范围是极度有限的。
你想想,人类的身高范围是多少?哪怕加上吉尼斯世界纪录那个最高的哥们,也不会超过3米。如果我们精确到厘米,那就是0到300。如果我们精确到毫米,那就是0到3000。
不管是一百亿人,还是一千亿人,他们的身高都在这0到3000的范围内打转。
这时候你上快排?太浪费了。
这种场景下,最强的是计数排序Counting Sort或者桶排序
Bucket Sort。
如果你们对基础算法的底层逻辑感兴趣,别只看教科书,去翻翻Jon Bentley写的Programming Pearls,中文名叫编程珠玑。这本书虽然老,但里面关于利用数据属性来优化算法的思路,到现在都是神级操作。
回到身高这个问题。
我们可以申请一个长度为3000的数组,这在内存里几乎不占地儿。数组的下标0代表0毫米,下标1代表1毫米,以此类推,下标2999代表2999毫米。
然后我们把那100亿人过一遍。
遇到一个1米7的,就在下标1700的那个位置加1。遇到一个1米8的,就在1800那个位置加1。
遍历这100亿人,就是个线性的O(N)过程。不管人再多,我只需要扫一遍。
等扫完了,这个长度3000的数组里,存的就是每个身高段对应的人数。比如1700这个坑里有5000万人,1701这个坑里有4800万人。
接下来怎么输出?
那就太简单了。从下标0开始,里面存了多少就在结果里写多少个对应的身高。
这就是O(N)的时间复杂度。比起O(N log N),这在100亿这个量级上,节省下来的算力是惊人的。
但是,慢着。
这就完了?
你要是真这么干,上线大概率还是要挂。
因为刚才那个逻辑,只能输出身高的数值。也就是输出一串数字:170、170、170... 171、171...
但老板的需求是把人排成队。他要的是人,不是身高的数字。这意味着输出结果里得是张三、李四、王五,而且是按个头站好的。
这时候,简单的计数排序里的计数器就不够用了。我们需要桶排序的思想。
我们还是建立3000个桶。但这次桶里不存数字,存一个链表,或者一个动态数组。
把100亿人的数据读进来,张三1米7,扔进1700号桶;李四1米8,扔进1800号桶。
全部分完之后,按顺序把0号桶、1号桶...一直到2999号桶里的人倒出来,连在一起,就是最终排好序的队伍。
看起来很完美对吧?
这就到了工程落地的深水区了。
刚才说了,100亿人的完整数据是TB级别的。你把他们往内存里的3000个桶里塞,内存早就炸了。也就是常说的OOM,Out Of Memory。
这时候,我们就得聊聊外部排序
External Sorting。
这是每一个搞数据的人必须掌握的基本功。现在的程序员太依赖Spark
这种封装好的框架,反而忘了最基础的磁盘IO处理。
推荐大家去看看Knuth老爷子的The Art of Computer Programming卷三。虽然那书厚得能砸死人,而且里面数学公式多得让人头秃,但关于外部排序的章节,是所有现代数据库和大数据框架的鼻祖。
外部排序的核心思想就是分治。
内存塞不下,那我就把数据切成小块。
假设我手头只有16GB内存。我就每次从磁盘读10GB的人员数据进来。
这10GB数据在内存里,我是可以用快排或者归并给排好序的。排好之后,把这10GB有序的数据写回磁盘,存成一个临时小文件。
100亿人的数据,假设一共1TB。我就能切分出100个临时小文件。每个小文件内部都是有序的。
现在的局面是:磁盘上有100个有序的小文件,我们需要把它们合并成一个有序的大文件。
这就是经典的K路归并K-way Merge。
我们在内存里维护一个最小堆Min Heap。
从这100个小文件里,每个文件读出第一条记录(也就是该文件里最矮的那个人),一共100个人,扔到最小堆里。
堆顶的那个人,肯定是这100个人里最矮的,也肯定是全局最矮的那个(或者并列最矮之一)。
把堆顶这个人弹出来,写入最终结果文件。
然后,这个被弹出来的人来自哪个小文件,我们就去那个小文件里再读一条记录补充进堆。
只要维持这个堆不空,不断地弹出堆顶,写入结果,补充新数据。最后你就得到了一个全局有序的100亿人大长队。
这个过程里,内存始终只存了那100个待排的小数据和缓冲区,完全可控。
这就是单机处理大数据的老底子。
但是在实际工作里,特别是互联网大厂,我们很少真的去手写这个C++的外部排序程序。为什么?因为维护成本高,容错性差。你跑到一半机器断电了怎么办?磁盘坏道了怎么办?
所以现在都是上分布式集群。
提到分布式,就不能不提Hadoop MapReduce
或者现在的Spark。
这题要是放在Spark里做,其实就两行代码的事儿。
val people = sc.textFile("hdfs://path/to/people")
val sorted = people.sortBy(line => parseHeight(line))
虽然代码简单,但背后的原理跟我们刚才说的殊途同归,但又更加复杂。
在分布式场景下,这100亿人是分散在几十台甚至上百台服务器的硬盘上的。
你做sortBy的时候,Spark会做Shuffle
。
Shuffle这个词,是所有大数据工程师的噩梦,也是饭碗。
它要把分布在不同机器上的数据,按照身高的范围,重新分发。
比如,机器A负责处理0到1米5的,机器B负责1米5到1米6的,机器C负责1米6到1米7的...
这里就涉及到一个严重的问题:数据倾斜
Data Skew。
咱们都知道,成年人的身高是符合正态分布的。
1米6到1米8这中间的人,可能占了总人口的80%。而2米以上的人,和1米以下的人,少之又少。
如果你简单粗暴地按数值范围切分,负责1米7那个范围的机器B会被活活累死,硬盘转冒烟了也处理不完几十亿条数据。而负责2米以上的那台机器C,可能几秒钟就干完活儿了,然后在那儿闲得发慌。
整个集群的完成时间,取决于最慢的那台机器。这就是木桶效应。
所以,在分布式环境下排这个队,你得搞采样。
先从100亿人里随机抽样个几百万样本,看看身高的分布情况。
发现1米7的人特别多?行,那我就把1米7这个范围再细分。比如170.00mm到170.10mm分给一台机器,170.11mm到170.20mm分给另一台机器。
通过这种细粒度的范围划分,让每一台机器分到的数据量大致均匀。
这在工业界叫Range Partitioning
。
说到这儿,我想讲个真实的案例。
几年前我在一家做广告业务的公司,当时我们要给全量用户的活跃度做排序,大概也是几十亿的量级。活跃度这个指标跟身高很像,大部分人都集中在一个低活跃的区间,极少数是大R用户。
当时有个哥们直接裸写了一个Spark Job,按默认Hash分区去跑。结果跑了三个小时,进度条卡在99%不动了。
我们就上去查。发现是一个Executor(执行节点)卡死了。那台机器的CPU和内存全都打满,GC日志疯狂报错。
原因就是数据严重倾斜。大量的低活用户被分到了同一个分区。
后来我们重写了分区逻辑,用了我刚才说的采样策略,自定义了Partitioner,先把热点数据打散,把任务时间从3小时压到了20分钟。
这就是理论和工程的差距。
再聊深一点,关于IO。
不管是单机外部排序,还是分布式Shuffle,真正的瓶颈其实不在CPU比较高矮那几下子,而在磁盘IO和网络IO。
100亿条数据,从磁盘读出来,过一遍总线,进内存,再写回磁盘,或者通过网线发给另一台机器。这个数据的搬运过程才是最耗时的。
所以高手写代码,都在抠Buffer的大小,抠序列化的方式。
比如,Java的对象序列化是很重的。一个整数4字节,变成Integer对象可能就几十字节了,再序列化成二进制流通过网络发送,又有额外开销。
在处理这100亿人时,如果你用普通的Java Object来存人,内存带宽会被这种无效的元数据吃光。
现在的先进做法是利用Off-heap Memory
堆外内存,或者像Spark Tungsten那样,直接在内存里操作二进制数据,甚至利用CPU的L1/L2 Cache Line特性来优化。
这里推荐大家关注一下Systems Performance: Enterprise and the Cloud这本书,Brendan Gregg写的。这不是讲算法的,是讲怎么看系统性能瓶颈的。当你面对大规模数据处理慢得像蜗牛时,这本书能教你用火焰图去看到底是卡在磁盘读写上,还是卡在CPU计算上。
咱们再回到题目本身,开个脑洞。
如果这100亿人数据,不仅是静态的,而且是实时流动的呢?
比如这100亿人正在排队进站,每个人刷一下身份证,系统就要立马把他插到队伍里正确的位置,并且实时告诉他前面还有多少人。
这就不是批处理了,这是流计算。
这时候你再用全量排序就傻了。每来一个人你重新排100亿人?机器得炸。
这种场景下,我们通常会用跳表Skip List或者红黑树这种平衡二叉搜索树的变体来维护一个实时的有序结构。
或者,回到最开始的桶排序思想。如果只是要排名,我只需要维护各个身高桶的计数器。来一个1米7的,我就知道他前面那些1米6、1米5的桶里一共有多少人,瞬间就能算出他的排名,复杂度是O(1)。
这其实揭示了一个核心真理:数据结构是为业务场景服务的。
没有绝对最好的算法,只有最适合当下约束条件的方案。
在面试里,当面试官问你这个问题时,他期待的往往不是你默写一段代码。
他期待的是你会问:
“这100亿人是存在文件里还是数据库里?”
“机器配置是多少?”
“是要求实时性还是离线跑一次就行?”
“数据有严重的倾斜吗?”
当你问出这些问题的时候,你就不再是一个做题的学生,而是一个可以解决问题的工程师了。
我在团队里带新人的时候,经常发现他们有一个误区,就是觉得算法是屠龙技,平时写业务代码用不上。
其实不是的。
你写个SQL,后面是数据库在做排序;你调个Redis,后面是Skip List在工作。
当你面对的数据量小的时候,你写的垃圾代码能跑,是因为现在的硬件太强了,掩盖了你的无能。
一旦数据量到了100亿这个级别,到了海量并发这个级别,任何一点算法上的复杂度劣势,任何一点IO上的浪费,都会被放大无数倍,最后变成生产事故。
所以,把这100亿人排好队,不仅仅是一个排序问题。
它是一次对计算机体系结构的综合考量。
从最上层的算法思想(分治、桶排序),到中间的工程架构(分布式、Shuffle、负载均衡),再到底层的硬件特性(顺序读写vs随机读写、内存缓存),你需要打通这所有的关卡。

870

被折叠的 条评论
为什么被折叠?



