2.4 优先队列
许多应用程序都需要处理有序的元素,但不一定要求它们全部有序,或是不一定要一次就将它们排序。很多情况下会收集一些元素,处理当前键值最大的元素,然后再收集更多的元素,再处理当前键值最大的元素,如此这般 。 例如,你可能有一台能够同时运行多个应用程序的电脑(或者手机)。这是通过为每个应用程序的事件分配一个优先级,并总是处理下一个优先级最高的事件
来实现的 。 例如,绝大多数手机分配给来电的优先级都会比游戏程序的高 。
在这种情况下,一个合适的数据结构应该支持两种操作: 删除最大元素和插入元素。这种数据类型叫做优先队列。 优先队列的使用和队列( 删除最老的元素)以及栈(删除最新的元素)类似,但高效地实现它则更有挑战性 。
优先队列的一些重要的应用场景包括模拟系统,其中事件的键即为发生的时间,而系统需要按照时间顺序处理所有事件;任务调度,其中键值对应的优先级决定了应该首先执行哪些任务;数值计算 , 键值代表计算错误,而我们需要按照键值指定的顺序来修正它们。
通过插入一列元素然后一个个地删掉其中最小的元素,我们可以用优先队列实现排序算法 。 一种名为堆排序的重要排序算法也来自于基于堆的优先队列的实现 。
2.4.1 API
优先队列是一种抽象数据类型,它表示了一组值和对这些值的操作,它的抽象层使我们能够方便地将应用程序(用例)和我们将在本节中学习的各种具体实现隔离开来。优先队列最重要的操作就是删除最大元素和插入元素,所以我们会把精力集中在它们身上 。 删除最大元素的方法名为delMax(),插入元素的方法名为insert() 。 按照惯例,我们只会通过辅助函数 less () 来比较两个元素,和排序算法一样 。 如果允许重复元素,最大表示的是所有最大元素之一 。 为了将 API定义完整,还需要加入构造函数和一个空队列测试方法 。 为了保证灵活性,在实现中使用了泛型,将实现了Comparable接口的数据的类型作为参数 Key 。 这使得可以不必再区别元素和元素的键,对数据类型和算法的描述也将更加清晰和简洁 。 为了用例代码的方便,API 包含的三个构造函数使得用例可以构造指定大小的优先队列(还可以用给定的一个数组将其初始化)。 为了使用例代码更加清晰,会在适当的地方使用另一个类MinPQ。它和MaxPQ 类似,只是含有一个delMin()方法来删除并返回队列中键值最小的那个元素 。MaxPQ 的任意实现都能很容易地转化为MinPQ的实现,反之亦然,只需要改变一下less()比较的方向即可 。
输入 N 个字符串,每个字符串都对应着一个整数,从中找出最大的(或是最小的 ) M个整数(及其关联的字符串)。
2.4.2 初级实现
2.4.2.1 链表表示法
使用无序序列是解决这个问题的惰性方法 , 我们仅在必要的时候才会采取行动(找出最大元素);使用有序序列则是解决问题的积极方法,因为会尽可能未雨绸缪(在插入元素时就保持列表有序),使后续操作更高效。
实现栈或是队列与实现优先队列的最大不同在于对性能的要求。 对于栈和队列,我们的实现能够在常数时间内完成所有操作;而对于优先队列,在初级实现中,插入元素和删除最大元素这两个操作之一在最坏情况下需要线性时间来完成。
2.4.3 堆的定义
数据结构二叉堆能够很好地实现优先队列的基本操作 。 在二叉堆的数组中,每个元素都要保证大于等于另两个特定位置的元素 。 相应地,这些位置的元素又至少要大于等于数组中的另两个元素 ,以此类推 。 如果我们将所有元素画成一棵二叉树,将每个较大元素和两个较小的元素用边连接就可以很容易看出这种结构 。
定义: 当一棵二叉树的每个结点都大子等于它的两个子结点时,它被称为堆有序 。
相应地,在堆有序的二叉树中,每个结点都小于等于它的父结点(如果有的话)。从任意结点向上,我们都能得到一列非递减的元素;从任意结点向下,我们都能得到一列非递增的元素 。
二叉堆表示法
如果我们用指针来表示堆有序的二叉树,那么每个元素都需要三个指针来找到它的上下结点(父结点和两个子结点各需要一个)。 如果我们使用完全二叉树,表达就会变得特别方便 。 要画出这样一棵完全二叉树,可以先定下根结点,然后一层一层地由上向下 、从左至右,在每个结点的下方连接两个更小的结点,直至将 N 个结点全部连接完毕 。完全二叉树只用数组而不需要指针就可以表示 。 具体方法就是将二叉树的结点按照层级顺序放人数组中,根结点在位置1 ,它
的子结点在位置 2 和 3 ,而子结点的子结点则分别在位置 4 、5,6 和 7 ,以此类推。
定义: 二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存(不使用数组的第一个位置)。
在一个堆中,位置 k 的结点的父结点的位置为Lk/2」,而它的两个子结点的位置则分别为 2k 和 2k+ l 。 这样在不使用指针的情况下,也可以通过计算数组的索引在树中上下移动:从 a[k ] 向上一层就令 k 等于 k/ 2,向下一层则令 k 等于 2k 或 2k+1。
用数组(堆)实现的完全二叉树的结构是很严格的,但它的灵活性已经足以让我们高效地实现优先队列 。 用它们我们将能实现对数级别的插入元素和删除最大元素的操作 。 利用在数组中无需指针即可沿树上下移动的便利和以下性质,算法保证了对数复杂度的性能。
2.4.4 堆的算法
用长度为 N+1的私有数组 pq[ ]来表示一个大小为N的堆,我们不会使用pq[0],堆元素放在pq[1]至pq[N]中。在排序算法中,我们只通过私有辅助函数 less()和 exch()来
访 问 元素, 但因为所有的元素都在数组pq[ ]中。堆的操作会首先进行一些简单的改动 , 打破堆的状态,然后再遍历堆并按照要求将堆的状态恢复 。 我们称这个过程叫做堆的有序化。
在有序化的过程中我们会遇到两种情况 。 当某个结点的优先级上升 (或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序 。 当某个结点的优先级下降( 例如 ,将根结点替换为一个较小的元素)时, 我们需要由上至下恢复堆的顺序。
2.4.4.1 由下至上的堆有序化(上浮)
如果堆的有序状态因为某个结点变得比它的父结点更大而被打破 ,那么我们就需要通过交换它和它的父结点来修复堆。 交换后, 这个结点比它的两个子结点都大(一个是曾经的父结点,另一个 比它更小 ,因为它是曾经父结点的子结点),但这个结点仍然可能比它现在的父结点更大。我们可以一遍遍地用同样的办法恢复秩序 , 将这个结点不断向上移动直到我们遇到了一个更大的父结点。只要记住位置 k 的结点的父结点的位置是Lk/2」,这个过程实现起来很简单。swim( )方法中的循环可以保证只有位置k上的结点大于它的父结点时堆的有序状态才会被打破。因此只要该结点不再大于它的父结点,堆的有序状态就恢复了。 至于方法名,当一个结点太大的时候它需要浮(swim)到堆的更高层。
2.4.4.2 由上至下的堆有序化(下沉)
如果堆的有序状态因为某个结点变得比它的两个子结点或是其中之一更小了而被打破了, 那么我们可以通过将它和它的两个子结点中的较大者交换来恢复堆 。 交换可能会在子结点处继续打破堆的有序状态,因此我们需要不断地用相同的方式将其修复,将结点向下移动直到它的子结点都比它更小或是到达了堆的底部。由位置为 k 的结点的子结点位于2k和 2k+1 可以直接得到对应的代码。当一个结点太小的时候它需要沉( sink )到堆的更低层 。
1、插入元素 。 我们将新元素加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位置。
2、删除最大元素 。 我们从数组顶端删去最大的元素并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。
2.4.4.3 多叉堆
基于用数组表示的完全三叉树构造堆并修改相应的代码并不困难 。 对于数组中1 至 N 的 N 个元素,位置 k的结点大于等于位于3k-1 、3k 和 3k+1的结点,小于等于位于 L(k+ 1)/3」的结点 。 甚至对于给定的 d,将其修改为任意的 d 叉树也并不困难 。 我们需要在树高( log(d)N)和在每个结点的 d 个子结点找到最大者的代价之间找到折中,这取决于实现的细节以及不同操作的预期相对频繁程度。
2.4.4.4 元素的不可变性
优先队列存储了用例创建的对象,但同时假设用例代码不会改变它们(改变它们就可能打破堆的有序性)。我们可以将这个假设转化为强制条件,但程序员通常不会这么做,因为增加代码的复杂性会降低性能 。
2.4.4.5 索引优先队列
在很多应用中,允许用例引用已经进入优先队列中的元素是有必要的 。 做到这一点的一种简单方法是给每个元素一个索引 。 另外,一种常见的情况是用例已经有了总量为 N 的多个元素,而且可能还同时使用了多个(平行)数组来存储这些元素的信息 。 此时 ,其他无关的用例代码可能已经在使用一个整数索引来引用这些元素了。
2.4.5 堆排序
堆排序可以分为两个阶段 。 在堆的构造阶段中,我们将原始数组重新组织安排进一个堆中;然后在下沉排序阶段,我们从堆中按递减顺序取出所有元素并得到排序结果 。