堆和优先队列
二叉堆
堆通常使用了树的存储方式,比较常用的堆是:二叉堆,也就是满足一些特殊性质的二叉树。
二叉堆的性质
1️⃣ 二叉堆必须是一颗完全二叉树,所谓完全二叉树可以理解为将元素按顺序一层一层的排列成二叉树的形状,完全二叉树的特点为:
- 完全二叉树是一颗叶子节点只能出现在最下面两层的二叉树。
- 完全二叉树最下面一层的叶子节点必须从左至右连续出现,中间不能出现空节点。
- 完全二叉树倒数第二层的如果有叶子节点,一定位于右边连续位置。
- 完全二叉树中如果节点只有一个子节点,那么一定是左子节点,完全二叉树中不存在只有右子节点的情况。
2️⃣ 二叉堆分为最大堆和最小堆。
-
最大堆:堆中任意的子节点的值 <= 其父节点的值。(根节点值最大)
) -
最小堆:堆中任意的子节点的值 >= 其父节点的值。(根节点值最小)
3️⃣ 同层次节点间没有大小关系,如上面两张图,最大堆中第二层的右子节点为 2,而第三层中节点 5 的两个子节点都 > 2,但是这并不影响各节点在自己所位于的子树中满足堆的性质,也即是各节点在自己所处的子树中满足大(小)堆的性质即可,与层级无关。
数组存储二叉堆
堆因为是一颗完全二叉树,因此可以使用数组的方式进行存储,最后一个节点就是数组最后一个元素。
节点下标计算公式:
-
已知父节点的下标为 i ,左孩子下标 = 2 × i + 1 ,右孩子下标 = 2 × i + 2。
-
已知孩子节点的下标为就 j,父节点 = (j - 1) / 2。
堆的实现
本文中实现的堆为最大堆,最小堆的实现方式其实基本相同只是对于大小的定义不同。
堆的基本结构和辅助函数
上面已经介绍,在本文中实现的最大堆,底层使用数组作为容器来存放堆中元素。因此设计Heap(堆) 这个类时,需要定义一个数组作为私有的成员变量。同时需要注意的是,我们实现的堆采用了泛型,但是堆中的元素具备可比较性因此对于泛型设定要继承于Comparable 接口。
具体代码如下:
public class MaxHeap<E extends Comparable<E>> {
private E[] data; // 底层容器
private int size; // 纪录堆中元素的个数
private static final int DEFAULT_CAPACITY = 11; // 默认容量
public MaxHeap() {
this(DEFAULT_CAPACITY);
}
@SuppressWarnings("unchecked")
public MaxHeap(int initCapacity) {
data = (E[]) new Comparable[initCapacity];
}
// 根据 index 计算出父亲节点的下标
private int parent(int index) {
if (index == 0) {
throw new IllegalArgumentException("Index-0 doesn't have parent!");
}
return (index - 1) / 2;
}
// 根据 index 计算出左孩子节点的下标
private int leftChild(int index) {
return (index << 1) + 1;
}
/**
* 扩容,参考PriorityQueue类
*/
public void grow() {
int oldCapacity = data.length;
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
data = Arrays.copyOf(data, newCapacity);
}
// 返回堆中元素个数
public int size() {
return size;
}
// 判断堆是否为空
public boolean isEmpty() {
return size == 0;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("[");
for (int i = 0; i < size(); i++) {
sb.append(data[i]);
if (i < size() - 1) {
sb.append(",");
}
}
sb.append("]\n");
return sb.toString();
}
public static void main(String[] args) {
Integer[] arr1 = ArrayGenerator.randomArrayGenerator(100, 100);
MaxHeap<Integer> maxHeap1 = new MaxHeap<>(arr1);
while (!maxHeap1.isEmpty()) {
System.out.print(maxHeap1.extractMax() + " ");
}
}
}
Sift Up 和 Sift Down
Sift Up 和 Sift Down 是堆中最重要的两个操作。
Sift Up 数据上浮。当我们需要在向堆中添加元素时,通常情况下是直接在数组最后一个元素的后一个索引位置添加元素。不过对于堆来说,添加进元素后必须维护住堆的性质,因此就需要对新添加的元素进行 Sift Up 操作,使得新的元素位于堆中合适的位置。
具体过程为,从新添加的节点开始向上与父亲节点进行比较,如果大于父亲节点,则交换两个节点的位置,使得新添加的元素成为这颗子树的父节点,并继续向上进行 Sift up 操作,知道根节点为止。
代码实现
/**
* 向堆中添加元素,添加的元素将会进行向上调整
*/
public void add(E e) {
if (e == null)
throw new NullPointerException();
if (size >= data.length)
grow();
data[size++] = e; // 数组末尾添加元素,并维护size
if (size > 1) // 当堆中元素大于1个时,执行 sift up 操作
siftUp(size - 1);
}
/**
* 将索引c的元素向上调整
* c: 孩子节点索引
* p: 父亲节点索引
*/
private void siftUp(int c) {
int p;
E child = data[c]; // 保存上浮元素
// 当 c 下标大于0, 即不为根节点, 并且当前父节点小于子节点时进入循环
while (c > 0 && data[p = parent(c)].compareTo(child) < 0) {
data[c] = data[p]; // 将父节点的值覆盖到子节点上
c = p; // c 纪录 child 当前应该存放的位置
}
data[c] = child; // 当退出循环时, c 中纪录的就是 child 应该存放的位置, 赋值到该位置。
}
Sift Down 数据下沉。对于堆的这种数据结构我们一般只关心堆顶的元素,当我们需要取出堆顶元素时,如果直接将堆顶元素取出,那么此时的二叉堆中就存在了两个子堆,将两个子堆重新合并成一个堆的操作比较麻烦,因此对于取出堆顶元素的操作,一般是先保存堆顶元素,再让堆中最后一个元素顶上去,那么此时堆顶的元素不符合堆的性质,就需要将堆顶元素下沉到它合适的位置。
具体操作为,当堆尾的元素顶到堆顶后,让该元素与其左右子节点中较大(大堆)的元素进行比较,如果当前的堆顶元素小于较大的子元素时,就交换两个节点的位置。接着继续向下执行重复操作,直到该元素所处的位置满足大堆的性质时停止,这就是 Sift Down 操作。
代码实现
/**
* 将堆中的最大元素(根元素)删除,并返回该元素的值。
* 具体操作为:
* 1、保存待删除根元素的值
* 2、用堆中最后一个元素(数组最后一个元素)覆盖根元素
* 3、维护size变量
* 4、将覆盖后的根元素向下调整到合适位置
*/
public E extractMax() {
E ret = findMax(); // 1
data[0] = data[--size]; // 2、3
data[size] = null;
siftDown(0); // 4
return ret;
}
/**
* 将索引为p的元素下层
*/
private void siftDown(int p) {
int half = size >>> 1; // 计算最后一个非叶子节点的后一个节点下标,即第一个子节点的下标
E parent = data[p]; // 保存需要下沉的元素
while (p < half) { // p 代表父节点,当 p < half 时表示 p 指向的节点存在子节点
int l = leftChild(p); // 计算左孩子下标
if (l + 1 < size && // 如果存在右孩子
data[l + 1].compareTo(data[l]) > 0) // 比较左右孩子大小
l++; // data[l] 是左右孩子中的最大值
if (parent.compareTo(data[l]) >= 0) // 如果父元素大于等于最大子元素,则无序下沉,直接退出循环
break;
data[p] = data[l]; // 否则, 让较大的孩子覆盖到父节点的位置
p = l; // p 更新为 l, 继续下沉操作,p中纪录的位置是 parent 此时应存放的位置
}
data[p] = parent; // 当退出循环时,将 parent 赋值到 data[p] 上
}
// 返回堆顶元素
public E findMax() {
if (isEmpty())
throw new RuntimeException("Heap is empty!");
return data[0];
}
Heapify 和 replace
Heapify ,将任意的数组整理成堆的形状。具体实现为通过数组的元素个数计算出最后一个非叶子节点的下标,并从该节点开始从后向前执行 Sift Down,一直到根结点执行完 Sift Down 为止,那么此时数组就被转换为一个二叉堆。
// 提供参数为数组的构造器
public MaxHeap(E[] arr) {
if (arr == null)
throw new NullPointerException();
data = Arrays.copyOf(arr, arr.length);
size = data.length;
if (size > 1)
heapify(); // 大于1个元素,则堆化
}
/**
* 将任意的数组整理成堆的结构
* 实现思路:从最后一个非叶子节点开始向下调整,直到调整到根节点为止。lastNonLeaf = parent(size - 1);
* 时间复杂度:O(n),如果是直接将整个数组中所有元素依次添加进堆中则算法复杂度为O(nlogn),因此该算法要优于直接添加数
* 组元素。
*/
private void heapify() {
int nonLeaf = parent(size - 1);
while (nonLeaf >= 0) {
siftDown(nonLeaf);
nonLeaf--;
}
}
replace ,取出堆顶元素,并放入一个新的元素。具体实现非常简单,先获取堆顶元素,再将新的元素放入堆顶位置,并对该元素指向 Sift Down 操作即可。
/**
* 取出堆中最大元素,并用元素e覆盖该元素,元素e将会被sift down
*/
public E replace(E e) {
E ret = findMax();
data[0] = e;
siftDown(0);
return ret;
}
整体代码
public class MaxHeap<E extends Comparable<E>> {
private E[] data;
private int size;
private static final int DEFAULT_CAPACITY = 11;
public MaxHeap() {
this(DEFAULT_CAPACITY);
}
@SuppressWarnings("unchecked")
public MaxHeap(int initCapacity) {
data = (E[]) new Comparable[initCapacity];
}
public MaxHeap(E[] arr) {
if (arr == null)
throw new NullPointerException();
data = Arrays.copyOf(arr, arr.length);
size = data.length;
if (size > 1)
heapify();
}
private void heapify() {
int nonLeaf = parent(size - 1);
while (nonLeaf >= 0) {
siftDown(nonLeaf);
nonLeaf--;
}
}
public void add(E e) {
if (e == null)
throw new NullPointerException();
if (size >= data.length)
grow();
data[size++] = e;
if (size > 1)
siftUp(size - 1);
}
private void siftUp(int c) {
int p;
E child = data[c];
while (c > 0 &&
data[p = parent(c)].compareTo(child) < 0) {
data[c] = data[p];
c = p;
}
data[c] = child;
}
public void grow() {
int oldCapacity = data.length;
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
data = Arrays.copyOf(data, newCapacity);
}
public E extractMax() {
E ret = findMax(); // 1
data[0] = data[--size]; // 2、3
data[size] = null; // for gc
siftDown(0); // 4
return ret;
}
public E replace(E e) {
E ret = findMax();
data[0] = e;
siftDown(0);
return ret;
}
private void siftDown(int p) {
int half = size >>> 1;
E parent = data[p];
while (p < half) {
int c = leftChild(p);
if (c + 1 < size &&
data[c + 1].compareTo(data[c]) > 0)
c++;
if (parent.compareTo(data[c]) >= 0)
break;
data[p] = data[c];
p = c;
}
data[p] = parent;
}
public E findMax() {
if (isEmpty())
throw new RuntimeException("Heap is empty!");
return data[0];
}
private int parent(int index) {
if (index == 0) {
throw new IllegalArgumentException("Index-0 doesn't have parent!");
}
return (index - 1) / 2;
}
private int leftChild(int index) {
return (index << 1) + 1;
}
public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("[");
for (int i = 0; i < size(); i++) {
sb.append(data[i]);
if (i < size() - 1) {
sb.append(",");
}
}
sb.append("]\n");
return sb.toString();
}
}
优先队列
在了解了堆的实现原理后,对于优先队列的实现也就比较简单了。优先队列一般是基于堆作为底层的数据结构,它与普通队列的区别在于:
-
普通队列:先进先出。底层数据结构:顺序表,链表。
-
优先队列:出队顺序和入队顺序无关,优先级高者先出队。
优先队列的应用场景
操作系统中对于任务的调度,使用了优先队列这种高级的数据结构。操作系统会同时执行多个任务,那么操作系统就需要为这多个任务分配资源,包括为 cpu 分配时间片。那么操作系统在具体分配资源的过程中就需要看这多个任务的优先级,动态的选择优先级最高的任务去执行。
另外在很多游戏中都会有一个游戏排行榜,而这个排行榜是动态更新的,那么使用优先队列就可以实时的查询到当前优先级最高的用户是谁。
代码实现
优先队列作为队列的一种,所支持操作也和普通一样,只是底层使用堆作为数据结构后可以很方便的实现优先出队的操作。因此在具体代码实现上,只需要在优先队列类的内部创建一个私有的 Heap 对象,并调用 Heap 相应的方法即可以实现优先队列操作。
// 定义队列的接口
public interface Queue<E> {
void offer(E e);
E poll();
E peek();
boolean isEmpty();
int size();
}
/**
* Description: 优先级队列,以大根堆为底层数据结构
*/
public class MyPriorityQueue<E extends Comparable<E>> implements Queue<E> {
private MaxHeap<E> maxHeap;
public MyPriorityQueue() {
maxHeap = new MaxHeap<>();
}
public MyPriorityQueue(int initCapacity) {
maxHeap = new MaxHeap<>(initCapacity);
}
public MyPriorityQueue(E[] data) {
maxHeap = new MaxHeap<>(data);
}
@Override
public void offer(E e) {
maxHeap.add(e);
}
@Override
public E poll() {
return maxHeap.extractMax();
}
@Override
public E peek() {
return maxHeap.findMax();
}
@Override
public boolean isEmpty() {
return maxHeap.isEmpty();
}
@Override
public int size() {
return maxHeap.size();
}
}
TopK问题
一组数据中找到前k个最大/最小的数据,可以使用优先级队列来解决,也可以使用快排来解决。
优先队列解决
1️⃣ 找前 k 个最大的元素
- 将这组数据前 k 个元素建成小堆。
- 从这组数据第 k+1 个元素开始与堆顶元素进行比较。
- 如果第 k + 1个元素大于堆顶元素,那么将堆顶元素出堆,再将第 k+1 的元素入堆。直到遍历完整组数据。最终这拥有 k 个元素的小堆中存放的就是前 k 个最大的元素。
为什么找前 k 个最大的元素要建小堆?
因为小堆可以保证堆顶元素一定是堆中最小的,如果一个元素小于小堆堆顶元素,那么它肯定小于堆中所有元素,这个元素肯定不是前 k 个最大的元素,而如果一个元素大于堆顶元素,那么可以肯定当前的堆顶元素一定不是前 k 个最大元素。其核心思路就是:将较小的元素从堆中剔除,留下的都是较大的元素。
/**
* TopK问题:获取数组前 K 个最大元素
* 思路:要取得前 K 个最大的元素,则以数组前 K 个元素创建小根堆,并将剩余元素与堆顶元素进行比较,留下较大者,最终堆
* 中剩余的 K 个元素就是前 K 个最大的元素
*/
public <E extends Comparable<E>> E[] maxK(E[] arr, int k) {
if (arr == null)
return null;
E[] res = (E[]) Array.newInstance(arr.getClass().getComponentType(), k);
PriorityQueue<E> minHeap = new PriorityQueue<>(k); // 默认小根堆
for (int i = 0; i < arr.length; i++) {
if (i < k)
minHeap.offer(arr[i]);
else {
if (arr[i].compareTo(minHeap.peek()) > 0) {
minHeap.poll();
minHeap.offer(arr[i]);
}
}
}
for (int i = 0; i < k; i++) {
res[i] = minHeap.poll();
}
return res;
}
2️⃣ 找前k个最小的元素
- 将这组数据前 k 个元素建成大堆。
- 从这组数据第 k+1 个元素开始与堆顶元素进行比较。
- 如果第 k + 1个元素小于堆顶,那么将堆顶元素出堆,再将第 k+1 的元素入堆。直到遍历完整组数据,最终这拥有 k 个元素的大堆中存放的就是前 k 个最小的元素。
为什么找前 k 个最小的元素要建大堆?
同上,因为大堆可以保证堆顶元素一定是堆中最大的,如果一个元素大于堆顶元素,那么它一定大于堆中所有元素,这个元素肯定不是前 k 个最小的元素。而如果一个元素小于堆顶元素,那么可以肯定当前的堆顶元素一定不是前 k 个最小元素。其核心思路就是:将较大的元素从堆中剔除,留下的都是较小的元素。
/**
* TopK问题:获取数组前 K 个最小元素
* 思路:要取得前 K 个最小的元素,则以数组前 K 个元素创建大根堆,并将剩余元素与堆顶元素进行比较,留下较小者,最终堆
* 中剩余的 K 个元素就是前 K 个最小的元素
*/
@SuppressWarnings("unchecked")
public <E extends Comparable<E>> E[] minK(E[] arr, int k) {
if (arr == null)
return null;
E[] res = (E[]) Array.newInstance(arr.getClass().getComponentType(), k);
PriorityQueue<E> maxHeap = new PriorityQueue<>(k,Comparator.reverseOrder());// (e1, e2) -> e2.compareTo(e1) 调换比较逻辑,PriorityQueue以大根堆的形式创建
for (int i = 0; i < arr.length; i++) {
if (i < k) {
maxHeap.offer(arr[i]);
} else {
if (arr[i].compareTo(maxHeap.peek()) < 0) {
maxHeap.poll();
maxHeap.offer(arr[i]);
}
}
}
for (int i = 0; i < k; i++) {
res[i] = maxHeap.poll();
}
return res;
}
快排解决
使用快速排序中每一次 partition 的过程都可以确定一个 pivotkey 的位置,获取 pivotkey 的位置后与 k 进行比较。剑指 Offer 40. 最小的k个数
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if(arr.length == 0 || k == 0)
return new int[0];
return getLeast(arr,k - 1, 0, arr.length - 1, new Random());
}
private int[] getLeast(int[] arr, int k, int left, int right, Random rd) {
int p = rd.nextInt(right - left + 1) + left; // 生成[left,right]区间内的随机索引
swap(arr, left, p); // 将随机索引上的元素移动到最左边
int i = left + 1, j = right; // [left + 1, i - 1] <= 第k大的数, [j + 1, right] >= 第k大的数
while(true) {
while(i <= j && arr[i] < arr[left])
i++;
while(i <= j && arr[j] > arr[left])
j--;
if(i >= j) break;
swap(arr, i, j);
i++;
j--;
}
swap(arr, left, j);
if(j == k)
return Arrays.copyOf(arr, j + 1);
return j > k ? getLeast(arr, k , left, j - 1, rd) : getLeast(arr, k, j + 1 , right, rd);
}
private void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
SelectK问题
找一组数组中第 k 最大(或最小)的元素。例题:数组中的第K个最大元素
1️⃣ 使用快速排序进行解决。
快速排序的 partition 函数每一次都可以将一个 pivotkey 放置在它应该放置的位置,因此使用快速排序算法,每一次 partition 后,都将本次 pivotkey 最终的索引 j 与 k 进行比较,如果 j > k 则说明待查找元素在 j 的左边,如果 j < k 则说明待查找元素在 j 的右边,如果 j == k 则 j 位置上的元素即为待查找的元素。
/*
解题思路:利用快排的特点,按照升序快排(从小到大),计算第k大元素在升序排列的索引 *target = length - k* ,每一次的*partion*都能将一个元素放在它最终的位置,当*partition*完毕后,比较该元素的索引是否等于target,等于则直接返回该元素。如果大于,则说明target在当前标定点的左边。如果小于则说明在当前标定点的右边,根据判断继续递归遍历即可。
*/
class Solution {
public int findKthLargest(int[] nums, int k) {
int left = 0;
int right = nums.length - 1;
int target = nums.length - k; // 计算第k大元素的下标
Random rnd = new Random();
while(left < right) {
int p = partition(nums, left, right, rnd);
if(p == target)
return nums[p];
if(p > target)
right = p - 1;
else // p < target
left = p + 1;
}
return nums[left];
}
// nums[left, right]区间执行partition
// 维持循环不变量 [left + 1, i - 1] <= nums[left] , [j + 1, right] >= nums[left];
private int partition(int[] nums, int left, int right, Random rnd) {
int rand = rnd.nextInt(right - left + 1) + left; // 生成[left, right]区间内的随机索引
swap(nums, left, rand); // 交换
int i = left + 1, j = right;
while(true) {
while(i <= j && nums[i] < nums[left])
i++;
while(j >= i && nums[j] > nums[left])
j--;
if(i >= j)
break;
swap(nums, i, j);
i++;
j--;
}
swap(nums, left, j);
return j;
}
private void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
2️⃣ 使用优先队列解决。
如果找第 k 个最大的元素,利用上面TopK优先队列1️⃣ 思路,最终会得到一个存放前 k 个最大元素的小堆,那么堆顶元素即为第 k 个最大元素。
如果找第 k 个最小的元素,利用上面TopK优先队列2️⃣ 思路,最终会得到一个存放前 k 个最小元素的大堆,那么堆顶元素即为第 k 个最小元素。
class Solution {
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> minHeap = new PriorityQueue(k);
for(int i = 0; i < nums.length; i++) {
if(i < k)
minHeap.offer(nums[i]);
else
if(!minHeap.isEmpty() && minHeap.peek() < nums[i]) {
minHeap.poll();
minHeap.offer(nums[i]);
}
}
return minHeap.poll();
}
}
TopK 和 SelectK 解决方式的选择
对于 TopK 和 SelectK 问题,可以选择使用快速排序的思路和优先队列的思路进行解决,它们的复杂度如下:
- 快速排序解决:时间复杂度O(n),空间复杂度O(1)。
- 优先队列解决:时间复杂度O(nlogn),空间复杂度O(k)。
从复杂度对比上来说,快速排序无论是在时间复杂度还是空间复杂度度上都要优于使用优先队列解决,但是优先队列在解决此类问题上也具有特殊的优势:不需要一次性加载所有数据。
如果使用快速排序解决需要将所有的数据加载至内存中,才能开始对数据进行处理。
而如果使用优先队列,不许提前将所有数据加载到内存中,而只需要将前k个数据加载到内存中,后面的数据可以一个一个进行处理。
当以数据流的方向进行传输时,应该使用优先队列进行处理。
-
极大的数据规模时,无法将数据一次性加载到内存中,使用优先队列并使用流的方式一点一点处理。
-
一些实时更新的应用场景,比如在线游戏排行榜等。