来源:https://turingplanet.org/2020/03/07/%e4%bc%98%e5%85%88%e9%98%9f%e5%88%97-priorityqueue/
在之前提到的数据结构中,如果我们想要寻找所存元素中最大值或者最小值,需要挨个查找,而本章所学的优先队列和堆会按照优先级给元素排列,帮助我们以最快的速度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)
以下是最大堆支持的操作
- add: 将新元素插入堆
- poll: 将根节点(数值最大的元素)删除
- peek: 获取根节点的数值
在任何的时间点,最大堆都应该保持其特性:父节点的数值比所有子节点大。在插入新元素的时候,我们要遵循以下的步骤:
- 在堆的最后新建一个节点。
- 将数值赋予新节点。
- 将其节点和父节点比较。
- 如果新节点的数值比父节点大,调换父子节点的位置。
- 重复步骤3和4直到最大堆的特性被满足。
以下是删除根节点的步骤:
- 移除根节点
- 将最后的元素移到根节点处
- 将子节点和父节点比较
- 如果父节点的数值比子节点小,替换父子节点
- 重复步骤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题目和源代码
- Kth Largest Element in an Array (215)
- Top K Frequent Elements (347)
- Merge k Sorted Lists (23)
- Sliding Window Maximum (239)
Git源代码链接