Java 数据结构:优先级队列与堆,从建堆到 Top-K

本文系统讲解Java中优先级队列的底层实现——堆,涵盖堆的概念、数组存储方式、建堆(含向下调整与O(N)时间复杂度分析)、插入(shiftUp)与删除(poll)操作;深入剖析JDK PriorityQueue的特性、构造、接口使用及扩容机制;重点解析Top-K问题的最优解法:用大小为K的堆实现O(NlogK)时间与O(K)空间复杂度。

队列讲的是"先来后到",可生活里更多时候是"谁急谁先来":打游戏时来电要立刻接、急诊按病情分级、成绩好的同学先挑座位……这些场景用普通队列都不合适,需要的是优先级队列。这篇笔记从"为什么需要优先级队列"讲起,把堆的概念、存储、建堆、插入删除、PriorityQueue 的用法和 Top-K 一次串完,代码都按 Java 手写,并补上了课件里省略的边界处理。

本节目标

  1. 掌握堆的概念及实现
  2. 掌握 PriorityQueue 的使用
  3. 能用堆解决 Top-K 一类的问题

一、优先级队列:从 FIFO 到"按优先级出队"

1.1 为什么普通队列不够用

前面学的队列是 FIFO(先进先出):元素从队尾进、从队头出,顺序完全由到达时间决定。

但很多场景里,到达时间并不是最重要的:

  • 手机正在打游戏,忽然来了电话,系统必须优先处理来电;
  • 初中班主任排座位,可能让成绩好的同学先挑;
  • 医院急诊分诊,病情危重的人先看,而不是先挂号的人先看;
  • 操作系统的任务调度,优先级高的进程先占用 CPU。

这些场景的共同点是:数据自带一个优先级,出队时应该让优先级最高的先出。

一句话:普通队列是"先来后到",优先级队列是"优先级高的先来"。

1.2 优先级队列需要提供什么

优先级队列(Priority Queue)至少要有两个基本操作:

  1. 返回最高优先级对象(peek);
  2. 添加新的对象(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, …,则称它为小堆或大堆。

  • 大根堆(最大堆):根节点最大,任意节点的值都 ≥ 其孩子;
  • 小根堆(最小堆):根节点最小,任意节点的值都 ≤ 其孩子。


▲ 小根堆与大根堆:左边是逻辑结构,右边是对应的数组存储

堆有两条必须记住的性质:

  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 交换,交换后继续向下调整

以小堆为例,向下调整的过程:

  1. 让 parent 标记需要调整的节点,child 标记 parent 的左孩子(注意:parent 如果有孩子一定先是有左孩子);
  2. 如果 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)

插入分两步:

  1. 把元素放到数组末尾(空间不够先扩容);
  2. 对新元素向上调整,直到满足堆性质。


▲ 插入 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)

堆的删除一定删除堆顶元素(最高优先级)。步骤:

  1. 将堆顶元素与堆中最后一个元素交换;
  2. 将堆中有效数据个数减少一个(逻辑删除末尾);
  3. 对新的堆顶元素向下调整。


▲ 删除堆顶 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]。

  1. 比较 15 和 10,选出较小者 10(第 1 次比较);
  2. 比较 12 和 10,12 > 10,交换(第 2 次比较);
  3. 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 接口,底层是堆

使用时要注意:

  1. 必须导入 java.util.PriorityQueue;
  2. 元素必须可比较大小:要么实现 Comparable,要么构造时传入 Comparator,否则抛 ClassCastException;
  3. 不能插入 null,否则抛 NullPointerException;
  4. 没有容量限制,插入时自动扩容(但扩容有代价,能预估容量最好先给);
  5. 插入和删除元素的时间复杂度为 O(logN);
  6. 底层使用堆数据结构;
  7. 默认情况下是小堆,每次 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()获取优先级最高的元素,空队列返回 nullO(1)
E poll()删除并返回优先级最高的元素,空队列返回 nullO(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 堆排序

堆排序就是利用堆的思想排序,分两步:

  1. 建堆:升序建大堆,降序建小堆;
  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 的活跃玩家。

最简单直接的方式是排序,但当数据量大到不能一次性加载进内存时,排序就不可取了。最佳方式是用堆:

  1. 用数据集合中前 K 个元素建堆:
    • 求前 K 个最大元素,建小堆;
    • 求前 K 个最小元素,建大堆;
  2. 用剩余的 N-K 个元素依次与堆顶比较,不满足条件就替换堆顶,然后向下调整;
  3. 遍历完剩下的元素,堆中留下的 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-KO(NlogK)维护大小为 K 的堆

几个必须记住的结论:

  1. 堆 = 完全二叉树 + 偏序,用数组层序存储,父子下标关系是 (i-1)/2、2i+1、2i+2;
  2. 向下调整的前提是子树已经是堆,所以建堆必须从后往前;
  3. 建堆是 O(N),不是 O(NlogN)——这是面试高频考点;
  4. 插入用向上调整,删除堆顶用向下调整;
  5. PriorityQueue 默认是小堆,要大堆传 Comparator,元素不能为 null,线程不安全;
  6. 求最大 K 个建小堆,求最小 K 个建大堆,这是 Top-K 的口诀。

下一篇进入排序章节,堆排序会和插入、希尔、归并、快速排序放在一起对比,届时可以回头再看这一篇的建堆与调整,理解会更顺。


如果这篇笔记帮你把"堆"理顺了,麻烦点个赞 👍、收藏 ⭐ 支持一下,也欢迎关注我,后续会继续更新《Java 数据结构》系列笔记。

你的每一个赞和收藏,都是我继续写下去的动力,感谢阅读!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值