[数据结构]优先级队列(最大堆)详解

Posted 豆子前端笔记

tags:

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

基本性质

优先级队列,也叫二叉堆、堆(不要和内存中的堆区搞混了,不是一个东西,一个是内存区域,一个是数据结构)。

堆的本质上是一种完全二叉树,分为:

  • 最小堆(小根堆):树中每个非叶子结点都不大于其左右孩子结点的值,也就是根节点最小的堆,图(a)。
  • 最大堆(大根堆):树中每个非叶子结点都不小于其左右孩子结点的值,也就是根节点最大的堆,图(b)。

tip:本文之后的部分,均以大根堆为例。

存储方式

堆本质上是一颗完全二叉树,使用数组进行存储,**从a[1] 开始存储,这样对于下标为k 的结点 a[k]来说,其左孩子的下标为 2∗k,右孩子的下标为 2∗k+1。**且不论 k 是奇数还是偶数,其父亲结点(如果有的话)就是 [k/2]。

// 父节点的索引
int parent(int root) {
    return root / 2;
}
// 左孩子的索引
int left(int root) {
    return root * 2;
}

// 右孩子的索引
int right(int root) {
    return root * 2 + 1;
}

具体实现

需要实现的api主要如下所示

img

向上调整swim

假如我们向一个堆中插入一个元素,要使其仍然保持堆的结构。应该怎么办呢?

可以把想要添加的元素放在数组的最后,也就是完全二叉树的最后一个结点的后面,然后进行向上调整(heapinsert)。

向上调整总是把欲调整结点与父亲结点比较,如果权值比父亲结点大,那么就交换其与父亲结点,反复比较,直到到达堆顶或父亲结点的值较大为止。向上调整示意图如下:

img

最大堆每个节点都比它的两个子节点大,但是在插入元素和删除元素时,难免破坏堆的性质,这就需要通过这两个操作来恢复堆的性质了。

对于最大堆,会破坏堆性质的有有两种情况:

  • 如果某个节点 A 比它的子节点(中的一个)小,那么 A 就不配做父节点,应该下去,下面那个更大的节点上来做父节点,这就是对 A 进行下沉。

  • 如果某个节点 A 比它的父节点大,那么 A 不应该做子节点,应该把父节点换下来,自己去做父节点,这就是对 A 的上浮。

当然,错位的节点 A 可能要上浮(或下沉)很多次,才能到达正确的位置,恢复堆的性质。所以代码中肯定有一个while循环。

//    上浮第k个元素,以此维护最大堆性质
private void swim(int k) {

    if (k > N) {
        // 不符合要求的输入
        return;
    }
    //  如果浮到了堆顶,就不能再上浮
    while (k > 1 && isLess(parent(k), k)) {
        //   如果第k个元素比上层大,就浮上去
        exchange(parent(k), k);
        k = parent(k);
    }
}





向下调整sink

假如我们要删除一个堆中的堆顶元素,要使其仍然保持堆的结构。应该怎么办呢?

移除堆顶元素后,将最后一个元素移动到堆顶,然后对这个元素进行向下调整(heapify),向下调整总是把欲调整结点 KK 与其左右孩子结点比较,如果孩子中存在权值比当前结点 KK 大的,那么就将其中权值最大的那个孩子结点与结点 KK,反复比较,直到到结点 KK 为叶子结点或结点 KK 的值比孩子结点都大为止。向下调整示意图如下:

img

时间复杂度也是O(logn)

// 下沉第k个元素,以此维护最大堆性质
private void sink(int k) {
    // 如果沉到底,就沉不下去了
    while (left(k) <= N) {
        // 假设左边节点比较大
        int bigger = left(k);

        // 如果右边节点存在,比较一下大小
        if (right(k) <= N && isLess(bigger, right(k))) {
            bigger = right(k);
        }

        // 节点k比两个孩子都大,不用下沉了
        if (isLess(bigger, k)) break;

        // 否则,不符合最大堆的结构,下沉k节点
        exchange(k, bigger);

        k = bigger;
    }
}

实现delMax (删除队顶元素)

这个方法是建立在swimsink上的。

delMax方法先把堆顶元素 A 和堆底最后的元素 B 对调,然后删除 A,最后让 B 下沉到正确位置。

