面试经过

面试官: 你好,欢迎再次参加面试,几天不见,感觉头发又少了呢?

龙哥: 本来头发就少,又当了程序员,哎。

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_后端

面试官: 我们回归正题,上回说到ScheduledThreadPoolExecutor,那ScheduledThreadPoolExecutor里面用的什么阻塞队列?

龙哥: 这个说起来就有点复杂了,首先我们通过ScheduledThreadPoolExecutor构造方法来看,都是调用了父类ThreadPoolExecutor的构造函数,然后阻塞队列使用的都是DelayedWorkQueue。

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_开发语言_02

DelayedWorkQueue这个队列的最大容量是Integer.MAX_VALUE,近似是一种无界队列,毕竟一般也不会有这么多的定时任务不是?而且只能接受RunnableScheduledFuture类型的任务,是一种特殊定制化的阻塞队列。

面试官: 那么为什么周期性线程池ScheduledThreadPoolExecutor要选择这种阻塞队列来存放待执行的任务啊,它和平常我们用的哪种队列比较像呢?

龙哥: 它其实实现方式和我们常见的优先级队列PriorityQueue是差不多的,但是它是线程安全的。至于为什么要选择一种类似于优先级队列的阻塞队列,那是因为周期性线程池ScheduledThreadPoolExecutor的特性决定的,线程池里面的任务都是由时间的,根据要执行时间的远近可以区分优先级是怎么样的,换句话说,我们可以把最快要执行的任务放到最前面,这样的话,我线程池就只用监视第一个任务,如果它都没有到时间的话,所有的任务就都没有到时间,这样比遍历所有的任务来看谁到时间了,要快的多。

面试官: (这小子怎么什么都知道,不行我再往下问问)那我就很好奇了,照你说DelayedWorkQueue、PriorityQueue这两种队列还是慢厉害的嘛,那他们是怎么做到永远都能能到最优先的任务的呢?是不是遍历一遍所有的任务,把最优先的放到最前面啊?

龙哥: (又给我挖坑)那肯定不是的,如果是每次都要遍历一遍的话,那么就需要O(n)的时间复杂度,效率就很低。所以其实DelayedWorkQueue、PriorityQueue这两种队列底层是用堆这种数据结构存储的。

面试官: 哦?堆,那你能详细的说一下堆是个怎么回事么?

龙哥: (对于能手写十种排序算法的我,堆还不是信手拈来),堆其实就是就是一个数组,但是这个数组我们是可以把它想成一个完全二叉树的,就比如有数组【4、6、8、5、9】,我们就可以把它看成完全二叉树。

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_后端_03

堆就是一个完全二叉树,但是堆又分为大顶堆和小顶堆。

大顶堆:每个结点的值都大于或等于其左右孩子结点的值(在堆排序算法中用于从小到大排序)。

小顶堆:每个结点的值都小于或等于其左右孩子结点的值(在堆排序算法中用于从大到小排序)。

根据这个来想象,我们的优先级队列,就是每次把最快要到期执行的任务放到堆顶,执行完了之后,再重新构建一个大顶堆,这样就能每次拿到最快要执行的任务。

面试官: 那你能拿你举的数组的例子和我详细说一下,构建大顶堆的过程么?

龙哥: 这个还是画个图吧,你有笔嘛?

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_java_04

这里面涉及到几个堆的比较重要的公式:

如何确认第一个非叶子节点: arr.length / 2 - 1

如何确认一个节点的左子节点的位置:i * 2 + 1 (i是父节点在数组中的索引值)

如何确认一个节点的父节点的位置:(i - 1) / 2 (i是子节点在数组中的索引值)

面试官: 不错,写出这个堆排序应该就没有问题,但是对于优先级队列来讲,向队列里面添加元素,是怎么形成的顶堆呢?

龙哥: 这个嘛,其实原理差不错,优先级队列底层维护了一个数组,如果数组为空的话,就给索引0赋值,如果要查看堆顶的元素的话,也直接返回索引值为0的元素;如果数组不为空的话,那么就把这个元素放到当前容量加一的位置,假设是索引值 i 的位置,(如果容量不够了就要扩容)。然后就要对这个新元素进行上浮,也就是执行 siftUp 方法。

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_后端_05

这个方法的逻辑也比较简单,就是找到当前位置的元素的父节点,如果父节点没有我新加的这个元素这么着急的话,大家就换一个位置。直到父节点比我还急,或者上升到了数组的首位(索引值为0),下一个就轮到我执行了,这就是上浮的过程。至于在完全二叉树中如何确认父节点的位置,上面的公式有写 (i - 1) / 2 (i是子节点在数组中的索引值)。额,还是不明白,好吧,我再画个图。

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_后端_06

我们可以看到,每次上浮最大也是是树的高度,时间复杂度也就是O(lgn),咳咳,比你上面说的遍历O(n)可强的不是一点半点啊。

面试官: 这小子这么强的嘛,上厕所不带纸啊(怎么讲????? — 高手),那poll元素和删除元素呢。

龙哥: 这个就差不多了,poll之后,就是堆顶的元素拿走了嘛,我们就把堆尾的元素拿到堆顶,再下潜就好 siftDown ,下潜的源码。

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_数组_07

什么什么,还是不明白下潜的过程,算了算了,我再画个图吧,还是用那个数组举例。

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_开发语言_08

有一个比较难理解的点要注意的是,当删除指定元素的时候,需要先下潜再上浮。

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_数组_09

为什么这么做呢,因为总末尾拿的元素,优先级肯定是比较低的,首先肯定是下潜,但是如果下潜不下去的话,说明移除肯定是叶子节点,那么为什么还要上浮呢?因为可能是在完全二叉树的两个分支上面,你在右面比较优先级低,没准在左边就比较高,还能升上去。

比如删除了元素1,8到元素1的位置了,8和父节点5比,优先级就比较,所以需要上浮的过程。

史上最强优先级队列教程PriorityQueue、DelayedWorkQueue_后端_10

面试官: 可以,回去等通知吧,我们改日再战,,,再面试。

龙哥: 别啊,来几道算法题吧。

面试官: 滚!

龙哥: 好嘞。


DelayedWorkQueue实现方式基本和PriorityQueue差不多,就是线程安全的,然后只能处理RunnableScheduledFuture类型的任务,本文就以PriorityQueue举例了。

堆排序

既然都说了堆的详细内容,那么堆排序,大家也应该明白了。下面有堆排序的完整代码,如果有不理解的,欢迎留言。

class Solution {
public int[] sortArray(int[] arr) {
if (arr == null || arr.length == 0) {
return null;
}
int length = arr.length;
// 初始化构建大顶堆
for (int i = arr.length / 2 - 1; i >= 0; i--) {
adjustHeap(i, arr, length);
}

for (int i = length - 1; i > 0; i--) {
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
adjustHeap(0, arr, i);
}
return arr;
}

private void adjustHeap(int point, int[] arr, int length) {
int value = arr[point];
for (int i = 2 * point + 1; i < length; i = 2 * i + 1) {
if (i + 1 < length && arr[i + 1] > arr[i]) {
i = i + 1;
}
if (arr[i] > value) {
arr[point] = arr[i];
point = i;
} else {
break;
}
}
arr[point] = value;
}
}