优先队列和堆-代码实现

目录

一、优先队列

二、堆-树的一种

1、满二叉树

2、完全二叉树

3、二叉堆

(1)用数组存储二叉堆

(2)向堆中添加元素sift up

(3)取出堆中的最大元素和sift down

(4)堆的时间复杂度分析

(5)Heapify整理成堆和replace替换

三、优先队列


一、优先队列

普通队列:先进先出,后进后出

优先队列:出队顺序和入队顺序无关,和优先级相关

使用优先队列的动态场景

所谓的动态,就是队列中的数据是不确定的,其中的元素是不断变化的,无法预知所有数据,无法对一个整体进行排序,因此我们需要使用优先队列。

所谓的优先队列,其实就是一个队列,它的接口跟队列是一样的

对于队列的实现,我们可以使用不同的底层数据结构,如图:

底层使用线性的数据结构,可分为普通线性结构和顺序线性结构,这两种结构各有所长,同样也各有所短。它们的缺点是,入队和出队操作中,不得不有一个操作的时间复杂度是O(n)级别的。为了解决O(n)级别的时间复杂度问题,这里详细介绍这种数据结构的实现逻辑,它的时间复杂度是O(log(n))级别的,我们知道O(log(n))级别的时间复杂度也是一种很快的使时间复杂度

二、堆-树的一种

1、满二叉树

如下是一颗满二叉树,它的每个非叶子节点都具备左孩子和右孩子。它的每一层拥有的节点个数是固定的,比如一层1个,二层2个,三层4个...形成一种后一层比前一层的节点数多2倍的这种关系。

2、完全二叉树

完全二叉树跟满二叉树不同的是,它的非叶子节点并不一定都具备左孩子和右孩子,但是,它的数据存放逻辑一定是一层一层来存放的,如图,只有当第二层数据放满了,才会去放第三层的位置,按这样的方式存储元素的树,我们称之为一棵完全二叉树。

3、二叉堆

二叉堆首先是一棵完全二叉树,二叉堆有一个重要的性质:堆中某个节点的值总是不大于其父节点的值。所以,二叉堆的根节点是所有元素中最大的值。根据这种方式定义的堆称为最大堆(相应的也可以定义最小堆)。

注:最大堆并不保证树中层次直之间的顺序,比如,第二层的数据不一定比第三层的大,它只保证父节点和孩子节点的顺序大小

(1)用数组存储二叉堆

我们把二叉堆的节点通过数字进行层序(按照层级从左到右)标记,通过数组来进行存储,那么对于任意一个节点,我们通过什么样的方法来寻找它的左右孩子节点或者父节点呢?

如上图,我们可以很快的发现,对于任意一个节点,它的左右孩子以及父节点的索引跟此节点位置的索引(i)有如下规律:

左孩子索引:2 x i ;右孩子索引:2 x i + 1;父节点索引:i / 2(计算机中的整数的除法都是整数)。

不过,在计算机中,我们都习惯使用0作为索引的开始。如下图,是我们使用0作为开始索引进行标号的图示,相对应的,我们在计算任意节点的父亲节点和孩子节点时,都要相应的加上一个偏移量。

下边我们通过程序来实现一个最大堆的逻辑,在这个最大堆的实现逻辑中,我们使用之前我们自己实现的动态数组Array类,使得我们在添加和删除数据时,不在需要考虑数组的容量问题,对于动态数组的实现,不清楚的可以参照我这篇文章:动态数组—代码实现

我们通过数组来表示完全二叉树时,我们所存储的元素都限定为可比较的,它的代码实现逻辑如下:

public class MaxHeap<E extends Comparable<E>> {
    // 使用最大堆,所存储的元素都是可以用来比较的
    private Array<E> data;
    // 构造方法
    public MaxHeap(){
        data = new Array<E>();
    }
    public MaxHeap(int capacity){
        data = new Array<E>(capacity);
    }
    // 获取元素存储数量
    public int size(){
        return data.getSize();
    }
    public boolean isEmpty(){
        return data.isEmpty();
    }
    // 设计辅助函数,获取父节点、左孩子节点和右孩子节点的索引
    private int parent(int index){
        if(index == 0){
            throw new IllegalArgumentException("index-0 doesn't have parent.");
        }
        return (index - 1) / 2;
    }
    // 完全二叉树数组表示中,获取左孩子
    private int leftChild(int index) {
        return index * 2 + 1;
    }
    // 完全二叉树数组表示中,获取右孩子
    private int rightChild(int index){
        return index * 2 + 2;
    }
}

