【Java基础】堆与优先级的艺术:从急诊分诊到Top-K,手写一个PriorityQueue

堆与优先级的艺术:从急诊分诊到Top-K,手写一个PriorityQueue


1. 面试真题引入

场景还原:某大厂二面现场。

面试官:给你 10 亿条搜索日志,找出搜索次数最多的前 100 个关键词,你会怎么做?

候选人:先统计频率,然后排序取前 100。

面试官:10 亿条数据全排序?内存和时间撑得住吗?

候选人 A:额......建大顶堆,每次弹出堆顶?

面试官:你建大顶堆需要 O(n) 建堆 + O(k log n) 弹出,n=10 亿时建堆本身就很昂贵。再想想,有没有更优雅的方案?

如果你也犹豫了,这篇文章就是为你准备的。Top-K 问题是面试中的超高频考点,其最优解"小顶堆 + 门槛淘汰"是检验你是否真正理解堆的一把标尺。我们从底层源码出发,一步步揭开堆的面纱。


2. 底层时空解构:二叉堆的源码级透视

2.1 堆是什么?------数组里藏着一棵树

堆(Heap)是一种完全二叉树 ,同时满足堆序性质

  • 小顶堆(Min-Heap):任意节点 ≤ 其子节点。根是最小值。
  • 大顶堆(Max-Heap):任意节点 ≥ 其子节点。根是最大值。

关键创新在于数组存储 。对于索引 i 的节点:

关系 公式
父节点索引 (i - 1) / 2
左子节点索引 2 * i + 1
右子节点索引 2 * i + 2

一棵树不用指针,靠下标计算就能完成父子定位------这就是堆的高效根源。

下面是一棵小顶堆在数组和树形两个视角下的对照:

复制代码
数组视角:[2, 5, 8, 12, 9, 10, 15]
索引:    0   1  2   3  4   5   6

树形视角:
         2
       /   \
      5     8
     / \   / \
    12  9 10 15

验证:节点 5(索引 1)→ 左子 = 2×1+1=3 ✓(值为12),父 = (1-1)/2=0 ✓(值为2)

类比:这就像学校体测排队------最矮的同学站在排头(根节点=堆顶=最值),后面的人层层递增。后面会详细展开这个类比。

2.2 上浮与下沉:堆的核心操作

堆的所有操作都围绕两个原子动作展开。下面我们手写一份不依赖 JDK 任何类的纯手工二叉堆实现,彻底理解底层逻辑。

java 复制代码
/**
 * 手写小顶堆------零依赖,只有数组和两个核心操作
 * 泛型 T 必须实现 Comparable,表示元素本身携带比较能力
 */
public class MinHeap<T extends Comparable<T>> {

    private Object[] data;      // 底层数组存储
    private int size;           // 当前元素个数

    public MinHeap(int capacity) {
        this.data = new Object[capacity];
        this.size = 0;
    }

    // ==================== 下沉(Shift Down) ====================
    /**
     * 从上往下调整:当堆顶被替换为较大值时,
     * 把它逐步"沉"到满足堆序的位置
     *
     * @param k 需要下沉的起始索引
     */
    @SuppressWarnings("unchecked")
    private void shiftDown(int k) {
        T current = (T) data[k];           // 暂存当前元素
        int half = size >>> 1;              // 非叶子节点边界:size/2

        while (k < half) {                 // 只对非叶子节点下沉
            int child = (k << 1) + 1;      // 左子索引:2*k+1
            int right = child + 1;          // 右子索引

            // 选左右孩子中较小的那个
            if (right < size
                && ((T) data[child]).compareTo((T) data[right]) > 0) {
                child = right;
            }

            // 如果当前元素已经 ≤ 最小孩子,堆序满足,停止
            if (current.compareTo((T) data[child]) <= 0) {
                break;
            }

            // 否则最小孩子上移,k 下潜到孩子位置继续比较
            data[k] = data[child];
            k = child;
        }
        data[k] = current;                 // 放入最终位置
    }

    // ==================== 上浮(Shift Up) ====================
    /**
     * 从下往上调整:当新插入元素很小时,
     * 把它逐步"浮"到满足堆序的位置
     *
     * @param k 需要上浮的起始索引(通常为新插入元素的索引)
     */
    @SuppressWarnings("unchecked")
    private void shiftUp(int k) {
        T current = (T) data[k];           // 暂存新元素

        while (k > 0) {
            int parent = (k - 1) >>> 1;    // 父节点索引:(k-1)/2

            // 如果新元素 ≥ 父节点,堆序满足,停止
            if (current.compareTo((T) data[parent]) >= 0) {
                break;
            }

            // 否则父节点下移,k 上浮到父节点位置继续比较
            data[k] = data[parent];
            k = parent;
        }
        data[k] = current;                 // 放入最终位置
    }

