《Java数据结构》——优先级队列(小根堆的模拟实现)

简介: 《Java数据结构》——优先级队列(小根堆的模拟实现)

引子

我们之前学过队列,那么什么是优先级队列呢?

🌰举个例子

队列是一种先进先出(FIFO)的数据结构,但是有些情况下,操作的数据可能带有优先级,一般出队列时,可能需要优先级高的元素先出队列,在这种情况下使用队列就不行了,比如玩游戏的时候突然女朋友一通电话,游戏屏幕瞬间被电话占领,这时候就应该优先处理电话。

在这种情况下,我们的数据结构应该提供两个最基本的操作,一个是返回最高优先级对象,一个是添加新对象,这种数据结构就是优先级队列(PriorityQueue)。

🔔但其实这种对优先级队列的定义是不严谨的,严谨的说法是

优先级队列是逻辑结构是小根堆,存储结构是动态数组(到达上限,容量自动加一)的集合类。

所以说优先级队列不是数据结构中的概念,而是java中的集合类。

但在JDK1.8中的PriorityQueue底层使用了堆的数据结构,而堆实际就是在完全二叉树的基础之上进行了一些元素的调整,所以说优先级队列是一种数据结构也未尝不可

既然优先级队列的底层用到了堆这种数据结构,那么什么是堆呢?


一、堆的概念

前提知识:二叉树的顺序存储

使用数组存储二叉树的方式,就是将二叉树按照层序遍历放入数组

一般只适合完全二叉树,因为非完全二叉树会有空间的浪费

20200725195843897.png

而堆其实就是一棵完全二叉树,所以就可以用数组来储存堆这一数据结构

概括堆就是一颗顺序存储的完全二叉树,底层是一个数组

     1.堆逻辑上是一颗完全二叉树

  1. 2.堆物理上是保存在数组中


二、堆的性质

既然是完全二叉树,那么之前我们得出来的那些完全二叉树的性质也同样适用于堆

下面是一个完全二叉树,也同样是一个堆

4dc2bd67928a43e7b221326c56a612bd.png📝也就是说已知父亲结点的下标就能求出孩子结点的下标,同理如果知道了孩子结点的下标也能求出他所对应的父亲结点的下标


堆分两种:大根堆和小根堆


小根堆:每一个父结点的值均小于等于其对应的子结点的值,而根结点的值就是最小的。

大根堆:每一个父结点的值均大于等于其对应的子结点的值,而根结点的值就是最大的。

df6f667452f14f30b52b59fecaa74a97.png

如此一来我们就又得到了堆的另外两条性质

  1. 1.堆中某个节点的值总是不大于或不小于其父节点的值。
  2. 2.堆总是一棵完全二叉树。
  3. 3.但完全二叉树不一定能称作堆(可能无序,不满足堆的性质)

三、堆的操作

🍑向下调整算法

2568e8a3603c4a8caee34de37ad8e3d4.png

此时我们看到,这个二叉树整体上不符合堆的性质,但是其根部的左子树和右子树均满足堆的性质

🔔注意上面说的也是向下调整的前提即——必须得确保根结点的左右子树均为小堆才可

接下来,就要进行向下调整,确保其最终是个堆。只需三步。


  1. 1.找出左右孩子中最小的那个
  2. 2.跟父亲比较,如果比父亲小,就交换
  3. 3.更新父亲结点(继续向下调整,因为经过上一次调整后下面的子树可能就不满足小根堆的性质了)

调整过程如图所示

98634622494d4c109130849e0aa51789.png

你可能问会问:你是怎样得到父亲结点和他所对应的孩子结点的位置的,不就是根据完全二叉树的性质呀!


左孩子结点的下标 = (2 * 父亲结点的下标 + 1)

右孩子结点的下标 = (2 * 父亲结点的下标 + 2)

父亲结点的下标 = (孩子结点的下标 - 1) / 2


但要注意我们的数组长度是有限的,表示孩子结点的下标不能超过数组所允许的最大下标

所以说,只要我们一开始给出要调整的二叉树的根节点坐标(父亲结点坐标),我们就能将给定的这棵完全二叉树变成小根堆


