数据结构(优先级队列/堆)--Java实现

310 阅读5分钟

为什么写这篇博客

​ 首先这是比较基础的数据结构的相应知识,需要重新思考一下堆的结构,插入或者删除一个元素的过程。第二就是校招面试的时候曾经面过TopK问题,当时我用的是Java自带的优先级队列API去实现的,但是写完了面试官说不可以用API,我当时因为以前仅仅看过labuladong的算法笔记,没有进行实操,所以写的时间比较长,并且按照该笔记的写法,堆的大小都是固定好的,超出长度就会抛异常,所以我就顺理成章的挂掉了。因此我在今天做面试复盘的时候决定还是重新写一下基本的堆操作。

基本概念

​ 一般而言,我们选择实现优先级队列可以有如下的方式:

​ (1) 链表:在表头插入O(1)时间复杂度,删除需要遍历整个链表需要O(N)的时间复杂度。如果考虑有序链表,那么插入一个元素的代价就是O(N),删除则是O(1)

​ (2) 二叉查找树:平均的时间复杂度是O(LogN), 对于插入来讲这个时间复杂度是可以满足的,但是如果删除的元素都是一侧(要么左子树要么右子树),那么删除操作的时间复杂度就会退化成一条链表,即O(N)

​ 那么实际中我们用到更多的数据结构就是二叉堆,和二叉查找树一样,他也是类似一棵树,但是不同的是他是一颗完全二叉树,为了更好地理解,我们将其画成如下形式:

那这种树我们可以换一种表示方法:就是用数组来进行描述

首先我们结合上面两个图来看,数组中任意一个位置的父节点索引都是当前元素索引/2,而任意一个节点的左孩子和右孩子节点就分别是当前索引*2当前索引*2+1;其次,可以发现在第二张图中,首位的元素为空,这是为了我们以后进行插入元素的时候方便比较插入的元素和要进行交换的元素。

数据结构实现

那么谈论完概念以后,我们就需要去实现堆,在这片本章里,我们主要实现的是最小堆,即堆顶的元素是最小的,其余的孩子节点都是比堆顶要大的元素,并且我们还要保证堆是一颗完全二叉树,能够通过插入一个元素或者删除一个最小元素调整堆的结构。

首先就来看具体的类是什么样子:

 public class Heap<Integer extends Comparable<? super Integer>>{
	//无参构造函数
    public Heap() {}
     
	//可以指定具体大小的构造函数
    public Heap(int capacity) {}
     
	//如果传入的是Integer数组,转换成堆的构造函数
    public Heap(Integer [] items) {}
     
	//插入堆操作
    public void insert(Integer x) {}
     
	//删除最小元素
    public Integer deleteMin() {}
     
	//拿到最小元素
    public Integer getMin() {}
     
	//判断当前堆是不是为空
    public boolean isEmpty() {}
     
	//将堆置空
    public void makeEmpty() {}
     
	//设置默认堆大小为10
    private static final int DEFAULT_CAPACITY = 10;
     
    //当前堆大小
    private int currentSize;
     
    //存储元素的底层数组
    private Integer[] array;
     
    //构建一个堆
    private void buildHeap() {}
     
    //元素下潜
    private void sink(int idx) {}
     
    //堆扩容
    private void resizeArray(int newSize) {}
	
}

具体的注释我都写在代码上了,大致也就是这些属性和方法。那么对于一个堆而言最重要的就是一些基本操作:插入、删除,下面我们就一起来看一下这些方法怎么实现。

插入操作

​ 插入的思路是这样的,我们的元素需要被放在堆中合适的位置,那么我们可以从堆的最后面开始进行比较,也就是在堆的最后面的节点创建一个新的节点,然后将新节点和父节点的值进行比较,如果确实是新节点比较小的话,就需要把新节点**“上移”**,同时把父节点转移到原先新节点的位置,那么这个时候就相当于新节点就上了一层“台阶”,接下来的操作就是继续和上一层台阶的父节点再进行比较,直到找到合适的位置。我们可以通过图来理解:

这个过程用图片描述的比较清楚了,下面我们给出具体的代码: 这个过程用图片描述的比较清楚了,下面我们给出具体的代码:

/***
* 插入操作
* @Param:x是要插入的元素
***/
public void insert(Integer x) {
    //首先判断是不是需要扩容
    if (currentSize == array.length - 1) {
        resizeArray(array.length * 2 + 1);
    }
    
    //插入操作
    //这一步对应图中所说的新建一个节点,即将currentSize+1
    int idx = ++currentSize; 
    for (array[0] = x; x.compareTo(array[idx / 2]) < 0; idx /= 2) {
        //这一步就是之前文章说的,要将待插入的元素和父节点循环进行比较,如果x比父节点小,那么就上移,否则就找到了要插入的位置。
        array[idx] = array[idx / 2];//先把父节点copy下来
    }
    array[idx] = x;
}

关于代码里为什么不在循环内做x新节点和父节点的交换操作,书📕Data Structures and Algorithm Analysis in Java中提到如果元素上浮d层,其中每次循环中就有三次赋值交换,总共就是3d次交换,而上面代码的写法就仅仅交换了d+1次,提升了效率。

删除操作

首先拿到堆的最小元素的操作很简单,就是数组index=1位置的元素值,问题是如果删除了最小的元素,整个堆要怎么进行调整。

删除了堆顶元素之后相当于,堆顶节点空了,所以我们一般来说就要找到一个节点,然后放到这个堆顶的空位置,然后依次向下层重复这个操作。我们一般都是选择将空节点的两个孩子节点中比较小的那个转移到原来堆顶的位置,那么转移过后,原来的那个较小的孩子节点的位置又有变成了空节点“堆顶”,然后一直**“下潜”**就可以了。

我们还是通过图来说明这个过程:

书📕Data Structures and Algorithm Analysis in Java中还提到,堆的实践中容易发生错误的情况就是堆中元素个数为偶数时,就会产生一个节点可能只有一个孩子,那么解决的办法就是依旧把它当作拥有两个孩子节点进行比较。(这一段我看的是中文版的书,机翻真的不知所云...)

来看代码解释:

/***
* 删除最小的堆顶元素
* 
*/
public Integer deleteMin() {
    if (isEmpty()) {
        //首先判空,抛异常
        throw new NullPointerException();
    }
    
    //拿到最小的元素
    Integer min = getMin();
    //将图中的“31”节点先记录到空节点上
    array[1] = array[currentSize--];
    //sink下移
    sink(1);
    
    return min;
}


/***
* 下移
* @Param:idx是从哪个索引处开始下移
*/
private void sink(int idx) {
    //孩子节点的index
    int childIdx;
    //保存我们翻上来的“31”到临时节点
    Integer tmp = array[idx];
    
    //下移操作
    for (; idx * 2 <= currentSize; idx = childIdx) {
        if (childIdx != currentdxSize && 
           array[childIdx + 1].compareTo(array[childIdx]) < 0) {
            //这一步就是比较左右孩子节点谁更小
            childIdx++;
        }
        if (array[childIdx].compareTo(array[tmp]) < 0) {
            //如果左右孩子中小的那个比“31”小,就把孩子节点放到堆顶
            array[idx] = array[childIdx];
        } else {
            //"31"找到了位置
            break;
        }
    }
    array[idx] = tmp;
}

扩容操作

其实没啥说的,但是我面试的时候就没写这个,所以...挂了

private void resizeArray(int newSize) {
        Integer[] oldArray = array;
        array = (Integer[]) new Comparable[newSize];
        for (int i = 0; i < oldArray.length; i++) {
            array[i] = oldArray[i];
        }
    }

构建堆操作

这一步我们重点对如果传入的是一个数组进行堆化的操作,一般来说可以将长度为N的数组用任意的顺序放到我们的二叉堆中,然后进行sink操作就可以了。

public Heap(Integer[] items) {
    //调整堆的大小
    currentSize = items.length;
    
    array = (Integer[])new Comparable((currentSize + 2) * 11 / 10);
    
    int i = 1;
    //写到底层数组里
    for (Integer num : items) {
        array[i++] = num;
    }
    //堆化
    buildHeap();
}

private void buildHeap() {
    for (int i = currentSize / 2; i >= 0; i--) {
        sink(i);
    }
}


总结

​ 堆的基本操作基本都涵盖到了,但是还有一些细枝末节的部分这篇博文就不涉及了(主要是懒得写了),主要是为了总结一下重点,了解一下堆的各种基本操作。


参考文献

1.Data Structures and Algorithm Analysis in Java

2.labuladong的算法小抄