优先级队列顾名思义首先是一种队列,但是和普通队列每次出队都是出队首元素不同,优先级队列每次出队出优先级最高的元素.
首先想到使用数组或者链表来实现优先级队列。
如果使用有序数组,数组按优先级队列排序,出队和队列类似,区别在于入队时要找出新元素的位置,还要移动新元素位置后面的元素;如果使用无序数组,入队与队列类似,只要将队尾元素出队就可以,但出队需要找出优先级最高的元素,然后将空出来的位置填满。
如果使用链表,那么就类似有序数组,链表头为优先级最高的元素,那么出队直接将链表头元素出队即可,入队需要找到新元素的位置。
综上,如果使用数组或者链表,优先级队列的插入和出队操作都需要o(n)复杂度,而使用堆可以将时间复杂度降到o(logn);
插入 | 删除 | |
无序数组 | O(1) | O(n) |
有序数组 | O(n) | O(1) |
链表 | O(n) | O(1) |
堆 | O(logn) | O(logn) |
因此,优先级队列这种数据结构使用一种叫最大堆的数据结构来实现,最大堆本质上是一种完全二叉树,二叉树的顶点(即最大堆的堆顶)是最大元素,二叉树的两个子树也是最大堆。因此最大堆满足如下性质:
1.最小堆是完全二叉树。
2.父节点比子节点大。
而二叉树也有两种实现方式,链表和数组,用链表实现的话,节点对象中需要存储自己和表示父节点以及两个子节点的数据。而数组实现方式,因为完全二叉树的性质,可以很容易的表示父节点和子节点:a[k]的父节点为a[k/2],a[k]的子节点为a[2*k]和a[2*k+1]。(二叉树数组a[]从a[1]开始存储最小堆)。
最大堆的基本api如下:
public class MaxPQ>
{
MaxPQ();//构造函数
void insert(Key v);//插入元素
Key delMax();//删除并返回最大元素
}
在实现插入元素和删除元素之前,需要先实现两个辅助操作:
上浮swim:由下至上的堆有序化,如果堆的有序状态因为某个元素变得比父元素大而导致堆不再有序,那么我们就要通过移动这个元素来使堆有序化,因为元素变大后还是肯定比子元素要大,所以要与父元素进行交换。交换后如果这个元素还是比父元素大,继续与父元素交换,直到比父元素小为止。
private void swim(int index)//向上堆化,index为无序的元素下表
{
int cur = index;
while (cur != 1 && less(pq[cur], pq[cur/2]) > 0 )
{
exch(cur,cur/2);
cur = cur/2;
}
}
下沉sink:由上至下的堆有序化,如果堆的有序状态因为某个元素变得比子元素小而导致堆不再有序,那么我们就要通过移动这个元素来使堆有序化。和swim类似,但又有所不同,这时候这个元素要与两个子元素进行比较,然后与较大的子元素进行交换,然后再与两个子元素进行比较,直到比子元素大为止。
private void sink(int index)//向下堆化
{
int cur = index;
while ((2 *cur + 1) <= size)
{
int max = getMax(cur * 2,cur *2 + 1);
if (less(pq[cur], pq[max]) < 0)
{
exch(cur,max);
cur = max;
}
else
{
break;
}
}
}
有了这两个辅助操作后,我们就可以实现插入和删除操作了。当往最大堆中插入一个元素时,将其放到最后,然后对其执行上浮操作,恢复堆的有序度。当从最大堆删除堆顶元素时,将其与数组最后一个元素交换,然后对堆顶元素进行下沉操作,恢复堆的有序度。
public void insert(Key x)//插入一个元素
{
pq[++size] = x;
swim(size);
}
public Key delmax() //删除堆顶元素
{
if (size == 0)
{
throw new IllegalArgumentException("空队列");
}
Key result = pq[1];
exch(1,size);
pq[size--] = null;
sink(1);
return result;
}
有了最大堆之后,优先级队列的实现就轻而易举了。
1.上面的最大堆实现使用的是二叉树,如果使用m叉树,可以再次减少时间复杂度,而m的选择取决于插入和删除操作的比例。
以下为n个元素的二叉树和n个元素的m叉树的基本操作比较:
插入 | 删除 | |
二叉树 | logn | logn |
m叉树 |
2.使用索引优化队列来完成一些特定的任务,在优先级队列中加入一个索引数组来标识元素。
有了最大堆之后,堆排序的实现也很简单了。首先构造一个最大堆,然后每次取出最大元素放到因为取走元素而缩小的数组后面。所以问题就是如何根据一个数组构造最大堆。这也有两种实现方法。
第一种,堆一开始只有第一个元素,然后插入第二个元素,插入第三个元素,直到最后。
第二种,从叶子节点,往上,一层一层的下沉每一个节点,最后是根节点。即先让子堆有序,然后调整两个已经有序的子堆的父节点,只要sink这个父节点即可。因为叶子节点只有一个,已经有序了,不需要调整,所以从叶子节点上面一层开始调整即可。
一般都是使用第二种实现,不仅是因为第二种实现一般比第一种快20%左右,而且是因为第二种实现堆排序只需要sink操作,可以不用实现swim操作,所以第二种实现的代码如下:
void heapsort(Key[] keys)
{
int len = keys.length;
pq = (Key[])new Comparable[len + 1];
size = len;
for (int i = 0; i < len; i++)
{
pq[i+1] = keys[i];
}
for (int i = len/2; i >= 1; i--)
{
sink(i);
}
for (int i = 0; i < len;i++)
{
keys[len-i-1] = delmax();
}
}
堆排序是唯一同时实现了原地排序和稳定O(nlogn)的排序算法,与其相比,快速排序最差为O(n^2),归并排序不是原地排序。
但是实际情况却是,快速排序才是使用最广泛的算法。
在Arrays.sort()使用的是快速排序和归并排序的结合,当排序基本元素时,使用快排,排序对象时,使用归并排序,堆排序完全缺席,那堆排序为什么很少使用呢?
原因在于堆排序的数据比较是跳着的,比如父节点和子节点,这样的话就对缓存很不友好,CPU缓存一般会缓存一块元素,所以堆排序破坏了局部性,实际性能并不如快速排序。
比如在一亿数据中选出最大的k个元素,有了优先级队列(最小堆)之后,就不需要将其先排序再拿出k个元素了,一亿数据排序不仅占时间,而且内存也不一定放得下。这个时候建一个大小为k的优先级队列,来一个元素,将其插入,然后删除堆顶元素即可。
建一个大顶堆,一个小顶堆,然后大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。也就是说,如果有 n 个数据,n 是偶数,我们从小到大排序,那前 2n 个数据存储在大顶堆中,后 2n 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果 n 是奇数,情况是类似的,大顶堆就存储 2n+1 个数据,小顶堆中就存储 2n 个数据。如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆。当不符合约定时,再将一个堆中的元素调整到另一个堆。/