(2)向堆中添加元素sift up

向堆中添加元素的过程逻辑(元素上浮):

1) 首先我们向树的最尾端添加一个新的元素,比如添加元素52,相应的我们也要维护一下在这个位置的索引值size++;

2) 然后我们发现52作为右孩子,它比父节点16还要大,因此为了满足最大堆的定义,我们需要交换16和52的节点位置。对于交换位置后的二叉树,我们又要去看它是否满足最大堆的定义,如果仍不满足,我们继续把父节点和子节点进行交换,直到满足定义为止。

对于以上逻辑的实现,我们需要在原来的Array数组中新增加一个交换元素的方法:

// 交换两者之间的位置
    public void swap(int i , int j){
        if (i < 0 || i >= size || j < 0 || j >= size) {
            throw new IllegalArgumentException("index is illegal.");
        }
        E temp = data[i];
        data[i] = data[j];
        data[j] = temp;
    }

下边,我们通过代码来实现一下增加的逻辑

// 向堆中添加元素
    public void add(E e){
        data.addLast(e);
        // 维护一下元素的性质
        siftUp(data.getSize()-1);
    }
    private void siftUp(int k){
        // 如果父节点比当前节点要小,交换两者的位置
        while (k > 0 && data.get(parent(k)).compareTo(data.get(k)) < 0) {
            data.swap(k,parent(k));
            // 重新赋值=当前索引=原父节点位置的索引,进行新一轮比较
            k = parent(k);
        }
    }

(3)取出堆中的最大元素和sift down

对于最大堆的取出操作,我们只能取出最大堆中根节点位置的元素(即堆中最大的元素)。——下沉操作

取出最大元素的实现逻辑:

1)当取出堆中最大的元素62时,我们把二叉树末尾的元素16提取到62的位置然后删掉末尾的元素16,从而形成一棵新的二叉树。

2)当交换完队首和队尾的元素时,二叉树不能满足最大堆的定义,因此我们需要进行元素的下沉操作,即把交换位置的16和两个孩子节点52和30进行比较,然后跟其中最大的那个元素进行位置交换,在下图的示例中,也就是16跟52进行位置交换;交换完成后,我们需要再一次进行上一步的比较工作,只到所有的节点都满足最大堆的定义为止。

根据上边所述的逻辑,我们通过代码来实现一个最大堆的元素移除过程

// 获取堆中最大的元素
    public E findMax(){
        if(data.getSize() == 0){
            throw new IllegalArgumentException("heap is empty。");
        }
        return data.get(0);
    }
    // 取出堆中最大的元素
    public E extraMax() {
        E ret = findMax();
        // 交换位置
        data.swap(0, data.getSize() - 1);
        // 删除末尾的元素
        data.removeLast();
        siftDown(0);
        return ret;
    }
    private void siftDown(int k) {
        // 循环条件,获取左孩子的索引值,索引值没有越界,左孩子存在
        while(leftChild(k) < data.getSize()){
            // 左孩子节点的索引
            int j = leftChild(k);
            // 如果右孩子存在,且右孩子的值大于左孩子的值,那么返回右孩子的索引
            if (j + 1 < data.getSize() && data.get(j + 1).compareTo(data.get(j)) > 0) {
                j = rightChild(k);
            }
            // data[j] 时左孩子和右孩子中的最大值
            if (data.get(k).compareTo(data.get(j)) >= 0) {
                break;
            }
            // 交换两者之间的值
            data.swap(k, j);
            // 替换索引进行新的循环和对比
            k = j;
        }
    }

到此为止,我们通过代码实现一个最大堆的操作过程基本就算完成了,接下来我们写一个简单的测试程序进行一下测试

