优先级队列 与 堆

        在日常生活中,“优先级” 无处不在:手机玩游戏时来电会优先中断游戏,班主任排座位可能让成绩好的同学先选,外卖配送会优先处理距离近或订单金额高的单。这些场景背后,都隐藏着一种特殊的数据结构 ——优先级队列。而优先级队列的高效实现,离不开另一个核心结构 ——堆(Heap)

一、 什么是优先级队列?

        普通队列遵循 “先进先出”(FIFO)的规则,就像排队买咖啡,先到的人先拿到饮品。但优先级队列打破了这种 “公平性”,它的核心规则是:优先级高的元素先出队

优先级队列需要支持两个核心操作:

  • 插入新元素(offer):将元素加入队列并维护优先级顺序;
  • 取出优先级最高的元素(poll):移除并返回队列中优先级最高的元素;
  • 查看优先级最高的元素(peek):不删除,仅返回最高优先级元素。

二、 堆:优先级队列的 “底层引擎”

        Java 的 PriorityQueue 底层采用来实现,因为堆能高效支持优先级队列的核心操作(插入、删除均为 O(logN) 时间复杂度)。那堆到底是什么?

2.1 堆的定义与性质

堆是一种完全二叉树(按层序存储,除最后一层外每一层都满,最后一层从左到右连续),且满足以下 “父子大小关系”:

  • 小根堆:每个父节点的值 ≤ 其左右子节点的值(堆顶是最小值);
  • 大根堆:每个父节点的值 ≥ 其左右子节点的值(堆顶是最大值)。

2.2 堆的存储方式

        由于堆是完全二叉树,我们可以用一维数组高效存储(无需存储空节点,空间利用率 100%)。数组中节点的下标满足以下规律(假设节点下标为 i):

  • 父节点下标:(i - 1) / 2(整数除法,忽略小数);
  • 左孩子下标:2 * i + 1(若小于数组长度则存在左孩子);
  • 右孩子下标:2 * i + 2(若小于数组长度则存在右孩子)。

        比如小根堆 [10,15,25,56,30,70],下标 0 是根(10),左孩子是下标 1(15),右孩子是下标 2(25),完全符合上述规律。

三、 堆的核心操作实现

        堆的一切操作都围绕 “维护堆的性质” 展开,核心是两个调整动作:向下调整向上调整

3.1 向下调整:修复堆的 “基石”

        向下调整的前提是:当前节点的左右子树均已满足堆的性质,仅当前节点可能破坏堆结构。我们需要将当前节点 “下沉” 到合适位置,让堆恢复有序。

以小根堆为例,向下调整步骤:

  1. 标记当前节点(parent)和其左孩子(child = 2*parent + 1);
  2. 若右孩子存在且比左孩子小,更新 child 为右孩子(找到更小的孩子);
  3. 比较 parent 和 child
    • 若 parent ≤ child:堆已有序,调整结束;
    • 若 parent > child:交换两者,然后将 parent 指向 childchild 指向新的左孩子,重复步骤 2-3。

代码实现(小根堆)

//对array中以parent为根的子树进行向下调整(小根堆)
public static void shiftDown(int[] array, int size, int parent) {
    int child = 2 * parent + 1; //先找左孩子
    while (child < size) { //孩子存在才需要调整
        //1. 找左右孩子中更小的那个
        if (child + 1 < size && array[child + 1] < array[child]) {
            child++; //右孩子更小,更新child
        }
        //2. 比较父节点和最小孩子
        if (array[parent] <= array[child]) {
            break; //父节点更小,堆有序
        } else {
            //交换父节点和孩子
            int temp = array[parent];
            array[parent] = array[child];
            array[child] = temp;
            //继续向下调整(子树可能被破坏)
            parent = child;
            child = 2 * parent + 1;
        }
    }
}

时间复杂度:最坏情况从根调整到叶子,需遍历树的高度,即 O(logN)

3.2 堆的创建:从无序到有序

        如果给一个无序数组,如何将其建成堆?关键是从最后一个非叶子节点开始,依次向前做向下调整

        为什么从最后一个非叶子节点开始?因为叶子节点本身就是 “单个节点的堆”,无需调整;最后一个非叶子节点的下标是 (size - 2) / 2(推导:最后一个节点下标是 size-1,其父节点就是 (size-1 -1)/2 = (size-2)/2)。

代码实现(建小根堆)

