PriorityQueue 初探

850 阅读7分钟
知乎:zhuanlan.zhihu.com/p/78071908

概念

PriorityQueue 是一个优先队列,每次出队的元素是优先级最高的。

示例

public class Demo {
    public static void main(String args[]) {
        java.util.PriorityQueue<Integer> queue = new java.util.PriorityQueue<>();
        queue.add(100);
        queue.add(50);
        queue.add(2);
        queue.add(31);
        queue.add(5);

        System.out.println("初始队列: " + queue);

        System.out.println("出队: " + queue.poll());
        System.out.println("当前队列: " + queue);

        System.out.println("出队: " + queue.poll());
        System.out.println("当前队列: " + queue);
    }
}

初始队列: [2, 5, 50, 100, 31]
出队: 2
当前队列: [5, 31, 50, 100]
出队: 5
当前队列: [31, 100, 50]
  • 队列中的元素并不是完全有序的
  • 队列的头节点在数值上是最小的

DIY

自己动手实现一个简单的优先队列

设计思路

  • 队列底层存储可以选择使用数组或链表,但是由于数组的增删逻辑比较复杂,所以优先使用链表来实现。
  • 每当插入一个元素,我们就遍历底层的链表,逐一比较大小。确保新元素插入后仍然保持整体有序。
  • 使链表的头节点优先度最高,那么就能保证出队的元素优先级最高。

代码实现

import java.util.Comparator;
import java.lang.Comparable;

public class DiyPriorityQueue<E extends Comparable> {

    private Node head;

    static class Node<E> {
        public E value;
        public Node<E> next;
        public Node<E> pre;
    }

    public void add(E e) {
        if (head == null) {
            Node<E> node = new Node<E>();
            node.value = e;
            head = node;
            return;
        }

        Node<E> n = head;
        
        while(n != null) {
            if (n.value.compareTo(e) < 0) {
                if (n.pre == null) {
                    Node<E> eNode = new Node<>();
                    eNode.next = n;
                    eNode.value = e;
                    head = eNode;

                    n.pre = eNode;
                    break;
                } else {
                    Node<E> eNode = new Node<>();
                    eNode.next = n;
                    eNode.value = e;
                    eNode.pre = n.pre;
                    
                    n.pre.next = eNode;
                    n.pre = eNode;
                    break;
                }
            } else if (n.value.compareTo(e) > 0 && n.next == null) {
                Node<E> eNode = new Node<>();
                n.next = eNode;
                eNode.value = e;
                eNode.pre = n;
                break;
            }
            n = n.next;
        }

    }

    public E poll() {
        if (head == null) {
            return null;
        }

        Node<E> result = head;

        head = head.next;

        return result.value;
    }

    public String toString() {
        if (head == null) return "";

        String result = head.value.toString();
        Node e = head;
        while((e = e.next) != null) {
            result = result + "," + e.value.toString();
        }

        return result;
    }

    public static void main(String args[]) {
        DiyPriorityQueue<Integer> queue = new DiyPriorityQueue<>();
        queue.add(1);
        queue.add(2);
        queue.add(0);
        queue.add(100);
        queue.add(98);
        System.out.println(queue);
        // 100, 98, 2, 1, 0

        System.out.println(queue.poll()); // 100
        System.out.println(queue.poll()); // 98
        System.out.println(queue.poll()); // 2
        System.out.println(queue.poll()); // 1
        System.out.println(queue.poll()); // 0 
        System.out.println(queue.poll()); // null
    }
}

缺陷

  • 每次入队时,对需要逐个比较,时间复杂度较高

延伸

  • 队列底层如果使用数组来实现,那么插入可以使用二分查找法提高查找插入位置的效率。但是当数组插入元素时,就会引起剩余元素的迁移问题,引起大量的数据拷贝。同时,如果底层数组容量过小,则又会触发扩容拷贝的现象。但是 JDK 的优先队列底层确是使用数组来存储的,后面我们会看到具体的实现。

JDK8

设计思路

  • 使用二叉堆(最小堆)作为底层数据存储结构
  • 使用数组实现二叉堆

二叉堆(Binary Heap)

性质

  • 任意节点小于(或大于)它的所有后裔,最小元(或最大元)在堆的根上(堆序性)。
  • 二叉堆总是一棵完全树。即除了最底层,其他层的节点都被元素填满,且最底层尽可能地从左到右填入。

将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

下图为最小堆示例。

存储

我们可以使用数组来存储上面的这个二叉堆。并且元素的索引有如下特性:

  • 左叶子节点索引 = 其父节点索引 * 2 + 1; left = parent * 2 + 1
  • 右叶子节点索引 = 其父节点索引 * 2 + 2; right = parent * 2 + 2
  • 某父节点索引 = (其任一子节点索引 - 1) / 2; parent = (child - 1) / 2

代码实现

主要成员变量

public class PriorityQueue<E> {

    /**
     * Priority queue represented as a balanced binary heap: the two
     * children of queue[n] are queue[2*n+1] and queue[2*(n+1)].  The
     * priority queue is ordered by comparator, or by the elements'
     * natural ordering, if comparator is null: For each node n in the
     * heap and each descendant d of n, n <= d.  The element with the
     * lowest value is in queue[0], assuming the queue is nonempty.
     */
    Object[] queue;

    int size = 0;
}

添加元素 add(E e) offer(E e)

将待插入元素从队列末尾逐个比较,并进行替换。过程图如下。

待插入元素值为1,将其放在二叉堆的最后一个位置

