手撸优先队列——二叉堆

简介: 队列在生活中常见,如买早点排队。但有时需要优先处理某些元素,如老幼病残孕优先上车,或打印机优先处理单页请求。这种情况下,使用优先队列更为合理。优先队列的基本操作包括入队和出队,常见的实现方法是二叉堆。二叉堆是一种完全二叉树,可以用数组表示,支持高效插入和删除操作。插入时使用上滤,删除时使用下滤,确保堆序性质。构建二叉堆时,从倒数第二层节点开始下滤,直至根节点。

在数据结构中,队列可以对应我们生活中的排队现象,像买早点排队,上公共汽车排队等。但是有一些情况,我们要优先某些元素,比如:上公共汽车时,虽然在排队,还是要优先老幼病残孕先上车;当有多个电脑向打印机发送打印请求时,一台电脑要打印100页,而其他电脑都是单页打印,此时更合理的做法时,优先打印单页的请求,最后再打印100页的请求。虽然我们都向往公平,在排队时也讲究先来后到,但是在某些特殊的情况下,还是要允许加塞现象的。这就是今天要给大家讲的——优先队列

优先队列也是队列,那么最基本的两个操作是必须有的,那就是入队和出队操作。我们能想到的几种简单的实现方法有,比如一个简单的链表,入队时就在链表的最后添加元素,那么出队时就要遍历整个链表,找出最小元素,这显然不是一个好的方案。或者我们直接使用AVL平衡二叉树,最小元素就是最左侧的子节点,很容易找到,但是在入队和出队的过程中,涉及到了节点的增加和删除,那么就要进行树的旋转而维持树的平衡,这额外花费了很多开销。那么有没有相对廉价一点的方案呢?这就是二叉堆的方案。

二叉堆

优先队列的实现使用二叉堆是相当普遍的,二叉堆是一棵二叉树,但是是有要求的二叉树,它是一棵完全二叉树。完全二叉树就是树的节点都是从上到下,从左到右去排列的,而且中间不能隔有空节点。我们看下图中的两个例子:

image-20241025141913032.png

左图中,J节点并没有按照从左到右依次排列,所以不是完全二叉树,而右图中,满足完全二叉树的特点,是一棵完全二叉树。

二叉堆有连个性质,一个是结构性质,一个是堆序性质。我们先来看结构性质,堆是一棵完全二叉树,是非常有规律的。我们可以直接用数组去表示二叉堆,而不使用链的结构,看下图:

image-20241025142824525.png

数组中第0个元素我们空着不用,第1个元素是根节点,后面的顺序就是按照完全二叉树的顺序去排。通过观察,我们惊奇的发现了如下的规律,数组中第i个元素的左子节点的位置是2i,右子节点的位置是2i+1,父节点的位置是i/2(根节点除外)。我们可以使用数组的结构表示树,而不是使用链的结构,这使得我们在遍历树的时候操作非常简单。但是数组的结构也有一个问题,那就是数组的长度需要预先估算出来,然后随着数组长度的增加我们还要对其进行扩容操作。这就是二叉堆的结构性质,我们可以使用数组去表示。

接下来我们再看看堆序性质,由于我们快速的找到最小元素,那么最小元素我们要放到根节点上。同理,我们考虑到任意子树也是一个二叉堆,那么子树中的最小元素应当在子树的根节点。那么也就是任意节点都应该小于它的后代节点。所以二叉堆的堆序性质就是,对于二叉堆中的任意节点,它的父节点要小于或等于该节点。我们再看下面两个例子:
image-20241025144843494.png

左图中节点6的父节点是21,小于6,不满足堆序性质,所以左图不是二叉堆。右图满足堆序性质,是二叉堆。

插入

当我们向二叉堆中插入一个新的元素时,为了满足二叉堆从上到下,从左到右的性质,我们先确定插入元素的位置,然后再和该位置的父节点作比较,如果大于父节点,那么是满足二叉堆性质的,直接插入就好了;如果不满足,则需要交换两个元素的位置,交换后再去和父节点作比较,就这样一直递归下去,直到满足二叉堆性质为止,或者交换到了根节点,是二叉堆中的最小元素。还使用上面的例子,比如我们要插入新的元素14,

image-20241028101808606.png