public Key delMax() {
    // 最大堆的堆顶就是最大元素
    Key max = pq[1];
    // 把这个最大元素换到最后,删除之
    exch(1, N);
    pq[N] = null;
    N--;
    // 让 pq[1] 下沉到正确位置
    sink(1);
    return max;
}

实现insert

这个方法也是建立在swimsink上的。

insert方法先把要插入的元素添加到堆底的最后,然后让其上浮到正确位置。

public void insert(Key e) {
    N++;
    // 先把新元素加到最后
    pq[N] = e;
    // 然后让它上浮到正确的位置
    swim(N);
}








完整代码

一个优先级队列就实现了,插入和删除元素的时间复杂度为 O(logK)。

K为当前二叉堆(优先级队列)中的元素总数。

因为时间复杂度主要花费在sink或者swim上,而不管上浮还是下沉,最多也就树(堆)的高度,也就是 log 级别。

package com.dataSruct;

public class MaxPQ<Key extends Comparable<Key>> {
    // 存储元素的数组
    private Key[] pq;

    // 当前Priority Queue中的元素个数
    private int N = 0;

    public MaxPQ(int cap) {
        // 索引0 不用,所以多分配一个空间
        pq = (Key[]) new Comparable[cap + 1];
    }

    //    返回当前队列中最大元素
    public Key max() {
        return pq[1];
    }

    //    插入元素e
    public void insert(Key e) {
        N++;
        //  先把新元素加到最后
        pq[N] = e;

        // 让其上浮到正确位置
        swim(N);
    }

    //    删除并返回当前队列中最大元素
    public Key delMax() {
        // 最大堆的堆顶就是最大元素
        Key max = pq[1];

        // 把这个最大元素换到最后,删除之
        exchange(1, N);

        pq[N] = null;

        N--;

        // 让pq[1]下沉到正确位置
        sink(1);

        return max;
    }

    //    上浮第k个元素,以此维护最大堆性质
    private void swim(int k) {

        if (k > N) {
            // 不符合要求的输入
            return;
        }
        //  如果浮到了堆顶,就不能再上浮
        while (k > 1 && isLess(parent(k), k)) {
            //   如果第k个元素比上层大,就浮上去
            exchange(parent(k), k);
            k = parent(k);
        }
    }

    // 下沉第k个元素,以此维护最大堆性质
    private void sink(int k) {
        // 如果沉到底,就沉不下去了
        while (left(k) <= N) {
            // 假设左边节点比较大
            int bigger = left(k);

            // 如果右边节点存在,比较一下大小
            if (right(k) <= N && isLess(bigger, right(k))) {
                bigger = right(k);
            }

            // 节点k比两个孩子都大,不用下沉了
            if (isLess(bigger, k)) break;

            // 否则,不符合最大堆的结构,下沉k节点
            exchange(k, bigger);

            k = bigger;
        }
    }

    //    交换数组的两个元素
    private void exchange(int i, int j) {
        Key temp = pq[i];
        pq[i] = pq[j];
        pq[j] = temp;
    }

    //    比较大小  i是否比j小?
    private boolean isLess(int i, int j) {
        return pq[i].compareTo(pq[j]) < 0;
    }


    private int parent(int root) {
        return root / 2;
    }

    private int left(int root) {
        return root * 2;
    }

    private int right(int root) {
        return root * 2 + 1;
    }

    //    获得父节点
    private Key parentNode(int k) {
        return pq[k / 2];
    }

    // 获得左子结点
    private Key leftNode(int k) {
        return k * 2 > N ? pq[k * 2] : null;
    }

    // 获得右节点
    private Key rightNode(int k) {
        return k * 2 + 1 > N ? pq[k * 2 + 1] : null;
    }


}

建堆

自顶向下建堆

自顶向下建堆的思想是,从第 i=1个元素开始,对其进行向上调整,始终使前 i 个元素保持堆的结构。时间复杂度 O(nlogn)

// 从一个数组 自顶向下建堆
// 自顶向下建堆的思想是,从第 i=1 个元素开始,对其进行向上调整,始终使前 i 个元素保持堆的结构。时间复杂度 O(nlogn)
public void ArrayToPQ(Key[] a){
    N = a.length;
    pq = a;

    for(int i=1; i <= N; i++){
        // 向上调整 上浮
        swim(i);
    }
}


