欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-03-31 19:08:18
...

基本性质

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

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

  • 最小堆(小根堆):树中每个非叶子结点都不大于其左右孩子结点的值,也就是根节点最小的堆,图(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主要如下所示

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

向上调整swim

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

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

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

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

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

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

  • 如果某个节点 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 的值比孩子结点都大为止。向下调整示意图如下:

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

时间复杂度也是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))算法的,而且如果数据规模是动态增加的,那堆就要完全优于排序算法了,