堆与堆排序、优先级队列

一、堆

1.1、堆的概念

堆(heap):一种有特殊用途的数据结构——用来在一组变化频繁(发生增删改的频率较高)的数据集中查找最值。

堆在逻辑结构上,是一颗完全二叉树。

堆在物理层面上,表现为一组连续的数组区间:long[] array ,将整个数组看作是堆,一般我们做算法题时都是在操作数组,或者利用优先队列(PriorityQueue)来快速的构建一个堆。

满足任意结点的值都大于其子树中结点的值,叫做大根堆,或者最大堆;反之,则是小根堆,或者最小堆。当一个堆为大根堆时,它的每一棵子树都是大根堆。

在这里插入图片描述

1.2、堆的存储方式与父子节点的关系

从堆的概念可知,堆的逻辑结构是一棵完全二叉树,所以我们可以按照层序的规则来转化成数组来存储(二叉树的层序遍历)。

假设 i 为结点在数组中的下标,则有:

  • i结点的父结点下标为(i – 1) / 2
  • 它的左右子结点下标分别为2 * i + 12 * i + 2,如果越界说明没有这个子结点。

img

二、堆的基本操作

2.1、创建堆,向下调整与向上调整

创建堆只有两种堆可以创建,要不就是大根堆,要不就是小根堆。而要满足大根堆还是小根堆的逻辑,就要向下调整的操作才能实现。要想自己实现堆,堆本身就是一个数组,因此创建一个数组来创建堆。

向小调整指的是,从当前节点开始,第一次调整是自身与其两个孩子节点,如果这一次的调整有变动,就会影响下一层的结构,所以要一直向下调整直到叶子结点。

向上调整,是从最后一个节点的父亲节点开始,调整当前这三个节点组成的堆结构,调整后再调整上一层的,注意只要发生了交换调整,那就从发生变化的这个节点开始,进行一遍【向下调整】这是为了保证自此以下的堆结构是正常的。

看一个例子,对于集合 { 27,15,19,18,28,34,65,49,25,37 } 中的数据,如果将其创建成小顶堆呢?

在这里插入图片描述

public class HeapTest {
    /**
     * 小顶堆的向下调整,此方法只是从一个父节点开始向下调整,并没有涉及到整个堆,下面有构建整个堆的
     * @param array 堆所在的数组
     * @param size  前 size 个元素视为堆中的元素(一开始为最大下标)
     * @param parent 要调整位置的下标(也就是父亲节点)
     */
    public static void shiftDown(long[] array, int size, int parent) {
 
        // 这里直接 while(true)即可
        // while (2 * parent + 1 < size) {    如果这么写,下面就不用再进行叶子的判断
        while (true) {
            int left = 2 * parent + 1;
          	// 判断左孩子节点下标是否越界,越界 -> 没有左孩子 -> 是叶子 -> 调整结束
            if (left >= size) {
                return; 
            }
          	
          	// 下面就是找三个数中最小的那个(因为是小顶堆)
            int right = 2 * parent + 2;
            int min = left;
          	// 假设最小值就是左孩子,所以 min 保存的最小值孩子所在的下标,
            if (right < size && array[right] < array[left]) {
                // right < size 必须在 array[right] < array[left] 之前,不能交换顺序
                min = right;            
            }
            // 当前要调整的结点的值 <= 最小的孩子值;说明这里也满足堆的性质了,所以,调整结束
            if (array[parent] <= array[min]) {
                return; 
            }
 						
            // 孩子节点的值比父节点小,所以交换两个下标的值
            long t = array[parent];
            array[parent] = array[min];
            array[min] = t;
 
            // min > parent,上面交换了值,所以需要对 min 位置重新进行同样的操作
          	// 对 min 位置进行向下调整操作
            parent = min;
        }
    }
 
    public static void main(String[] args) {
        long[] array = { 27, 15, 19, 18, 28, 34, 65, 49, 25, 37 };
        shiftDown(array,9,0);
      
      	// (将整个堆构建成小顶堆),这样数组的第一个元素就是最小的那个数
        createHeap(array, array.length);
    }
  
  
  