由于14小于21,需要继续向上调整,

image-20241028101916418.png

调整到这个位置时,满足了二叉堆的性质,我们把14插入。这样的一个整个过程就做上滤。下面我们编写程序实现这一过程。

/**
 * 二叉堆
 * @param <T>
 */
public class BinaryHeap<T extends Comparable<T>> {
   

    private static final int DEFAULT_CAPACITY = 10;
    private int currentSize;
    private T[] array;

    public BinaryHeap() {
   
        this(DEFAULT_CAPACITY);
    }

    @SuppressWarnings("unchecked")
    public BinaryHeap(int defaultCapacity) {
   
        this.currentSize = 0;
        this.array = (T[])new Comparable[defaultCapacity];
    }

    /**
     * 二叉堆是否为空
     * @return
     */
    public boolean isEmpty() {
   
        return this.currentSize == 0;
    }

    /**
     * 使二叉堆为空
     */
    @SuppressWarnings("unchecked")
    public void makeEmpty() {
   
        this.currentSize = 0;
        this.array = (T[])new Comparable[DEFAULT_CAPACITY];
    }

    /**
     * 扩展数组
     * @param newSize 扩展数组大小
     */
    @SuppressWarnings("unchecked")
    private void enlargeArray(int newSize) {
   
        if (newSize < this.array.length) {
   
            throw new RuntimeException("扩展数组小于原始数组");
        }

        T[] tmpArray = (T[])new Comparable[newSize];
        System.arraycopy(this.array,0,tmpArray,0,this.array.length);
        this.array = tmpArray;
    }


    /**
     * 二叉堆插入元素
     * @param element 插入元素
     */
    public void insert(T element) {
   
        if (currentSize == this.array.length-1) {
   
            enlargeArray(this.array.length * 2 - 1);
        }

        int hole = ++currentSize;
        for (this.array[0] = element;element.compareTo(this.array[hole/2]) < 0;hole /= 2) {
   
            this.array[hole] = this.array[hole/2];
        }
        this.array[hole] = element;
    }
}

由于二叉堆中的元素是可比较的,所以我们定义了泛型,必须实现了Comparable接口。然后我们定义数组array,和数组的初始长度DEFAULT_CAPACITY。最后再定义二叉堆当前的节点数currentSize。两个构造函数和isEmptymakeEmpty方法比较简单,这里不做过多介绍了。接下来我们看一下数据扩容的方法enlargeArray,先比较一下新的长度和数组当前长度,如果小于,则抛出异常。然后就是创建新数据,数据复制,替换老数据。

接下来我们重点看一下insert方法,先判断currentSize和数组长度-1,这里为什么要减1呢?因为数据的第0个元素是不用的,二叉堆的根节点在第1个元素。如果相等,说明数组已经用尽,需要扩容,扩容的时候也是采用2倍扩容,这里减1还是因为不用根节点。然后先确定空穴的位置,hole=++currentSize,下面的for循环,就是上滤的过程,也是这一段的精华。大家在实现的时候,可能都会和父元素作比较,然后进行交换,这种方法没有问题,但是交换两个元素要用3行代码来完成,先把第一个变量赋值给临时变量,再把第二个变量赋值给第一个变量,最后把临时变量赋值给第二个变量。而这里只使用了1行代码,这就是使用空穴位置的好处。在for循环中,将新元素赋值给第0个元素,这里使用第0个元素是有用处的,我们接着看,然后新元素和父节点作比较,父节点的下标是hole/2,这个在前面介绍过,如果小于,当前空穴位置的值就是父节点的值,然后处理空穴的位置,就是父节点的位置,hole /=2。如果这样一直到了根节点,也就是hole=1的时候,父节点是不存在的,但是程序中写的是hole/2,那么就是第0个元素,第0个元素就是新插入的元素,等于是自己和自己比较,是相等的,所以就跳出了循环。最后把空元素的值赋给空穴位置。这里我们巧妙的使用第0个元素,实现了根节点的比较,使得跳出循环。

删除最小值

删除最小值,就是我们的出队操作,由于我们使用二叉堆,所以最小值就在根节点,删除之后,在根节点产生了一个空穴,我们把二叉堆的最后一个元素,也就是currentSize的元素放到空穴位置,再和两个子节点的最小元素作比较,如果大于,则交换两个元素,空穴位置下移,直到满足二叉堆的性质为止。这个过程叫下滤