public static void main(String[] args) {
        int n = 1000000;
        MaxHeap<Integer> maxHeap = new MaxHeap<Integer>();
        Random random = new Random();
        for (int i = 0; i < n; i++) {
            maxHeap.add(random.nextInt(Integer.MAX_VALUE));
        }
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = maxHeap.extraMax();
        }
        // 验证最大堆的取出元素的顺序是不是倒序的
        // 每次对比相邻的两个元素,验证逻辑的正确性, i=1开始
        for (int i = 1; i < n; i++) {
            if (arr[i-1] < arr[i]) {
                throw new IllegalArgumentException("error");
            }
        }
        System.out.println("test maxHeap completed");
    }

查看验证结果,表明我们以上实现逻辑的正确性——堆排序

(4)堆的时间复杂度分析

总之,对于我们的树结构来说,因为它的遍历深度跟我们树的层次结构的深度相关,所以它的时间复杂度也是O(log(n))级别的。

(5)Heapify整理成堆和replace替换

replace操作取出最大元素后,并替换成新的元素

替换操作后,其实堆的总体元素没有发生变化,我们只要把堆顶的元素替换成新的元素,然后再进行下沉操作(Sift Down)就可以了,这种操作的时间复杂度是O(log(n))的。代码实现逻辑如下

// 取出最大的元素,并替换成e
    public E replace(E e) {
        E ret = extraMax();
        data.set(0, e);
        // 元素下沉操作
        siftDown(0);
        return ret;
    }

Heapify操作将任意数组整理成堆的形状

按照常规思维,我们会选择将一个数组中的数据全部取出来,然后一一插入到最大堆中。不过,在这里,我们可以使用更加高效的方式来实现。我们可以把原数组看成一个完全二叉树,然后从最后一个非叶子节点开始,循环进行下沉操作,使所有元素都满足最大堆的定义。

对于求最后一个非叶子节点索引的问题,我们只要获取最后一个元素的索引,就可以求得它的父节点(即倒数第一个非叶子节点)的索引。求得最后一个非叶子节点的索引后,我们就可以对所有的非叶子节点都做一遍下沉操作了,如下图,从索引为4的节点开始,我们依次对索引为3、2、1、0位置的节点进行下沉操作。

下图中最后一个叶子节点的求解算式为:(9-1)/2

Heapify操作的好处是,从一开始操作我们就抛弃了占用将近一半数量的叶子节点,简化了操作节点的数量,而直接往空堆中插入元素的操作需要对所有的元素都进行一遍操作。

因而他们的时间复杂度分析如下:

将n个元素逐个插入到一个空堆中,算法复杂度是O(nlogn);而heapify得过程,它的算法复杂度为O(n)

接下来,我们通过代码来实现这一个逻辑,在往MaxHeap中新增方法时,我们需要往自己实现的Array类中新增一个构造方法,即支持传入一个数组形成一个动态数组

public Array(E[] arr) {
        data = (E[]) new Object[arr.length];
        for (int i = 0; i < arr.length; i++) {
            data[i] = arr[i];
        }
        size = arr.length;
    }

有了传入一个数组来构造动态数组的构造函数,那么要实现传入一个数组来构造一个最大堆就比较容易了,根据上边我们分析的逻辑,代码实现如下

public MaxHeap(E[] arr) {
        data = new Array<E>(arr);
        for (int i = parent(data.getSize() - 1); i >= 0; i--) {
            siftDown(i);
        }
    }

三、优先队列

前边通过动态数组定义了一个最大堆,现在,我们需要使用最大堆,作为底层数据结构来实现一个优先队列,介于最大堆本身的特点,在其基础上实现一个优先队列还是很方便的。

public class PriorityQueue<E extends Comparable<E>> implements Queue<E> {
    // 使用最大堆实现优先队列
    private MaxHeap<E> maxHeap;
    public PriorityQueue(){
        maxHeap = new MaxHeap<E>();
    }

    @Override
    public int getSize() {
        return maxHeap.size();
    }

    @Override
    public boolean isEmpty() {
        return maxHeap.isEmpty();
    }

    @Override
    public void enqueue(E e) {
        maxHeap.add(e);
    }

    @Override
    public E dequeue() {
        return maxHeap.extraMax();
    }

    @Override
    public E getFornt() {
        return maxHeap.findMax();
    }
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

swadian2008

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值