🌰代码如下

/**
     * 向下调整——使得当前子树为小根堆
     * @param root 是每棵子树的根结点的下标
     * 向下调整的时间复杂度O(log2n)(最坏情况下就是树的高度)
     */
    public void shiftDown(int root) {
        int parent = root; // 父亲结点的坐标
        int child = 2 * parent + 1; // 获取左孩子结点的坐标
        // 为什么不能child下标要小于usedSize,因为当前数组的最大下标就是usedSize - 1,如果大于或等于usedSize就越界了
        while (child < usedSize) { // 每个子树在调整的时候,是按从上到下,当child的下标小于usedSize时候就结束
            // 这一步目的是找出孩子结点最大的那个值,然后在让该值和父亲结点比较(不过先要确定孩子结点存在)
            if (child + 1 < usedSize && elem[child] > elem[child + 1]) {
                child = child + 1;
            }
            if (elem[child] < elem[parent]) {
                int tmp = elem[child];
                elem[child] = elem[parent];
                elem[parent] = tmp;
                parent = child; // 从上向下调整子树,更新父亲结点的下标
                child = 2 * parent + 1; // 更新左孩子孩子结点的下标
            }
            // 因为我们是从上向下调整子树,当我们在调整上面的子树时,下面的子树一定是调整好了的,如果上面都已经满足小根堆,下面也一定满足
            else {
                break; // 此时已经是小根堆了,不需要再次调整,直接退出循环接着调整下一个子树
            }
        }
    }

🍑小根堆的创建

🔔声明一下:我们通常所用到的堆,要么是小根堆、要么就是大根堆,这里我们所创建的就一个小根堆

🍑既然说堆是一种可以用数组来储存,那么如果给了一个无序的数组,怎样把他变成一个有序的小根堆(完全二叉树)呢?

🍑我们是不是就应该在创建的过程中,及时的调整数组中的元素位置,让该堆成为一个小根堆(根结点的值大于左右孩子结点的值)

比如这样一个数组

2378eca0783844b0be230fb2fb49123a.png

那该怎么调整呢?

好像每一个子树都不符合小根堆的定义啊!那么我们就需要从从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,这样从上到下调整不同的子树后就可以调整成堆

ce2857ad20d646b7ba3538b0821602f9.png至于每个子树的调节方式,那不就是我们刚才提到的从根结点开始对指定的子树进行向下调节

为什么要从下面的较小的子树调节到上面的大子树?


这是因为我们刚才的向下调整法,所能作用的二叉树是:整体上不符合堆的性质,但是其根部的左子树和右子树均满足堆的性质


当我们从上向下进行调节时,即从倒数的第一个非叶子节点的子树开始调节时,此时的二叉树一定是满足这个性质的,当下面的子树调节好了后,你会发现此时原来不满足这个性质的上面的那些子树竟然也满足这些性质了(因为他下面的子树已经调节过了)


🌻思路如下图所示(这里是虽然构建的是大堆根,但思路是一样的):

c63f838d1c4f4a8bbcd481c9581473b5.png

🌰代码如下:

public class MyHeap {
    int[] elem;   // 我们说过堆可以用数组来存放
    int usedSize; // 优先级队列中有效元素的个数
    MyHeap() {
        elem = new int[10]; // 构造方法初始化一下,堆(数组)的容量
    }
    public void createHeap(int[] arrays) {
        for (int i = 0; i < arrays.length; i++) {
            elem[i] =arrays[i]; // 元素初始化,给堆中元素赋值
            ++usedSize;
        }
        // 倒数的第一个非叶子节点的子树开始调节,一直调节到根结点(根节点在数组中的下标为0)
        // 注意这里是usdSize - 1 - 1,因为父亲结点的下标 = (孩子结点的下标 - 1) / 2,
        // 我们是从倒数的第一个非叶子节点的子树开始调节的,而该子树的孩子结点坐标为usedSize - 1
        for (int i = (usedSize - 1 - 1) / 2; i >= 0 ; --i) {
            shiftDown(i); // 从下面的子树一直调到上面的子树
        }
    }
    /**
     * 向下调整——使得当前子树为小根堆
     * @param root 是每棵子树的根结点的下标
     * 向下调整的时间复杂度O(log2n)(最坏情况下就是树的高度)
     */
    public void shiftDown(int root) {
        int parent = root; // 父亲结点的坐标
        int child = 2 * parent + 1; // 获取左孩子结点的坐标
        // 为什么不能child下标要小于usedSize,因为当前数组的最大下标就是usedSize - 1,如果大于或等于usedSize就越界了
        while (child < usedSize) { // 每个子树在调整的时候,是按从上到下,当child的下标小于usedSize时候就结束
            // 这一步目的是找出孩子结点最大的那个值,然后在让该值和父亲结点比较(不过先要确定孩子结点存在)
            if (child + 1 < usedSize && elem[child] > elem[child + 1]) {
                child = child + 1;
            }
            if (elem[child] < elem[parent]) {
                int tmp = elem[child];
                elem[child] = elem[parent];
                elem[parent] = tmp;
                parent = child; // 从上向下调整子树,更新父亲结点的下标
                child = 2 * parent + 1; // 更新左孩子孩子结点的下标
            }
            // 因为我们是从上向下调整子树,当我们在调整上面的子树时,下面的子树一定是调整好了的,如果上面都已经满足小根堆,下面也一定满足
            else {
                break; // 此时已经是小根堆了,不需要再次调整,直接退出循环接着调整下一个子树
            }
        }
    }
}

🍑向上调整算法

我们由堆的插入引入该算法

堆的插入不像先前顺序表一般,可以头插,任意位置插入等等,因为是堆,要符合大根堆或小根堆的性质,不能改变堆原本的结构,所以尾插才是最适合的,并且尾插后还要检查是否符合堆的性质。

a9736e64f42d4dc6932ce6cd736fcb12.png

为了确保在插入数字10后依然是个小根堆,所以要将10和28交换,依次比较父结点parent和子结点child的大小,当父小于子结点的时候,就返回,反之就一直交换,直到根部。


由前文的得知的规律,parent = (child - 1) / 2,我们可以从下到上调整子树,不断的更新child和parent下标,直到根部15

🔔需要注意的是:我们操控的是数组,但要把它想象成二叉树。画图演示调整过程:


0539671afc1d478ba79a129413a4b2a4.png

这就是向上调整算法

🌰代码如下:

/**
     * 从下向上调整子树
     * @param child 要调整子树的孩子坐标
     */
    public void shiftUp(int child) {
        int parent = (child - 1) / 2; // 通过孩子坐标得出父亲坐标
        while (child > 0) { // 当孩子坐标等于0时,说明根结点已经调整完毕,退出循环
            // 因为之前的该子树的孩子结点的值肯定满足小根堆,所以我们只用考虑新加入的孩子结点的值
            if (elem[parent] > elem[child]) {
                int tmp = elem[parent];
                elem[parent] = elem[child];
                elem[child] = tmp;
                // 更新
                child = parent;
                parent = (child - 1) / 2;
            }
            else {
                break; // 因为我们是从下向上调整子树的,我们上面的结点之前是满足小根堆的,所以如果下面也满足了,说明整个子树都满足了,直接退出循环
            }
        }
    }

🍑堆的插入

讲完了说完了向上调整算法,那堆的插入不就很简单了吗!

🌰代码如下:

/**
     * 入队,当要保证入队后仍是小根堆
     * @param val 要入队的元素
     */
    public void offerHeap(int val) {
        if (isFull()) {
            elem = new int[2 * usedSize];
        }
        else {
            elem[usedSize] = val; // 把新添加的元素放到数组中最后的位置
            ++usedSize;
            shiftUp(usedSize - 1); // 放完后就从新元素的位置——从下向上调整该子树
        }
    }
    /**
     * 从下向上调整子树
     * @param child 要调整子树的孩子坐标
     */
    public void shiftUp(int child) {
        int parent = (child - 1) / 2; // 通过孩子坐标得出父亲坐标
        while (child > 0) { // 当孩子坐标等于0时,说明根结点已经调整完毕,退出循环
            // 因为之前的该子树的孩子结点的值肯定满足小根堆,所以我们只用考虑新加入的孩子结点的值
            if (elem[parent] > elem[child]) {
                int tmp = elem[parent];
                elem[parent] = elem[child];
                elem[child] = tmp;
                // 更新
                child = parent;
                parent = (child - 1) / 2;
            }
            else {
                break; // 因为我们是从下向上调整子树的,我们上面的结点之前是满足小根堆的,所以如果下面也满足了,说明整个子树都满足了,直接退出循环
            }
        }
    }
     // 判断当前队列是否已满
    public boolean isFull() {
        return usedSize == elem.length;
    }