    /** 
    * 创建小顶堆:从一个无规则数组开始,经过调整,得到一个小顶堆 (将整个堆构建成小顶堆),这样数组的第一个元素就是最小的那个数
    * @param array 存储堆元素的数组 
    * @param size 前 size 元素视为堆中元素,一开始为数组长度
    */
    public static void createHeap(long[] array, int size) {
        // 从最后一个非叶子结点的父节点开始
        // 最后一个结点的下标一定是: size - 1
        // 它的父节点下标一定是: ((size - 1) - 1) / 2 = (size - 2) / 2
        // 从后往前遍历,直到根也被向下调整过 下标范围:[(size - 2) / 2, 0]  左闭右闭
        for (int i = (size - 2) / 2; i >= 0; i--) {
            shiftDown(array, size, i);
        }
    }
}

2.2、堆的插入

堆的插入总共需要两个步骤:

  • 1、先将元素放入到底层空间中,数组的最后的一个位置(注意:空间不够时需要扩容)

  • 2、将最后新插入的节点向上调整,直到满足堆的性质 ;

以一个大顶堆为例,新插入一个80,放到最后(数组的最后一个数),然后逐渐的往上调整

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IiwZfUaE-1684746792114)(/Users/wangluwei/Downloads/堆3.png)]

// child就是新插入元素的下标(末尾元素)
public void shiftUp(int child) {
    // 找到child的父节点
     int parent = (child - 1) / 2;
    
    while (child > 0) {
        // 如果双亲比孩子大,parent满足堆的性质,调整结束
        if (array[parent] > array[child]) {
            break;
       }
        else{
            // 父节点将与孩子节点进行交换
            int t = array[parent];
            array[parent] = array[child];
            array[child] = t;
        
            // 小的元素向下移动,可能导致上一层的子树不满足堆的性质,因此需要继续向上调增
          	// 一开始是45 35 80,45与80交换,导致之前的72 53 45变成了 72 53 80,需要重新排
            // 所以这里的 child parent要变成上一层的父节点与孩子节点
            child = parent;
            parent = (child - 1) / 2;
       }
   }
}

2.3、堆的删除

具体如下:( 注意:堆的删除一定删除的是堆顶元素。)

  • 1、将堆顶元素对堆中最后一个元素交换;

  • 2、将堆中有效数据个数减少一个;

  • 3、对堆顶元素进行向下调整;

补充:上面这个逻辑,在堆排序中也是关键。

   public long poll() {
        // 返回并删除堆顶元素
        if (size < 0) {
            throw new RuntimeException("队列是空的");
        }
 
        long e = array[0];
 
        // 用最后一个位置替代堆顶元素,删除最后一个位置
        array[0] = array[size - 1];
     		// 0 代表这个位置被删除了,不是必须要写的
        array[size - 1] = 0; 
     		// 堆的大小减-1,将刚刚换到最后的元素,排除掉
        size--; 
 
        // 针对堆顶位置,做向下调整,
     		//(做删除,前提整个堆都是符合条件的,删除堆顶,只用从顶部开始重建就好了)
        shiftDown(array, size, 0);
 
        return e;
    }

三、堆排序

如果要升序,要创建大顶堆来排,然后把堆顶的元素最大数,换到最后,交换后再向下调整,调整后再与堆的倒数第二个元素进行交换,直到最后。

同理,降序,要创建小顶堆来排。

以升序为例:

/**
 * 堆排序:利用最小/大堆的特性,每次选择出最小/大的值(这也是完全二叉树的一种应用,利用的节点与节点之前的顺序关系)
 * 实现思路:一步一步实现:
 * 1.先只考虑三个结点如何建立最大堆,
 * 2.然后考虑如何将整个数组建立成一个最大堆,:从下往上的把每一棵子树建立成最大堆即可,所以应该从下标为 n/2-1 的节点开始,依次-1,就可把所有的子树都弄完
 * 3.最后 将最大的元素与最后的元素交换顺序,"除去"最后一个,剩下的继续建立最大堆,找出最大元素,再放到最后......
 *
 */
public class HeapSort {
    public static void main(String[] args) {
        int[] keys = {7, 6, 10, 1, 2, 9, 8};
        heapSort(keys, keys.length);
        print(keys);
    }

