问:谈谈你对二叉堆数据结构的理解及在 PriorityQueue 中的实现?
答:这算是一道比较有深度的问题了,要回答好首先得解释什么是二叉堆数据结构,接着解释其优点,然后解释在 JDK 1.5 的 PriorityQueue 中是怎么使用的,只有这几个方面都点到才算比较满意的答案。
首先堆的特点总是一棵完全二叉树且某个节点值总是不大于或不小于其父节点值,PriorityQueue 使用的是堆中比较特殊的二叉堆,二叉堆是完全二叉树或者是近似完全二叉树,二叉堆分为最大堆和最小堆,最大堆的父结点值总是大于或等于任何一个子节点值,最小堆的父结点值总是小于或等于任何一个子节点值。如下图就是一个最小二叉堆结构图:
可以看见二叉堆(完全二叉树)在第 N 层深度被填满之前是不会开始填第 N+1 层的,且元素插入也是从左往右顺序。此外我们通过上面的树状图和数组连续内存分布可以看到父子节点的索引顺序存在如下关系:
parentNodeIndex = (currentNodeIndex-1)/2;
leftNodeIndex = parentNoIndex*2+1;
rightNodeIndex = parentNodeIndex*2+2;
可以看见,通过公式能直接计算出某个节点的父节点以及子节点的下标,所以这也就是为什么可以直接用数组来存储二叉堆而不用链表的原因之一,故 PriorityQueue 的 peek()/element() 操作时间复杂度是 O(1),而 add()/offer()/poll()/remove() 操作的时间复杂度是 O(log(N))。
了解了二叉堆的原理和特点之后我们就来看看 PriorityQueue 中是怎么使用二叉堆实现操作的,我们主要要看的方法为add()/offer()/peek()/element()/poll()/remove(),下面会对这些方法进行分组实现解说。
1. add()/offer()
PriorityQueue 的 add()/offer() 操作都是向优先队列中插入元素,add() 的实现就是直接调用 offer() 方法返回,所以我们直接看下 offer() 方法的实现:
public boolean offer(E e) {
//PriorityQueue元素不允许为null
if (e == null) throw new NullPointerException();
modCount++;
int i = size;
//数组需要扩容,arraycopy操作
if (i >= queue.length) grow(i + 1);
size = i + 1;
if (i == 0)
queue[0] = e;
//队列为空时第一个元素插在数组开头
else
siftUp(i, e);
//队列不为空时堆结构调整数组元素位置
return true;
}
// 使用不同的比较器进行比较
private void siftUp(int k, E x) {
if (comparator != null) siftUpUsingComparator(k, x);
else siftUpComparable(k, x);
}
//k为currentNodeIndex,x为要插入的元素
private void siftUpComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>) x;
while (k > 0) {
int parent = (k - 1) >>> 1;
//等价于parentNodeIndex=(currentNodeIndex-1)/2;
Object e = queue[parent];
//将x逐层与parent元素比较交换,只到x>=queue[parent]结束
if (key.compareTo((E) e) >= 0) break;
queue[k] = e;
k = parent;
}
queue[k] = key;
}
上面的代码用图示流程演示如下(9个元素的优先级列表插入一个调整后变为10个元素):
2. peek()/element()
PriorityQueue 的 peek()/element() 操作,都是取出最小堆顶元素但不删除队列堆顶元素,区别就是 element() 的实现是 peek() 且 element() 取出元素为 null 会抛出异常而 peek() 不会,所以我们直接看下 peek() 方法的实现:
public E peek () {
return (size == 0) ? null : (E) queue[0];
}
演示流程图如下:
3. poll()
PriorityQueue 的 poll() 操作,其目的就是取出最小堆顶部元素并从队列删除,当失败时返回 null,所以该方法的实现如下:
public E poll() {
if (size == 0) return null;
int s = --size;
modCount++;
//最小二叉堆的最小元素自然在数组的index为0处
E result = (E) queue[0];
// 取出数组最后一个元素,即二叉堆树最深层最右侧的元素
E x = (E) queue[s];
// 最后一个元素位置置空
queue[s] = null;
if (s != 0) siftDown(0, x);
// 调整二叉堆数组元素位置
return result;
}
// 直接看siftDown中的Comparable情况,k索引0开始,x为二叉堆最后一个元素
private void siftDownComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>) x;
int half = size >>> 1; // loop while a non-leaf
while (k < half) {
// 找到当前元素的左子节点索引
int child = (k << 1) + 1; // assume left child is least
Object c = queue[child];
// 找到当前元素的右子节点索引
int right = child + 1;
if (right < size && ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0) c = queue[child = right];
if (key.compareTo((E) c) <= 0) break;
queue[k] = c;
k = child;
}
queue[k] = key;
}
上面的代码用图示流程演示如下(10个元素的优先级列表 poll 删除一个调整后变为9个元素):
4. remove()
PriorityQueue 的 remove(E e) 操作,其目的就是将指定的元素从队列删除,当失败时返回 false,该方法的实质是先遍历获取 e 在数组的 index 索引,然后调用 removeAt(index) 方法,所以我们看下 removeAt(index) 方法源码如下:
//i为要删除的元素在数组的索引
E removeAt ( int i )
{
// assert i >= 0 && i < size;
modCount++;
int s = --size;
if (s == i) // removed last element
queue[i] = null;
//如果要删除的元素恰巧在最后一个则直接删除不用调整
else {
//取出二叉堆树的最后一个节点元素
E moved = (E) queue[s];
//最后一个节点置为空
queue[s] = null;
//然后类似poll进行siftDown向下子节点比较交换(从i位置当做顶层父节点)
siftDown(i, moved);
// 向下沉淀完发现没变化则需要向上浮动,说明最后一个元素换到 i 位置后是最小元素
if (queue[i] == moved) {
siftUp(i, moved);
if (queue[i] != moved) return moved;
}
}
return null;
}
上面的代码用图示流程演示如下(10个元素的优先级列表 remove 删除一个调整后变为9个元素):
在作答这个题时你可以选择画图也可以选择直接写父子节点关系公式和 siftUp、siftDown 的机制即可,核心答出来就行,当然不要忘记最小二叉堆是数组实现且 PriorityQueue 元素不允许为空的特性。