优先队列是一种重要的数据结构,与普通队列不同,它每次从队列中取出的是具有最高优先级的元素。本文将介绍如何使用最小堆来实现优先队列,并提供详细的 Java 代码示例和解释。
什么是优先队列?
优先队列是一种抽象数据类型,其中每个元素都有一个与之相关的优先级。在删除操作中,总是删除具有最高优先级的元素(对于最小堆来说是最小值)。优先队列的典型应用包括任务调度、图算法(如 Dijkstra 算法)等。
为什么选择最小堆?
最小堆是一种完全二叉树,能够高效地进行插入和删除操作:
- 插入操作:时间复杂度为 (O(\log n))。
- 删除最小元素操作:时间复杂度为 (O(\log n))。
最小堆的这种高效性使其成为实现优先队列的理想选择。
构造树的选择:完全二叉树的数组表示
在实现最小堆时,我们有多种选择来构造树结构,但完全二叉树的数组表示方法是最为高效和适用的。以下是一些常见的树结构及其优缺点对比,说明我们为什么选择完全二叉树的数组表示。
实现方式 1:显式引用的树结构
Tree1A
public class Tree1A<Key> {
Key k;
Tree1A left;
Tree1A middle;
Tree1A right;
...
}
优点:
- 结构清晰,容易理解。
- 方便直接操作每个节点和子节点。
缺点:
- 每个节点都需要存储多个引用(left, middle, right),占用额外内存。
- 遍历和查找操作的复杂度较高,尤其是对于非二叉树的情况。
Tree1B
public class Tree1B<Key> {
Key k;
Tree1B[] children;
...
}
优点:
- 结构较为灵活,可以处理任意数量的子节点。
缺点:
- 需要为每个节点存储一个子节点数组,内存开销较大。
- 访问子节点时需要遍历数组,效率较低。
Tree1C
public class Tree1C<Key> {
Key k;
Tree1C favoredChild;
Tree1C sibling;
...
}
优点:
- 适用于偏斜树(如二叉堆),可以优化特定树结构的存储。
缺点:
- 代码复杂度较高,尤其是在处理兄弟节点关系时。
- 内存开销较大,需要存储额外的指针。
实现方式 2:父数组和键数组
public class Tree2<Key> {
Key[] keys;
int[] parents;
...
}
优点:
- 只需两个数组,存储效率高。
- 可以直接通过数组索引访问父节点和子节点,访问效率高。
缺点:
- 需要额外的父数组来存储父节点索引。
- 对于完全二叉树,这种表示有些冗余,因为完全二叉树可以直接通过索引计算父子关系。
实现方式 3:完全二叉树的数组表示
public class TreeC<Key> {
Key[] keys;
...
}
优点:
- 存储效率最高,只需要一个数组,没有额外的指针或索引开销。
- 通过简单的索引计算(例如,对于节点i,其左子节点为2i+1,右子节点为2i+2)可以高效访问节点。
缺点:
- 适用于完全二叉树,对于不完全二叉树,需要处理“间隙”问题。
- 不适合表示非完全二叉树或其他非规则树结构。
结论
对于堆这种完全二叉树结构,用于优先队列的实现时,**实现方式 3(完全二叉树的数组表示)**是最优选择。
理由如下:
- 存储效率:只需要一个数组来存储节点,无需额外存储指针或索引。
- 访问效率:通过索引计算可以高效访问父节点和子节点。
- 代码简单性:实现和维护简单,不需要复杂的指针操作。
在实现优先队列时,由于堆的性质决定了其是一个完全二叉树,完全二叉树的数组表示方式最为高效和简单,因此是最佳选择。
最小堆优先队列的实现
我们将用数组实现一个最小堆,以下是关键方法的详细解释和代码。
1. 数据结构
首先,我们定义了一个泛型类 MinHeapPriorityQueue
,用来存储我们的最小堆:
import java.util.NoSuchElementException;
public class MinHeapPriorityQueue<Key extends Comparable<Key>> {
private Key[] keys; // 用于存储堆元素的数组
private int size; // 当前堆中的元素数量
// 构造函数,初始化优先队列,指定初始容量
public MinHeapPriorityQueue(int capacity) {
keys = (Key[]) new Comparable[capacity];
size = 0;
}
// 检查堆是否为空
public boolean isEmpty() {
return size == 0;
}
// 返回堆中的元素数量
public int size() {
return size;
}
// 返回节点 k 的父节点索引
private int parent(int k) {
return (k - 1) / 2;
}
// 返回节点 k 的左子节点索引
private int leftChild(int k) {
return 2 * k + 1;
}
// 返回节点 k 的右子节点索引
private int rightChild(int k) {
return 2 * k + 2;
}
// 交换索引 i 和 j 处的元素
private void swap(int i, int j) {
Key temp = keys[i];
keys[i] = keys[j];
keys[j] = temp;
}
2. 核心操作:swim 和 sink
swim
方法用于在插入元素时维护堆的性质,通过将新插入的元素上浮到适当位置:
// 上浮操作,用于维持堆的性质
private void swim(int k) {
while (k > 0 && keys[parent(k)].compareTo(keys[k]) > 0) {
swap(k, parent(k));
k = parent(k);
}
}
sink
方法用于在删除最小元素时维护堆的性质,通过将替换到根位置的元素下沉到适当位置:
// 下沉操作,用于维持堆的性质
private void sink(int k) {
while (leftChild(k) < size) {
int j = leftChild(k);
if (j < size - 1 && keys[j].compareTo(keys[j + 1]) > 0) {
j++;
}
if (keys[k].compareTo(keys[j]) <= 0) {
break;
}
swap(k, j);
k = j;
}
}
3. 添加和删除操作
add
方法用于向优先队列添加新元素:
// 向堆中添加新元素
public void add(Key key) {
if (size == keys.length) {
resize(2 * keys.length);
}
keys[size] = key;
swim(size);
size++;
}
getSmallest
方法用于获取堆顶元素,即最小元素:
// 获取堆顶元素(最小元素)
public Key getSmallest() {
if (isEmpty()) {
throw new NoSuchElementException("Priority queue underflow");
}
return keys[0];
}
removeSmallest
方法用于删除并返回堆顶元素:
// 删除并返回堆顶元素(最小元素)
public Key removeSmallest() {
if (isEmpty()) {
throw new NoSuchElementException("Priority queue underflow");
}
Key min = keys[0];
swap(0, size - 1);
size--;
sink(0);
keys[size] = null; // 避免对象游离
if (size > 0 && size == keys.length / 4) {
resize(keys.length / 2);
}
return min;
}
4. 扩容和缩容
为了确保数组具有足够的空间存储新元素,当数组已满时需要扩容,当数组元素较少时进行缩容:
// 调整数组
容量
private void resize(int capacity) {
Key[] temp = (Key[]) new Comparable[capacity];
for (int i = 0; i < size; i++) {
temp[i] = keys[i];
}
keys = temp;
}
}
结论
通过上述实现,我们完成了一个基于最小堆的优先队列。此实现不仅高效,而且简单易懂,适用于大多数需要优先队列的数据结构和算法应用场景。无论是插入还是删除操作,时间复杂度均为 (O(\log n)),这使得最小堆成为实现优先队列的理想选择。
通过学习和实现最小堆优先队列,我们不仅掌握了优先队列的基本原理,还深入理解了堆这种数据结构的高效性和适用性。希望本文对您有所帮助,能够更好地理解和应用优先队列。