算法原理系列:优先队列
第一次总结这种动态的数据结构,一如既往,看了大量的教程,上网搜优先队列原理,能出来一大堆,但不知道为什么怎么这么多人搞不清楚原理和实现的区别?非要把实现讲成原理,今天就说说自己对优先队列的看法吧。
缘由
顾名思义,优先队列是对队列的一种改进,队列为先进先出的一种数据结构,而优先队列则保持一条性质:
在队头的原始始终保持优先级最高。
优先级最高,我们可以用一个数来衡量,所以简单的想法就是时刻把持队首的key元素最小(最小堆),或者key元素最大(最大堆)。
好了,开始构建优先队列的结构吧,现假定给你一堆数:
nums = [78, 82, 75, 35, 71, 23, 41, 42, 58, 8]
目标:
每次输出最小值,并从nums中删除该元素,直到nums的大小为0。
很简单,最navie的做法,每次遍历一遍数组,找出最小值,输出,并且置为INF,防止下次继续遍历,代码如下:
public static void main(String[] args) {
int[] test = RandomUtil.randomSet(1, 100, 10);
solve(test);
}
static final int INF = 1 << 30;
private static void solve(int[] nums){
for (int i = 0; i < nums.length; i++){
int index = -1, min = INF;
for (int j = 0; j < nums.length; j++){
if (nums[j] != INF && nums[j] < min){
index = j;
min = nums[j];
}
}
System.out.println(min);
nums[index] = INF;
}
}
最简单的做法,但时间复杂度爆表,依次输出n个元素时,时间复杂度为O(n2),找最小值操作也需要O(n),此处有几个非常别捏的地方,该算法无法解决数组的动态扩展,而且把使用过的元素变为INF,所以我们需要设计一种动态扩展的数据结构来存储不断变动的nums,于是有了优先队列的数据结构。
优先队列API如下:
public class PriorityQueue<Key extends Comparable<Key>> {
Key[] array;
int N = 0; //记录插入元素个数
int SIZE; //当N>SIZE时,可以动态扩展
@SuppressWarnings("unchecked")
public PriorityQueue(int SIZE){
this.SIZE = SIZE;
array = (Key[]) new Comparable[SIZE];
}
public void offer(Key key){
}
public Key poll(){
return null;
}
public Key peek(){
return null;
}
public boolean isEmpty(){
return false;
}
}
有了这样的API,我们就可以把nums中的元素全部offer进去,而当想要输出最小值时,直接poll出来即可,非常方便,想用就用,所以改进的做法如下:
public static void main(String[] args) {
int[] test = RandomUtil.randomSet(1, 100, 10);
PriorityQueue<Integer> queue = new PriorityQueue<>(10);
for (int i = 0; i < test.length; i++){
queue.offer(test[i]);
}
while (!queue.isEmpty())
System.out.println(queue.poll());
}
同样能够最大不断输出最小值,且支持动态加入元素,是不是高级很多。所以该问题就转变成了设计优先队列的API了。
API设计
开始吧,在《算法》书中介绍了初级优先队列的实现,一种是基于stack操作的无序惰性算法,核心思想是,把所有的元素压入栈中,不管它们的顺序,只有当我需要最小值时,用O(n)的算法实现求最小,输出。惰性源于尽量满足插入常数级,不到迫不得已不去使用O(n)的操作。
另外一种IDEA是在插入时就保持元素的有序,这样在取的操作,我们可以简单的移动一个指针来不断输出最小值,所以为了【维持插入的有序】操作,有了时间复杂度为O(n)的插入算法,而在取最小时,可以O(1)。
这是书中提到的两个初级实现,可以说它们没有什么特别的地方,想到也是理所当然的事,接下来就是实现细节的事了,我直接给出数组有序的版本(未实现动态扩展,感兴趣的可以自己实现下)。
代码如下:
/**
*
* @author DemonSong
*
* 实现基于插入排序的优先队列
*
* 插入元素 O(n)
* 删除元素 O(n)
*
* @param <T>
*/
public class PriorityQueue<T extends Comparable<T>> {
T[] array;
int N = 0;
int SIZE;
@SuppressWarnings("unchecked")
public PriorityQueue(int SIZE){
this.SIZE = SIZE;
array = (T[]) new Comparable[SIZE];
}
public void offer(T key){
if (N == 0){
array[N++] = key;
return;
}
int i = 0;
while (i < N && key.compareTo(array[i]) > 0) i++;
if (i == N){
array[N++] = key;
return;
}
rightShift(i);
array[i] = key;
}
private void rightShift(int insert){
N++;
for (int i = N-1; i >= insert+1; --i){
array[i] = array[i-1];
}
}
public T poll(){
if (this.isEmpty()) return null;
T ele = array[0];
leftShift();
return ele;
}
private void leftShift(){
for (int i = 1; i < N; i++){
array[i-1] = array[i];
}
array[N-1] = null;
N--;
}
public T peek(){
return array[0];
}
public boolean isEmpty(){
return N == 0;
}
@Override
public String toString() {
if (this.isEmpty())
return "[]";
StringBuilder sb = new StringBuilder();
sb.append('[');
for (int i = 0; i < N; i++){
sb.append(array[i]+", ");
}
String res = sb.toString().substring(0, sb.length()-2);
return res + "]";
}
public static void main(String[] args) {
int[] test = RandomUtil.randomSet(1, 100, 10);
PriorityQueue<Integer> queue = new PriorityQueue<>(10);
for (int i = 0; i < test.length; i++){
queue.offer(test[i]);
}
while (!queue.isEmpty())
System.out.println(queue.poll());
}
}
对代码感兴趣的可以研究下细节,注意,为了实现简单,我的poll操作还是O(n),并不是达不到O(1).
堆原理
怎么说呢,上述初级实现中有一个array数组,它的结构相当低级,这也是为什么offer操作是O(n)的时间复杂度,假设你把nums一堆数offer进去,该数组在计算机看来是这样子的:
nums = [12, 16, 37, 41, 51, 55, 56, 74, 77, 84]
queue的视角:
12 -> 16 -> 37 -> 41 -> 51 -> 55 -> 56 -> 74 -> 77 ->84
可以想象,这个关系是有多么的强,我只需要开头的最小值,结果你却维护了整个数组的有序,而为了维护整个数组的有序,我又是rightShift,又是比较的,这耗费了多少操作?
所以归根结底的原因在于,取最小的操作不值得维护整体的有序性,比如,我们换个视角来看问题,如下:
堆的视角:
12 -> 16 -> 41 -> 74
-> 37 -> 51 -> 77
-> 55 -> 84
-> 56
该结构相当零活,在第一层的一定是最小的,而第二层的元素一定比第三层元素小,符合这种结构的解唯一么?如下:
12 -> 37 -> 41 -> 74
-> 16 -> 55 -> 77
-> 51 -> 84
-> 56
同样符合,是吧?
在这里,我们可以得到一个有趣的猜想,一种数据结构出现的结果越不“唯一”,维护该结构所需要的消耗越小。
那么现在问题来了,动态加入一个元素后,如下:
queue.offer(19);
12 -> 37 -> 41 -> 74
-> 16 -> 55 -> 77
-> 51 -> 84
-> 56
怎么加,加哪里,该如何维护?想想,如果维护的是一个树结构,假设从根结点开始插入该元素,因为19>12,所以必然放入下一层,但放入37还是16这个结点下?无所谓,你可以放入任何一个结点的子树中,关键来了!!!这就少维护了一半的关系!每次对半坎,所以说树结构高级的原因就在于,有些操作在判断时,都会去掉一半元素,这就好比原本规模大小为n的问题,一下子递归为n2的子问题,那自然而然递归深度只有log2n了,是吧?
所以加入一个元素如下:
12 -> 19(37) -> 41 -> 74
-> 16 -> 55 -> 77
-> 51 -> 84
-> 56
这是堆原理的另一个关键步骤,我们知道了19比37来的小,所以它不可能再进入下一层,那37怎么办?第二层已经容不下它了,所以它必须下一层找出入。这里需要注意一个堆的性质,16结点的子树和37结点的子树是不相关的,它们各自维护一层层的关系,所以37没必要和16去判断。
ok,到这堆的原理已经讲完了,真正的两个精髓被我们找到了。
- 维护一个相对较弱的层级结构而非很强关系的有序结构,前者的维护成本要小很多。
- 维护插入元素时,随机挑选某个子结点进行沉降,遇到比它大的结点,把该结点的元素代入到下一层,直到叶子结点。
好了,接下来的优先队列的实现都是细节问题了,比如为什么用数组去维护二叉堆?
答:数组就不能维护二叉堆么?队首元素从下标1开始,所以当某个父结点为k时,左子结点为2k,右子结点为2k+1。同样的,已知子结点k,父结点为k/2。
为什么插入是从数组尾部开始?
答:我也想从头部开始插啊,但头部开插,我要写个随机算法来随机选择某个子结点?而且即使实现了,你这叶子结点的生长性也太随机了吧?随机的结果必然导致父子结点的k求法失效。
尾插的好处是什么?
答:每次都是严格的扩展完全二叉堆,这还不够好么?所以刚才是沉降操作,反过来自然有了上浮操作。
删除了头元素该怎么办?
答:现在是不是就可以头插了?毕竟没有元素在头,这个位置必须有元素可以替代,为了保证二叉堆的严格性,那肯定也是从最后一个元素取嘛,ok了,优先队列的设计完毕了。然后,再看看图吧,这些操作本来就是自然而然产生的。
上浮操作:
下沉操作:
再来看看具体实现吧,建议亲自实现一遍,敲的时候就不要抄了,脑子过一遍,印象深刻。代码如下:
import java.util.Comparator;
import com.daimens.algorithm.utils.RandomUtil;
public class PriorityQueue<Key extends Comparable<Key>> {
Key[] array;
int N = 0;
int SIZE;
private Comparator<? super Key> comparator;
public PriorityQueue(int SIZE){
this.SIZE = SIZE;
array = (Key[]) new Comparable[SIZE+1];
}
public PriorityQueue(int SIZE, Comparator<? super Key> comparator){
this(SIZE);
this.comparator = comparator;
}
public void offer(Key key){
array[++N] = key;
swin(N);
}
private void swin(int k){
while (k > 1 && less(k, k / 2)){
swap(k, k / 2);
k = k / 2;
}
}
private boolean less(int i, int j){
if (comparator != null)
return comparator.compare(array[i], array[j]) < 0;
else
return array[i].compareTo(array[j]) < 0;
}
private void swap(int i, int j){
Key tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
public Key poll(){
Key key= array[1];
swap(1, N);
array[N] = null;
N--;
sink(1);
return key;
}
private void sink(int k){
while (2*k <= N){
int j = 2 * k;
if (j < N && less(j+1, j)) j++;
if(!less(j, k)) break;
swap(k, j);
k = j;
}
}
public Key peek(){
return array[1];
}
public boolean isEmpty(){
return N == 0;
}
@Override
public String toString() {
if (this.isEmpty())
return "[]";
StringBuilder sb = new StringBuilder();
sb.append('[');
for (int i = 1; i <= N; i++){
sb.append(array[i]+", ");
}
String res = sb.toString().substring(0, sb.length()-2);
return res + "]";
}
public static void main(String[] args) {
int[] test = RandomUtil.randomSet(1, 100, 10);
PriorityQueue<Integer> queue = new PriorityQueue<>(10, (a, b) -> (b-a));
for (int i = 0; i < test.length; i++){
queue.offer(test[i]);
}
while (!queue.isEmpty()){
System.out.println(queue.poll());
}
}
}