优先队列:从Java源码到实战

一、引言:为什么需要优先队列?

在日常生活和算法竞赛中,我们经常遇到这样的场景:

  • 医院急诊室根据病情严重程度决定就诊顺序,而不是先来后到

  • 操作系统调度任务时,高优先级的进程先获得CPU

  • 海量数据中找出最大的K个数

这些问题的共同特点是:我们需要一种能够动态维护元素集合,并快速取出"最值"的数据结构。普通队列FIFO无法满足,普通数组查找最值需要O(n),而优先队列能在O(log n)时间内完成插入和删除最值操作。

Java中的PriorityQueue正是为此而生,它基于二叉堆实现,是面试和算法题中的常客。


二、优先队列的核心原理

2.1 二叉堆:优先队列的骨架

优先队列的底层通常采用二叉堆数据结构。二叉堆是一棵完全二叉树,满足以下性质:

  • 堆序性:任意节点的值不大于(或不小于)其子节点的值

  • 小顶堆:父节点 ≤ 子节点,堆顶为最小值

  • 大顶堆:父节点 ≥ 子节点,堆顶为最大值

为什么用完全二叉树?因为可以用数组紧凑存储,不需要指针,且父子节点索引关系简单:

复制代码
对于节点 i(从0开始):
左子节点 = 2*i + 1
右子节点 = 2*i + 2
父节点 = (i - 1) / 2

2.2 核心操作详解

插入:

将新元素放在数组末尾,然后上浮 :不断与父节点比较,如果违背堆序则交换,直到满足堆序。

最坏情况:从叶子到根,比较次数 = 树高 = O(log n)

删除堆顶

将堆顶元素移除,把数组末尾元素放到堆顶,然后下沉 :不断与较小的子节点比较,如果大于子节点则交换,直到满足堆序。

最坏情况:从根到叶子,比较次数 = 树高 = O(log n)

查看堆顶

直接返回数组第一个元素,O(1)。

2.3 时间复杂度总结

操作 时间复杂度
插入(offer) O(log n)
删除堆顶(poll) O(log n)
查看堆顶(peek) O(1)
删除任意元素(remove) O(n) + O(log n)
构建堆(heapify) O(n)
堆排序 O(n log n)

注意:删除任意元素需要先遍历查找(O(n)),然后上浮或下沉(O(log n)),实际使用较少。


三、Java PriorityQueue 源码深度剖析

3.1 类结构概述

java 复制代码
public class PriorityQueue<E> extends AbstractQueue<E> 
    implements Serializable {
    // 底层数组,默认初始容量11
    transient Object[] queue;
    // 当前元素个数
    private int size;
    // 比较器,决定堆序
    private final Comparator<? super E> comparator;
}

PriorityQueue是无界队列(基于数组动态扩容),不允许null元素。它默认是小顶堆,可以通过Comparator定制大顶堆或其他排序规则。

3.2 核心方法源码分析

java 复制代码
public boolean offer(E e) {
    if (e == null) throw new NullPointerException();
    modCount++;
    int i = size;
    if (i >= queue.length)
        grow(i + 1);  // 扩容:通常翻倍
    size = i + 1;
    if (i == 0)
        queue[0] = e;
    else
        siftUp(i, e);
    return true;
}

关键的siftUp实现:

java 复制代码
private void siftUp(int k, E x) {
    if (comparator != null)
        siftUpUsingComparator(k, x);
    else
        siftUpComparable(k, x);
}

private void siftUpComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>) x;
    while (k > 0) {
        int parent = (k - 1) >>> 1;  // 父节点索引
        Object e = queue[parent];
        if (key.compareTo((E) e) >= 0)
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = key;
}

逻辑非常清晰:从插入位置开始,不断与父节点比较,如果当前元素更小则上移父节点,直到找到合适位置。

poll() - 删除堆顶

java 复制代码
public E poll() {
    if (size == 0) return null;
    int s = --size;
    modCount++;
    E result = (E) queue[0];
    E x = (E) queue[s];
    queue[s] = null;
    if (s != 0)
        siftDown(0, x);
    return result;
}

siftDown实现(以无比较器为例):

java 复制代码
private void siftDownComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>) x;
    int half = size >>> 1;  // 最后一个非叶子节点索引
    while (k < half) {
        int child = (k << 1) + 1;  // 左子节点
        Object c = queue[child];
        int right = child + 1;
        if (right < size &&
            ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
            child = right;  // 选择较小的子节点
        if (key.compareTo((E) c) <= 0)
            break;
        queue[k] = c;
        k = child;
    }
    queue[k] = key;
}

这里的关键优化:不需要比较到叶子节点,只要下沉到half位置(最后一个非叶子节点)即可,因为叶子节点没有子节点。

3.3 扩容机制

size >= queue.length时触发扩容。新容量计算规则:

  • 如果旧容量 < 64,新容量 = 旧容量 * 2 + 2

  • 否则新容量 = 旧容量 * 1.5

3.4 迭代器与有序性

PriorityQueue的迭代器不保证有序 ,因为迭代器按数组顺序遍历,而非堆序。要获取有序序列,需要不断调用poll(),但会破坏原队列。


