目录
[2.1 大根堆](#2.1 大根堆)
[2.2 小根堆](#2.2 小根堆)
[3.1 大根堆的创建](#3.1 大根堆的创建)
[3.2 小根堆的创建](#3.2 小根堆的创建)
[4.1 插入](#4.1 插入)
[4.2 删除](#4.2 删除)
一、何为优先级队列
之前我们说过队列是一种先进先出的数据结构,但是某些情况下数据会带有优先级,出队列的时候可能会需要优先级高的先出队列,这种时候就需要我们的优先级队列(Priority Queue)了。一句话概括:优先级队列是一种特殊的队列,出队顺序不按 "先进先出",而是按元素的优先级高低来决定谁先出。内部是用堆来实现的那么这二者有什么关系?优先级队列只规定行为一个是返回最高级优先级对象,一个是添加新对象,堆是一棵完全二叉树是具体的数据结构。优先级队列就是功能,堆就是实现这个功能的工具。
二、堆的概念
堆是一棵完全二叉树,所有元素都按照完全二叉树的顺序存储方式存储在一维数组当中,堆又分为大根堆和小根堆
2.1 大根堆
所谓大根堆是指在一棵完全二叉树当中,根结点一定比自己的左右节点都要大,也就是根节点是最大的,并且每一颗子树也都要是大根堆的形式,这样这个堆才能称为大根堆
2.2 小根堆
小根堆是指在一棵完全二叉树当中,根结点一定比自己的左右节点都要小,也就是根节点是最小的,并且每一颗子树也都要是小根堆的形式,这样这个堆才能称为小根堆
三、堆的创建
堆的创建有两种向上调整建堆和向下调整建堆这两种其实都差不多,这两种都可以用来创建大根堆或者小根堆。我们主要说向下调整。在创建之前我们先来定义和初始化。
java
public class Heap {
int[] elme;
int Usize;
public Heap() {
elme = new int[10];
}
public void insert(int[] elme) {
for (int i = 0; i < elme.length; i++) {
this.elme[i] = elme[i];
Usize++;
}
}
}
3.1 大根堆的创建
我们创建大根堆的思路是让根节点和左右节点当中最大的一个进行交换,这其中的逻辑我们用向下调整,交换完成后再让根节点指向自己的左节点再重置左节点。下面的代码实现,带有详细的注释。
java
/**
* 建堆:将整个数组调整为大根堆
* 思想:从最后一个非叶子节点开始,从下往上、从右往左,逐个向下调整
*/
public void createHeap() {
// (elme.length - 1 - 1) / 2 → 最后一个非叶子节点下标
// i 从最后一个非叶子节点开始,一直走到根节点 0
for (int i = (Usize - 1 - 1) / 2; i >= 0; i--) {
// 对下标为 i 的节点执行向下调整
siftDown(i, Usize);
}
}
/**
* 向下调整(下沉):调整以 i 为根的子树为大根堆
* @param i 要调整的节点下标
* @param usize 堆的有效元素边界(控制调整范围)
*/
private void siftDown(int i, int usize) {
// x 初始为 i 节点的**左孩子**下标:左孩子 = 父下标 * 2 + 1
int x = i * 2 + 1;
// 循环:只要孩子节点没有越界,就继续向下调整
while (x < usize) {
// 步骤1:在左、右孩子中,找到**值更大**的那个孩子
// 如果右孩子存在,且右孩子值 > 左孩子,就让 x 指向右孩子
if (x + 1 < usize && elme[x] < elme[x + 1]) {
x++;
}
// 步骤2:如果最大的孩子值 > 父节点值,说明违反大根堆,需要交换
if (elme[x] > elme[i]) {
// 交换父节点与较大孩子节点的值
int temp = elme[x];
elme[x] = elme[i];
elme[i] = temp;
// 关键:交换后,原孩子位置可能仍不满足堆结构
// 继续向下调整:i 指向刚才的孩子,x 指向新的左孩子
i = x;
x = i * 2 + 1;
} else {
// 父节点已经比两个孩子都大 → 满足大根堆,直接退出
break;
}
}
}
3.2 小根堆的创建
我们创建小根堆的思路是让根节点和左右节点当中最小的一个进行交换,交换完成后再让根节点指向自己的左节点再重置左节点。下面的代码实现,带有详细的注释。
java
/**
* 构建小根堆
* 从最后一个非叶子节点开始,从后往前遍历,逐个向下调整
*/
public void createHeap() {
// 找到最后一个非叶子节点下标:(数组最后一个元素下标 - 1) / 2
// 从这个位置往前遍历到根节点,依次向下调整
for (int i = (Usize - 1 - 1) / 2; i >= 0; i--) {
// 对下标 i 的节点进行向下调整
siftDown(i, Usize);
}
}
/**
* 小根堆的向下调整(下沉)
* 保证以 i 为根的子树满足小根堆:父节点 ≤ 左右孩子
* @param i 要调整的节点下标
* @param usize 堆中有效元素的个数
*/
private void siftDown(int i, int usize) {
// x 初始为当前节点 i 的左孩子下标:左孩子 = i * 2 + 1
int x = i * 2 + 1;
// 左孩子没有越界,就继续向下调整
while (x < usize) {
// 在左、右孩子中找到值更小的那个孩子
// 如果右孩子存在,且右孩子比左孩子小,就把 x 指向右孩子
if (x + 1 < usize && elme[x] > elme[x + 1]) {
x++;
}
// 如果最小的孩子 比 父节点 还小
// 说明违反小根堆规则,交换父子节点
if (elme[x] < elme[i]) {
// 交换 elme[i] 和 elme[x]
int temp = elme[x];
elme[x] = elme[i];
elme[i] = temp;
// 交换后,继续向下检查,看是否还需要调整
// i 移动到原来孩子的位置,x 重新计算新的左孩子
i = x;
x = i * 2 + 1;
} else {
// 父节点已经比孩子小,满足小根堆,直接退出循环
break;
}
}
}
至于向上调整我把代码放这里供参考思路和向下调整是差不多的。
大根堆
java
/**
* 建堆:将整个数组调整为大根堆
* 这里改用向上调整的方式建堆(逐个元素上浮)
*/
public void createHeap() {
// 从第二个元素开始,逐个向上调整
for (int i = 1; i < Usize; i++) {
siftUp(i);
}
}
/**
* 向上调整(上浮):调整为大根堆
* 从当前节点 i 向上比较,比父节点大就交换上浮
* @param i 要向上调整的节点下标
*/
private void siftUp(int i) {
// 没有到根节点就继续往上
while (i > 0) {
// 计算父节点下标
int parent = (i - 1) / 2;
// 大根堆:子节点比父节点大,就交换上浮
if (elme[i] > elme[parent]) {
int temp = elme[i];
elme[i] = elme[parent];
elme[parent] = temp;
// 继续向上检查
i = parent;
} else {
// 满足堆结构,停止调整
break;
}
}
}
小根堆
java
/**
* 构建小根堆(仅使用 向上调整 实现)
* 从第二个元素开始,逐个向上调整,形成小根堆
*/
public void createHeap() {
// 从下标 1 开始,逐个元素向上调整
for (int i = 1; i < Usize; i++) {
siftUp(i);
}
}
/**
* 小根堆的【向上调整】(上浮)
* 用于:建堆、插入新元素
* @param i 要向上调整的节点下标
*/
private void siftUp(int i) {
// 从当前节点 i 开始,一直往上找父节点,直到根节点(i=0 停止)
while (i > 0) {
// 计算父节点下标:父节点 = (当前下标 - 1) / 2
int parent = (i - 1) / 2;
// 小根堆规则:如果当前节点 比 父节点 更小 → 往上交换
if (elme[i] < elme[parent]) {
// 交换当前节点与父节点的值
int temp = elme[i];
elme[i] = elme[parent];
elme[parent] = temp;
// 继续向上检查:把 i 移动到父节点位置
i = parent;
} else {
// 已经满足小根堆,不需要继续调整,退出
break;
}
}
}
总而言之就是:
向下调整:建堆、删除堆顶
向上调整:插入新元素
四、堆的插入与删除
4.1 插入
插入就是把元素放到最后,对这个最后位置的元素执行向上调整 ,不断和父节点比较,大根堆:子 > 父 就交换,小根堆:子 < 父 就交换。一句话:插到最后 → 往上浮。
代码是实现也是十分简单
java
/**
* 向堆中插入一个元素(入队 / 入堆)
* @param val 要插入的值
*/
public void push(int val) {
// 判断堆是否已满,如果满了就进行扩容
if (isFull()) {
// 数组扩容为原来的 2 倍 + 1,避免频繁扩容
elme = Arrays.copyOf(elme, 2 * elme.length + 1);
}
// 把新元素放到当前堆的最后一个位置
elme[Usize] = val;
// 对最后这个新元素执行【向上调整】,维持堆结构
siftUp(Usize);
// 堆中有效元素个数 +1
Usize++;
}
/**
* 判断堆是否已满
* @return 有效元素个数等于数组长度时表示已满
*/
private boolean isFull() {
return Usize == elme.length;
}
4.2 删除
删除就是把堆顶元素 和 数组最后一个元素交换然后删除最后一个元素(相当于删掉了原来的堆顶),之后堆大小 -1,最后对新堆顶执行向下调整 不断和孩子比较,找到最大 / 最小孩子交换,直到满足堆结构。一句话就是:堆顶和尾巴换 → 删尾巴 → 堆顶往下沉。
下面是代码实现
java
/**
* 删除堆顶元素并返回(对应优先级队列的出队)
* @return 堆顶元素的值
*/
public int poll() {
//判空
if (isEmpty()) {
throw new NoSuchElementException("堆为空,无法弹出元素");
}
// 先保存堆顶元素,用于最后返回
int val = elme[0];
// 定义临时变量,交换堆顶和最后一个有效元素
int temp = elme[0];
elme[0] = elme[Usize - 1];
elme[Usize - 1] = temp;
// 有效元素个数 -1,同时对新的堆顶进行向下调整,维持堆结构
siftDown(0, --Usize);
// 返回原来的堆顶元素
return val;
}
private boolean isEmpty() {
return Usize == 0;
}
五、PriorityQueue的使用
Java是为我们直接提供了PriorityQueue和PriorityBlockingQueue为我们创建堆并提供方法,二者的区别在于PriorityQueue是线 程不安全的,PriorityBlockingQueue是线程安全的,除此并无差别,本文介绍PriorityQueue。下面是使用的代码
java
import java.util.PriorityQueue;
/**
* 自定义学生类
* 必须实现 Comparable<Student> 接口,才能让 PriorityQueue 知道比较规则
*/
class Student implements Comparable<Student> {
String name; // 姓名
int grade; // 分数(优先级依据)
// 构造方法:创建学生对象
public Student(String name, int grade) {
this.name = name;
this.grade = grade;
}
/**
* 重写 compareTo 方法:定义优先级规则
* PriorityQueue 根据这个方法决定谁在堆顶
* @param other 另一个学生对象
* @return 比较结果
*/
@Override
public int compareTo(Student other) {
// 按分数【升序】排列:分数小的在前 → 小顶堆
// this.grade - other.grade < 0 → 优先级更高
return Integer.compare(this.grade, other.grade);
// 如果想改成【分数高的在前】(大顶堆),只需改成:
// return Integer.compare(other.grade, this.grade);
}
}
public class CustomObjectExample {
public static void main(String[] args) {
// 创建优先级队列,存储 Student 类型
// 队列会自动根据 compareTo 规则排序
PriorityQueue<Student> queue = new PriorityQueue<>();
// 添加元素(offer = 入队 = 堆插入)
queue.offer(new Student("Alice", 85));
queue.offer(new Student("Bob", 90));
queue.offer(new Student("Charlie", 80));
// peek():获取堆顶元素(优先级最高的),但不删除
// 当前规则:分数最小的在堆顶 → Charlie(80)
System.out.println("Top student: " + queue.peek().name);
}
}
注意事项:
- 使用时必须导入PriorityQueue所在的包,即:
java
import java.util.PriorityQueue;
- PriorityQueue中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出
ClassCastException异常 - 不能插入 null对象,否则会抛出NullPointerException
- 没有容量限制,可以插入任意多个元素,其内部可以自动扩容
- 插入和删除元素的时间复杂度为:插入 / 删除: O(log n) ,建堆: O(n)
- PriorityQueue底层使用了堆数据结构
- PriorityQueue默认情况下是小堆---即每次获取到的元素都是最小的元素
相信大家在看上面代码的时候注意到实现了我们我们的类实现了 Comparable<Student>接口,这是因为PriorityQueue 不知道怎么比较自定义对象,必须通过 compareTo 告诉它:谁优先级更高。
如果想在使用PriorityQueue创建的时候就是大根堆要做一些调整,有两种方法
java
// 方式1:使用比较器,直接变成大根堆
PriorityQueue<Integer> pq = new PriorityQueue<>(Collections.reverseOrder());
java
// 方式2:自定义类里反过来比较
return Integer.compare(other.grade, this.grade);
下面我给一张表格展示一下常用的方法
| 方法 | 作用 | 有无异常 | 等价理解(堆操作) |
|---|---|---|---|
offer(E e) |
向队列添加元素 | 失败返回 false |
堆插入 → 放到末尾 + 向上调整 |
add(E e) |
添加元素(继承自 Collection) | 失败抛异常 | 同上,不推荐用 |
peek() |
获取但不删除堆顶元素 | 队列为空返回 null |
直接读 elem[0] |
element() |
获取堆顶元素 | 空则抛 NoSuchElementException |
同上,带判空 |
poll() |
删除并返回堆顶元素 | 空则返回 null |
堆删除堆顶 → 交换末尾 + 向下调整 |
remove() |
删除堆顶元素 | 空则抛异常 | 同上,更严格 |
isEmpty() |
判断队列是否为空 | - | 看元素个数是否为 0 |
size() |
返回元素个数 | - | 堆有效大小 |
clear() |
清空队列 | - | 重置数组 / 大小置 0 |
contains(Object o) |
判断是否包含某元素 | - | 遍历数组查找 |
六、总结
本文从头到尾讲透了优先级队列的核心逻辑、底层堆实现,以及 Java 中 PriorityQueue 的实战用法,核心内容一句话就能拎清:优先级队列定出队规则,堆做底层实现,核心操作全靠向上 / 向下调整维持堆结构。
具体核心要点如下:
- 优先级队列是特殊队列,不遵循先进先出,只按元素优先级高低决定出队顺序;堆是一棵完全二叉树,是实现优先级队列最高效的载体,二者是功能定义与实现工具的关系。
- 堆分为大根堆和小根堆:大根堆根节点永远是最大值,小根堆根节点永远是最小值,且每一棵子树都必须符合对应堆的规则。
- 建堆的核心是向下调整与向上调整:向下调整从最后一个非叶子节点开始,从下往上逐个修正堆结构;向上调整从第二个元素开始,逐个向上比对修正,核心逻辑都是比对父子节点大小,不符合堆规则就交换,直到结构合规。
- 堆的核心操作一句话记清:插入 = 插到数组末尾 + 向上调整;删除 = 堆顶和末尾元素互换 + 删除末尾 + 新堆顶向下调整。
- Java 原生 PriorityQueue 默认是小根堆,线程不安全,元素必须可比较大小,禁止插入 null;想要大根堆可通过比较器快速反转,自定义对象需实现 Comparable 接口定义优先级规则,同时整理了日常开发高频 API 与避坑注意事项。
优先级队列与堆是数据结构的核心基础,更是 TopK 问题、任务调度、定时器实现等高频业务与算法场景的核心解决方案,吃透底层原理,才能跳出单纯的 API 调用,在实际场景中灵活运用。