TopK 问题 | 青训营笔记

175 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第6篇笔记

TopK 问题描述:在(无序)数组中找到前 k 大/小的元素。

解法一:二分搜索

寻找N个数中最大的K个数,本质上就是寻找最大的K个数中最小的那个,也就是第K大的数。可以使用二分搜索的策略来寻找N个数中的第K大的数。对于一个给定的数p,可以在O(N)的时间复杂度内找出所有不小于p的数。假如N个数中最大的数为Vmax,最小的数为Vmin,那么这N个数中的第K大数一定在区间[Vmin,Vmax]之间。那么,可以在这个区间内二分搜索N个数中的第K大数P。时间复杂度为O(N*logN)

解法二:快速选择

假设N个数存储在数组S中,我们从数组S中随机找出一个元素X(随机法/三数取中法),把数组分为两部分Sa和Sb。Sa中的元素大于等于X,Sb中的元素小于X。这是有两种可能:

  • Sa中的元素个数小于K,Sa中的所有数和Sb中最大的K-|Sa|个元素就是数组S中最大的K个数。
  • Sa中的元素的个数大于或等于K,则需要返回Sa中最大的K个元素。

这样递归下去,不断把问题分解成更小的问题,平均时间复杂度 编程之美-O(N*logK)/网上大多-O(N),但是最坏的时间复杂度为O(N^2)

缺点:内存问题,在海量数据的情况下,我们很有可能没办法一次性将数据全部加载入内存,这个时候这个方法就无法完成使命了;需要我们修改输入的数组。

解法三:堆排序

可以用容量为K的最小堆来存储最大的K个数。最小堆的堆顶元素就是最大K个数中最小的一个。每次新考虑一个数X,如果X比堆顶的元素Y小,则不需要改变原来的堆,因为这个元素比最大的K个数小。如果X比堆顶元素大,那么用X替换堆顶的元素Y。在X替换堆顶元素Y之后,X可能破坏最小堆的结构(每个结点都比它的父亲结点大),需要更新堆来维持堆的性质。更新过程花费的时间复杂度为O(logK)。

因此,算法只需要扫描所有的数据一次,时间复杂度为O(NlogK)。这实际上是部分执行了堆排序的算法。在空间方面,由于这个算法只扫描所有的数据一次,因此我们只需要存储一个容量为K的堆。大多数情况下,堆可以全部载入内存。如果K仍然很大,我们可以尝试先找最大的K'个元素,然后找第K'+1个到第2K,个元素,如此类推(其中容量K'的堆可以完全载入内存)。不过这样,我们需要扫描所有数据ceil(K/K')^2次。

当k远小于N时,两者都近似等于O(N)。由于Top k算法的时间复杂度下界是 Ω(n)(至少要遍历一遍数组),这两种基于堆的方案在k比较小时可以说是最佳方案了。不过,我们当然会问,如果k不太小呢,比如从无序数组中找出中位数,此时k=N/2,这种情况下基于堆的方案都变成了O(NlogN),似乎不太理想。

解法四:BFPRT:Median of medians

在快速选择算法中,我们分析了最佳和最坏情况的时间复杂度。如果每次选择主元,都恰好选中中位数,那么自然就会落入最佳情况。BFPRT正是利用了这一点,在快速选择算法的基础上,额外增加了计算近似中位数的步骤。

首先,将N个数据每五个分为一组,共得到 N/5 组。注意,这里为了方便起见,我们不去考虑余数的问题,那些常数项在分析时间复杂度时并不重要。使用插入排序对每一组排序,找出每一组的中位数,共得到 N/5 个中位数。接下来这步很奇幻,我们递归调用BFPRT算法计算这 N/5 个数的中位数。没错,调用我们正在描述的这个算法本身,因为中位数就是第 N/2 大的数,BFPRT的目标就是计算这个。假设这个中位数真的计算出来了,那么这个数就可以称为中位数的中位数(Median of medians),也是整个数据的近似中位数。近似中位数被限制在整个数组的30%~70%范围内。

接下来的计算步骤和快速选择算法一样,选取刚才计算出的近似中位数作为主元,递归调用BFPRT,直到找到Top k的数。

BFPRT算法的时间复杂度为O(N),而且是最坏情况的时间复杂度。证明

注意:虽说BFPRT是Top k的终极解法,但实际上众多库函数并不直接采用这一算法。原因是其中隐含的常数因子比随机化快速选择算法大很多,而且,随机化快速选择算法在实际中表现很好,因为在随机化算法中,很难出现导致其进入最坏情况的案例。于是,有人提出了Introselect算法,该算法是随机化快速选择算法与BFPRT的结合。它内部默认采用随机化快速选择算法,同时监控是否出现退化现象,一旦发现退化,立即切换到BFPRT。显然,Introselect的核心是如何检测退化,这可以通过检查连续的若干次迭代是否在一定比例上降低了数据规模来实现,因为正常情况下,数据规模应该是成比例下降的,而退化情况下,数据规模则按照等差级数下降。

在实际中,也可以将BFPRT替换为我们最初介绍的堆选择算法,当快速选择出现退化时,切换到堆选择算法。当然,这种方案在 [公式] 较小时比较高效。

解法五:计数排序

如果所有N个数都是正整数,且它们的取值范围不太大,可以考虑申请空间,记录每个整数出现的次数,然后再从大到小取最大的K个。比如,所有整数都在(0,MAXN)区间中的话,利用一个数组count[MAXN]来记录每个整数出现的个数。我们只需要扫描一遍就可以得到cout数组。然后,寻找第K大的元素。

极端情况下,如果N个整数各不相同,我们甚至只需要一个bt来存储这个整数是否存在。

当实际情况下,并不一定能保证所有元素都是正整数,且取值范围不太大。上面的方法仍然可以推广适用。如果N个数中最大的数为Vmax,最小的数为Vmin,我们可以把这个区间[Vmim,Vmax]分成M块,每个小区间的跨度为d=(Vmax一Vmin)/M,即[Vmin,Vmin+d],[Vmin+d,Vmin+2d],…然后,扫描一遍所有元素,统计各个小区间中的元素个数,跟上面方法类似地,我们可以知道第K大的元素在哪一个小区间。然后,再对那个小区间,继续进行分块处理。这个方法介于二分搜索和类计数排序方法之间,不能保证线性。跟二分搜索类似地,时间复杂度为O((N+M)logM)。遍历文件的次数为2logM。当然,我们需要找一个尽量大的M,但M取值要受内存限制。

总结:利用分布式思想处理海量数据

面对海量数据,我们可以将数据分散在多台机器中,然后每台机器并行计算各自的 TopK 数据,最后汇总,再计算得到最终的 TopK 数据。