Leecode 347. 前 K 个高频元素 由此引发的对于优先级队列 最大堆&最小堆的思考

题目描述

给定一个非空的整数数组,返回其中出现频率前 k 高的元素。

  • 示例 1:
    输入: nums = [1,1,1,2,2,3], k = 2
    输出: [1,2]
  • 示例 2:
    输入: nums = [1], k = 1
    输出: [1]

link

思路

  1. 首先,题目中有”前 k 高“这样的字眼,应该很自然地联想到优先队列。(只要有“前K” 都可以联想下优先级队列)
  2. 那出现频率怎么统计呢,我们既要保留原数组的元素,还要统计它出现的频率。那么很明显,需要一个map来存放<元素,元素出现的频率>
  3. 那么优先级队列的优先级别可以根据元素出现频率作为依据,出现频率越高,优先级越高

步骤

  1. 创建存放结果的数组 int[] res
  2. 创建一个HashMap,遍历数组,统计元素的出现频率
  3. 创建优先级队列(最小堆),自定义实现堆中的Comparator接口的compare方法
  4. 将hashMap的值存入优先级队列,并维护优先级队列的size最大为k,如果大于K,就poll()
  5. 将队列中的所有元素赋给结果数组,返回结果数组

代码

public class TopKFrequent {

    public int[] topKFrequent(int[] nums, int k) {
        Map<Integer, Integer> map = new HashMap<>();
        //最小堆,lambda表达式 等同于下方的重写Comparator接口的compare方法
        PriorityQueue<Integer> heap = new PriorityQueue<Integer>((n1, n2) -> map.get(n1) - map.get(n2));
        
//        PriorityQueue pq = new PriorityQueue<>(new Comparator() {
//            @Override
//            public int compare(Integer n1, Integer n2) {
//                return map.get(n1) - map.get(n2);
//            }
//        });

        for(int i : nums){
            map.put(i, map.getOrDefault(i, 0) + 1);
        }
        //每次加入一个元素到堆中,会先放在堆的底部,然后向上筛选(siftUpUsingComparator)
        //如果它的父节点比它的优先级低,则替换两个节点,直到父节点不再比它的优先级低
        //这里我使用的是最小堆,也就是说数字越小,优先级越高,会被放到堆顶点。  达到K个数之后,就会每次移除堆顶元素。
        //也就是移除那些小的元素
        for(int n : map.keySet()){
            heap.add(n);
            if(heap.size() > k)
                heap.poll();
        }

        int[] res = new int[k];
        int j = 0;
        while (!heap.isEmpty()){
            res[j++] = heap.poll();
        }
        return res;
    }
}

对于优先级队列的思考

起初我并不了解 到底怎样创建是最大堆 怎样是最小堆; 对于优先级队列太久没用了,加上以前也是囫囵吞枣,今天才发现自己并没有实际了解清楚
今天看了下源码,才算分清楚了

初始化部分

这是优先级队列的构造函数
public PriorityQueue(Comparator<? super E> comparator) {
        this(DEFAULT_INITIAL_CAPACITY, comparator);
    }

可以看到有个初始容量

private static final int DEFAULT_INITIAL_CAPACITY = 11;

所以优先级队列创建时如果没有指定容量,默认就是11

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;
    }

而且看到其实queue底层就是一个Object数组实现的。

最大堆 最小堆

那是如何保证优先级队列以什么样的标准维护插入元素之后依然会保持整个队列的优先级顺序呢
我们去看 offer方法 (add方法底层就是调用的offer方法)

	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;
    }

这里可以看到插入的步骤

  1. 先判断元素是否合法,如果是null,直接抛出异常
  2. 获取当前队列的大小,如果队列中元素数量大于或者等于 底层数组的大小,那么Object数组就需要扩容了,调用扩容方法 grow
    2.1 扩容方法内部是如果原来的数组长度小于64,那么就扩容成 2 * oldCapacity + 2,如果原来的数组长度大于64,那么就扩容成 oldCapacity + (oldCapacity >> 1)
  3. 如果此时队列中没有元素,即i == 0,那么直接把元素赋给队头
  4. 执行向上筛选方法 siftUp()
siftUp()
	/**
	* @param k the position to fill
    * @param x the item to insert
    */
	private void siftUp(int k, E x) {
        if (comparator != null)
            siftUpUsingComparator(k, x);
        else
            siftUpComparable(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;
    }
  1. 进入siftUp方法之后,先判断 comparator 是否是null(如果在init 优先级队列的时候没有定义这个接口,那么就是null),不是null,就转换到 siftUpUsingComparator 方法
  2. 首先可以看到,k是插入的位置,从前面的方法可以知道,k其实就是堆底的位置。
  3. while循环,k必须要 > 0 才合法。 计算当前堆节点的父节点在队列中的index。 这里可以理解为二叉树的结构,孩子节点计算父亲节点的位置,那么就是孩子节点的 (index - 1) / 2。
  4. 找到父节点的位置后,得到它位置上的元素,通过comparator的compare方法对插入元素和父节点元素的比较,这个时候我们自定义的比较规则就起了作用。如果compare方法返回值 >= 0,就不会继续向上筛选,也就是说该元素现在的位置就是它按照优先级应该存在的位置。如果 < 0, 就代表它的优先级比父节点高,应该继续往上移动。
    重点
	PriorityQueue<Integer> pq = new PriorityQueue<>(new Comparator<Integer>() {
            @Override
            public int compare(Integer n1, Integer n2) {
                return map.get(n1) - map.get(n2);
            }
        });

我们自定义的比较方法是 第一个元素与第二个元素的值进行减法运算。返回的是差值。
相当于 如果第一个元素 >= 第二个元素 那么返回值就 >= 0。那么第一个元素大于第二个元素返回值 > 0 按照里面的逻辑就会发现:
插入元素的值比父节点的元素大,就不会进行向上筛选;
如果比父节点小小,插入元素就会赋给父节点,继续往上筛选。
那么很明显,就是小的元素会往堆顶走,大的元素会沉淀在堆底。

 这就是最小堆。

相反,只需要把比较方法调换一下,就变成了最大堆。

	PriorityQueue<Integer> pq = new PriorityQueue<>(new Comparator<Integer>() {
            @Override
            public int compare(Integer n1, Integer n2) {
                return map.get(n2) - map.get(n1);
            }
        });
这就是最大堆

首尾呼应

然后现在来看Leecode这道题。是需要返回频率最高的前K个数,那么最小堆很合适。
我们只需要创建一个最小堆,一直往里面丢元素进去。
等优先级队列的size达到K之后,就每次都删除一个堆顶元素出来。
因为删除的都是最小的元素,所以最后优先级队列里面存放的肯定是出现频率最高的前K个数。

你可能感兴趣的:(java,数据结构与算法,数据结构,算法,优先级队列)