队列讲的是"先来后到",可生活里更多时候是"谁急谁先来":打游戏时来电要立刻接、急诊按病情分级、成绩好的同学先挑座位……这些场景用普通队列都不合适,需要的是优先级队列。这篇笔记从"为什么需要优先级队列"讲起,把堆的概念、存储、建堆、插入删除、
PriorityQueue的用法和 Top-K 一次串完,代码都按 Java 手写,并补上了课件里省略的边界处理。
本节目标
- 掌握堆的概念及实现
- 掌握
PriorityQueue的使用 - 能用堆解决 Top-K 一类的问题
一、优先级队列:从 FIFO 到"按优先级出队"
1.1 为什么普通队列不够用
前面学的队列是 FIFO(先进先出):元素从队尾进、从队头出,顺序完全由到达时间决定。
但很多场景里,到达时间并不是最重要的:
- 手机正在打游戏,忽然来了电话,系统必须优先处理来电;
- 初中班主任排座位,可能让成绩好的同学先挑;
- 医院急诊分诊,病情危重的人先看,而不是先挂号的人先看;
- 操作系统的任务调度,优先级高的进程先占用 CPU。
这些场景的共同点是:数据自带一个优先级,出队时应该让优先级最高的先出。
一句话:普通队列是"先来后到",优先级队列是"优先级高的先来"。
1.2 优先级队列需要提供什么
优先级队列(Priority Queue)至少要有两个基本操作:
- 返回最高优先级对象(
peek); - 添加新的对象(
offer)。
通常还会配套"删除最高优先级对象"(poll)、判空、求大小等操作。JDK 中的 java.util.PriorityQueue 就是这套接口的标准实现。
| 操作 | 普通队列 | 优先级队列 |
|---|---|---|
| 入队 | 添加到队尾 | 按优先级放置 |
| 出队 | 删除队头 | 删除优先级最高的元素 |
| 顺序 | 先进先出(按时间) | 偏序(只保证堆顶最优) |
| 典型底层 | 链表 / 循环数组 | 堆 |
1.3 用什么结构实现
最容易想到的两种"笨办法":
- 有序数组:入队时插入到正确位置,出队取末尾。入队 O(N),出队 O(1);
- 无序数组:入队直接放末尾,出队时遍历找最大。入队 O(1),出队 O(N)。
两者总有一端是 O(N),数据量大时都不可接受。
能不能让入队和出队都做到 O(logN)?可以,答案就是堆(Heap)。JDK 的 PriorityQueue 底层用的正是堆。
二、堆:优先级队列的底层结构
2.1 堆的概念
堆在逻辑上是一棵完全二叉树,在物理上通常用数组存储。
如果有一个关键码集合 K = {k0, k1, k2, …, kn-1},把它的所有元素按完全二叉树的顺序存储方式放在一维数组里,并满足:
Ki <= K2i+1且Ki <= K2i+2(小堆);或Ki >= K2i+1且Ki >= K2i+2(大堆),
其中 i = 0, 1, 2, …,则称它为小堆或大堆。
- 大根堆(最大堆):根节点最大,任意节点的值都 ≥ 其孩子;
- 小根堆(最小堆):根节点最小,任意节点的值都 ≤ 其孩子。

▲ 小根堆与大根堆:左边是逻辑结构,右边是对应的数组存储
堆有两条必须记住的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树。
注意:堆只保证"父子之间"的偏序,兄弟节点之间没有大小关系。所以堆不是排序数组——
[10, 15, 56, 25, 30, 70]是小根堆,但数组本身并不是升序的。
2.2 堆的存储方式
因为堆是完全二叉树,所以可以按层序把节点放进数组,既不浪费空间,又能用下标直接定位父子:
- 若
i = 0,i表示的节点是根节点;否则i的双亲节点为(i - 1) / 2; - 若
2 * i + 1 < size,则i的左孩子下标为2 * i + 1,否则没有左孩子; - 若
2 * i + 2 < size,则i的右孩子下标为2 * i + 2,否则没有右孩子。