四、优先队列在力扣中的经典应用

我们将通过5道精心挑选的力扣题,展示优先队列在不同场景下的妙用。

题目1:【215】数组中的第K个最大元素

题目描述

给定整数数组nums和整数k,请返回数组中第k个最大的元素。

示例:[3,2,1,5,6,4],k=2 → 5

思路分析

解法很多,但优先队列提供优雅的O(n log k)方案。维护一个大小为k的小顶堆,堆顶就是当前k个最大元素中的最小值,遍历完整个数组后堆顶即为第k大。

代码实现

java 复制代码
public int findKthLargest(int[] nums, int k) {
    // 小顶堆,保留最大的k个元素
    PriorityQueue<Integer> heap = new PriorityQueue<>();
    for (int num : nums) {
        heap.offer(num);
        if (heap.size() > k) {
            heap.poll(); // 移除最小的,保持堆大小为k
        }
    }
    return heap.peek();
}

复杂度分析

  • 时间:O(n log k),n为数组长度

  • 空间:O(k),堆的大小

延伸思考

如果k接近n,可用快速选择(O(n))更优;如果k较小(如k=1),优先队列简单直接。这道题也常考手写堆排序变种。


题目2:【347】前 K 个高频元素

题目描述

给定整数数组nums和一个整数k,返回出现频率前k高的元素。

示例:[1,1,1,2,2,3],k=2 → [1,2]

思路分析

先统计频率(HashMap),再按频率排序。使用小顶堆维护频率最高的k个元素,堆顶是这k个中频率最低的。

代码实现

java 复制代码
public int[] topKFrequent(int[] nums, int k) {
    // 1. 统计频率
    Map<Integer, Integer> freq = new HashMap<>();
    for (int num : nums) {
        freq.put(num, freq.getOrDefault(num, 0) + 1);
    }
    
    // 2. 小顶堆,按频率比较
    PriorityQueue<Map.Entry<Integer, Integer>> heap = 
        new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
    
    for (Map.Entry<Integer, Integer> entry : freq.entrySet()) {
        heap.offer(entry);
        if (heap.size() > k) {
            heap.poll(); // 移除频率最低的
        }
    }
    
    // 3. 提取结果
    int[] res = new int[k];
    for (int i = 0; i < k; i++) {
        res[i] = heap.poll().getKey();
    }
    return res;
}

复杂度分析

  • 时间:O(n log k),统计频率O(n),堆操作O(n log k)

  • 空间:O(n),用于哈希表和堆

优化技巧

如果要求按频率从高到低输出,可以将堆中元素倒序取出,或者使用大顶堆(需自定义比较器)。


题目3:【23】合并K个升序链表

题目描述

给你一个链表数组,每个链表都已按升序排列,将所有链表合并成一个升序链表。

示例:[[1,4,5],[1,3,4],[2,6]][1,1,2,3,4,4,5,6]

思路分析

将每个链表的头节点放入小顶堆,每次取出最小节点,将其下一个节点入堆。重复直到堆空。

代码实现

java 复制代码
public ListNode mergeKLists(ListNode[] lists) {
    if (lists == null || lists.length == 0) return null;
    
    // 小顶堆,按节点值比较
    PriorityQueue<ListNode> heap = new PriorityQueue<>(
        (a, b) -> a.val - b.val
    );
    
    // 所有链表的头节点入堆
    for (ListNode node : lists) {
        if (node != null) {
            heap.offer(node);
        }
    }
    
    ListNode dummy = new ListNode(0);
    ListNode curr = dummy;
    
    while (!heap.isEmpty()) {
        ListNode node = heap.poll();
        curr.next = node;
        curr = curr.next;
        if (node.next != null) {
            heap.offer(node.next);
        }
    }
    return dummy.next;
}

复杂度分析

  • 时间:O(N log k),N为总节点数,k为链表个数

  • 空间:O(k),堆的大小

对比其他解法

  • 分治法:O(N log k) + O(log k)递归栈

  • 暴力两两合并:O(kN),效率较低

    优先队列在此题中代码最简洁,且时间复杂度最优。


题目4:【264】丑数 II

题目描述

编写程序找出第n个丑数。丑数是指只包含质因数2, 3, 5的正整数。

示例:n=10 → 12(前10个丑数:1,2,3,4,5,6,8,9,10,12)

思路分析

使用小顶堆,初始放入1。每次取出最小丑数,分别乘以2、3、5后入堆,注意去重(用Set防止重复)。重复n次。

代码实现

java 复制代码
public int nthUglyNumber(int n) {
    PriorityQueue<Long> heap = new PriorityQueue<>();
    Set<Long> visited = new HashSet<>();
    
    heap.offer(1L);
    visited.add(1L);
    
    long ugly = 1;
    int[] factors = {2, 3, 5};
    
    for (int i = 0; i < n; i++) {
        ugly = heap.poll();
        for (int factor : factors) {
            long next = ugly * factor;
            if (visited.add(next)) {
                heap.offer(next);
            }
        }
    }
    return (int) ugly;
}

