最大堆 / 最小堆

1、什么是堆?

堆(heap)是计算机科学中一类特殊的数据结构的统称。堆通常是一个可以被看做一棵树的数组对象。堆总是满足下列性质:

  • 堆中某个节点的值总是不大于或不小于其父节点的值;
  • 堆总是一棵完全二叉树。
  1. 完全二叉树:若设二叉树的深度为k,除第 k 层外,其它各层 (1~k-1) 的结点数都达到最大个数,第k 层所有的结点都连续集中在最左边,这就是完全二叉树。
  2. 满二叉树:一棵二叉树的结点要么是叶子结点,要么它有两个子结点(如果一个二叉树的层数为K,且结点总数是(2^k) -1,则它就是满二叉树。

将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆

2、优先队列

优先队列和其实是队列的一种

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

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

我们对队列这种抽象的数据结构做一些限制,创造出优先队列这个概念,我们在实现这个概念时可以使用不同的底层实现

我们可以用堆这样的数据结构实现优先队列这样的逻辑结构

3、最大堆的实现

3.1、最大堆的几个操作
  • 建立堆:指定堆空间大小
  • 插入元素insert
  • 查看元素的个数
  • 删除堆顶元素:上浮操作下沉操作
3.2、实现

我们为什么可以用数组这样的数据结构实现最大堆?

因为一棵节点数量为k的完全二叉树满足这样的性质:

如果将完全二叉树按照层序遍历放入一个长度为k的数组中,则:

  • 如果一个节点的数组索引为m,则如果该节点有父节点,父节点的索引为 m / 2
  • 如果该节点有孩子节点,则左孩子节点索引为 2 * m,右孩子为2 * m + 1

如此我们就可以用数组这样简单的数据结构实现了

代码实现:

// 堆中的数据元素必须实现Comparable接口,根据定义的比较规则作为优先级的规则
class MaxPQ<Key extends Comparable<Key>> {

    		public Key[] pq;	  // 整体用数组实现,但是索引为0不存储,从索引1开始
        private int N = 0;	// 表示目前堆的数据占用情况

  			// 建堆
        public MaxPQ(int maxN) {	// maxN为堆空间的最大值
            pq = (Key[])new Comparable[maxN + 1];	// 因为索引0不存储,所以总共需要maxN+1个空间
        }

  			// 查看当前堆中是否有元素
        public boolean isEmpty(){
            return N == 0;		
        }
  
  				// 查看堆顶元素
        public Key peek(){
            return pq[1];
        }

  			// 查看当前堆空间占用几个
        public int size(){
            return N;
        }

        // 插入元素
  			// 首先将新插入的元素放在数组最后,然后执行上浮操作
        public void insert(Key v) {
            pq[++N] = v;	
            swim(N);
        }

        // 删除最大值(堆顶元素)
  			// 首先将数组最后的元素覆盖堆顶元素,删除最后元素,然后下沉找到合适位置
        public Key delMax(){
            Key max = pq[1];
            exch(1, N--);
            pq[N + 1] = null;
            sink(1);
            return max;
        }

        // 根据数组索引比较堆中元素大小(优先级)
        private boolean less(int i, int j){
            return pq[i].compareTo(pq[j]) < 0;
        }

        // 根据数组索引交换堆中元素
        private void exch(int i, int j){
            Key t = pq[i];
            pq[i] = pq[j];
            pq[j] = t;
        }

        // 上浮,索引为k
  			// 如果一个节点的优先级大于父节点,就要上浮
  			// 将该节点和父节点交换,知道不大于父节点或者到堆定
        private void swim(int k){
            /**
            * k/2: 代表父节点的索引
            * 如果k不是堆顶元素 并且比它的父节点的值还要大,就要上浮
            */
            while (k > 1 && less(k/2, k)){
                exch(k, k/2);
                k = k / 2;
            }
        }
  
        // 下沉操作
  			// 如果一个节点(优先级)不能满足大于两个孩子节点,就要下沉
  			// 下沉操作需要将该元素和孩子节点中的较大节点交换,直到大于两个子节点或到堆底
        private void sink(int k){
            /**
            * 2*k: 代表左子节点
            * 首先判断如果该节点有没有坐子节点,有的话才执行
            * 首先j设置成坐子节点的索引,比较坐子节点和右子节点,将j设置为较大的一个
            * 然后在该节点的优先级小于较大子节点的优先级的情况下交换,否则退出循环
            * 将k设置为交换后的节点,继续循环
            */
            while (2 * k  <= N){
                int j = 2 * k;
                if(j < N && less(j, j+1)) j++;
                if(!less(k, j)) break;
                exch(k, j);
                k = j;
            }
        }
}

4、复杂度分析

很容易看出,弹出 / 插入的时间复杂度都为O(logN) N为堆的节点数量

堆排序的就是先根据元素集合建立堆,然后不断的弹出堆顶元素,知道堆为空,这样弹出的顺序就是有序的,其中构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)...1]逐步递减,近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)级。