Skip to content

堆的应用

搜索引擎的热门排行榜是都见过吧,这个功能是怎么实现的?搜索引擎每天会收集大量用户的搜索请求,它会把这些用户输入的关键词记录下来,然后再离线统计分析,得到热门的 Top 10 搜索关键词。

现在假设,有一个包含 10 亿个关键词的日志文件,如何快速获取到热门棒 Top 10 搜索关键词?

这个问题可以用堆来解决。看看堆这种数据结构几个非常重要的应用:优先级队列、求 Top K 和中位数。

应用一:优先队列

优先级队列,顾名思义,首先它是一个队列。队列的最大特性就是先进先出。不过,在优先级队列中,数据的出队顺序并不是先进先出,而是按照优先级来,优先级最高的,最先出队

如何实现一个优先级队列呢?方法有很多,但是用堆来实现是最直接、最高效的。这是因为,堆和优先级队列非常相似。一个堆就可以看作一个优先级队列。很多时候,它们只是概念上的区分而已。

往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。

优先级队列的应用场景很多,通过例子看一下它的具体使用。

合并有序的小文件

假设有 100 个小文件,每个文件的大小是 100MB,每个文件中存储的都是有序的字符串。希望将这些 100 个小文件合并成一个有序的大文件。

从这 100 个文件中,各取第一个字符串,放入数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除。

假设,这个最小的字符串来自于 13.txt 这个小文件,我们就再从这个小文件取下一个字符串,放到数组中,重新比较大小,并且选择最小的放入合并后的大文件,将它从数组中删除。依次类推,直到所有的文件中的数据都放入到大文件为止。

这里用数组这种数据结构,来存储从小文件中取出来的字符串。每次从数组中取最小字符串,都需要循环遍历整个数组,显然,这不是很高效。有没有更加高效方法呢?

这里就可以用到优先级队列,也可以说是堆。

将从小文件中取出来的字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。

删除堆顶数据和往堆中插入数据的时间复杂度都是 O(logn),n 表示堆中的数据个数,这里就是 100。这不数组的遍历高效很多!

应用二:求 Top K

堆的另外一个非常重要的应用场景,那就是“求 Top K 问题”。

把这种求 Top K 的问题抽象成两类:一类是针对静态数据集合,也就是说数据集合事先确定,不会再变。另一类是针对动态数据集合,也就是说数据集合事先并不确定,有数据动态地加入到集合中。

  1. 针对静态数据

针对静态数据,如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?

可以维护一个大小为 K 的小顶堆。顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组

这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。

遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)。

  1. 针对动态数据

针对动态数据求得 Top K 就是实时 Top K

怎么理解呢?举一个例子。一个数据集合中有两个操作,一个是添加数据,另一个询问当前的前 K 大数据。

如果每次询问前 K 大数据,我们都基于当前的数据重新计算的话,那时间复杂度就是 O(nlogK),n 表示当前的数据的大小。

实际上,可以一直都维护一个 K 大小的小顶堆,当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。如果比堆顶元素大,就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前 K 大数据,我们都可以立刻返回给他。

应用三:求中位数

如何求动态数据集合中的中位数?

中位数,顾名思义,就是处在中间位置的那个数。如果数据的个数是奇数,把数据从小到大排列,那第 n/2 ​+ 1 个数据就是中位数(注意:假设数据是从 0 开始编号的);如果数据的个数是偶数的话,那处于中间位置的数据有两个,第 n/2 个和第 n/2 ​+ 1 个数据,这个时候,我们可以随意取一个作为中位数,比如取两个数中靠前的那个,就是第 n/2 个数据。

对于一组静态数据,中位数是固定的,我们可以先排序,第 n/2 个数据就是中位数。每次询问中位数的时候,直接返回这个固定的值就好了。所以,尽管排序的代价比较大,但是边际成本会很小。

但是,如果我们面对的是动态数据集合,中位数在不停地变动,如果再用先排序的方法,每次询问中位数的时候,都要先进行排序,那效率就不高了。

借助堆这种数据结构,我们不用排序,就可以非常高效地实现求中位数操作。看一下它是如何做到的?

  1. 需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。也就是说,如果有 n 个数据,n 是偶数,我们从小到大排序,那前 n/2 个数据存储在大顶堆中,后 n/2 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果 n 是奇数,情况是类似的,大顶堆就存储 n/2​+1 个数据,小顶堆中就存储 n/2 个数据。

  2. 新添加数据,如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆。

  3. 调整堆,让大顶堆中的堆顶元素继续是中位数。添加数据之后,就肯出现两个堆中的数据个数不满足前面约定的情况。这个时候,我们可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。

于是,就可以利用两个堆,一个大顶堆、一个小顶堆,实现在动态数据集合中求中位数的操作。

插入数据因为需要涉及堆化,所以时间复杂度变成了 O(logn),但是求中位数只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 O(1)。

这种方法还可以用来快速求接口的 99% 响应时间。

总结

再看看开头的问题,假设现在我们有一个包含 10 亿个搜索关键词的日志文件,如何快速获取到 Top 10 最热门的搜索关键词呢?

因为用户搜索的关键词,有很多可能都是重复的,所以首先要统计每个搜索关键词出现的频率。我们可以通过散列表、平衡二叉查找树或者其他一些支持快速查找、插入的数据结构,来记录关键词及其出现的次数。

然后,再根据前面讲的用堆求 Top K 的方法,建立一个大小为 10 的小顶堆,遍历散列表,依次取出每个搜索关键词及对应出现的次数,然后与堆顶的搜索关键词对比。如果出现次数比堆顶搜索关键词的次数多,那就删除堆顶的关键词,将这个出现次数更多的关键词加入到堆中

以此类推,当遍历完整个散列表中的搜索关键词之后,堆中的搜索关键词就是出现次数最多的 Top 10 搜索关键词了。

堆的应用 has loaded