🍑堆的删除(堆顶元素的删除)

堆的删除和堆的插入很相似,就是把要删除的根节点和堆的最后一个元素互换位置,然后从上向下调整子树:

  1. 1.将堆顶元素对堆中最后一个元素交换
  2. 2.将堆中有效数据个数减少一个
  3. 3. 对堆顶元素进行向下调整


🌰 举个栗子

2741e3f705fa4bf2a3d40f031060ba84.png

删除过程如下

972831afccf840abbab80179123eafaf.png

代码如下:

/**
     * 出队删除,每次删除的都是优先级高的元素——当前完全二叉树的根结点
     * 出队后仍要保证该二叉树是小根堆
     */
    public void pollHeap() {
        if (isEmpty()) {
            System.out.println("当前优先级队列为空!");
            return;
        }
        // 将当前的队首元素与队尾元素互换位置,然后将向下调整以队首元素为根节点的那个子树,使得满小根堆
        int tmp = elem[usedSize - 1];
        elem[usedSize - 1] = elem[0];
        elem[0] = tmp;
        --usedSize; // 既然是删除,有效元素个数要减一
        shiftDown(0);
    }
    /**
     * 向下调整——使得当前子树为小根堆
     * @param root 是每棵子树的根结点的下标
     * 向下调整的时间复杂度O(log2n)(最坏情况下就是树的高度)
     */
    public void shiftDown(int root) {
        int parent = root; // 父亲结点的坐标
        int child = 2 * parent + 1; // 获取左孩子结点的坐标
        // 为什么不能child下标要小于usedSize,因为当前数组的最大下标就是usedSize - 1,如果大于或等于usedSize就越界了
        while (child < usedSize) { // 每个子树在调整的时候,是按从上到下,当child的下标小于usedSize时候就结束
            // 这一步目的是找出孩子结点最大的那个值,然后在让该值和父亲结点比较(不过先要确定孩子结点存在)
            if (child + 1 < usedSize && elem[child] > elem[child + 1]) {
                child = child + 1;
            }
            if (elem[child] < elem[parent]) {
                int tmp = elem[child];
                elem[child] = elem[parent];
                elem[parent] = tmp;
                parent = child; // 从上向下调整子树,更新父亲结点的下标
                child = 2 * parent + 1; // 更新左孩子孩子结点的下标
            }
            // 因为我们是从上向下调整子树,当我们在调整上面的子树时,下面的子树一定是调整好了的,如果上面都已经满足小根堆,下面也一定满足
            else {
                break; // 此时已经是小根堆了,不需要再次调整,直接退出循环接着调整下一个子树
            }
        }
    }
    // 判断当前优先级队列是否为空
    public boolean isEmpty() {
        return usedSize == 0;
    }

四、优先级队列的模拟实现(小根堆)

🌻到了这里,相信大家对小根堆的各自操作应该已经不陌生了吧!那么接下来就让我们用这些操作模拟实现一个优先级队列

/**
 * 用小根堆模拟实现优先级队列
 */
