1优先队列
很多情况下,我们会收集一些元素,处理当前键值最大的元素,然后在收集更多的元素,再处理当前键值最大的元素,如此这般。
1.1定义
在上述情况下,一个合适的数据结构应该支持两种操作:删除最大元素
和插入元素
。
这种数据类型就是优先队列
。
1.2实现方式
实现方式有两种类型:
-
初级方式:
使用无序或有序的数组和链表,其一或两种操作能在
线性
时间内完成。 -
高级方式:
使用堆,两种操作都能在
对数
时间内完成。
为了保证灵活性,我们在实现中使用了泛型
,将实现了Comparable接口
的数据的类型作为参数Key
。
1.3一个应用问题
为了展现优先队列的抽象模型的价值,考虑一下问题:
- 输入
N
个字符串,每个字符串都对应着一个整数,你的任务就是从中找出最大(或最小)的M
个整数(及其对应的字符串)。
1.4初级实现
-
数组实现(无序)
基于2.1节中的下压栈的代码。
-
数组实现(有序)
参照插入排序。
-
链表表示法
基于链表的下压栈的代码。
优先队列的各种实现在最坏情况下运行时间的增长数量级:
数据结构 | 插入元素 | 删除最大元素 |
---|---|---|
有序数组 | N | 1 |
无序数组 | 1 | N |
堆 | logN | logN |
理想情况 | 1 | 1 |
2堆
数据结构二叉堆能够很好地实现优先队列的基本操作。
2.1定义
堆有序:
- 当一棵二叉树的每个结点都大于或等于它的两个子结点时,它被称为堆有序。
如此一来,根结点就是堆有序的二叉树中的最大结点。
二叉堆表示法:
为了方便,我们使用完全二叉树,因此只需要使用数组,而不需要使用指针来表示。
具体方法:
- 将二叉树的结点按照层次顺序放入数组中,根结点在位置1,它的子结点在位置2,3。
- 而子结点的子结点则分别位于位置4,5,6,7中,以此类推。
因此:我们可以通过利用数组的索引在树种上下移动。
- 从
a[k]
向上一层就令k
等于k/2
- 向下一层就令
k
等于2k
或2k+1
一棵大小为N
的完全二叉树的高度为logN
。
2.2堆的算法
堆的有序化:
- 堆的操作会首先进行一些简单的改动,打破堆的状态,然后再遍历堆,并按照要求将堆的状态恢复。
由下至上的堆有序化(上浮)
/**
* 上浮,当某个结点太大时
* @param k
*/
private void swim(int k) {
while (k > 1 && less(k / 2, k)) {//该结点比父结点大
exch(k / 2, k);//与父结点交换位置
k = k / 2;
}
}
由上至下的堆有序化(下沉)
/**
* 下沉,当某个结点太小时
* @param k
*/
private void sink(int k) {
while (2 * k <= N) {//防止溢出
int j = 2 * k;//指向左子结点
if (j < N && less(j, j + 1)) {//左子结点小于右子结点
j++;//指向右子结点
}
if (!less(k, j)) {//该节点大于左右子结点
break;
}
exch(k, j);//该结点与子结点中较大者交换位置
k = j;
}
}
2.3基于堆的优先队列
完整实现:
/**
* 基于堆的优先队列
* @Author: AZhu
* @Date: 2021/2/23 16:33
*/
public class MaxPQ<Key extends Comparable<Key>> {
private Key[] pq;//基于堆的完全二叉树
private int N = 0;//存储于pq[1..N]中,pq[0]没有使用
public MaxPQ(int maxN) {
pq = (Key[]) new Comparable[maxN + 1];
}
/**
* 判断优先队列是否为空
* @return
*/
public boolean isEmpty() {
return N == 0;
}
/**
* 返回优先队列中的元素个数
* @return
*/
public int size() {
return N;
}
/**
* 插入一个元素
* @param v
*/
public void insert(Key v) {
pq[++N] = v;//插入末尾
swim(N);//上浮
}
/**
* 删除最大的元素并返回
* @return
*/
public Key delMax() {
Key max = pq[1];//从根结点得到最大的元素
exch(1, N--);//和最后的结点交换位置
pq[N + 1] = null;//防止对象游离
sink(1);//下沉,恢复堆的有序性
return max;
}
/**
* 比较队列中索引i的元素是否小于j的
* @param i
* @param j
* @return
*/
private boolean less(int i, int j) {
return pq[i].compareTo(pq[j]) < 0;
}
/**
* 交换队列中索引为i和j的元素
* @param i
* @param j
*/
private void exch(int i, int j) {
Key t = pq[i];
pq[i] = pq[j];
pq[j] = t;
}
/**
* 上浮,当某个结点太大时
* @param k
*/
private void swim(int k) {
while (k > 1 && less(k / 2, k)) {//该结点比父结点大
exch(k / 2, k);//与父结点交换位置
k = k / 2;
}
}
/**
* 下沉,当某个结点太小时
* @param k
*/
private void sink(int k) {
while (2 * k <= N) {//防止溢出
int j = 2 * k;//指向左子结点
if (j < N && less(j, j + 1)) {//左子结点小于右子结点
j++;//指向右子结点
}
if (!less(k, j)) {//该节点大于左右子结点
break;
}
exch(k, j);//该结点与子结点中较大者交换位置
k = j;
}
}
}
分析:
对于一个含有N
个元素的基于堆的优先队列:
- 插入元素的操作只需要不超过
lgN+1
次比较, - 删除最大元素的操作需要不超过
2lgN
次比较。
基于堆的实现能够保证在对数时间内完成两种基本操作。
优化:
多叉堆:
- 基于用数组表示的完全三叉树构造对并不困难,
- 位置
k
的结点大于等于位于3k-1
、3k
、3k+1
的结点,小于等于位于(k+1)/3
的结点。
调整数组的大小:
- 可以添加一个无参构造器,
- 在
insert()
中添加数组长度加倍的代码, - 在
delMax()
中添加将数组长度减半的代码。
元素的不可变性:
- 优先队列存储了用例创建的对象,用例改变它们就可能会打破堆的有序性。
- 可以做一个强制条件,但是代码会变复杂,降低性能。
索引优先队列:
- 允许用例引用已经进入优先队列中的元素,
- 给每个元素一个索引。
3堆排序
3.1定义
我们可以将任意优先队列变成一种排序方法。
- 将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作将它们按顺序删去。
用无序数组实现的优先队列这么做就相当于进行了一次选择排序,
用堆实现的优先队列这么做就是堆排序。
3.2堆排序算法
堆排序分为两个阶段:
-
堆的构造阶段:
将原始数组重新组织安排进一个堆中。
-
下沉排序阶段:
从堆中按递减顺序取出所有元素并得到排序结果。
我们在排序时直接将需要排序的数组本身作为堆,因此无需任何额外的空间。
堆的构造:
Q:如何高效地由N
个给定的元素构造一个堆?
A:从右向左,用sink()
方法构造子堆。
一个命题:
- 用下沉操作由
N
个给定的元素构造一个堆,只需要少于2N
次比较和少于N
次交换。
实现:
//堆排序
public static void sort(Comparable[] a){
int N = a.length;
for(int k=N/2; k>=1; k--){//构造堆
sink(a, k, N);//k在a[1..N]中下沉
}
while(N>1){//下沉排序
exch(a, 1, N--);//在a[]中交换1和N
sink(a, 1, N);//1在a[1..N]中下沉
}
}
分析:
将N
个元素排序,堆排序只需要少于2NlgN+2N
次比较(以及一半次数的交换)。
堆排序和选择排序的某些地方很像,但是堆排序的比较次数要少得多。
堆排序是我们所知的唯一能够同时最优地利用空间和时间的方法:
- 在最坏的情况下它也能够保证使用 ~
2NlgN
次比较和恒定的额外空间。
优化:
大多数在下沉排序期间重新插入堆的元素会被直接加入到堆底。