【数据结构】从堆到 PriorityQueue:手写一个优先级队列

一,堆

1,概念

1.1 堆的概念

堆是一种特殊的完全二叉树,它保证每个父节点的值都不大于(或不小于)其子节点的值,因此堆顶永远是最大(或最小)的元素。其具有以下性质:

  • 堆中某个节点的值总是不大于或不小于其父节点的值
  • 堆总是一棵完全二叉树

堆又分为大根堆和小根堆,父节点比子节点大的是大根堆,反之则是小根堆。

1.2 堆的存储

因为堆是一棵完全二叉树,所以能以按层序(从上到下、从左到右)的顺序存储在数组中(根节点为下标0)。存储结构如下图所示:

注:

在二叉树章节我们曾提及:二叉树分为链式存储和线性存储。但若使用数组存储非完全二叉树,其会存在大量空节点,造成空间上的浪费;而完全二叉树则可以按照层序遍历顺序进行存储,不会浪费空间,因此一般的:

完全二叉树 ==> 使用线性存储

非完全二叉树 ==> 使用链式存储

并且在调整堆时,我们可以使用到以下性质获取节点下标:

简单来说就是

  • 由父得子:左孩子下标 = (父 * 2) + 1;右孩子下标 = (父 * 2) + 2
  • 由子得父:父节点下标 = (子 - 1)/ 2

2,实现及重要方法讲解

接下来将以小根堆为例进行讲解,代码中所有抛出的异常都是自定义异常类。

2.1 类定义

由概念部分我们可知,堆是一颗完全二叉树,可以使用数组进行存储,因此定义elem数组作为基本存储结构;定义usedSize变量用于记录堆中有效元素个数;定义DEFAULT_INITIAL_CAPACITY 作为默认堆容量。

java 复制代码
public class MyHeap {

    private int[] elem;

    private int usedSize = 0;

    private static final int DEFAULT_INITIAL_CAPACITY = 11;
}

2.2 siftUp:向上调整

在向上调整时,siftUp函数一般接收待调整的子节点下标child作为形参列表,其执行逻辑如下:

1,若是child > 0,则说明往上还有节点,故进入循环继续调整

2,利用 父节点下标 = (子 - 1)/ 2 这一性质获取到父节点下标,使用parent记录

3,比较子节点和父节点值大小:

3.1 ***子节点值 < 父节点值:***父节点大于子节点,违反小根堆性质,因此交换父子节点元素值将其调整为小根堆,再让child = parent继续向上调整(因为交换后修改了以parent为子节点的树,故向上调整即是调整受影响的树)

3.2 ***子节点值 >= 父节点值:***父节点小于等于子节点,是小根堆,无需调整,直接返回即可(默认整棵树是小根堆,因此当目前子树是小根堆时即无需再调整)

4,循环结束则说明整棵树调整完毕

调整流程如下图所示:

代码如下所示:

java 复制代码
    /**
     * 向上调整
     * @param child 新插入节点位置
     */
    private void siftUp(int child) {
        // 当child<=0时则说明已调整完整棵树
        while(child > 0) {
            // 获取父节点下标
            int parent = (child - 1) / 2;
            if(this.elem[child] < this.elem[parent]) {
                // 父节点>子节点 ==> 交换元素并使child = parent向上继续调整
                swap(child, parent);
                child = parent;
            } else {
                // 父节点<=子节点 ==> 符合小根堆,无需调整,直接返回
                break;
            }
        }
    }

2.3 siftDown:向下调整

在向下调整时,siftDown函数一般需要接收待调整子树的父节点下标parent与堆有效元素个数len(用于明确向下调整的结束位置)这两个参数,其执行逻辑如下:

1,利用左孩子下标 =(父 * 2)+ 1 这一性质获取到左孩子下标,使用child记录

2,若是child < len,则说明左孩子存在,进入循环

3,判断右孩子是否存在,若是右孩子存在 且 右孩子值<左孩子值,则child保存右孩子下标(child保存左右孩子中值最小的那个)

4,比较child子节点与parent父节点值大小:

4.1 ***子节点值 < 父节点值:***父节点大于子节点,违反小根堆性质,因此交换父子节点元素值将其调整为小根堆,再执行parent = child,child = (child * 2)+ 1继续向下调整(因为交换后修改了以child为父节点的树,故向下调整即是调整受影响的树)

4.2 ***子节点值 >= 父节点值:***父节点小于等于子节点,是小根堆,无需调整,直接返回即可(默认整棵树是小根堆,因此当目前子树是小根堆时即无需再调整)

5,循环结束则说明整棵树调整完毕

注:

使用len确定向下调整的结束位置,其本质是判断当前节点是否还有左右子树。若是child>=len,则说明左右子树不存在,没有受影响的子树,整颗树调整完毕。

调整流程如下图所示:

代码如下所示:

java 复制代码
    /**
     * 向下调整
     * @param parent 根节点下标
     * @param len 调整界限
     */
    private void siftDown(int parent, int len) {
        // 获取左孩子下标
        int child = parent * 2 + 1;
        // 左孩子存在则进入循环调整
        while(child < len) {
            // child保存左右孩子中值最小的那个
            if(child + 1 < len && this.elem[child + 1] < this.elem[child]) {
                child ++;
            }
            if(this.elem[child] < this.elem[parent]) {
                // 调整本树,并继续向下调整
                swap(child, parent);
                parent = child;
                child = child * 2 + 1;
            } else {
                break;
            }
        }
    }

2.4 add / poll / peek

<1> add 在堆中插入元素

新增逻辑如下:

1,判满,若满则扩容

2,不满则将尾插新元素

3,向上调整受影响的位置,使其在插入后仍能保证是小根堆

4,维护有效元素个数

流程如下图所示:

代码如下图所示:

java 复制代码
    /**
     * 在堆中插入元素
     *  插入到尾部,然后向上调整
     * @param val 待插入元素
     */
    public void add(int val) {
        // 判满并扩容(1.5倍)
        if(isFull()) {
            grow();
        }
        // 尾插新元素
        this.elem[this.usedSize] = val;
        // 向上调整
        siftUp(this.usedSize);
        this.usedSize ++;
    }

<2> poll 删除堆顶元素

删除逻辑如下:

1,判空,若空则抛出异常(具体操作视实际场景需求而定)

2,非空,交换首尾元素,维护有效元素个数(交换后就执行usedSize--,也就是逻辑删除尾部元素,并且也利于向下调整时传递结束位置)(此处代码我先维护了有效元素个数,是因为尾节点下标为usedSize-1,故提前处理)

3,向下调整根节点,使受交换影响的树重新成为小根堆

流程如下图所示:

代码如下所示:

java 复制代码
    /**
     * 弹出堆顶元素
     *  首尾交换,然后调整下标为0的树
     * @return int
     */
    public int poll() {
        // 判空并抛出异常
        if(isEmpty()) {
            throw new HeapEmptyException("堆为空,无法执行删除操作!");
        }
        // 记录堆顶元素
        int oldVal = this.elem[0];
        // 交换首尾元素并维护有效元素个数
        this.usedSize --;
        swap(0, this.usedSize);
        // 向下调整根节点
        siftDown(0, this.usedSize);
        return oldVal;
    }

<3> 获取堆顶元素

获取逻辑较为简单,如下:

1,判空,若空则抛出异常(具体操作视实际场景需求而定)

2,非空,返回堆顶元素(下标为0的值)

代码如下所示:

java 复制代码
    /**
     * 获取堆顶元素
     * @return int
     */
    public int peek() {
        // 判空并抛出异常
        if(isEmpty()) {
            throw new HeapEmptyException("堆为空,无法执行获取堆顶元素操作!");
        }
        return this.elem[0];
    }

2.5 heapify:建堆

我们通常使用向下调整的方式建堆,流程如下:

1,获取最后一棵子树的父节点,从该节点开始,逐树向下调整

注:

最后一棵子树父节点下标 = (最后一个有效元素下标 - 1)/ 2

2,调整完毕后建堆完成

流程如下图所示:

代码如下所示:

java 复制代码
    /**
     * 建堆
     */
    private void heapify() {
        // 从最后一棵子树开始,逐树向下调整
        for(int parent = (this.usedSize - 1 - 1) / 2; parent >= 0; parent --) {
            siftDown(parent, this.usedSize);
        }
    }

2.6 扩容与构造器

<1> 构造器

在本次实现中,我定义了三个构造方法,分别是:

1,无参构造方法

为底层数组开辟默认大小空间

java 复制代码
    /**
     * 无参构造方法
     * */
    public MyHeap() {
        this.elem = new int[DEFAULT_INITIAL_CAPACITY];
    }

2,带有一个初始容量的构造方法

若是给定的初始容量合法,则开辟指定大小空间;否则使用默认容量

java 复制代码
    /**
     * 给定带有一个初始容量的构造方法
     */
    public MyHeap(int capacity) {
        // 校验capacity
        if(capacity < 0 || capacity > (Integer.MAX_VALUE - 8)) {
            this.elem = new int[DEFAULT_INITIAL_CAPACITY];
        } else {
            this.elem = new int[capacity];
        }
    }

3,带有一个初始化序列的构造方法

按照给定的初始化序列建堆,此处涉及开辟内存,需要校验合法性。

java 复制代码
    /**
     * 给定带有一个初始化序列的构造方法
     * @param initialArray 初始化序列
     */
    public MyHeap(int[] initialArray) {
        // 判空
        if(initialArray == null) {
            this.elem = new int[DEFAULT_INITIAL_CAPACITY];
        } else {
            // 校验数组
            if(initialArray.length > Integer.MAX_VALUE - 8) {
                throw new IllegalArgumentException("初始数组过大!");
            }
            // 开辟内存
            long cap = Math.max(1, (long)(initialArray.length * 1.5));
            if(cap > Integer.MAX_VALUE - 8) {
                cap = Integer.MAX_VALUE - 8;
            }
            this.elem = new int[(int)cap];
            // 写入数组
            for(int i = 0; i < initialArray.length; i ++) {
                this.elem[i] = initialArray[i];
                this.usedSize ++;
            }
            // 建堆
            heapify();
        }
    }