▲ 完全二叉树适合顺序存储;一般二叉树要补很多空节点,空间利用率低
这也是"堆为什么用数组而不用链表"的原因:完全二叉树的层序编号连续,数组下标就是天然的父子指针,不需要额外存储指针,缓存也更友好。
反过来,非完全二叉树不适合顺序存储:为了还原结构必须把空节点也存下来,空间浪费会非常明显。
2.3 堆的创建
建堆分两层理解:先会"调整一棵子树",再会"把整个数组调整成堆"。
2.3.1 向下调整(shiftDown)
看集合 {27, 15, 19, 18, 28, 34, 65, 49, 25, 37}:根节点 27 比孩子大,但它的左右子树本身已经是小堆了。这时只需要把根节点向下调整。

▲ 27 与较小的孩子 15 交换,交换后继续向下调整
以小堆为例,向下调整的过程:
- 让
parent标记需要调整的节点,child标记parent的左孩子(注意:parent如果有孩子一定先是有左孩子); - 如果
parent的左孩子存在,即child < size,循环执行以下操作,直到parent的左孩子不存在:- 如果右孩子存在,先找到左右孩子中较小的那个,让
child指向它; - 将
parent与child比较:- 若
parent小于等于child,说明已经满足堆性质,调整结束; - 否则交换
parent与child。交换后大的元素下沉,可能破坏子树,于是parent = child; child = 2 * parent + 1;继续向下调整。
- 若
- 如果右孩子存在,先找到左右孩子中较小的那个,让
/**
* 向下调整(小堆)
* 前提:parent 的左右子树都已经是堆
*/
private void shiftDown(int parent) {
int child = 2 * parent + 1; // 先指向左孩子
while (child < size) {
// 右孩子存在,且右孩子更小,则让 child 指向右孩子
if (child + 1 < size && array[child + 1] < array[child]) {
child++;
}
// 双亲已经比最小的孩子小,满足堆性质
if (array[parent] <= array[child]) {
break;
}
swap(parent, child);
// 大的元素继续下沉
parent = child;
child = 2 * parent + 1;
}
}
private void swap(int i, int j) {
int t = array[i];
array[i] = array[j];
array[j] = t;
}
易错点:向下调整的前提是"左右子树已经是堆"。如果子树本身不满足堆性质,先调整根是没用的,必须从下往上建堆。
时间复杂度:最坏从根一路比较到叶子,比较次数为完全二叉树的高度,即 O(logN)。
2.3.2 从数组建堆(createHeap)
对于普通序列 {1, 5, 3, 8, 7, 6},根的左右子树并不满足堆性质,不能直接对根向下调整。
正确做法:从最后一个非叶子节点开始,从后往前,对每个节点做一次向下调整。

▲ 从最后一个非叶子节点往前,逐个向下调整,最终构造出大根堆
public void createHeap() {
// 最后一个非叶子节点的下标: (size - 2) / 2
for (int root = (size - 2) / 2; root >= 0; root--) {
shiftDown(root);
}
}
为什么从后往前?因为向下调整要求子树已经是堆。叶子天然是堆,从最后一个非叶子节点往前处理,轮到某个节点时,它的左右子树一定已经调整好了。
为什么起点是
(size - 2) / 2?最后一个节点下标是size - 1,它的双亲是(size - 2) / 2,这是最后一个有孩子的节点。
2.3.3 建堆的时间复杂度
建堆看起来是"N 个节点 × 每个 O(logN)",但这是上界,不是紧确界。实际复杂度是 O(N)。

