在之前提到的数据结构中,如果我们想要寻找所存元素中最大值或者最小值,需要挨个查找,而本章所学的优先队列和堆会按照优先级给元素排列,帮助我们以最快的速度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直到最大堆的特性被满足。
比如我们要将以下数值挨个插入堆:35 33 42 10 14 19 27 44 26 31,以下是图示:
以下是删除根节点的步骤:
- 移除根节点
- 将最后的元素移到根节点处
- 将子节点和父节点比较
- 如果父节点的数值比子节点小,替换父子节点
- 重复步骤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源代码链接