    //堆排序
    public static void heapSort(int[] keys, int len) {
        // 一开始先建立一个堆,此时第一个就是最大元素
        buildHeap(keys, len);

        //	建立第一个堆之后,将第一个(最大的)与数组最后的元素交换,之后再建立堆,
      	// 这里有i来控制把排好序的最大值去掉,一开始就减1了
        for (int i = len - 1; i >= 0; i--) {
            // 第一个(最大的)与数组最后的元素交换
            swap(keys, i, 0);
            // 再建立堆,找最大值
            buildHeap(keys, i);
        }
    }

    /**
     * 构建大顶堆,找到最大元素
     * @param keys 数组
     * @param len  长度
     */
    static void buildHeap(int[] keys, int len) {
        // 从最后一个非叶子结点的父节点开始
        for (int parent = (len - 1 - 1)/ 2; parent >= 0; parent--) {
            int c1 = 2 * parent + 1;
            int c2 = 2 * parent + 2;
            int max = parent;
            if (c1 < len && keys[c1] > keys[max]) {
                max = c1;
            }
            if (c2 < len && keys[c2] > keys[max]) {
                max = c2;
            }
            if (max != parent) {
                swap(keys, max, parent);
            }
        }
    }

    static void swap(int[] keys, int i, int j) {
        int temp = keys[i];
        keys[i] = keys[j];
        keys[j] = temp;
    }

    static void print(int[] keys) {
        for (int key : keys) {
            System.out.print(key+" ");
        }
    }
}


在堆排序中,从最后一个节点的父亲节点开始构建整个大顶堆,这是【向上调整】,构建一次花费的时间为O(log2N),每找到一个最大元素,就交换到最后,然后进行下一次的重建堆,交换,重复了n(数组长度)次,所以整体的时间复杂度为O(N*log2N)

堆排序空间复杂度为O(1)。

堆排序算法不稳定。因为堆上的操作可以更改相等项目的相对顺序。

  • 考虑数组21 20a 20b 12 11 8 7(已采用最大堆格式),在这里20a = 20b只是为了区分我们将它们表示为20a和20b的顺序。
  • 虽然首先删除堆排序21并将其放置在最后一个索引中,然后删除20a并将其放置在最后一个但另一个索引中,将20b放置在最后一个但将两个索引中,所以在堆排序之后,数组看起来像7 8 11 12 20b 20a 21。它不保留元素的顺序,因此不稳定。
  • 稳定表示两个元素具有相同的元素,则它们将保持相同的顺序或位置。但这不是堆排序的情况。堆排序不稳定,因为堆上的操作可以更改相等元素的相对顺序。

四、优先级队列(PriorityQueue)

4.1、概念

队列是一种先进先出(FIFO)的数据结构,但有些情况下,操作的数据可能带有优先级,一般出队列时,可能需要优先级高的元素先出队列。

此时,数据结构应该提供两个最基本的操作,一个是返回最高优先级对象,一个是添加新的对象。这种数据结构就是优先级队列:PriorityQueue,实现了Queue接口。

JDK1.8中的PriorityQueue底层使用了堆的数据结构,而堆实际就是在完全二叉树的基础之上进行了一些元素的调整。所以我们可以通过PriorityQueue类来快速的构建出一个大顶堆/小顶堆。

4.2、用堆模拟实现优先级队列

// 优先级队列(堆)的模拟实现
public class PriorityQueue {
    public int[] elem;
    public int usedSize;

    // 构造方法:进行变量初始化
    public PriorityQueue() {
        this.elem = new int[10];
        this.usedSize = 0; // 有效长度
    }

    // 初始化数组
    public void initArray(int[] arr) {
        this.elem = Arrays.copyOf(arr, arr.length); //将数组中元素拷贝给数组
        this.usedSize = this.elem.length;
    }

    /**
     * 建堆的时间复杂度:差比数列求和--O(N)
     * 注意:从最后一个根节点依次向上的根结点遍历,以使得每棵子树都是大堆形式--使用根结点循环
     * @param array
     */
    public void createHeap(int[] array) {
        // 注意:如果这里不传参时,usedSize就不用重新计算,直接使用initArray中已经初始化好的就行;
        // 其实,就算传入数组参数也可以直接使用this.usedSize
        int usedSize = array.length;
        for (int parent=(usedSize-1-1)/2; parent>=0; parent--) {
            shiftDown(parent,usedSize);  // 注意结束条件是数组长度!
        }
    }