image-20241028114138991.png

删除根节点13后,产生一个空穴,同时,整个数组长度减1,我们用最后一个元素31,和空穴的最小子节点14作比较,31大于14,所以交换位置,如下:

image-20241028114427838.png

继续比较,31大于最小子节点21,空穴位置下移,

image-20241028114552895.png

最后,31小于子节点32,那么31就放在空穴位置,满足了二叉堆的性质,整个下滤过程结束。我们用代码实现一下,

/**
 * 取出最小值
 * @return 根元素
 */
public T deleteMin() {
   
    if (isEmpty()) {
   
        throw new RuntimeException("二叉堆为空");
    }
    T minItem = this.array[1];
    this.array[1] = this.array[currentSize--];
    perlocateDown(1);

    return minItem;
}

/**
 * 下滤过程
 * @param hole
 */
private void perlocateDown(int hole) {
   
    int child;
    T tmp = this.array[hole];
    for (;hole * 2 <= currentSize;hole=child) {
   
        child = hole * 2;
        if (child != currentSize && this.array[child].compareTo(this.array[child+1]) > 0) {
   
            child += 1;
        }
        if (this.array[child].compareTo(tmp) < 0) {
   
            this.array[hole] = this.array[child];
        } else {
   
            break;
        }
    }
    this.array[hole] = tmp;
}

deleteMin方法很简单,就是取根节点元素,将最后一个元素赋值给根节点,节点个数减1,然后调用下滤方法。我们重点要看的就是下滤方法,入参是空穴的位置,传入的是1,也就是根节点的位置,我们将值赋给临时变量,这里根节点的值是二叉堆的最后一个元素。接下来我们进入循环,循环成立的条件是空穴位置有子节点,hole*2 <= currentSize。那么左子节点的位置是hole*2,右子节点是hole*2+1。这里我们特殊处理的是空穴是不是只有一个子节点,只有一个子节点的情况只会发生在二叉堆的最后的位置,如果hole*2 == currentSize,说明后只有一个子节点,而且只能是左子节点,这样,我们就能够找出hole的最小子节点了,判断的逻辑是:如果hole*2 == currentSize,那么hole只有一个左子节点,最小子节点就是hole*2;其他情况就需要比较左右子节点,谁小就用谁。这就是我们for循环中第一个if处的逻辑。后面的逻辑就比较简单了,如果hole的值大于最小子节点,就进行交换,hole下移,等于最小子节点的位置,直到跳出循环。最后将临时值赋给空穴位置。这就是整个的删除和下滤的过程。

构建二叉堆

最重点的插入和删除方法我们已经讲完了,那么如果给我们一个数组,我们怎么去构建一个二叉堆呢?我们还是要从二叉堆的性质入手,也就是结构性质和堆序性质。结构性质比较容易我们将第0个元素空着就可以了,那么堆序性质怎么解决呢?由于上面我们已经将下滤过程抽象成了一个方法,这也就不难实现了。我们先将最小的子树,通过下滤方法变成二叉堆,最小的子树的节点就是树中倒数第二层的节点,倒数第二层的节点中,有的节点有子节点,有的节点没有子节点,没有子节点的不用下滤,那么怎么找到有子节点的节点呢?我们之前有个变量currentSize,这是最后一个节点的位置,它的父节点是currentSize/2,也是最后一个有子节点的节点,然后我们向前循环,每个节点执行一遍下滤方法,直到根节点下滤完,那么整棵树就是一个二叉堆了。我们实现一下,

/**
 * 构建二叉堆
 * @param items
 */
@SuppressWarnings("unchecked")
public BinaryHeap(T[] items) {
   
    this.currentSize = items.length;
    this.array = (T[])new Comparable[this.currentSize * 2 +1];
    int i = 1;
    for (T item: items) {
   
        this.array[i++] = item;
    }
    buildHeap();
}

/**
 * 构建二叉堆
 */
private void buildHeap() {
   
    for (int i = this.currentSize / 2;i>0;i--) {
   
        perlocateDown(i);
    }
}

