设定一百亿人,按照高矮顺序排队怎么计算

一百亿个人,按高矮排序。

这个问题乍一看是算法题,其实是工程题,更是对数据理解深度的考察。

咱们今天不聊那些虚头巴脑的时间复杂度公式,也不整那些花里胡哨的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

Flink

这种封装好的框架,反而忘了最基础的磁盘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随机读写、内存缓存),你需要打通这所有的关卡。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值