数据结构学习 -- 堆和优先队列

Posted 庸人冲

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构学习 -- 堆和优先队列相关的知识,希望对你有一定的参考价值。

堆和优先队列

二叉堆

堆通常使用了树的存储方式,比较常用的堆是:二叉堆,也就是满足一些特殊性质的二叉树。

二叉堆的性质

1️⃣ 二叉堆必须是一颗完全二叉树,所谓完全二叉树可以理解为将元素按顺序一层一层的排列成二叉树的形状,完全二叉树的特点为:

  1. 完全二叉树是一颗叶子节点只能出现在最下面两层的二叉树。
  2. 完全二叉树最下面一层的叶子节点必须从左至右连续出现,中间不能出现空节点。
  3. 完全二叉树倒数第二层的如果有叶子节点,一定位于右边连续位置。
  4. 完全二叉树中如果节点只有一个子节点,那么一定是左子节点,完全二叉树中不存在只有右子节点的情况。

2️⃣ 二叉堆分为最大堆最小堆

  1. 最大堆:堆中任意的子节点的值 <= 其父节点的值。(根节点值最大)

    )

  2. 最小堆:堆中任意的子节点的值 >= 其父节点的值。(根节点值最小)

3️⃣ 同层次节点间没有大小关系,如上面两张图,最大堆中第二层的右子节点为 2,而第三层中节点 5 的两个子节点都 > 2,但是这并不影响各节点在自己所位于的子树中满足堆的性质,也即是各节点在自己所处的子树中满足大(小)堆的性质即可,与层级无关。

数组存储二叉堆

堆因为是一颗完全二叉树,因此可以使用数组的方式进行存储,最后一个节点就是数组最后一个元素。

节点下标计算公式:

  1. 已知父节点的下标为 i左孩子下标 = 2 × i + 1右孩子下标 = 2 × i + 2

  2. 已知孩子节点的下标为就 j父节点 = (j - 1) / 2

堆的实现

本文中实现的堆为最大堆,最小堆的实现方式其实基本相同只是对于大小的定义不同。

堆的基本结构和辅助函数

上面已经介绍,在本文中实现的最大堆,底层使用数组作为容器来存放堆中元素。因此设计Heap(堆) 这个类时,需要定义一个数组作为私有的成员变量。同时需要注意的是,我们实现的堆采用了泛型,但是堆中的元素具备可比较性因此对于泛型设定要继承于Comparable 接口。

具体代码如下:

public class MaxHeap<E extends Comparable<E>> {

    private E[] data;  // 底层容器
    private int size;  // 纪录堆中元素的个数
    private static final int DEFAULT_CAPACITY = 11;  // 默认容量


    public MaxHeap() {
        this(DEFAULT_CAPACITY);
    }

    @SuppressWarnings("unchecked")
    public MaxHeap(int initCapacity) {
        data = (E[]) new Comparable[initCapacity];
    }

    
    // 根据 index 计算出父亲节点的下标
    private int parent(int index) {
        if (index == 0) {
            throw new IllegalArgumentException("Index-0 doesn't have parent!");
        }
        return (index - 1) / 2;
    }

    // 根据 index 计算出左孩子节点的下标
    private int leftChild(int index) {
        return (index << 1) + 1;
    }
    

    /**
     * 扩容,参考PriorityQueue类
     */
    public void grow() {
        int oldCapacity = data.length;
        int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                (oldCapacity + 2) :
                (oldCapacity >> 1));
        data = Arrays.copyOf(data, newCapacity);
    }

    // 返回堆中元素个数
    public int size() {
        return size;
    }

    // 判断堆是否为空 
    public boolean isEmpty() {
        return size == 0;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        for (int i = 0; i < size(); i++) {
            sb.append(data[i]);
            if (i < size() - 1) {
                sb.append(",");
            }
        }
        sb.append("]\\n");
        return sb.toString();
    }

    public static void main(String[] args) {

        Integer[] arr1 = ArrayGenerator.randomArrayGenerator(100, 100);

        MaxHeap<Integer> maxHeap1 = new MaxHeap<>(arr1);
        while (!maxHeap1.isEmpty()) {
            System.out.print(maxHeap1.extractMax() + " ");
        }
    }
}

Sift UpSift Down

Sift UpSift Down 是堆中最重要的两个操作。

Sift Up 数据上浮。当我们需要在向堆中添加元素时,通常情况下是直接在数组最后一个元素的后一个索引位置添加元素。不过对于堆来说,添加进元素后必须维护住堆的性质,因此就需要对新添加的元素进行 Sift Up 操作,使得新的元素位于堆中合适的位置。