实现起来很简单,这里注意一下循环的时候,条件是i>0,不是大于等于,因为第0个元素是不用的。

总结

好了,到这里二叉堆就介绍完了,它是实现优先队列最基本的方法,有问题的小伙伴欢迎评论区留言~~

目录
相关文章
|
SQL 安全 测试技术
『渗透测试基础』| 什么是渗透测试?有哪些常用方法?如何开展?测试工具有哪些?优势在哪里?
『渗透测试基础』| 什么是渗透测试?有哪些常用方法?如何开展?测试工具有哪些?优势在哪里?
621 0
|
数据采集 运维 测试技术
软件测试之道 -- 做一个有匠心的程序员
作者一年前围绕设计模式与代码重构写了一篇《代码整洁之道 -- 告别码农,做一个有思想的程序员!》的文章。本文作为续篇,从测试角度谈程序员对软件质量的追求。
300 16
|
11月前
|
人工智能 自动驾驶 搜索推荐
【通义】AI视界|苹果AI本周正式上线,将引入四大功能
本文由【通义】自动生成,涵盖苹果AI上线、特斯拉被华尔街重新评估、谷歌开发控制计算机的AI、Meta与路透社合作及Waymo获56亿美元融资等科技动态。点击链接或扫描二维码获取更多信息。
|
8月前
|
存储 Java 开发者
【潜意识Java】深入详细理解分析Java中的toString()方法重写完整笔记总结,超级详细。
本文详细介绍了 Java 中 `toString()` 方法的重写技巧及其重要
358 10
【潜意识Java】深入详细理解分析Java中的toString()方法重写完整笔记总结,超级详细。
|
11月前
|
传感器 弹性计算 人工智能
简介Multi-Agent
多智能体系统(MAS)是由多个自主智能体组成的计算系统,各智能体能独立决策、协同作业,无需中央控制。其特点包括自主性、分布性、交互性、异构性和适应性,广泛应用于人工智能、经济、交通、医疗和环保等领域,展现出巨大潜力。然而,MAS也面临通信开销、一致性、安全性和可扩展性等挑战。
388 3
|
Rust API Go
API 网关 OpenID Connect 实战:单点登录(SSO)如此简单
单点登录(SSO)可解决用户在多系统间频繁登录的问题,OIDC 因其标准化、简单易用及安全性等优势成为实现 SSO 的优选方案,本文通过具体步骤示例对 Higress 中开源的 OIDC Wasm 插件进行了介绍,帮助用户零代码实现 SSO 单点登录。
1270 106
|
8月前
|
前端开发 搜索推荐 Java
网络基础重定向和转发的区别
本文介绍了网络基础中重定向和转发的区别。重定向是服务器告知客户端访问新URL,涉及两次请求,URL变化;转发是服务器内部处理,客户端无感知,URL不变。文中详细对比了两者的请求次数、数据传递及应用场景,并通过实例演示帮助理解。
213 9
|
8月前
|
JSON 数据挖掘 API
京东店铺所有商品 API 接口系列(京东 API)
京东店铺所有商品API接口用于获取指定店铺的全面商品信息,包括基本属性、价格、库存、销售数据等。前期需仔细研读接口文档,掌握请求地址、参数格式及频率限制。接口支持分页和筛选参数,返回JSON格式数据。Python示例中使用`requests`库发送HTTP请求并处理返回数据。该API适用于竞品分析、商品管理工具开发、市场调研及价格监测等场景,助力电商从业者优化运营策略。
|
机器学习/深度学习 监控 安全
【博士每天一篇文献-综述】Threats, Attacks, and Defenses in Machine Unlearning A Survey
本文提供了对机器遗忘领域的综合性调查,提出了新的威胁、攻击和防御分类法,深入分析了机器遗忘系统中的安全问题,并探讨了如何利用攻击手段评估遗忘有效性,同时讨论了遗忘作为防御机制的角色以及面临的挑战和未来研究方向。
159 2
|
12月前
|
关系型数据库 MySQL 数据库
MySQL高级篇——MVCC多版本并发控制
什么是MVCC、快照读与当前读、隐藏字段、Undo Log版本链、ReadView、举例说明、InnoDB 解决幻读问题
MySQL高级篇——MVCC多版本并发控制

热门文章

最新文章