<2> 扩容

使用1.5倍扩容

java 复制代码
    /**
     * 1.5倍扩容
     */
    private void grow() {
        // 扩容时至少保证容量大于当前的usedSize
        int oldCap = this.elem.length;
        int minCap = this.usedSize + 1;
        long newCapacity = Math.max(minCap, (long)(oldCap * 1.5));
        // 判断新容量合法性
        if(newCapacity > (Integer.MAX_VALUE - 8)) {
            if(oldCap >= Integer.MAX_VALUE - 8) {
                throw new HeapFullException("堆容量已达上限!");
            }
            newCapacity = Integer.MAX_VALUE - 8;
        }
        // 扩容
        this.elem = Arrays.copyOf(this.elem, (int)newCapacity);
    }

二,优先级队列

1,基于堆实现优先级队列

优先级队列是一种抽象的数据类型,它与普通队列先进先出的规则不同:每个元素都有一个优先级,出队时总是优先级最高(最低)的元素被取出。因为它是一种抽象的数据类型,因此存在多种实现方式,其中堆是实现它最经典、最常用的方式之一。

堆实现优先级队列与实现堆本身的代码高度相似。为避免重复实现并保持封装(不把堆的全部 public 方法暴露给使用者),此处采用组合 + 委托的方式,基于上文的小根堆实现一个最小优先的优先级队列,代码如下所示:

java 复制代码
/**
 * 基于堆实现优先级队列
 * @author 14993
 * @date 2026/10/21
 */
public class MyPriorityQueue {

    private MyHeap heap;

    /**
     * 无参构造方法
     */
    public MyPriorityQueue() {
        heap = new MyHeap();
    }

    /**
     * 给定带有一个初始容量的构造方法
     * @param capacity 初始容量
     */
    public MyPriorityQueue(int capacity) {
        heap = new MyHeap(capacity);
    }

    /**
     * 给定带有一个初始化序列的构造方法
     * @param initialArray 初始化序列
     */
    public MyPriorityQueue(int[] initialArray) {
        heap = new MyHeap(initialArray);
    }

    /**
     * 入队
     * @param val 新元素
     */
    public void offer(int val) {
        heap.add(val);
    }

    /**
     * 出队
     * @return int
     */
    public int poll() {
        return heap.poll();
    }

    /**
     * 获取队头元素
     * @return int
     */
    public int peek() {
        return heap.peek();
    }

    /**
     * 判空
     * @return boolean
     */
    public boolean isEmpty() {
        return heap.isEmpty();
    }

    /**
     * 获取有效元素个数
     * @return int
     */
    public int size() {
        return heap.size();
    }
}

通过上面代码可以看出:MyPriorityQueue持有一个 MyHeap成员,并将 offer/poll/peek等操作委托给该成员完成。也就是说,它是以组合方式复用堆这一数据结构,来实现优先级队列这一抽象数据类型;堆是底层实现,优先级队列是对外呈现的接口。

2,Java集合类中的实现

优先级队列在Java中提供了两种实现,分别是PriorityQueue和PriorityBlockingQueue,PriorityQueue是线程不安全的,PriorityBlockingQueue是线程安全的。

其中PriorityQueue具有以下特性:

  1. 默认是小根堆
  2. 默认容量为11,可以传递Comparator比较器,并且我们可以通过修改传递比较器的比较逻辑去定义优先级。例如PriorityQueue默认小根堆,传递Comparator.reverseOrder()可变为大根堆。
  3. 若是没有传递Comparator比较器,则必须实现Comparable接口,否则无法比较并且会抛出异常
  4. 扩容时,旧容量小于64时约按2倍扩容(new = old*2+2),否则按1.5倍扩容
相关推荐
y1su2 小时前
Leetcode 字符串模板
java·数据结构·算法·leetcode·职场和发展·动态规划
十二8 小时前
【洛谷题解】P1115 最大子段和(前缀和 + 维护最小前缀)
数据结构·c++·算法·蓝桥杯
2501_933923259 小时前
双指针算法:移动零+复写零(Java)
数据结构·算法·双指针
suhan12 小时前
【数据结构】六、图:4.图的遍历(深度优先算法DFS、广度优先算法BFS)
数据结构·算法·排序算法
suhan12 小时前
【数据结构】六、图:3.十字链表、邻接多重表、边集数组
数据结构·算法·排序算法
kiracrimson12 小时前
STL简单引入:把数据结构做成模板
开发语言·数据结构·c++
HPtiger13 小时前
交换类排序详解:冒泡排序与快速排序
数据结构·算法·排序算法
kyle~13 小时前
数据结构 --- 无锁队列(Lock-Free Queue)
linux·数据结构·c++·多线程·并发
Cccp.12314 小时前
【leetcode】(十四)从暴力递归到动态规划
数据结构·算法·leetcode·动态规划