public static void createHeap(int[] array) {
    int size = array.length;
    //从最后一个非叶子节点开始,向前遍历每个节点并调整
    for (int parent = (size - 2) / 2; parent >= 0; parent--) {
        shiftDown(array, size, parent);
    }
}

        时间复杂度:看似每个调整是 O(logN),但整体是 O(N)(数学推导略,核心是上层节点调整次数少,下层节点多但调整次数少,加权后趋近于 N)。

3.3 堆的插入与删除

3.3.1 插入:“上浮” 调整

        插入元素时,为了保持完全二叉树的结构,我们先将元素放到数组末尾,再通过 “向上调整” 让其回到合适位置(小根堆为例):

步骤:

  1. 将新元素插入数组末尾(size++);
  2. 标记新元素为 child,找到其父节点 parent = (child - 1) / 2
  3. 比较 child 和 parent
    • 若 child ≥ parent:堆有序,调整结束;
    • 若 child < parent:交换两者,child 指向 parentparent 指向新的父节点,重复步骤 3。

代码实现

public static void shiftUp(int[] array, int size, int child) {
    int parent = (child - 1) / 2;
    while (child > 0) { //孩子不是根节点才需要调整
        if (array[child] >= array[parent]) {
            break; //堆有序
        } else {
            //交换
            int temp = array[child];
            array[child] = array[parent];
            array[parent] = temp;
            //继续向上
            child = parent;
            parent = (child - 1) / 2;
        }
    }
}

//插入元素
public static boolean offer(int[] array, int size, int value) {
    //实际场景需考虑扩容,这里简化
    array[size] = value;
    shiftUp(array, size + 1, size); //新元素下标是size,调整后size+1
    return true;
}
3.3.2 删除:仅删堆顶,“下沉” 调整

        堆的删除有个规定:只能删除堆顶元素(优先级最高的元素)。为了保持完全二叉树结构,步骤如下:

  1. 交换堆顶元素(下标 0)和最后一个元素(下标 size-1);
  2. size--(相当于删除了原堆顶元素);
  3. 对新堆顶(原最后一个元素)做向下调整,恢复堆结构。

代码实现

//删除堆顶元素,返回删除的值
public static int poll(int[] array, int size) {
    if (size == 0) {
        throw new NoSuchElementException("堆为空");
    }
    int top = array[0]; //保存堆顶值
    //1. 交换堆顶和最后一个元素
    array[0] = array[size - 1];
    //2. 调整新堆顶
    shiftDown(array, size - 1, 0);
    return top;
}

四、手动模拟优先级队列

基于上面的堆操作,我们可以封装一个简单的优先级队列(小根堆):

public class MyPriorityQueue {
    private int[] data; //存储元素
    private int size;   //有效元素个数
    private static final int DEFAULT_CAPACITY = 11; //默认容量(参考JDK)

    //构造器:默认容量
    public MyPriorityQueue() {
        data = new int[DEFAULT_CAPACITY];
    }

    //构造器:指定初始容量
    public MyPriorityQueue(int initialCapacity) {
        if (initialCapacity < 1) {
            throw new IllegalArgumentException("容量不能小于1");
        }
        data = new int[initialCapacity];
    }

    //插入元素
    public boolean offer(int value) {
        //简化:实际需扩容(比如数组满了就扩)
        if (size == data.length) {
            throw new IllegalStateException("队列已满");
        }
        data[size] = value;
        shiftUp(data, size + 1, size);
        size++;
        return true;
    }

    //删除并返回堆顶
    public int poll() {
        if (isEmpty()) {
            throw new NoSuchElementException("队列为空");
        }
        int top = data[0];
        data[0] = data[size - 1];
        shiftDown(data, --size, 0);
        return top;
    }

    //查看堆顶
    public int peek() {
        if (isEmpty()) {
            throw new NoSuchElementException("队列为空");
        }
        return data[0];
    }

    //判断是否为空
    public boolean isEmpty() {
        return size == 0;
    }

    //向下调整(小根堆)
    public static void shiftDown(int[] array, int size, int parent) {
        int child = 2 * parent + 1; //先找左孩子
        while (child < size) { //孩子存在才需要调整
            //1. 找左右孩子中更小的那个
            if (child + 1 < size && array[child + 1] < array[child]) {
                child++; //右孩子更小,更新child
            }
            //2. 比较父节点和最小孩子
            if (array[parent] <= array[child]) {
                break; //父节点更小,堆有序
            } else {
                //交换父节点和孩子
                int temp = array[parent];
                array[parent] = array[child];
                array[child] = temp;
                //继续向下调整(子树可能被破坏)
                parent = child;
                child = 2 * parent + 1;
            }
        }
    }

