在之前提到的数据结构中,如果我们想要寻找所存元素中最大值或者最小值,需要挨个查找,而本章所学的优先队列和堆会按照优先级给元素排列,帮助我们以最快的速度O(1)获取优先级最高的元素。

优先队列 PriorityQueue

我们先来学习优先队列,优先队列把优先级最高的元素设为链表的头节点,这样我们获取或删除优先级最高的元素只需要O(1)的时间复杂度,这么设计的代价就是牺牲插入的效率,每次插入一个新的元素时,我们都需要迭代链表,并找到合适的地方插入,这个时间复杂度往往是O(n)。

以下是优先队列支持的操作:

  • push:插入一个新的元素
  • pop:将优先级最高的元素弹出(删除)
  • peek:查看优先级最高的值

Java实现

优先队列的定义和链表很相似,首先我们需要定义节点和PriorityQueue:

public class PriorityQueue {

    static class Node {
        int value;
        int priority;
        Node next;

        public Node(int value, int priority) {
            this.value = value;
            this.priority = priority;
        }
    }

    Node head = null;
}

其中Node就是队列中的节点,包含value数组和优先级priority,如果希望数值大的节点优先级高,那么可以将pririty的数值设为和value一样。反之,我们可以将priority设为数值的相反数,那么在此队列中,一个数值越小,优先级就越高。在PriorityQueue中,我们只需要记录一个头节点head即可。以下是核心方法 push 的定义:

public void push(int value, int priority) {
    if (head == null) {
        head = new Node(value, priority);
        return;
    }
    Node cur = head;
    Node newNode = new Node(value, priority);
    if(head.priority < priority) {
        newNode.next = head;
        this.head = newNode;
    } else {
        while(cur.next != null && cur.next.priority > priority) {
            cur = cur.next;
        }
        newNode.next = cur.next;
        cur.next = newNode;
    }
}

在Push中,我们需要检查是否头节点为空,如果是,那么就将新的节点设置为头节点。不然我们迭代循环头节点,将新的节点插入一个特定位置,插入后之前节点的优先级都比新节点高,之后节点的优先级都比新节点小。接下来实现的两个方法peek和pop很简单:

public Node peek() {
    return head;
}

public Node pop() {
    if (head == null) {
        return null;
    }
    Node temp = head;
    head = head.next;
    return temp;
}

peek只需要返回头节点即可。pop只需要弹出head的值,并让head指向自己的下一个节点即可。最后的方法isEmpty只需要查看head是否为空:

public boolean isEmpty() {
    return head == null;
}

以上就是优先队列的定义,以下是优先队列操作的复杂度分析:

  • push: O(n)
  • pop: O(1)
  • peek: O(1)

接下来我们来学习另一个与优先级相关的数据结构:栈(heap)。

堆 Heap

堆(Heap)是一种特殊的平衡二叉树,堆中的节点满足以下的条件:一个节点的父节点优先级比自己高,而自己的子节点优先级比自己底。优先级可以根据数值的大小来决定。最常见的堆有以下两种类型:

  • 最大堆(Max Heap):最大堆中根节点数值最大,所有父节点的数值比各自的子节点数值大。
  • 最小堆(Min Heap):根节点数值最小, 父节点数值比其子节点数值小。
Max Heap
Min Heap

最大堆(Max Heap)

以下是最大堆支持的操作

  • add: 将新元素插入堆
  • poll: 将根节点(数值最大的元素)删除
  • peek: 获取根节点的数值

在任何的时间点,最大堆都应该保持其特性:父节点的数值比所有子节点大。在插入新元素的时候,我们要遵循以下的步骤:

  1. 在堆的最后新建一个节点。
  2. 将数值赋予新节点。
  3. 将其节点和父节点比较。
  4. 如果新节点的数值比父节点大,调换父子节点的位置。
  5. 重复步骤3和4直到最大堆的特性被满足。

比如我们要将以下数值挨个插入堆:35 33 42 10 14 19 27 44 26 31,以下是图示:

