【数据结构与算法】->数据结构->堆(下)->堆的应用

Ⅰ 前言

在前一篇文章里,我详细讲解了堆的原理以及实现,也给出了一个堆的最容易想到的应用——堆排序,但实际上堆还有几个非常重要的应用,在软件开发中我们可能会经常用到,这篇文章我们就来看一看这几个应用。

【数据结构与算法】->数据结构->堆(上)->详解堆&堆排序

Ⅱ 堆的应用一:优先级队列

首先,我们来看堆的第一个应用场景——优先级队列

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

【数据结构与算法】->数据结构->队列->循环数组的应用&队列工具库的创建

那么如何实现一个优先级队列呢?方法其实有很多,但是最直接、最高效的就是用堆来实现。因为堆和优先级队列非常相似,一个堆就可以看作是一个优先级队列,很多时候,它们只是概念上的区分而已。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。

优先级队列的应用场景是非常多的,比如哈夫曼编码、图的最短路径、最小生成树算法等等。不仅如此,很多语言中,都提供了优先级队列的实现,比如 Java 中的 PriorityQueue, C++ 的 priority_queue 等。

现在,我们来举两个具体的例子,来看优先级队列是如何使用的。

1. 合并有序小文件

假设我们有 100 个小文件,每个文件的大小都是 100 MB,每个文件中存储的都是有序的字符串。我们希望将这些 100 个小文件合并成一个有序的大文件。这里就会用到优先级队列。

整体思路有点像归并排序中的合并函数。我们从这 100 个文件中,各取第一个字符串,放入数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除。

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

这里我们用数组这种数据结构,来存储从小文件中取出来的字符串。每次从数组中取最小字符串,都需要遍历整个数组,显然这样并不高效。

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

我们知道,删除堆顶数据和往堆中插入数据的时间复杂度都是 O(logn),n 表示堆中的数据个数,这里就是 100。是不是比原来用数组存储的效率高了很多?

2. 高性能定时器

假设我们有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间,比如 1 秒,就扫描一遍任务,看是否有任务到达设定的执行时间。如果到了,就拿出来执行。

定时时间 任务
2020. 9.11. 16:42 任务1
2020. 9.11. 17:52 任务2
2020. 9.11. 18:01 任务3
2020. 9.11. 13:15 任务4

但是,这样每过 1 秒就扫描一遍任务列表的做法比较低效,主要原因有两点:

  1. 任务的约定执行时间离当前时间可能还有很久,这样前面很多次扫描其实都是徒劳的;
  2. 每次都要扫描整个任务列表,如果任务列表很大的话,必然会很耗时。

针对这些问题,我们就可以用优先级队列来解决。我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。

这样,定时器就不需要每隔一秒就扫描一遍任务列表了。它拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T。

这个时间间隔 T 就是,从当前时间开始,需要等待多久,才会有第一个任务需要被执行,这样,定时器就可以设定在 T 秒之后,再来执行任务。从当前时间点到 (T-1) 秒这段时间里,定时器都不需要做任何事情。

当 T 秒时间过去之后,定时器取优先级队列中队首的任务执行,然后再计算新的队首任务的执行时间点与当前时间点的差值,把这个值作为定时器执行下一个任务需要等待的时间。

这样,定时器就既不用间隔 1 秒就轮询一次,也不用遍历整个任务列表,性能也就提高了。

Ⅲ 堆的应用二:利用堆求 Top K

现在我们再来看堆的另外一个非常重要的应用场景,那就是求 Top K 问题。

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

针对静态数据,如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?我们可以维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。

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

针对动态数据求得 Top K 就是实时 Top K。比如一个数据集合中有两个操作,一个是添加数据,另一个询问当前的前 K 大数据。

如果每次询问前 K 大数据,我们都基于当前的数据重新计算的话,那时间复杂度就是 O(nlogK),n 表示当前的数据的大小。实际上,我们可以一直都维护一个 K 大小的小顶堆,当有数据被添加到集合中时,我们就拿它与堆顶元素对比,如果比堆顶元素大,我们就把堆顶元素删除,并且把这个元素插入到堆中;如果比堆顶元素小,则不做处理。

这样,无论我们何时需要查询当前的前 K 大数据,我们都可以立刻返回给他。

Ⅳ 堆的应用三:利用堆求中位数