复杂度分析

  • 时间:O(n log n),每次堆操作O(log n)

  • 空间:O(n),堆和Set的大小

优化

使用动态规划(三指针法)可将时间复杂度优化到O(n),空间O(1)。但优先队列解法思路更通用,尤其适用于因数个数不固定的场景。


题目5:【973】最接近原点的 K 个点

题目描述

给定平面上的点列表points和整数k,返回离原点欧几里得距离最近的k个点。

示例:[[1,3],[-2,2]],k=1 → [[-2,2]]

思路分析

维护一个大顶堆(容量k),存储当前距离最大的k个点。当新点距离小于堆顶距离时,替换堆顶并下沉。最终堆中就是最近的k个点。

代码实现

java 复制代码
public int[][] kClosest(int[][] points, int k) {
    // 大顶堆,按距离平方从大到小排序
    PriorityQueue<int[]> heap = new PriorityQueue<>(
        (a, b) -> (b[0]*b[0] + b[1]*b[1]) - (a[0]*a[0] + a[1]*a[1])
    );
    
    for (int[] point : points) {
        heap.offer(point);
        if (heap.size() > k) {
            heap.poll(); // 移除距离最大的
        }
    }
    
    int[][] res = new int[k][2];
    for (int i = 0; i < k; i++) {
        res[i] = heap.poll();
    }
    return res;
}

复杂度分析

  • 时间:O(n log k)

  • 空间:O(k)

关键点

  • 这里的比较器要特别注意,大顶堆是(b距离 - a距离)

  • 不需要计算平方根,比较平方即可,避免了浮点误差


五、优先队列实战技巧与陷阱

5.1 自定义比较器写法

Java中的PriorityQueue默认小顶堆,但我们可以通过Comparator轻松改造:

java 复制代码
// 大顶堆(整数)
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);

// 按字符串长度排序
PriorityQueue<String> lenHeap = new PriorityQueue<>((a, b) -> a.length() - b.length());

// 多级排序(先按值,再按索引)
PriorityQueue<int[]> multiHeap = new PriorityQueue<>((a, b) -> {
    if (a[0] != b[0]) return a[0] - b[0];
    return a[1] - b[1];
});

注意:比较器返回负数表示a排在b前面(小顶堆语义),返回正数表示a在后面。

5.2 常见坑点

坑1:null值不允许

PriorityQueue不接受null,否则会抛出NPE。

坑2:迭代器无序

不要依赖迭代器顺序,即使你连续调用peekpoll也是不行的。

坑3:自定义对象需要实现Comparable或提供Comparator

否则运行时抛出ClassCastException

坑4:扩容影响性能

如果预先知道元素个数,建议在构造时指定初始容量,避免多次扩容。

java 复制代码
PriorityQueue<Integer> pq = new PriorityQueue<>(1000); // 指定初始容量

5.3 与TreeSet对比

特性 PriorityQueue TreeSet
底层 数组堆 红黑树
允许重复 ❌(去重)
查找任意元素 O(n) O(log n)
有序遍历 需要不断poll 迭代器有序
内存 紧凑 节点开销大

如果只需要频繁取最值且允许重复,选PriorityQueue;如果需要有序集合且去重,选TreeSet


六、总结与核心心得

经过深入分析,我们可以总结出优先队列的核心思维模型

  1. 动态维护最值:当我们需要不断从动态变化的集合中取最大/最小元素时,优先队列是首选

  2. TopK问题:维护大小为k的堆,能高效解决各种TopK变种

  3. 多路归并:多个有序流合并,优先队列是天然的工具

  4. 替代排序:当只需要部分有序结果(如前k个)时,堆比全排序更高效

相关推荐
mlidongfeng3 小时前
[AI][C++26] SIMD 编程模型思考
开发语言·c++·人工智能
j7~3 小时前
【Linux】二十七.线程篇四《Linux多线程编程:线程互斥(互斥量的底层到封装)、线程安全和冲入、死锁》---详解
linux·开发语言·c++·线程安全·死锁·线程互斥·重入
刘名喜3 小时前
第09篇-协程基础-Kotlin异步编程
开发语言·kotlin·springboot
其实防守也摸鱼3 小时前
HackBar 工具完全指南:信息探测、漏洞验证与安全测试实战
开发语言·人工智能·学习·安全·网络安全·安全威胁分析·安全性测试
qq_22589174663 小时前
基于Python的外卖订单数据分析可视化系统
python·信息可视化·django
MC皮蛋侠客3 小时前
SQLAlchemy 系列(三):声明式映射与 Schema——让类型、默认值和约束一致
数据库·python
会博通·代码搬运工3 小时前
会博通API对接实战:工程企业文档分布式采集系统的技术实现与Python SDK详解
开发语言·分布式·python·线性代数·矩阵·架构·电子档案合规
空堂与归3 小时前
大模型再火,也得先过 class 这一关
python
Muselit3 小时前
Python 泛型:把 list[User] 讲明白
python·fastapi
2501_916007473 小时前
Python实现HTTPS爬虫的完整指南:使用requests、BeautifulSoup、Selenium和Scrapy
爬虫·python·ios·小程序·https·uni-app·iphone