public class MyHeap {
    int[] elem;
    int usedSize; // 优先级队列中有效元素的个数
    MyHeap() {
        elem = new int[10]; // 构造方法初始化一下
    }
    public void createHeap(int[] arrays) {
        for (int i = 0; i < arrays.length; i++) {
            elem[i] =arrays[i]; // 元素初始化
            ++usedSize;
        }
        for (int i = (usedSize - 1) / 2; i >= 0 ; --i) {
            shiftDown(i); // 从下面的子树一直调到上面的子树
        }
    }
    /**
     * 向下调整——使得当前子树为小根堆
     * @param root 是每棵子树的根结点的下标
     * 向下调整的时间复杂度O(log2n)(最坏情况下就是树的高度)
     */
    public void shiftDown(int root) {
        int parent = root; // 父亲结点的坐标
        int child = 2 * parent + 1; // 获取左孩子结点的坐标
        // 为什么不能child下标要小于usedSize,因为当前数组的最大下标就是usedSize - 1,如果大于或等于usedSize就越界了
        while (child < usedSize) { // 每个子树在调整的时候,是按从上到下,当child的下标小于usedSize时候就结束
            // 这一步目的是找出孩子结点最大的那个值,然后在让该值和父亲结点比较(不过先要确定孩子结点存在)
            if (child + 1 < usedSize && elem[child] > elem[child + 1]) {
                child = child + 1;
            }
            if (elem[child] < elem[parent]) {
                int tmp = elem[child];
                elem[child] = elem[parent];
                elem[parent] = tmp;
                parent = child; // 从上向下调整子树,更新父亲结点的下标
                child = 2 * parent + 1; // 更新左孩子孩子结点的下标
            }
            // 因为我们是从上向下调整子树,当我们在调整上面的子树时,下面的子树一定是调整好了的,如果上面都已经满足小根堆,下面也一定满足
            else {
                break; // 此时已经是小根堆了,不需要再次调整,直接退出循环接着调整下一个子树
            }
        }
    }
    /**
     * 入队,当要保证入队后仍是小根堆
     * @param val 要入队的元素
     */
    public void offerHeap(int val) {
        if (isFull()) {
            elem = new int[2 * usedSize];
        }
        else {
            elem[usedSize] = val; // 把新添加的元素放到数组中最后的位置
            ++usedSize;
            shiftUp(usedSize - 1); // 放完后就从新元素的位置——从下向上调整该子树
        }
    }
    /**
     * 从下向上调整子树
     * @param child 要调整子树的孩子坐标
     */
    public void shiftUp(int child) {
        int parent = (child - 1) / 2; // 通过孩子坐标得出父亲坐标
        while (child > 0) { // 当孩子坐标等于0时,说明根结点已经调整完毕,退出循环
            // 因为之前的该子树的孩子结点的值肯定满足小根堆,所以我们只用考虑新加入的孩子结点的值
            if (elem[parent] > elem[child]) {
                int tmp = elem[parent];
                elem[parent] = elem[child];
                elem[child] = tmp;
                // 更新
                child = parent;
                parent = (child - 1) / 2;
            }
            else {
                break; // 因为我们是从下向上调整子树的,我们上面的结点之前是满足小根堆的,所以如果下面也满足了,说明整个子树都满足了,直接退出循环
            }
        }
    }
    // 判断当前队列是否已满
    public boolean isFull() {
        return usedSize == elem.length;
    }
    /**
     * 出队删除,每次删除的都是优先级高的元素——当前完全二叉树的根结点
     * 出队后仍要保证该二叉树是小根堆
     */
    public void pollHeap() {
        if (isEmpty()) {
            System.out.println("当前优先级队列为空!");
            return;
        }
        // 将当前的队首元素与队尾元素互换位置,然后将向下调整以队首元素为根节点的那个子树,使得满小根堆
        int tmp = elem[usedSize - 1];
        elem[usedSize - 1] = elem[0];
        elem[0] = tmp;
        --usedSize; // 既然是删除,有效元素个数要减一
        shiftDown(0);
    }
    // 判断当前优先级队列是否为空
    public boolean isEmpty() {
        return usedSize == 0;
    }
    /**
     * 获取堆顶元素
     * @return
     */
    public int peekHeap() {
        if (isEmpty()) {
            System.out.println("当前优先级队列为空!");
            return -1;
        }
        return elem[0];
    }
}