    /**
     * 向下调整(一次针对的是一棵子树)--大根堆
     * 比较左右孩子结点大小,找到最大,然后与根结点进行比较,若果根结点小就进行交换--循环实现
     * @param root 是每棵子树的根节点的下标
     * @param len  是每棵子树调整结束的结束条件
     * 向下调整的时间复杂度:O(logn)
     */
    private void shiftDown(int root, int len) {
      	// 左孩子结点
        int child = 2*root + 1; 
      	// 进入循环的条件其实是:左孩子结点要小于数组长度
        while(child < len) { 
            // 判断左右孩子结点大小:
            if((child+1<len) && (elem[child] < elem[child+1])) {
                child++; // 记录最大孩子结点的下标
            }
            // 来到这儿说明两种情况:只有左孩子or左孩子小于右孩子--两种情况都是child结点有最大值
            // 判断最大子结点和父亲节点的大小 -- 进行交换swap
            if(elem[child] > elem[root]) {
                swap(elem, child, root);
                // child root变化
                root = child;
                child = 2*root+1;
            } else {
                // 说明是:父节点大
                break;
            }
        }
    }

    private void swap(int[] elem, int child, int root) {
        int tmp = elem[child];
        elem[child] = elem[root];
        elem[root] = tmp;
    }


    /**
     * 入队是先加在尾部 然后进行向上调整
     * 入队:仍然要保持是大根堆
     * @param val
     */
    public void push(int val) {
        if(isFull()) {
            // 进行扩容
            this.elem = Arrays.copyOf(elem, 2*elem.length);
        }
        // 要么扩容成功,要么未满 就开始进行尾插
        this.elem[this.usedSize] = val;
        this.usedSize++; // 要注意++!!
        // 向上调整:
        shiftUp(this.usedSize - 1); // 因为之前已经usedSize++,此时有效下标需要--
    }

    // 向上调整:也是孩子结点与父亲节点比较
    private void shiftUp(int child) {
        int parent = (child-1)/2;
        // 注意调整条件:child>0
        while(child > 0) {
            // 直接与父亲节点进行比较就行,不需要再与另一个孩子结点进行比较,因为其他已经是有序的大根堆
            if(this.elem[parent] < this.elem[child]) {
                swap(this.elem, child,parent);
                // 注意交换后一定要进行变量的变化,进行上一层的调整
                child = parent;
                parent = (child-1)/2;
            } else {
                break;
            }
        }
    }

    public boolean isFull() {
        return this.usedSize==this.elem.length;
    }

    /**
     * 出队【删除】:每次删除的都是优先级高的元素!! 即:删除的是堆顶元素!
     * 仍然要保持是大根堆
     * 把堆顶数据域最后一个数据进行交换,然后进行向下调整成大根堆
     */
    public void pollHeap() {
        if(isEmpty()) {
            return;
        }
        int old = this.elem[0];
        swap(this.elem,this.usedSize-1,0);
        this.usedSize--; // 此时有效数据中被换到最后的数据就不被包含在内

        // 注意该方法其实是在<usdSize时进行变换,所以不需要-1!!!
        //shiftDown(0,this.usedSize-1); // 向下调整
        shiftDown(0,this.usedSize);
        System.out.println(old);
    }

    public boolean isEmpty() {
        return this.usedSize == 0;
    }

    /**
     * 获取堆顶元素
     * @return
     */
    public int peekHeap() {
        return this.elem[0];
    }
}

4.3、PriorityQueue介绍

Java集合框架中提供了PriorityQueue和PriorityBlockingQueue两种类型的优先级队列,PriorityQueue是线程不安全的,PriorityBlockingQueue是线程安全的。我们先看PriorityQueue。

  • PriorityQueue的底层默认是小根堆!如果需要大堆需要用户提供比较器。用户自己定义的比较器:直接实现Comparator接口,然后重写该接口中的compare方法即可;或者实现Comparable接口,重写compareTo方法。

  • PriorityQueue中放置的元素必须要能够比较大小,不能插入无法比较大小的对象,否则会抛出ClassCastException异常

  • 不能插入null对象,否则会抛出NullPointerException

  • 没有容量限制,可以插入任意多个元素,其内部可以自动扩容

  • 插入和删除元素的时间复杂度为:O(logN)