▲ 用满二叉树推导建堆复杂度:错位相减后 T(n) ≈ n
推导思路(设树高为 h):
- 第 1 层有
2^0个节点,最多向下移动h - 1层; - 第 2 层有
2^1个节点,最多移动h - 2层; - ……
- 第 h 层(叶子)有
2^(h-1)个节点,移动 0 层。
总移动步数:
T(n) = 2^0*(h-1) + 2^1*(h-2) + ... + 2^(h-2)*1
两边乘 2 后错位相减,得到:
T(n) = 2^h - 1 - h
又因为满二叉树 n = 2^h - 1,即 h = log2(n+1),所以:
T(n) = n - log2(n+1) ≈ n
因此 建堆的时间复杂度为 O(N)。
直观理解:越往下节点越多,但需要移动的距离越短;大部分节点集中在底层,几乎不用动。真正"移动距离长"的只有根附近少数几个节点。
2.4 堆的插入与删除
2.4.1 堆的插入(shiftUp)
插入分两步:
- 把元素放到数组末尾(空间不够先扩容);
- 对新元素向上调整,直到满足堆性质。

▲ 插入 10:先放到末尾,再顺着双亲一路向上交换
public void offer(int e) {
if (size == array.length) {
array = Arrays.copyOf(array, array.length * 2);
}
array[size++] = e;
shiftUp(size - 1);
}
/**
* 向上调整(小堆)
*/
private void shiftUp(int child) {
int parent = (child - 1) / 2;
while (child > 0) {
// 双亲已经比孩子小,满足堆性质
if (array[parent] <= array[child]) {
break;
}
swap(parent, child);
child = parent;
parent = (child - 1) / 2;
}
}
时间复杂度:最多从叶子走到根,O(logN)。
课件代码里
parent = (child - 1) / 1;是笔误,应为/ 2;另外注释"如果双亲比孩子大"也应改成"双亲比孩子小"(以小堆为例)。
2.4.2 堆的删除(poll)
堆的删除一定删除堆顶元素(最高优先级)。步骤:
- 将堆顶元素与堆中最后一个元素交换;
- 将堆中有效数据个数减少一个(逻辑删除末尾);
- 对新的堆顶元素向下调整。