    //向上调整(小根堆)
    public static void shiftUp(int[] array, int size, int child) {
        int parent = (child - 1) / 2;
        while (child > 0) { //孩子不是根节点才需要调整
            if (array[child] >= array[parent]) {
                break; //堆有序
            } else {
                //交换
                int temp = array[child];
                array[child] = array[parent];
                array[parent] = temp;
                //继续向上
                child = parent;
                parent = (child - 1) / 2;
            }
        }
    }
}

五、Java 中的 PriorityQueue

        JDK 已经帮我们实现了优先级队列 java.util.PriorityQueue,无需重复造轮子。我们需要掌握它的核心特性和用法。

5.1 核心特性

  1. 线程不安全:多线程场景需用 PriorityBlockingQueue
  2. 元素需可比较:插入的元素必须实现 Comparable 接口,或创建队列时传入 Comparator,否则抛 ClassCastException
  3. 禁止存 null:插入 null 会抛 NullPointerException
  4. 默认小根堆:默认按元素自然顺序(Comparable)排序,堆顶是最小值;
  5. 自动扩容:无固定容量,满了会自动扩容;
  6. 时间复杂度:插入(offer)、删除(poll)均为 O(logN)

5.2 常见构造器与基础使用

PriorityQueue 提供多种构造器,常用的有 3 种:

构造器功能
PriorityQueue()默认容量 11,小根堆
PriorityQueue(int initialCapacity)指定初始容量,小根堆
PriorityQueue(Collection<? extends E> c)用集合初始化

基础用法示例

import java.util.ArrayList;
import java.util.PriorityQueue;

public class PriorityQueueDemo {
    public static void main(String[] args) {
        //1. 空队列(默认容量11,小根堆)
        PriorityQueue<Integer> q1 = new PriorityQueue<>();
        q1.offer(5);
        q1.offer(2);
        q1.offer(8);
        System.out.println("q1堆顶:" + q1.peek()); //2(小根堆,最小值在顶)
        System.out.println("q1删除堆顶:" + q1.poll()); //2
        System.out.println("q1新堆顶:" + q1.peek()); //5

        //2. 用集合初始化
        ArrayList<Integer> list = new ArrayList<>();
        list.add(4);
        list.add(1);
        list.add(3);
        PriorityQueue<Integer> q2 = new PriorityQueue<>(list);
        System.out.println("q2大小:" + q2.size()); //3
        System.out.println("q2堆顶:" + q2.peek()); //1
    }
}

5.3 实现大堆:自定义比较器

默认是小根堆,若要实现大堆,需在创建队列时传入 Comparator(自定义排序规则)。有两种方式:

方式 1:匿名内部类
//大堆:比较器返回o2 - o1(o2大则返回正数,o2排在前面)
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2 - o1; //o2大则优先级高
    }
});

maxHeap.offer(5);
maxHeap.offer(2);
maxHeap.offer(8);
System.out.println(maxHeap.peek()); //8(大堆顶是最大值)
方式 2:Lambda 表达式(Java 8+,更简洁)
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((o1, o2) -> o2 - o1);

5.4 扩容机制

PriorityQueue 的扩容逻辑(JDK 1.8):

  • 若当前容量 < 64:扩容为原容量的 2 倍 + 2(比如 11→24,24→50);
  • 若当前容量 ≥ 64:扩容为原容量的 1.5 倍(比如 64→96,96→144);
  • 若扩容后超过 Integer.MAX_VALUE - 8:直接用 Integer.MAX_VALUE

六、堆的经典应用

        堆的应用非常广泛,除了实现优先级队列,还有两个高频场景:堆排序和 Top-K 问题。

6.1 堆排序

堆排序利用堆的性质实现高效排序,核心分两步:

  1. 建堆:升序排序建大堆(堆顶是最大值,方便放到末尾),降序排序建小堆
  2. 排序:循环将堆顶(最大值)与最后一个元素交换,然后对新堆顶做向下调整,直到所有元素有序。

代码实现(升序排序)