中位数相信大家都清楚,就是处在中间位置的那个数嘛。假定下标是从 0 开始排列的,如果数据的个数是奇数,把数据从小到大排列,那第 n/2 + 1 个数据就是中位数;如果数据的个数是偶数的话,那处于中间位置的数据有两个,第 n/2 个和第 n/2 +1 个数据,这个时候,我们可以随意取一个作为中位数,比如取两个数中靠前的那个,就是第 n/2 个数据。

对于一组静态数据,中位数是固定的,我们可以先排序,第 n/2 个数据就是中位数。每次询问中位数的时候,我们直接返回这个固定的值就好了。

但是,如果我们再对动态数据操作的话,因为中位数在不停地变动,如果再用先排序的方法,每次询问中位数的时候都要先进行排序,那效率就会很低。

借助堆这种数据结构,我们不用排序,就可以非常高效地实现求中位数操作。

我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。

也就是说,如果有 n 个数据,n 是偶数,我们从小到大排序,那前 n/2 个数据存储在大顶堆中, 后 n/2 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果 n 是奇数,情况是类似的,大顶堆就存储 n/2+1 个数据,小顶堆就存储 n/2 个数据。

【数据结构与算法】->数据结构->堆(下)->堆的应用
数据因为是动态变化的,所以我们在数据加入之后也要对堆做个调整。如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆。

这个时候,就有可能出现,两个堆中的数据个数不符合前面约定的情况:如果n 是偶数,前 n/2 个数据存储在大顶堆中, 后 n/2 个数据存储在小顶堆中。如果 n 是奇数,大顶堆就存储 n/2+1 个数据,小顶堆就存储 n/2 个数据。

那么我们可以从一个堆中不停地将堆顶元素移动到另一个堆中,通过这样的调整,来让两个堆中的数据满足上面的约定。

【数据结构与算法】->数据结构->堆(下)->堆的应用
于是,我们可以利用两个堆,一个大顶堆、一个小顶堆,实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化,所以时间复杂度变成了 O(logn),但是求中位数我们只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 O(1)。

实际上,利用两个堆不仅可以快速求出中位数,还可以快速求其他百分位的数据,比如我们要解决“快速求接口的 99% 响应时间”的问题。

我先解释一下什么是 “99% 响应时间”。

中位数概念就是将数据从小到大排列,处于中间位置,就叫中位数,这个数据会大于等于前面 50% 的数据。99 百分位数的概念可以类比中位数,如果将一组数据从小到大排列,这个 99 百分位数就是大于前面 99% 数据的那个数据。

如果你还有点模糊,我再举一个例子,比如有 100 个数据,分别是 1,2,3,……,100,那 99 百分位数就是 99,因为小于等于 99 的数占总数的 99%。

知道了这个概念,我们再来看看 99% 响应时间。如果有 100 个接口访问请求,每个接口请求的响应时间都不同,比如 55 毫秒、100 毫秒、23 毫秒等,我们把这 100 个接口的响应时间按照从小到大排列,排在第 99 的那个数据就是 99% 响应时间,也叫 99 百分位响应时间。

我们总结一下,如果有 n 个数据,将数据从小到大排列之后,99 百分位数大约就是第 n99% 个数据,同理,80 百分位数大约就是第 n80% 个数据。

接下来我们来看看如何求 99% 响应时间。

我们维护两个堆,一个大顶堆,一个小顶堆。假设当前总数据的个数是 n,大顶堆中保存 n99% 个数据,小顶堆中保存 n1% 个数据。大顶堆堆顶的数据就是我们要找的 99% 响应时间。

每次插入一个数据的时候,我们要判断这个数据跟大顶堆和小顶堆堆顶数据的关系,然后决定插入到哪个堆中。如果这个新插入的数据比大顶堆的堆顶数据小,那就插入大顶堆;如果这个新插入的数据比小顶堆的堆顶数据大,那就插入小顶堆。

但是,为了保持大顶堆中的数据占 99%,小顶堆中的数据占 1%,在每次新插入数据之后,我们都要重新计算,这个时候大顶堆和小顶堆中的数据个数,是否还符合 99 : 1 这个比例。如果不符合,我们就将一个堆中的数据移动到另一个堆,直到满足这个比例。移动的方法类似前面求中位数的方法,我不再赘述。

通过这样的方法,每次插入数据,可能会涉及几个数据的堆化操作,所以时间复杂度是 O(logn) 。每次求 99% 响应时间的时候,直接返回大顶堆中的堆顶数据即可,时间复杂度是 O(1)。

另,本文的内容来源于极客时间王争的《数据结构与算法之美》。