相关文章
|
12天前
|
存储 算法 Java
散列表的数据结构以及对象在JVM堆中的存储过程
本文介绍了散列表的基本概念及其在JVM中的应用,详细讲解了散列表的结构、对象存储过程、Hashtable的扩容机制及与HashMap的区别。通过实例和图解,帮助读者理解散列表的工作原理和优化策略。
28 1
散列表的数据结构以及对象在JVM堆中的存储过程
|
24天前
|
存储 Java
Java中的HashMap和TreeMap,通过具体示例展示了它们在处理复杂数据结构问题时的应用。
【10月更文挑战第19天】本文详细介绍了Java中的HashMap和TreeMap,通过具体示例展示了它们在处理复杂数据结构问题时的应用。HashMap以其高效的插入、查找和删除操作著称,而TreeMap则擅长于保持元素的自然排序或自定义排序,两者各具优势,适用于不同的开发场景。
41 1
|
26天前
|
存储 Java
告别混乱!用Java Map优雅管理你的数据结构
【10月更文挑战第17天】在软件开发中,随着项目复杂度增加,数据结构的组织和管理至关重要。Java中的Map接口提供了一种优雅的解决方案,帮助我们高效、清晰地管理数据。本文通过在线购物平台的案例,展示了Map在商品管理、用户管理和订单管理中的具体应用,有效提升了代码质量和维护性。
80 2
|
26天前
|
存储 Java 开发者
Java Map实战:用HashMap和TreeMap轻松解决复杂数据结构问题!
【10月更文挑战第17天】本文深入探讨了Java中HashMap和TreeMap两种Map类型的特性和应用场景。HashMap基于哈希表实现,支持高效的数据操作且允许键值为null;TreeMap基于红黑树实现,支持自然排序或自定义排序,确保元素有序。文章通过具体示例展示了两者的实战应用,帮助开发者根据实际需求选择合适的数据结构,提高开发效率。
57 2
|
18天前
|
安全 Java 编译器
Java对象一定分配在堆上吗?
本文探讨了Java对象的内存分配问题,重点介绍了JVM的逃逸分析技术及其优化策略。逃逸分析能判断对象是否会在作用域外被访问,从而决定对象是否需要分配到堆上。文章详细讲解了栈上分配、标量替换和同步消除三种优化策略,并通过示例代码说明了这些技术的应用场景。
Java对象一定分配在堆上吗?
|
14天前
|
存储 搜索推荐 算法
【数据结构】树型结构详解 + 堆的实现(c语言)(附源码)
本文介绍了树和二叉树的基本概念及结构,重点讲解了堆这一重要的数据结构。堆是一种特殊的完全二叉树,常用于实现优先队列和高效的排序算法(如堆排序)。文章详细描述了堆的性质、存储方式及其实现方法,包括插入、删除和取堆顶数据等操作的具体实现。通过这些内容,读者可以全面了解堆的原理和应用。
57 16
|
9天前
|
缓存 算法 Java
本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制
在现代软件开发中,性能优化至关重要。本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制。通过调整垃圾回收器参数、优化堆大小与布局、使用对象池和缓存技术,开发者可显著提升应用性能和稳定性。
30 6
|
15天前
|
存储 Java 索引
Java中的数据结构:ArrayList和LinkedList的比较
【10月更文挑战第28天】在Java编程世界中,数据结构是构建复杂程序的基石。本文将深入探讨两种常用的数据结构:ArrayList和LinkedList,通过直观的比喻和实例分析,揭示它们各自的优势与局限,帮助你在面对不同的编程挑战时做出明智的选择。
|
23天前
|
存储 算法 Java
Java 中常用的数据结构
【10月更文挑战第20天】这些数据结构在 Java 编程中都有着广泛的应用,掌握它们的特点和用法对于提高编程能力和解决实际问题非常重要。
24 6
|
24天前
|
存储 Java 开发者
Java中的Map接口提供了一种优雅的方式来管理数据结构,使代码更加清晰、高效
【10月更文挑战第19天】在软件开发中,随着项目复杂度的增加,数据结构的组织和管理变得至关重要。Java中的Map接口提供了一种优雅的方式来管理数据结构,使代码更加清晰、高效。本文通过在线购物平台的案例,展示了Map在商品管理、用户管理和订单管理中的具体应用,帮助开发者告别混乱,提升代码质量。
26 1