public class HeapSort {
    public static void sort(int[] array) {
        if (array == null || array.length < 2) {
            return;
        }
        int size = array.length;

        //步骤1:建大堆
        for (int parent = (size - 2) / 2; parent >= 0; parent--) {
            shiftDownMax(array, size, parent);
        }

        //步骤2:排序(循环交换堆顶和最后一个元素,调整堆)
        while (size > 1) {
            //交换堆顶(最大值)和最后一个元素
            int temp = array[0];
            array[0] = array[size - 1];
            array[size - 1] = temp;
            size--; //已排序元素不再参与堆调整

            //调整新堆顶
            shiftDownMax(array, size, 0);
        }
    }

    //大堆的向下调整
    private static void shiftDownMax(int[] array, int size, 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;
            }
            //交换
            int temp = array[parent];
            array[parent] = array[child];
            array[child] = temp;

            parent = child;
            child = 2 * parent + 1;
        }
    }

    //测试
    public static void main(String[] args) {
        int[] array = {5, 1, 7, 2, 3, 17};
        sort(array);
        System.out.println(Arrays.toString(array)); //[1, 2, 3, 5, 7, 17]
    }
}

时间复杂度:建堆 O(N) + 排序 O(NlogN),整体 O(NlogN)

6.2 Top-K 问题

        Top-K 问题是指:从海量数据中找出前 K 个最大 / 最小的元素(比如从 100 万个数中找前 100 个最大的数)。

        直接排序的问题:数据量大时(如 10 亿个数据),无法全部加载到内存,且排序 O(NlogN) 效率低。堆是最优解,思路如下:

找前 K 个最大的元素:建小堆
  1. 用前 K 个元素建小堆(堆顶是这 K 个元素中最小的,也是 “守门人”);
  2. 遍历剩余 N-K 个元素:
    • 若元素 > 堆顶:替换堆顶,然后向下调整(保证堆顶仍是 K 个中最小的);
    • 若元素 ≤ 堆顶:跳过(不可能是前 K 大);
  3. 遍历结束后,堆中 K 个元素就是前 K 个最大的。
找前 K 个最小的元素:建大堆

逻辑类似,只是用前 K 个元素建大堆,剩余元素 < 堆顶时替换并调整。

代码实现(找前 K 个最小元素)

import java.util.PriorityQueue;

public class TopK {
    //从arr中找前k个最小的元素
    public static int[] smallestK(int[] arr, int k) {
        if (arr == null || k <= 0 || k > arr.length) {
            return new int[0];
        }

        //步骤1:用前k个元素建大堆(堆顶是k个中最大的,筛选更小的元素)
        PriorityQueue<Integer> maxHeap = new PriorityQueue<>((o1, o2) -> o2 - o1);
        for (int i = 0; i < k; i++) {
            maxHeap.offer(arr[i]);
        }

        //步骤2:遍历剩余元素,比堆顶小则替换
        for (int i = k; i < arr.length; i++) {
            if (arr[i] < maxHeap.peek()) {
                maxHeap.poll(); //移除堆顶(当前k个中最大的)
                maxHeap.offer(arr[i]); //加入更小的元素
            }
        }

        //步骤3:将堆中元素存入结果数组
        int[] result = new int[k];
        for (int i = 0; i < k; i++) {
            result[i] = maxHeap.poll();
        }
        return result;
    }

    //测试
    public static void main(String[] args) {
        int[] arr = {3, 1, 4, 1, 5, 9, 2, 6};
        int[] top3 = smallestK(arr, 3);
        System.out.println(Arrays.toString(top3)); // [1, 1, 2]
    }
}

时间复杂度:建堆 O(KlogK) + 遍历剩余元素 O((N-K)logK),整体 O(NlogK),远优于排序的 O(NlogN)

总结

堆和优先级队列是 Java 开发中的 “常客”,核心要点可以概括为:

  1. 优先级队列是 “按优先级出队” 的队列,底层依赖堆实现;
  2. 堆是完全二叉树,分小根堆(顶小)和大根堆(顶大),用数组存储;
  3. 堆的核心操作是向下调整(建堆、删除)和向上调整(插入);
  4. PriorityQueue 默认小堆,需自定义比较器实现大堆;
  5. 堆的经典应用:堆排序(O(NlogN))、Top-K 问题(O(NlogK))。

掌握这些知识点,不仅能应对面试中的高频问题,也能在实际开发中(如任务调度、数据筛选)灵活运用。如果有疑问,建议多动手写代码,模拟堆的调整过程,加深理解!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值