    // ==================== 插入 ====================
    public void offer(T element) {
        if (size == data.length) {
            throw new IllegalStateException("堆已满");
        }
        // 放入末尾,然后上浮
        data[size] = element;
        shiftUp(size);
        size++;
    }

    // ==================== 删除堆顶 ====================
    @SuppressWarnings("unchecked")
    public T poll() {
        if (size == 0) {
            return null;
        }
        T result = (T) data[0];            // 保存堆顶
        // 末尾元素补到堆顶,然后下沉
        data[0] = data[size - 1];
        data[size - 1] = null;             // 帮助 GC
        size--;
        if (size > 0) {
            shiftDown(0);
        }
        return result;
    }

    // ==================== 查看堆顶 ====================
    @SuppressWarnings("unchecked")
    public T peek() {
        return size == 0 ? null : (T) data[0];
    }

    public int size() { return size; }
}

图解视角还原------配合图 10-1 理解两个操作的动态过程:

2.3 JDK PriorityQueue 底层源码解读

了解了手写实现,再看 JDK 源码就一目了然。PriorityQueue 的核心成员:

java 复制代码
public class PriorityQueue<E> extends AbstractQueue<E> {
    transient Object[] queue;   // 底层数组
    int size;                   // 元素个数
    private final Comparator<? super E> comparator;  // 自定义比较器

    // 默认构造------使用自然顺序,即小顶堆
    public PriorityQueue() {
        this(DEFAULT_INITIAL_CAPACITY, null);
    }
}

JDK 的实现和我们手写的版本惊人一致:

操作 JDK 方法 核心逻辑
插入 siftUp(int k, E x) 从索引 k 开始,逐层与父节点比较上浮
删除堆顶 siftDown(int k, E x) 从索引 k 开始,逐层与较小子节点比较下沉
建堆 heapify() size/2 往前,对每个非叶子节点执行下沉

三个关键设计细节值得注意:

  1. 默认小顶堆 :不传 Comparator 时,元素按自然顺序排列,堆顶是最小值。这与很多人"堆顶应该是最大"的直觉相反。
  2. 动态扩容grow() 方法中,小容量时翻倍(oldCapacity < 64),大容量时增长 50%,无需手动管理容量。
  3. 无界队列PriorityQueue 理论上可以无限插入,不受初始容量限制(会自动扩容),这一点与 ArrayBlockingQueue 不同。

3. 原创工程案例:从代码到现实

案例一:医院急诊智能分诊系统

急诊室的核心规则:病情越严重,越优先处理。这正是小顶堆(优先级数值越小越优先)的用武之地。

java 复制代码
/**
 * 急诊患者------携带病情等级与到达时间
 */
class Patient implements Comparable<Patient> {
    String name;
    int severity;    // 1=红区(危重)2=黄区(紧急)3=绿区(普通)
    long arriveTime; // 到达时间戳,同等级下先到先处理

    Patient(String name, int severity) {
        this.name = name;
        this.severity = severity;
        this.arriveTime = System.currentTimeMillis();
    }

    @Override
    public int compareTo(Patient other) {
        // 先按病情(数越小越紧急),再按到达时间
        if (this.severity != other.severity) {
            return Integer.compare(this.severity, other.severity);
        }
        return Long.compare(this.arriveTime, other.arriveTime);
    }
}

/**
 * 急诊分诊调度中心
 */
public class EmergencyTriage {
    private PriorityQueue<Patient> queue = new PriorityQueue<>();

    // 新患者到达
    public void arrive(Patient p) {
        queue.offer(p);
        System.out.printf("[入诊] %s(%d级)已加入等待队列,当前排队人数:%d\n",
                          p.name, p.severity, queue.size());
    }

    // 叫号下一个患者
    public Patient callNext() {
        Patient next = queue.poll();
        if (next != null) {
            System.out.printf("[叫号] %s(%d级)请到诊室就诊\n",
                              next.name, next.severity);
        }
        return next;
    }