自底向上建堆

自底向上建堆的思想是,自底向上建堆的思想是,从底 i=⌊n/2⌋ 个元素开始,对其进行向下调整,始终让后 n−i 个元素保持堆的结构。

// 从一个数组  自底向上建堆
// 自底向上建堆的思想是,从底 i=⌊n/2⌋ 个元素开始,对其进行向下调整,始终让后 n−i 个元素保持堆的结构。
private void ArrayToBPQ{Key []a}{
    N = a.length;
    pq = a;

    int i = n/2;
    for(; i>=1; i--){
        sink(i);
    }
}

堆排序

堆排序的思想:假设一个大根堆有 n 个元素,每次把第 1 个元素,与第 n 个元素交换,对第一个元素进行向下调整(sink),并使得 n=n−1 ,直到 n=1

// 堆排序 输入乱序数组 输出有序数组  仅供参考
public Key[] heapSort(Key[] arr) {
  // 先自底向上建堆
  ArrayToBPQ(arr);

  // 然后将最大堆变成降序数组
  for (int i = N; i > 1; i++) {
    exchange(1, i);
    sink(i - 1);
  }

  return pq;
}

例题

寻找第K大元素

首先用数组的前k个元素构建一个小根堆,然后遍历剩余数组和堆顶比较,如果当前元素大于堆顶,则把当前元素放在堆顶位置,并调整堆(heapify)。遍历结束后,堆顶就是数组的最大k个元素中的最小值,也就是第k大元素。

c++写法如下。

void heapify(int* a, int index, int length) {
 int left = index * 2 + 1;
 while (left <= length) {
  if (left + 1 <= length - 1 && a[left + 1] > a[left])left++;
  if (a[index] > a[left])break;
  swap(a[index], a[left]);
  index = left;
 }
}

void ArrayToBheap(int* a, int length) {
 int i = length / 2 - 1;
 for (; i >= 0; i--) {
  heapify(a, i, length);
 }
}

void FindKMax(int* a, int k, int length) {
 ArrayToBheap(a, k);
 for (int i = k; i < length; i++) {
  if (a[i] > a[0]) a[0] = a[i];
  heapify(a, 0, k);
 }
}

java写法如下

import java.util.PriorityQueue;


public class Solution {
    public int findKthLargest(int[] nums, int k) {
        // 小顶堆
        PriorityQueue<Integer> pq = new PriorityQueue<>();

        for (int num : nums) {
            pq.offer(num);
            // 堆中元素多于k个时,就删除堆顶元素 即 删除最小值
            if (pq.size() > k) {
                pq.poll();
            }
        }

        // pq中剩下的是nums中k个最大元素
        // 堆顶是最小的,即第k个大的元素
        return pq.peek();
    }
}

时间复杂度O(n),只是举个例子。





事实上对于这个问题是有更快的做法的,快速排序的思想,时间复杂度 O(logn)

int Search_K(int left, int right, int k) {
 int i = left, j = right;
 int p = rand() % (right - left + 1) + left;
 int sign = a[p];
 swap(a[p], a[i]);
 while (i < j) {
  while (i < j && a[j] >= sign)j--;
  while (i < j && a[i] <= sign)i++;
  swap(a[i], a[j]);
 }
 swap(a[i], a[left]);
 if (i - left + 1 == k)return a[i];
 if (i - left + 1 < k)return Search_K(i + 1, right, k - (i - left + 1));
 else return Search_K(left, i - 1, k);
}

堆更多时候,因为它建堆O(n),调整O(logn),当需要有序得到某些数据,是要优于排序(O(nlogn))算法的,而且如果数据规模是动态增加的,那堆就要完全优于排序算法了,


以上是关于[数据结构]优先级队列(最大堆)详解的主要内容,如果未能解决你的问题,请参考以下文章

最大堆与堆排序和优先队列

优先级队列实现

数据结构 ---[实现 堆(heap)(包含方法图解过程) 优先队列(Priority Queue)]

Black Box--[优先队列 最大堆最小堆的应用]

java 优先队列(基于最大堆)

手动实现最小堆和最大堆(优先队列)