引言
许多应用程序都需要处理有序的元素,但不一定要求他们全部有序,或者不一定要一次就将它们排序。很多情况下我们会先收集一些元素,处理当前键值最大的元素,然后再收集一些元素,再处理当前键值最大的元素,如此这般。例如,操作系统的进程调度、打印机中的打印任务调度等。
对于这些应用场景,一个合适的数据结构应该支持两种操作:删除最大/小元素、插入元素。这种数据结构叫做优先队列。
优先队列还可以作为算法设计工具用来构造其他算法,例如用来实现堆排序算法、以它为基础开发数据压缩算法、抽象若干重要的图搜索算法等等。
API
优先队列最重要的操作就是删除最大/小元素和插入元素。
MaxPQ(max priority queue) 的 API:
- 构造器,此处省略了
- void insert(Key v) 插入一个元素
- Key max() 返回最大元素
- Key delMax() 删除并返回最大元素
- boolean isEmpty() 返回队列是否为空
- int size() 返回优先队列中的元素个数
MinPQ 的 API 和 MaxPQ 类似,用 delMin() 替换 delMax() 就行了,也很容易实现。
初级实现
无序数组实现。要实现删除最大元素操作,先找到最大元素,将它和边界元素交换位置,然后删除边界元素。
1 |
|
有序数组实现。插入元素时,将较大元素向右移动一格以使数组保持有序(和插入排序一样)。
1 |
|
链表表示法也有两种:惰性方法,使用无序序列,删除时才找出最大元素;积极方法,使用有序序列,插入时就保持列表有序。下面是积极方法的实现:
1 |
|
基于堆的实现
什么是堆
首先,当堆(heap)这个词不加修饰地用在优先队列的上下文中时,一般都是指二叉堆这种数据结构。而二叉堆其实就是一棵二叉树,但它还有两个性质,结构性和堆序性(heap-order property),这点和二叉查找树一样。简单起见,下文就把二叉堆叫做堆。
结构性
一棵二叉树,其元素总是从左到右层层填入(要么树被完全填满,要么就只有最底层未填满),这样的树被称为完全二叉树(complete binary tree)。堆就是一棵完全二叉树。一棵完全被填满的二叉树也被称为理想二叉树。
堆序性
当一棵二叉树的每个节点都大于等于它的两个子节点时,它被称为是堆有序的。 堆也是一棵堆有序的二叉树。
因此,二叉堆就是一棵堆有序的完全二叉树。 堆的操作可能会破坏两个性质中的一个,因此,堆的操作必须到其所有性质都被满足时才能终止。
如何表示堆
对于完全二叉树,可以将数组下标用作节点指针,这样就可以将其节点按照层次顺序放入数组中,根节点在位置 1,它的子节点在位置 2 和 3,而子节点的子节点则分别在位置 4、5、6 和 7,以此类推。位置规律:位置 k 的节点的父节点的位置为 $\lfloor k/2 \rfloor$,而它的两个子节点的位置分别为 2k 和 2k+1。注意,不使用数组的第一个位置,因为当 k=0 时,这条规律就跟实际情况冲突了!
注意,对于非完全二叉树,上述位置规律并不适用。我们倒是可以使用这个规律来验证一棵二叉树是否是完全二叉树。
将完全二叉树展现为树形结构时,就很容易理解下面两条结论:
- 一棵高为 h 的完全二叉树有 $2^h$ 到 $2^{h+1} - 1$ 个节点。注意,根节点的 h 为 0。
- 一棵大小为 N 的完全二叉树的高度为 $\lfloor lgN \rfloor$。
堆的操作
堆的操作会先打破堆的有序状态,然后再遍历堆并将其状态恢复,这个过程叫做堆的有序化(reheapifying)。
堆的有序化有两种情况:节点的键值增大或者在堆底插入一个新元素时,需要由下至上恢复堆的顺序;节点的键值减小或者根节点被替换为一个较小的元素时,需要由上至下恢复堆的顺序。由下至上的堆有序化叫做上浮(swim)或向上渗透(percolate up),由上至下的堆有序化叫做下沉(sink)或向下渗透(percolate up)。
我们先看如何实现上浮、下沉这两个辅助操作,再看如何用这两个辅助操作实现插入、删除操作。
上浮操作(swim)
当某个节点比它的父节点更大时,该节点不断上浮,直到整个堆再次有序。有两种实现:
- 跟父节点比较,如果父节点小,就交换位置。重复该过程,直到遇到一个不小于它的父节点或到达堆的顶部
- 在下一个可用位置创建一个空位(针对插入新元素)或将键值变化的节点另存并将其所在位置置为空,然后检查 X 元素(即新元素或键值变化的元素)放在空位会不会破坏堆的序。如果不会,就将 X 元素放进空位,操作完成。否则,就将父节点的元素移入空位,这样空位就上浮至父节点所在位置,重复此过程,直到 X 能被放入空位为止。
第二种实现似曾相识,插入排序的一种优化方式就是将大于 X 的所有元素向后移动一格,然后直接插入。与不断交换的实现方式(第一种实现)相比,这种更高效,因为赋值操作更少了。
下沉操作(sink)
当某个节点比它的两个子节点或其中之一更小时,该节点不断下沉,直到整个堆再次有序。同样地,该操作也有两种实现,实现思想与上浮操作基本相同,这里不再啰嗦。注意边界条件测试,以及与较大的子节点进行比较即可。
插入元素
添加元素时,要从树顶向下一层一层从左向右填入新元素,这样才能确保添加新元素后它仍然是一棵完全二叉树,即保持了二叉树的结构性。对于数组表示形式来说,等价的描述就是将新元素添加到数组末尾,增加堆的大小并让新元素上浮到合适的位置。
删除最大/小元素
从数组顶端删除最大/小元素,并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。
构建堆(buildHeap)
两种实现:
- 逐个将输入元素插入到空优先队列。这种实现的运行时间与 $NlogN$ 成正比。
- 从右向左对前 1/2 的元素进行 sink 操作。之所以只扫描前 1/2,是因为对于二叉堆来说,至少有一半的节点是叶节点,具体证明请看 证明二叉堆至少有一半的节点是叶节点。用 sink 操作由 N 个元素构造堆只需少于 2N 次比较以及少于 N 次交换。
基于堆的优先队列
1 |
|
算法性能评估
对于一个含有 N 个元素的基于堆的优先队列,插入元素操作只需不超过 $(lgN+1)$ 次比较,删除最大元素操作需要不超过 $2lgN$ 次比较。
优先队列的各种实现在最坏情况下运行时间的增长数量级
数据结构 | 插入元素 | 删除最大元素 |
---|---|---|
有序数组 | N | 1 |
无序数组 | 1 | N |
堆 | $logN$ | $logN$ |
理想情况 | 1 | 1 |
参考资源
《算法(第四版)》Robert Sedgewick 等著,第 2 章-排序
《数据结构与算法分析-Java语言描述》Mark Allen Weiss 著,第 6 章-优先队列(堆)