    // 测试
    public static void main(String[] args) {
        EmergencyTriage triage = new EmergencyTriage();

        // 模拟患者陆续到达
        triage.arrive(new Patient("小王", 3));   // 绿区,普通
        triage.arrive(new Patient("老李", 1));   // 红区,危重!
        triage.arrive(new Patient("小张", 2));   // 黄区,紧急
        triage.arrive(new Patient("老赵", 1));   // 红区,危重!

        // 按优先级叫号------危重患者先看病
        System.out.println("\n===== 开始叫号 =====");
        Patient next;
        while ((next = triage.callNext()) != null) {
            // 持续叫号直到队列为空
        }
    }
}

运行结果

复制代码
[入诊] 小王(3级)已加入等待队列,当前排队人数:1
[入诊] 老李(1级)已加入等待队列,当前排队人数:2
[入诊] 小张(2级)已加入等待队列,当前排队人数:3
[入诊] 老赵(1级)已加入等待队列,当前排队人数:4

===== 开始叫号 =====
[叫号] 老李(1级)请到诊室就诊   ← 先到先得?不是,危重优先!
[叫号] 老赵(1级)请到诊室就诊   ← 同为红区,按到达时间排序
[叫号] 小张(2级)请到诊室就诊
[叫号] 小王(3级)请到诊室就诊

compareTo 中先比 severity 再比 arriveTime 的设计保证了:病情第一、时间第二------这正是堆排序规则在现实场景中的直观映射。

案例二:热搜排行榜 Top-10 实时维护

每条热搜词条有实时热度值。我们用一个固定容量为 10 的小顶堆持续维护当前 Top-10------堆顶就是第 10 名的热度门槛。

java 复制代码
/**
 * 实时热搜 Top-10 维护系统
 */
public class HotSearchTopK {

    // 小顶堆维护 Top-10:堆顶是门槛(当前第10名)
    private PriorityQueue<Entry> topK;
    private int k;

    static class Entry implements Comparable<Entry> {
        String keyword;
        int heat;          // 热度值

        Entry(String keyword, int heat) {
            this.keyword = keyword;
            this.heat = heat;
        }

        @Override
        public int compareTo(Entry other) {
            return Integer.compare(this.heat, other.heat);  // 小顶堆
        }

        @Override
        public String toString() {
            return String.format("%s(%d)", keyword, heat);
        }
    }

    public HotSearchTopK(int k) {
        this.k = k;
        this.topK = new PriorityQueue<>(k);
    }

    // 新词条进入------和堆顶门槛比较
    public void add(Entry e) {
        if (topK.size() < k) {
            // 还没满,直接进堆
            topK.offer(e);
        } else if (e.heat > topK.peek().heat) {
            // 比门槛高:淘汰门槛,新词入堆
            Entry eliminated = topK.poll();
            topK.offer(e);
            System.out.printf("[替换] %s 淘汰,%s 入榜\n", eliminated, e);
        } else {
            // 不达门槛,直接淘汰
            System.out.printf("[淘汰] %s 热度不够,被拒之门外\n", e);
        }
    }

    // 输出当前 Top-K(按热度降序)
    public void printTopK() {
        // 复制到列表排序后输出(不影响原堆结构)
        List<Entry> sorted = new ArrayList<>(topK);
        sorted.sort((a, b) -> Integer.compare(b.heat, a.heat));

        System.out.println("\n===== 当前热搜 Top-" + k + " =====");
        for (int i = 0; i < sorted.size(); i++) {
            System.out.printf("  #%d  %s\n", i + 1, sorted.get(i));
        }
    }

    public static void main(String[] args) {
        HotSearchTopK hotSearch = new HotSearchTopK(3);  // 示范 Top-3

        hotSearch.add(new Entry("高考成绩", 980));
        hotSearch.add(new Entry("梅西退役", 860));
        hotSearch.add(new Entry("新手机发布", 720));

        // 热度 820,大于堆顶 720(门槛),替换入榜
        hotSearch.add(new Entry("暴雨预警", 820));

        // 热度 500,小于堆顶 820(新门槛),直接淘汰
        hotSearch.add(new Entry("谁谁恋情", 500));

        hotSearch.printTopK();
    }
}

运行结果

复制代码
[替换] 新手机发布(720) 淘汰,暴雨预警(820) 入榜
[淘汰] 谁谁恋情(500) 热度不够,被拒之门外