以下是删除根节点的步骤:

  1. 移除根节点
  2. 将最后的元素移到根节点处
  3. 将子节点和父节点比较
  4. 如果父节点的数值比子节点小,替换父子节点
  5. 重复步骤3和4直到最大堆的特性被满足。

以下是删除根节点44的动图演示:

实现方式:Array

虽然堆看起来非常像树,但是我们不用链表式的节点来实现。因为堆的特殊属性,我们可以直接使用数组来实现。以下是最大堆heap的基本定义:

public class MaxHeap {

    private int capacity;
    private int size = 0;
    private int[] array;

    public MaxHeap(int capacity) {
        this.capacity = capacity;
        this.array = new int[capacity];
    }
}

其中capacity记录了可承载的节点数量,array就是用来储存节点的数组。而以下是一些有用的helper methods:

private int getLeftChildIndex(int parentIndex) {
    return 2 * parentIndex + 1;
}

private int getRightChildIndex(int parentIndex) {
    return 2 * parentIndex + 2;
}

private int getParentIndex(int childIndex) {
    return (childIndex - 1) / 2;
}

private boolean hasLeftChild(int index) {
    return getLeftChildIndex(index) < size;
}

private boolean hasRightChild(int index) {
    return getRightChildIndex(index) < size;
}

private boolean hasParent(int index) {
    return getParentIndex(index) >= 0;
}

private int leftChild(int parentIndex) {
    return array[getLeftChildIndex(parentIndex)];
}

private int rightChild(int parentIndex) {
    return array[getRightChildIndex(parentIndex)];
}

private int parent(int childIndex) {
    return array[getParentIndex(childIndex)];
}

以上的方法看方法名应该就懂了,getLeftChildIndex就是拿到一个父节点左孩子节点的数组位置,hasLeftChild查看一个节点是否拥有一个不为空的左孩子,leftChild就是拿到左孩子的数值。其他方法以此类推,应该很好理解,我就不过多解释了。接下来是核心函数 add 的实现:

public void add(int item) { // Time Complexity: O(logN)
    if(size == capacity) {
        array = Arrays.copyOf(array, capacity * 2);
        capacity = capacity * 2;
    }
    array[size] = item;
    size++;
    heapifyUp();
}

private void heapifyUp() {
    int index = size - 1;
    while (hasParent(index) && parent(index) < array[index]) {
        swap(getParentIndex(index), index);
        index = getParentIndex(index);
    }
}

在 add 中,我们首先查看数组是否为空,如果为空,则扩展数组的容量。不然就将新元素插入最后一个位置,然后heapifyUp新元素,heapifyUp的作用就是将元素不断与父节点对比,直到将新元素排到一个位置,它的父节点比他大,子节点都比他小。再来看一个核心函数poll:

public void poll() { // Time Complexity: O(logN)
   if (size == 0) {
       throw new NoSuchElementException();
   }
   int element = array[0];
   array[0] = array[size - 1];
   size--;
   heapifyDown();
}

private void heapifyDown() {
    int index = 0;
    while (hasLeftChild(index)) {
        int largerChildIndex = getLeftChildIndex(index);
        if (hasRightChild(index) && rightChild(index) > leftChild(index)) {
            largerChildIndex = getRightChildIndex(index);
        }
        if (array[index] < array[largerChildIndex]) {
            swap(index, largerChildIndex);
        } else {
            break;
        }
        index = largerChildIndex;
    }
}

在poll中,我们将最后一个元素放到第一位,然后对第一位元素进行heapifyDown,其作用就是将此元素与子节点比较,然后插入到一个位置,其父节点都比此节点大,子节点都比其节点小。最后是peek的实现:

public int peek() {
    if (size == 0) {
        throw new NoSuchElementException();
    }
    return array[0];
}

peek的实现很简单,只需要拿取第一个元素即可。 以下是各个操作的复杂度分析:

  • add: O(logN)
  • poll: O(logN)
  • peek: O(1)

这就是最大堆的实现啦,最小堆只需要将实现中有关大小比对的逻辑取反即可。

LeetCode题目和源代码

Git源代码链接