▲ 删除堆顶 10:与末尾 28 交换,删除 10,再让 28 下沉
public int poll() {
if (isEmpty()) {
throw new NoSuchElementException("优先级队列为空");
}
int oldValue = array[0]; // 堆顶
array[0] = array[--size]; // 末尾元素补到堆顶,size 减一
shiftDown(0); // 重新向下调整
return oldValue;
}
为什么不能直接删掉堆顶再整体前移?因为那样会破坏完全二叉树的结构,而且移动是 O(N)。交换 + 下沉只需要 O(logN)。
2.5 用堆模拟实现优先级队列
把上面的操作组装起来,就是一个简易版 PriorityQueue:
import java.util.Arrays;
import java.util.NoSuchElementException;
public class MyPriorityQueue {
private int[] array;
private int size;
public MyPriorityQueue() {
this(11); // 与 JDK 默认容量一致
}
public MyPriorityQueue(int initialCapacity) {
if (initialCapacity < 1) {
throw new IllegalArgumentException("容量不能小于 1");
}
array = new int[initialCapacity];
}
public MyPriorityQueue(int[] arr) {
array = Arrays.copyOf(arr, arr.length);
size = arr.length;
createHeap();
}
// 从最后一个非叶子节点开始,向前逐个向下调整
public void createHeap() {
for (int root = (size - 2) / 2; root >= 0; root--) {
shiftDown(root);
}
}
// 插入:尾插 + 向上调整
public void offer(int e) {
if (size == array.length) {
array = Arrays.copyOf(array, array.length * 2);
}
array[size++] = e;
shiftUp(size - 1);
}
// 删除堆顶:堆顶与末尾交换,有效长度减一,再向下调整
public int poll() {
if (isEmpty()) {
throw new NoSuchElementException("优先级队列为空");
}
int oldValue = array[0];
array[0] = array[--size];
shiftDown(0);
return oldValue;
}
public int peek() {
if (isEmpty()) {
throw new NoSuchElementException("优先级队列为空");
}
return array[0];
}
public boolean isEmpty() {
return size == 0;
}
public int size() {
return size;
}
private void shiftDown(int parent) {
int child = 2 * parent + 1;
while (child < size) {
if (child + 1 < size && array[child + 1] < array[child]) {
child++;
}
if (array[parent] <= array[child]) {
break;
}
swap(parent, child);
parent = child;
child = 2 * parent + 1;
}
}
private void shiftUp(int child) {
int parent = (child - 1) / 2;
while (child > 0) {
if (array[parent] <= array[child]) {
break;
}
swap(parent, child);
child = parent;
parent = (child - 1) / 2;
}
}
private void swap(int i, int j) {
int t = array[i];
array[i] = array[j];
array[j] = t;
}
public static void main(String[] args) {
MyPriorityQueue q = new MyPriorityQueue();
int[] data = {4, 1, 9, 2, 8, 0, 7, 3, 6, 5};
for (int e : data) {
q.offer(e);
}
System.out.println(q.size()); // 10
System.out.println(q.peek()); // 0
q.poll();
q.poll();
System.out.println(q.peek()); // 2
}
}
2.6 常见习题
1. 下列关键字序列为堆的是( )
A: 100,60,70,50,32,65 B: 60,70,65,50,32,100 C: 65,100,70,32,50,60
D: 70,65,100,32,50,60 E: 32,50,100,70,65,60 F: 50,100,70,65,60,32
答案:A
逐项检查父子关系(按大根堆):
- A:100 ≥ 60、70;60 ≥ 50、32;70 ≥ 65,全部满足;
- B:60 < 70,不满足;
- C:65 < 100,不满足;
- D:70 < 100,不满足;
- E:32 < 50,不满足;
- F:50 < 100,不满足。
2. 已知小根堆为 8,15,10,21,34,16,12,删除关键字 8 之后需重建堆,在此过程中,关键字之间的比较次数是( )
A: 1 B: 2 C: 3 D: 4
答案:C
删除过程:8 与末尾 12 交换 → [12,15,10,21,34,16]。
- 比较 15 和 10,选出较小者 10(第 1 次比较);
- 比较 12 和 10,12 > 10,交换(第 2 次比较);
- 12 落到下标 2,其孩子为 16,比较 12 和 16,12 < 16,停止(第 3 次比较)。
共 3 次,选 C。
3. 最小堆 [0,3,2,5,7,4,6,8],在删除堆顶元素 0 之后,结果是( )
A: [3,2,5,7,4,6,8] B: [2,3,5,7,4,6,8] C: [2,3,4,5,7,8,6] D: [2,3,4,5,6,7,8]
答案:C
0 与末尾 8 交换 → [8,3,2,5,7,4,6],删除末尾 0 后向下调整:
- 8 的孩子是 3、2,选较小的 2 交换 →
[2,3,8,5,7,4,6]; - 8 的孩子是 4、6,选较小的 4 交换 →
[2,3,4,5,7,8,6]。
得到 [2,3,4,5,7,8,6],选 C。
课件参考答案里把第 3 题写成了"4.C",是编号笔误,正确答案是 3.C。
三、PriorityQueue 常用接口
3.1 PriorityQueue 的特性
java.util.PriorityQueue 是 Java 集合框架提供的优先级队列,继承关系如下:

▲ PriorityQueue 实现 Queue 接口,底层是堆
使用时要注意:
- 必须导入
java.util.PriorityQueue; - 元素必须可比较大小:要么实现
Comparable,要么构造时传入Comparator,否则抛ClassCastException; - 不能插入
null,否则抛NullPointerException; - 没有容量限制,插入时自动扩容(但扩容有代价,能预估容量最好先给);
- 插入和删除元素的时间复杂度为 O(logN);
- 底层使用堆数据结构;
- 默认情况下是小堆,每次
peek/poll取到的都是最小的元素。
另外两个容易忽略的点:
PriorityQueue线程不安全;多线程场景要用PriorityBlockingQueue;- 它的迭代器不保证有序,想按优先级取元素只能用
poll。
3.2 构造方法
| 构造器 | 说明 |
|---|---|
PriorityQueue() | 创建空队列,默认容量 11 |
PriorityQueue(int initialCapacity) | 指定初始容量,小于 1 抛 IllegalArgumentException |
PriorityQueue(Collection<? extends E> c) | 用集合构造,内部会建堆 |
PriorityQueue(int initialCapacity, Comparator<? super E> comparator) | 指定容量与比较器 |
PriorityQueue(Comparator<? super E> comparator) | 只指定比较器(JDK 8+) |
static void testConstructor() {
// 空的优先级队列,底层默认容量是 11
PriorityQueue<Integer> q1 = new PriorityQueue<>();
// 指定初始容量
PriorityQueue<Integer> q2 = new PriorityQueue<>(100);
// 用集合构造,q3 中已经包含了四个元素
ArrayList<Integer> list = new ArrayList<>(List.of(4, 3, 2, 1));
PriorityQueue<Integer> q3 = new PriorityQueue<>(list);
System.out.println(q3.size()); // 4
System.out.println(q3.peek()); // 1
}
默认是小堆。如果需要大堆,就要自己提供比较器:
// 自定义比较器:实现 Comparator 接口,重写 compare 方法
class IntCmp implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return Integer.compare(o2, o1); // 降序:大的元素优先级更高
}
}
public class TestPriorityQueue {
public static void main(String[] args) {
PriorityQueue<Integer> p = new PriorityQueue<>(new IntCmp());
p.offer(4);
p.offer(3);
p.offer(2);
p.offer(1);
p.offer(5);
System.out.println(p.peek()); // 5
}
}
课件里写的是
return o2 - o1;,这样写虽然直观,但两个Integer相差超过int范围时会溢出,工程上更推荐Integer.compare(o2, o1)。
3.3 插入 / 删除 / 获取优先级最高的元素
| 方法 | 功能 | 时间复杂度 |
|---|---|---|
boolean offer(E e) | 插入元素,失败或 null 抛异常 | O(logN) |
E peek() | 获取优先级最高的元素,空队列返回 null | O(1) |
E poll() | 删除并返回优先级最高的元素,空队列返回 null | O(logN) |
int size() | 获取有效元素个数 | O(1) |
void clear() | 清空 | O(N) |
boolean isEmpty() | 判断是否为空 | O(1) |
static void testPriorityQueue2() {
int[] arr = {4, 1, 9, 2, 8, 0, 7, 3, 6, 5};
// 知道元素个数时,建议直接给好底层容量,避免反复扩容
PriorityQueue<Integer> q = new PriorityQueue<>(arr.length);
for (int e : arr) {
q.offer(e);
}
System.out.println(q.size()); // 10
System.out.println(q.peek()); // 0
// 删除两个元素,再取优先级最高的元素
q.poll();
q.poll();
System.out.println(q.size()); // 8
System.out.println(q.peek()); // 2
q.offer(0);
System.out.println(q.peek()); // 0
// 清空并检测
q.clear();
if (q.isEmpty()) {
System.out.println("优先级队列已经为空!!!");
}
}
peek/poll在队列为空时返回null,不会抛异常;但element/remove在为空时会抛NoSuchElementException,别混用。
3.4 扩容机制
以下是 JDK 1.8 中 PriorityQueue 的扩容代码(JDK 17 只是做了封装,思路一致):
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void grow(int minCapacity) {
int oldCapacity = queue.length;
// Double size if small; else grow by 50%
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
// overflow-conscious code
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
queue = Arrays.copyOf(queue, newCapacity);
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
扩容规则可以总结为三条:
- 容量小于 64 时,按
oldCapacity * 2 + 2扩容; - 容量大于等于 64 时,按
oldCapacity * 1.5扩容; - 超过
MAX_ARRAY_SIZE时,按MAX_ARRAY_SIZE扩容,再大就抛OutOfMemoryError。
所以"PriorityQueue 没有容量限制"指的是会自动扩容,而不是真的无限。频繁扩容要拷贝数组,能预估数据量时直接指定初始容量更划算。
3.5 OJ 练习:最小的 K 个数
最直接的写法:所有元素入小堆,再连续 poll k 次。
class Solution {
public int[] smallestK(int[] arr, int k) {
if (arr == null || k <= 0 || k > arr.length) {
return new int[0];
}
PriorityQueue<Integer> q = new PriorityQueue<>();
for (int e : arr) {
q.offer(e);
}
int[] ret = new int[k];
for (int i = 0; i < k; i++) {
ret[i] = q.poll();
}
return ret;
}
}
时间复杂度 O(N + klogN),空间复杂度 O(N)。这只是一个"会用 PriorityQueue"的解法,并不是最优解。要真正做到 O(NlogK)、O(K) 空间,需要维护一个大小为 K 的堆——这就是下面 Top-K 的思路。
四、堆的应用
4.1 用堆实现 PriorityQueue
前面 MyPriorityQueue 已经演示了核心思路:数组存完全二叉树,offer 向上调整,poll 交换堆顶后向下调整。
JDK 的实现更通用:数组元素是 Object,通过 Comparable 或 Comparator 比较;扩容、判空、批量建堆等细节更完善,但骨架和我们的实现一致。
4.2 堆排序
堆排序就是利用堆的思想排序,分两步:
- 建堆:升序建大堆,降序建小堆;
- 利用堆删除思想排序:每次把堆顶(当前最大值)与末尾交换,有效长度减一,再对新堆顶向下调整。

▲ 堆排序全过程:建大堆,然后不断把堆顶换到末尾并向下调整
public static void heapSort(int[] arr) {
// 1. 建大堆
for (int root = (arr.length - 2) / 2; root >= 0; root--) {
shiftDown(arr, arr.length, root);
}
// 2. 每轮把堆顶(最大值)换到末尾,再调整剩余部分
for (int end = arr.length - 1; end > 0; end--) {
int t = arr[0];
arr[0] = arr[end];
arr[end] = t;
shiftDown(arr, end, 0);
}
}
/**
* 大堆的向下调整
* @param size 当前堆的有效长度(不包含已经排好的末尾元素)
*/
private static void shiftDown(int[] arr, int size, int parent) {
int child = 2 * parent + 1;
while (child < size) {
if (child + 1 < size && arr[child + 1] > arr[child]) {
child++;
}
if (arr[parent] >= arr[child]) {
break;
}
int t = arr[parent];
arr[parent] = arr[child];
arr[child] = t;
parent = child;
child = 2 * parent + 1;
}
}
复杂度:建堆 O(N),每次调整 O(logN),共 N 次,所以总复杂度 O(NlogN);原地排序,空间 O(1);因为存在长距离交换,不稳定。
为什么升序要大堆?因为大堆的堆顶是最大值,换到末尾正好落在它的最终位置上;如果用小堆,堆顶是最小值,换到末尾反而把最小值放到了最后。
常见习题: 一组记录排序码为 (5 11 7 2 3 17),利用堆排序方法建立的初始堆为( )
A: (11 5 7 2 3 17) B: (11 5 7 2 17 3) C: (17 11 7 2 3 5)
D: (17 11 7 5 3 2) E: (17 7 11 3 5 2) F: (17 7 11 3 2 5)
答案:C
建大堆过程:从 root = (6 - 2) / 2 = 2 开始,7 与 17 交换 → [5,11,17,2,3,7];再处理 root = 1,11 已经大于孩子 2、3,不动;最后处理 root = 0,5 与较大的孩子 17 交换 → [17,11,5,2,3,7],5 继续与 7 交换 → [17,11,7,2,3,5]。选 C。
4.3 Top-K 问题
Top-K 问题:求数据集合中前 K 个最大的元素或最小的元素。一般数据量都比较大,比如专业前 10 名、世界 500 强、游戏前 100 的活跃玩家。
最简单直接的方式是排序,但当数据量大到不能一次性加载进内存时,排序就不可取了。最佳方式是用堆:
- 用数据集合中前 K 个元素建堆:
- 求前 K 个最大元素,建小堆;
- 求前 K 个最小元素,建大堆;
- 用剩余的 N-K 个元素依次与堆顶比较,不满足条件就替换堆顶,然后向下调整;
- 遍历完剩下的元素,堆中留下的 K 个元素就是答案。
为什么求"最大 K 个"反而建小堆?因为小堆的堆顶是这 K 个元素里最小的那个,也就是"当前候选集合里最差的一个"。新元素只要比堆顶大,就有资格挤掉它;如果比堆顶还小,连候选资格都没有。
以"最大的 K 个"为例:
public static int[] topK(int[] arr, int k) {
if (arr == null || k <= 0 || arr.length == 0) {
return new int[0];
}
k = Math.min(k, arr.length);
// 求最大的 K 个:维护一个大小为 K 的小根堆
PriorityQueue<Integer> heap = new PriorityQueue<>(k);
for (int i = 0; i < k; i++) {
heap.offer(arr[i]);
}
for (int i = k; i < arr.length; i++) {
if (arr[i] > heap.peek()) {
heap.poll();
heap.offer(arr[i]);
}
}
int[] ret = new int[k];
for (int i = 0; i < k; i++) {
ret[i] = heap.poll();
}
return ret;
}
求"最小的 K 个"时,把比较器换成大堆即可:
PriorityQueue<Integer> heap = new PriorityQueue<>(k, Comparator.reverseOrder());
// 后续判断改成 if (arr[i] < heap.peek())
复杂度:建堆 O(K),剩余 N-K 个元素每个最多调整一次 O(logK),总复杂度 O(NlogK),额外空间 O(K)。相比于全量排序的 O(NlogN),K 远小于 N 时优势非常明显,而且只需要 K 个元素的内存,适合海量数据和流式数据。
五、小结
把这一讲的核心复杂度集中在一张表里:
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 建堆 | O(N) | 从最后一个非叶子节点往前向下调整 |
| 插入 | O(logN) | 尾插 + 向上调整 |
| 删除堆顶 | O(logN) | 堆顶与末尾交换 + 向下调整 |
| 获取堆顶 | O(1) | 直接取 array[0] |
| 堆排序 | O(NlogN) | 建堆 + N 次调整,原地排序 |
| Top-K | O(NlogK) | 维护大小为 K 的堆 |
几个必须记住的结论:
- 堆 = 完全二叉树 + 偏序,用数组层序存储,父子下标关系是
(i-1)/2、2i+1、2i+2; - 向下调整的前提是子树已经是堆,所以建堆必须从后往前;
- 建堆是 O(N),不是 O(NlogN)——这是面试高频考点;
- 插入用向上调整,删除堆顶用向下调整;
PriorityQueue默认是小堆,要大堆传Comparator,元素不能为null,线程不安全;- 求最大 K 个建小堆,求最小 K 个建大堆,这是 Top-K 的口诀。
下一篇进入排序章节,堆排序会和插入、希尔、归并、快速排序放在一起对比,届时可以回头再看这一篇的建堆与调整,理解会更顺。
如果这篇笔记帮你把"堆"理顺了,麻烦点个赞 👍、收藏 ⭐ 支持一下,也欢迎关注我,后续会继续更新《Java 数据结构》系列笔记。
你的每一个赞和收藏,都是我继续写下去的动力,感谢阅读!
本文系统讲解Java中优先级队列的底层实现——堆,涵盖堆的概念、数组存储方式、建堆(含向下调整与O(N)时间复杂度分析)、插入(shiftUp)与删除(poll)操作;深入剖析JDK PriorityQueue的特性、构造、接口使用及扩容机制;重点解析Top-K问题的最优解法:用大小为K的堆实现O(NlogK)时间与O(K)空间复杂度。
2373

被折叠的 条评论
为什么被折叠?