===== 当前热搜 Top-3 =====
  #1  高考成绩(980)
  #2  梅西退役(860)
  #3  暴雨预警(820)

效率分析:每次插入/淘汰只需 O(log K),K=10 时几乎可视为常数操作。如果暴力全排序,每次都是 O(n log n)。


4. 避坑指南:从 C/Python 转 Java 最容易踩的三个坑

坑1:PriorityQueue 默认是小顶堆

这是 C++ 程序员转 Java 最频繁的"工伤事故"。C++ 的 std::priority_queue 默认是大顶堆

cpp 复制代码
// C++:默认大顶堆,top() 返回最大值
priority_queue<int> pq;  // 大顶堆!

而 Java 是小顶堆

java 复制代码
// Java:默认小顶堆,peek() 返回最小值
PriorityQueue<Integer> pq = new PriorityQueue<>();  // 小顶堆!

记忆技巧 :Java 的 PriorityQueue 配合 Comparable 使用------a.compareTo(b) < 0 表示 a 排在前面,所以"小的排前面"→ 小顶堆。

坑2:把堆当成排序数组来遍历

PriorityQueuetoString()toArray() 不保证有序 。只有 peek()poll() 返回的元素才保证堆序:

java 复制代码
PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.offer(5); pq.offer(1); pq.offer(3);

System.out.println(pq);          // [1, 5, 3] ← 这不是排序结果!
System.out.println(pq.peek());   // 1 ← 堆顶确实是最小的

堆只需保证父子间的偏序关系,兄弟节点之间没有顺序约束。

坑3:想让自定义类进堆,忘写 compareTo

PriorityQueue 需要知道怎么比较元素。如果自定义类没有实现 Comparable,也不传 Comparator,运行时直接抛 ClassCastException

java 复制代码
class Task { int priority; }  // 没实现 Comparable

PriorityQueue<Task> pq = new PriorityQueue<>();
pq.offer(new Task());  // ❌ ClassCastException!

解法 :要么 implements Comparable<Task>,要么构造时传 Comparator


5. 面试连环炮:Top-K 题花式追问

面试最容易从这里发起连续追问,下面是两道必答题及高分话术。

第一炮:前K大,建大顶堆还是小顶堆?

面试官:求海量数据中前 K 个最大的数,应该建什么堆?为什么?

高分话术

应该建小顶堆,大小只维护 K。堆顶永远存着当前 Top-K 中最小的那个值------它就是"门槛"。新元素和堆顶比较:大于门槛才能入堆(替换掉旧门槛),小于等于门槛直接丢弃。

时间复杂度 O(n log K),空间复杂度 O(K)。对比全排序 O(n log n),当 K 远小于 n 时优势巨大------比如 10 亿数据取 Top-100,K=100 时 logK 只有约 7,几乎可视为线性。

反过来,求前 K 小的数 就建大顶堆 ,堆顶是当前 K 个中最"大"的门槛。口诀:求大用小,求小用大------求前K大时用小顶堆淘汰小的,求前K小时用大顶堆淘汰大的。

第二炮:堆排序 O(n log n) 很稳定,为什么工程中更常用快排?

面试官:堆排序理论上 O(n log n) 很稳定,但为什么实际工程里更常用快速排序?

高分话术

核心原因在于 CPU 缓存不友好。快速排序对数组是连续访问(分区时左右指针逐步移动),数据局部性极好,缓存命中率高。

堆排序在 shiftDown 过程中,每次比较都需要跳转到 2*i+1 的子节点位置------这是跳跃式访问,破坏了空间局部性,导致大量缓存未命中。

同时,快排的常数因子更小------交换操作集中在数组局部区域,而堆排序每次交换跨越距离大。综合下来,快排在实际工程中的平均表现优于堆排序。

不过堆排序有它的不可替代性:它能高效找出第 K 大/小的值,且空间复杂度严格 O(1)------不需要递归栈空间。



6. 通俗类比

把"堆"比作学校体测排队

  • 最矮的站在排头 → 小顶堆(堆顶=最小值=门槛)
  • 新来的同学和排头比身高 → 新元素和堆顶比大小
  • 比排头还矮 → 换人(进入堆)。比排头高 → 站队尾去(被淘汰)
  • 排头就是"第 1 矮",换成 Top-K 就是"第 K 大/小"的门槛

这个类比的核心洞察是:堆的堆顶不是你想要的最终答案,它只是你用来快速筛选的"门槛"。