找到对应的父节点,发现 1 < 15,则将 1 和 15 进行对调

继续寻找对应的父节点,发现是根节点 5,而 1 < 5,所以仍然执行对调操作

对调操作全部完成,此时二叉堆的根为最小值 1

Java 具体 add、offer 代码

public boolean add(E e) {
    return offer(e);
}

public boolean offer(E e) {
    // ...
    
    int i = size;
    
    // 如果元素数量大于等于队列长度,则做扩容处理
    // 数组拷贝
    if (i >= queue.length)
        grow(i + 1);
    size = i + 1;
    // 如果 queue 为空,那么当前元素直接放在数组首位
    if (i == 0)
        queue[0] = e;
    else
        // 尝试从数组最后节点开始上移
        siftUp(i, e);
    return true;
}

siftUp 判断是否使用自定义比较器

private void siftUp(int k, E x) {
    if (comparator != null)
        // 基于自定义比较器比较队列元素
        siftUpUsingComparator(k, x);
    else
        // 使用类型强转,将队列元素转换为可比较类型
        siftUpComparable(k, x);
}

private void siftUpComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>) x;

    // k 为指针,从数组最后一个元素反向遍历
    while (k > 0) {
        // parent = (current - 1) / 2
        int parent = (k - 1) >>> 1;
        Object e = queue[parent];
        // 比较待添加节点与当前节点,如果待添加节点比当前节点大或者相等,则跳出
        if (key.compareTo((E) e) >= 0)
            break;

        // 如果待添加节点小于当前节点,那么需要执行交换逻辑
        // 当前节点使用父节点赋值
        queue[k] = e;
        // 指针指向父节点
        k = parent;
    }
    
    // 目标 k 位置值更新为待插入值
    queue[k] = key;
}

取出根元素 poll()

将根元素移除

将最后一个元素放置根节点

红色节点子节点中最小的为橙色节点

红色节点大于橙色节点,执行交换

红色节点的子节点都比红色节点大,则操作完毕

Java 具体 poll 代码

public E poll() {
    if (size == 0)
        return null;
    int s = --size;
    modCount++;
    // result 就是要 poll 出来的就是堆顶
    E result = (E) queue[0];

    // s 为数组最后一个元素的指针
    // x 则指向最后一个元素
    E x = (E) queue[s];
    
    // 将最后一个元素置为 null
    queue[s] = null;
    if (s != 0)
        // 目前堆顶的元素是原先数组的最后一个元素
        // 不符合堆序性这个特性,所以需要进行调整
        siftDown(0, x);
    return result;
}

private void siftDown(int k, E x) {
    if (comparator != null)
        // 如果有自定义比较器,则使用自定义比较器
        siftDownUsingComparator(k, x);
    else
        siftDownComparable(k, x);
}

private void siftDownComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>)x;
    // 这儿取一半还没搞懂
    int half = size >>> 1;        // loop while a non-leaf
    while (k < half) {
        // 先将指针指向左叶子节点
        int child = (k << 1) + 1; // assume left child is least
        Object c = queue[child];
        int right = child + 1;
        if (right < size &&
            ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
            // 如果右叶子节点比左叶子节点小
            // 则将 c 赋值为更小的那个叶子节点
            c = queue[child = right];

        // 现在将 x 与更小的叶子节点做比较
        // 如果 x 小于等于叶子节点,那说明堆序性已保持,则跳出循环
        if (key.compareTo((E) c) <= 0)
            break;

        // x 大于叶子节点
        // 将当前 k 位置上的元素赋值为叶子节点中更小的节点
        queue[k] = c;

        // 遍历指针指向叶子节点中更小的节点
        k = child;
    }
    queue[k] = key;
}

判断元素是否存在 contains(Object o)

public boolean contains(Object o) {
    return indexOf(o) != -1;
}

// 堆的性质无法通过二分查找进行遍历
// 所以直接遍历底层数组依次判断是否 equal 即可
private int indexOf(Object o) {
    if (o != null) {
        // size 为堆元素数量
        for (int i = 0; i < size; i++)
            if (o.equals(queue[i]))
                return i;
    }
    return -1;
}

删除指定元素 remove(Object o)

public boolean remove(Object o) {
    // indexOf 即上面提到过,复杂度为 O(n)
    int i = indexOf(o);
    if (i == -1)
        return false;
    else {
        // 如果找到了元素,则执行删除操作
        removeAt(i);
        return true;
    }
}

private E removeAt(int i) {
    // ...

    // s 表示元素被删除后,当前队列的元素数量
    int s = --size;
    // 如果删除的是最后一个元素,那么直接将数组有效元素的最后一位置为 null 即可。
    // 等价于 if ((size - 1) == i) { ... }
    if (s == i)
        queue[i] = null;
    else {
        // 把最后一个有效元素标记为待移动的元素
        E moved = (E) queue[s];
        // 最后一个有效元素置为 null
        queue[s] = null;
        // 将最后一个元素从当前被删除位置开始向下置换
        siftDown(i, moved);
        // 如果发现待移动的元素没有移动
        if (queue[i] == moved) {
            // 尝试将其向上置换
            siftUp(i, moved);
            // 如果发现待移动的元素移动了,那就作为返回值
            if (queue[i] != moved)
                // 这儿返回的是队列中有效元素的最后一个元素
                return moved;
        }
    }
    return null;
}

参考资料

堆 (heap) - Vamei - 博客园

Heap (data structure)