具体过程为,从新添加的节点开始向上与父亲节点进行比较,如果大于父亲节点,则交换两个节点的位置,使得新添加的元素成为这颗子树的父节点,并继续向上进行 Sift up 操作,知道根节点为止。

代码实现

	/**
     * 向堆中添加元素,添加的元素将会进行向上调整
     */
    public void add(E e) {
        if (e == null)
            throw new NullPointerException();
        if (size >= data.length)
            grow();     

        data[size++] = e;  // 数组末尾添加元素,并维护size
        if (size > 1)      // 当堆中元素大于1个时,执行 sift up 操作
            siftUp(size - 1); 
    }

	/**
     * 将索引c的元素向上调整
     * c: 孩子节点索引
     * p: 父亲节点索引
     */
    private void siftUp(int c) {
        int p;
        E child = data[c];  // 保存上浮元素
        // 当 c 下标大于0, 即不为根节点, 并且当前父节点小于子节点时进入循环
        while (c > 0 && data[p = parent(c)].compareTo(child) < 0) {
            data[c] = data[p];  // 将父节点的值覆盖到子节点上
            c = p;   // c 纪录 child 当前应该存放的位置
        }
        data[c] = child;  // 当退出循环时, c 中纪录的就是 child 应该存放的位置, 赋值到该位置。
    }

Sift Down 数据下沉。对于堆的这种数据结构我们一般只关心堆顶的元素,当我们需要取出堆顶元素时,如果直接将堆顶元素取出,那么此时的二叉堆中就存在了两个子堆,将两个子堆重新合并成一个堆的操作比较麻烦,因此对于取出堆顶元素的操作,一般是先保存堆顶元素,再让堆中最后一个元素顶上去,那么此时堆顶的元素不符合堆的性质,就需要将堆顶元素下沉到它合适的位置。

具体操作为,当堆尾的元素顶到堆顶后,让该元素与其左右子节点中较大(大堆)的元素进行比较,如果当前的堆顶元素小于较大的子元素时,就交换两个节点的位置。接着继续向下执行重复操作,直到该元素所处的位置满足大堆的性质时停止,这就是 Sift Down 操作。

代码实现

 /**
     * 将堆中的最大元素(根元素)删除,并返回该元素的值。
     * 具体操作为:
     * 1、保存待删除根元素的值
     * 2、用堆中最后一个元素(数组最后一个元素)覆盖根元素
     * 3、维护size变量
     * 4、将覆盖后的根元素向下调整到合适位置
     */
    public E extractMax() {

        E ret = findMax();        // 1
        data[0] = data[--size];   // 2、3
        data[size] = null;        
        siftDown(0);              // 4
        return ret;
    }

    /**
     * 将索引为p的元素下层
     */
    private void siftDown(int p) {
        int half = size >>> 1;  // 计算最后一个非叶子节点的后一个节点下标,即第一个子节点的下标 
        E parent = data[p];     // 保存需要下沉的元素
        while (p < half) {      // p 代表父节点,当 p < half 时表示 p 指向的节点存在子节点
            int l = leftChild(p);  // 计算左孩子下标
            if (l + 1 < size &&    // 如果存在右孩子
                    data[l + 1].compareTo(data[l]) > 0) // 比较左右孩子大小
                l++;            // data[l] 是左右孩子中的最大值

            if (parent.compareTo(data[l]) >= 0)  // 如果父元素大于等于最大子元素,则无序下沉,直接退出循环
                break;

            data[p] = data[l];   // 否则, 让较大的孩子覆盖到父节点的位置
            p = l;               // p 更新为 l, 继续下沉操作,p中纪录的位置是 parent 此时应存放的位置
        }
        data[p] = parent;        // 当退出循环时,将 parent 赋值到 data[p] 上
    }

	// 返回堆顶元素
    public E findMax() {
        if (isEmpty())
            throw new RuntimeException("Heap is empty!");
        return data[0];
    }

Heapifyreplace

Heapify ,将任意的数组整理成堆的形状。具体实现为通过数组的元素个数计算出最后一个非叶子节点的下标,并从该节点开始从后向前执行 Sift Down,一直到根结点执行完 Sift Down 为止,那么此时数组就被转换为一个二叉堆。

    // 提供参数为数组的构造器
	public MaxHeap(E[] arr) {
        if (arr == null)
            throw new NullPointerException();

        data = Arrays.copyOf(arr, arr.length);
        size = data.length;

        if (size > 1)
            heapify();  // 大于1个元素,则堆化
    }
	/**
     * 将任意的数组整理成堆的结构
     * 实现思路:从最后一个非叶子节点开始向下调整,直到调整到根节点为止。lastNonLeaf = parent(size - 1);
     * 时间复杂度:O(n),如果是直接将整个数组中所有元素依次添加进堆中则算法复杂度为O(nlogn),因此该算法要优于直接添加数
     * 组元素。
     */
    private void heapify() {
        int nonLeaf = parent(size - 1);
        while (nonLeaf >= 0) {
            siftDown(nonLeaf);
            nonLeaf--;
        }
    }