接口关系图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-achrSAtg-1684746792115)(/Users/wangluwei/Downloads/堆4.png)]

4.3.1、PriorityQueue的构造方法

    public PriorityQueue(int initialCapacity) {
        this(initialCapacity, null);
    }

    public PriorityQueue(Comparator<? super E> comparator) {
        this(DEFAULT_INITIAL_CAPACITY, comparator);
    }

    public PriorityQueue(int initialCapacity,
                         Comparator<? super E> comparator) {
        // Note: This restriction of at least one is not actually needed,
        // but continues for 1.5 compatibility
        if (initialCapacity < 1)
            throw new IllegalArgumentException();
        this.queue = new Object[initialCapacity];
        this.comparator = comparator;
    }


    @SuppressWarnings("unchecked")
    public PriorityQueue(Collection<? extends E> c) {
        if (c instanceof SortedSet<?>) {
            SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
            this.comparator = (Comparator<? super E>) ss.comparator();
            initElementsFromCollection(ss);
        }
        else if (c instanceof PriorityQueue<?>) {
            PriorityQueue<? extends E> pq = (PriorityQueue<? extends E>) c;
            this.comparator = (Comparator<? super E>) pq.comparator();
            initFromPriorityQueue(pq);
        }
        else {
            this.comparator = null;
            initFromCollection(c);
        }
    }

    @SuppressWarnings("unchecked")
    public PriorityQueue(PriorityQueue<? extends E> c) {
        this.comparator = (Comparator<? super E>) c.comparator();
        initFromPriorityQueue(c);
    }


    @SuppressWarnings("unchecked")
    public PriorityQueue(SortedSet<? extends E> c) {
        this.comparator = (Comparator<? super E>) c.comparator();
        initElementsFromCollection(c);
    }

4.3.2、为什么默认是小顶堆?

有上面可知,无论哪个构造都要比较元素大小,来构建小顶堆/大顶堆,我们来看下PriorityQueue中比较器Comparator的应用,也就是为什么默认是小顶堆?

	  // 添加一个元素
		public boolean offer(E e) {
        if (e == null)
            throw new NullPointerException();
        modCount++;
        int i = size;
        if (i >= queue.length)
            grow(i + 1);
        size = i + 1;
        if (i == 0)
            queue[0] = e;
        else
            siftUp(i, e);
        return true;
    }

		// 向上调整
    private void siftUp(int k, E x) {
        if (comparator != null)
            siftUpUsingComparator(k, x);
        else
            siftUpComparable(k, x);
    }

    // k是最后一个元素的下标,x是当前要插入的元素
    private void siftUpUsingComparator(int k, E x) {
        while (k > 0) {
          	// 最后一个非叶子结点的下标
            int parent = (k - 1) >>> 1;
            Object e = queue[parent];
          	// 当前元素 与 父节点元素比较大小,如果当前元素更小,则交换两者 
            if (comparator.compare(x, (E) e) >= 0)
                break;
            queue[k] = e;
            k = parent;
        }
        queue[k] = x;
    }

这里就得说下Comparator这个接口

public interface Comparator<T> {
	    int compare(T o1, T o2);
}

Java中默认规则是 o1 - o2,在PriorityQueue中当 o1 - o2 < 0 时,才交换,将更小的交换到父节点。

如果要是实现大顶堆,直接重写 Comparator的compare方法,使之为o2-o1,此时如果结果是 < 0的,说明o2小,o1大,映射到PriorityQueue的源码中,就是x大(新加入的),e小(父),然后交换,x就被换到父节点了,说明父节点是最大的那个,自然也就成了大顶堆。

PriorityQueue queue = new PriorityQueue<Integer>(
  new Comparator<Integer>() {
	        @Override
	        public int compare(Integer o1, Integer o2) {
						return o2 - o1;
	        }
	    }                                                
);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

悬浮海

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

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

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

打赏作者

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

抵扣说明:

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

余额充值