replace ,取出堆顶元素,并放入一个新的元素。具体实现非常简单,先获取堆顶元素,再将新的元素放入堆顶位置,并对该元素指向 Sift Down 操作即可。

	/**
     * 取出堆中最大元素,并用元素e覆盖该元素,元素e将会被sift down
     */
    public E replace(E e) {
        E ret = findMax();
        data[0] = e;
        siftDown(0);
        return ret;
    }

整体代码

public class MaxHeap<E extends Comparable<E>> {

    private E[] data;
    private int size;
    private static final int DEFAULT_CAPACITY = 11;


    public MaxHeap() {
        this(DEFAULT_CAPACITY);
    }

    @SuppressWarnings("unchecked")
    public MaxHeap(int initCapacity) {
        data = (E[]) new Comparable[initCapacity];
    }


    public MaxHeap(E[] arr) {
        if (arr == null)
            throw new NullPointerException();

        data = Arrays.copyOf(arr, arr.length);
        size = data.length;

        if (size > 1)
            heapify();  
    }

    private void heapify() {
        int nonLeaf = parent(size - 1);
        while (nonLeaf >= 0) {
            siftDown(nonLeaf);
            nonLeaf--;
        }
    }

    public void add(E e) {
        if (e == null)
            throw new NullPointerException();
        if (size >= data.length)
            grow();

        data[size++] = e;
        if (size > 1)
            siftUp(size - 1);
    }

    private void siftUp(int c) {
        int p;
        E child = data[c];
        while (c > 0 &&
                data[p = parent(c)].compareTo(child) < 0) {
            data[c] = data[p];
            c = p;
        }
        data[c] = child;
    }

    public void grow() {
        int oldCapacity = data.length;
        int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                (oldCapacity + 2) :
                (oldCapacity >> 1));
        data = Arrays.copyOf(data, newCapacity);
    }

    public E extractMax() {

        E ret = findMax();        // 1
        data[0] = data[--size];   // 2、3
        data[size] = null;        // for gc
        siftDown(0);           // 4
        return ret;
    }


    public E replace(E e) {
        E ret = findMax();
        data[0] = e;
        siftDown(0);
        return ret;
    }


    private void siftDown(int p) {
        int half = size >>> 1;
        E parent = data[p];
        while (p < half) {
            int c = leftChild(p);
            if (c + 1 < size &&
                    data[c + 1].compareTo(data[c]) > 0)
                c++;   

            if (parent.compareTo(data[c]) >= 0)
                break;

            data[p] = data[c];
            p = c;
        }
        data[p] = parent;
    }

    public E findMax() {
        if (isEmpty())
            throw new RuntimeException("Heap is empty!");
        return data[0];
    }

    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 << 1) + 1;
    }

    public int size() {
        return size;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        for (int i = 0; i < size(); i++) {
            sb.append(data[i]);
            if (i < size() - 1) {
                sb.append(",");
            }
        }
        sb.append("]\\n");
        return sb.toString();
    }
}

优先队列

在了解了堆的实现原理后,对于优先队列的实现也就比较简单了。优先队列一般是基于堆作为底层的数据结构,它与普通队列的区别在于:

  1. 普通队列:先进先出。底层数据结构:顺序表,链表。

  2. 优先队列:出队顺序和入队顺序无关,优先级高者先出队

优先队列的应用场景

操作系统中对于任务的调度,使用了优先队列这种高级的数据结构。操作系统会同时执行多个任务,那么操作系统就需要为这多个任务分配资源,包括为 cpu 分配时间片。那么操作系统在具体分配资源的过程中就需要看这多个任务的优先级,动态的选择优先级最高的任务去执行。

另外在很多游戏中都会有一个游戏排行榜,而这个排行榜是动态更新的,那么使用优先队列就可以实时的查询到当前优先级最高的用户是谁。

代码实现

优先队列作为队列的一种,所支持操作也和普通一样,只是底层使用堆作为数据结构后可以很方便的实现优先出队的操作。因此在具体代码实现上,只需要在优先队列类的内部创建一个私有的 Heap 对象,并调用 Heap 相应的方法即可以实现优先队列操作

以上是关于数据结构学习 -- 堆和优先队列的主要内容,如果未能解决你的问题,请参考以下文章

数据结构学习 -- 堆和优先队列

数据结构学习 -- 堆和优先队列

堆和优先级队列2:java实现堆和优先级队列

堆和优先队列

堆和堆的应用:堆排序和优先队列

0038数据结构之堆和优先队列