【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue

作者:学Java的冬瓜
博客主页:☀冬瓜的主页
专栏:【Java 数据结构与算法】
内容:TopK+Map题前K个高频单词+PriorityQueue的问题

【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue_第1张图片

文章目录

  • 前K个高频单词
    • 1、有缺陷的代码:
    • 2、代码的步骤整理:
    • 3、PriorityQueue的poll源码分析
    • 4、正确的代码

前K个高频单词

1、有缺陷的代码:

链接:【LeetCode692.前K个高频单词】

class Solution {
    public List<String> topKFrequent(String[] words, int k) {
        // 1、统计每个单词出现的次数
        Map<String, Integer> map = new HashMap();
        for(String s : words) {
            if(map.get(s) == null){
                map.put(s,1);
            }else{
                int val = map.get(s);
                map.put(s, val+1);
            }
        }

        // 注意:需要给minHeap传一个比较器,因为entry本身不可比较
        PriorityQueue<Map.Entry<String,Integer>> minHeap =
                new PriorityQueue<>(new Comparator<Map.Entry<String, Integer>>() {
                    @Override
                    public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
                    	// 注意:要把下面这段代码放出来才能正确运行:
                        // entry和topHeap的value相等时,以key从小到大排序,是大堆的方式。
                        //if (o1.getValue().compareTo(o2.getValue()) == 0){
                            //return o2.getKey().compareTo(o1.getKey());
                        //}
                        return o1.getValue().compareTo(o2.getValue());
                    }
                });

        // 注意:题目要求先以次数(value)从大到小打印,次数相等的以字母(key)从小到大打印
        // 2、遍历Map,把key和value打包成entry,然后把前k个放入小堆priorityQueue中,
        //   之后的元素entry的value就和小堆中堆顶元素的value比较,比topHeap大,就让topHeap出堆,entry入堆
        //   如果value相等,那就用key比较,比topHeap的key小的entry入堆(相当于在value相等的元素使用大堆排序)
        Set<Map.Entry<String,Integer>> set = map.entrySet();
        for (Map.Entry<String,Integer> entry : set) {
            if(minHeap.size() < k){
                minHeap.offer(entry);
            }else {
                Map.Entry<String,Integer> topHeap = minHeap.peek();
                if (entry.getValue().compareTo(topHeap.getValue()) > 0){
                    minHeap.poll();
                    minHeap.offer(entry);
                } else if (entry.getValue().compareTo(topHeap.getValue()) == 0) {
                    if (entry.getKey().compareTo(topHeap.getKey()) < 0){
                        minHeap.poll();
                        minHeap.offer(entry);
                    }
                }
            }
        }


        // 3、把minHeap中的entry出堆同时把的key逐个存入list
        List<String> list = new ArrayList<>();
        while(minHeap.size() > 0){
            String key = minHeap.poll().getKey();
            list.add(key);
        }
        // 4、因为此时key集合是从小堆中拿出的,在list中的顺序次数是从小到大
        // 调用Collections的reverse方法转置key集合,让key集合变成大到小
        Collections.reverse(list);
        return list; 
    }
}

2、代码的步骤整理:

  • 我们来理一理上面代码的思路:
    首先这道题要求前k个高频单词,很明显是TopK问题,需要特别注意的是当频率相同时,则按照字典排序这个要求。

  • TopK问题就可以使用小堆来实现,TopK的思路:把数组的前K个元素逐个插入小堆中,在插入后面n-k个元素时,让该元素和堆顶元素(堆中最小的元素)比较,如果比堆顶元素大,那就把堆顶元素出队,把这个元素入队,然后堆向下调整,变成小堆。当数组中的元素全部遍历完成时并且该插入的插入小堆后,这个小堆中存放的就是前k个大的元素。

  • 在这道题中比较的是单词频率,即单词次数,那么我们可以使用HashMap来存储key为单词,value为次数。所以接下来我们理一下代码步骤,是怎么使用Map和TopK的思想的。
    步骤:
    第一步:使用Map统计单词出现的次数,Map的key为这个单词,value为次数。
    第二步:遍历Map中的元素,具体是使用Set> set = map.entrySet();将map的key和value打包成entry装进Set,然后遍历set同时把前k个entry放入小堆minHeap中,当minHeap.size() == k时,就开始让剩下的entry和堆顶元素topHeap的value比较,如果entry的value比topHeap的value大,那就让堆顶的topHeap出堆,当前entry入堆同时堆自己向下调整变成新的小堆。当遍历完set集合时,minHeap这个堆中留下的就是出现频率前k个entry。
    需要格外注意的一点是,如果当前entry和topHeap的value相等,就按照字典排序,即字母从小到大。当前entry和topHeap的value相等,所以这时候用key比较,如果当前entry比topHeap的key小,topHeap出堆,entry入堆(相当于value相等的元素使用大堆排序,使用key比较)。
    总的来说就是:value不等时,用value作比较,建小堆;value相等时,在前面小堆的基础上,以key作比较,value相等的元素建立大堆。
    第三步:把minHeap的每个entry元素出堆,同时把entry的key存入list中。
    第四步:使用Collections.reverse()方法把list转置,因为第三步中的出堆时这个堆是小堆,value小的元素先弹出来,在list中就是value小的entry的key先装进list了,而题目要求从大到小,所以需要逆置。

这道题你以为到这里就完了?那就大错特错了,这里还差最重重要的部分,这个问题非常的隐蔽,非常难发现。
在LeetCode上测试时,下面这个用例过不了,那么我们接着往下看,看看这是为什么?

// 输入
words =
["i","love","leetcode","i","love","coding"]
k = 3

// 实际输出
["love","i","coding"]
// 预期输出
["i","love","coding"]

3、PriorityQueue的poll源码分析

为什么实际输出与预期输出不同?我们来慢慢分析。
第二步操作为:示例中前k个元素的k=3。向PriorityQueue中插入元素,第二步完成后可能会有以下两种情况,要想知道具体的结果是以下两种情况的哪一种?那就来分析插入的源码。
【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue_第2张图片

  • 我写了很多之后我才发现最开始就出了一个很大的问题!!!就是我误认为的将entry集合放进Map中时是上图中步骤1的情况,实际情况是不确定的!为什么?因为entry通过key哈希到数组中,在数组的哪一个位置上是无法确定的。也就是说,我们无法知道步骤第二步(把entry插入完后),完成后是上面哪种情况。
  • 那么为什么说是上面的情况呢?为什么不应该还有可能是"leetcode"、“love”、“i"的情况吗?我们把上面的代码运行得出实际输出为"love”,“i”,“coding"三个entry。由于是小堆,所以我们只知道堆顶是"coding”,但是堆顶的左右是上图中的步骤2中的第一种还是第二种,我们是不知道的。所以只有上面步骤2的两种情况。
  • 所以我们只能在步骤第三步中出堆时分析poll(),然后向下调整。而并不能通过分析offer得到步骤第二步的结果。接下来我们看poll()的源码。
    在这之前,先看看poll()操作:就是把minHeap的entry拿出来,然后把entry的key存储在list中。
    【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue_第3张图片
// poll()的源码:
	public E poll() {
        if (size == 0)
            return null;
        int s = --size;
        modCount++;
        E result = (E) queue[0];
        E x = (E) queue[s];
        queue[s] = null;
        if (s != 0)
            siftDown(0, x);
        return result;
    }
  • 看源码时,我们要知道E是泛型类,具体在题中就是我们要删除的堆顶元素topHeap,然后返回topHeap。我们可以从第二步骤完成后的两个情况知晓,此时堆顶元素为 entry<“coding”,1>。
    从poll源码中可以看出,这里的操作为:记录要删除并返回的节点result,然后把size-1的元素交给x,再把size-1位置置空,然后进入向下调整。我以步骤2的第二种作为分析来做一个示例:此时x就是entry<“i”,2>
    【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue_第4张图片

  • 那接下来我们来看向下调整的代码:这里中间还经过一个是否传入了比较器的判断,因为我们传给了优先级队列minHeap比较器,以value作为比较。
    【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue_第5张图片

// 比较器判断
	private void siftDown(int k, E x) {
        if (comparator != null)
            siftDownUsingComparator(k, x);
        else
            siftDownComparable(k, x);
    }
  • 接下来我们继续看siftDownComparable(k,x),这时k=0,x=entry<“i”,2>。
// siftDownComparable向下调整有比较器源码。
	private void siftDownComparable(int k, E x) {
        Comparable<? super E> key = (Comparable<? super E>)x;
        int half = size >>> 1;        // loop while a non-leaf
        while (k < half) {
            int child = (k << 1) + 1; // assume left child is least
            Object c = queue[child];
            int right = child + 1;
            if (right < size &&
                ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
                c = queue[child = right];
            if (key.compareTo((E) c) <= 0)
                break;
            queue[k] = c;
            k = child;
        }
        queue[k] = key;
    }
  • siftDownComparable代码中的k=0,x=entry<“i”,2>,此时x已经不在minHeap中了,因为在分析poll()时size-1的位置已经置空。再根据下面的分析中我们知道,源码中的c就是左子树entry<“love”,2>。
    当走到if (key.compareTo((E) c) <= 0)这一步时,问题来了,此时key是x=entry<“i”,2>,c是entry<“love”,2>,二者的value相等。直接就break了,然后把x=entry<“i”,2>放在了0下标位置。
    【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue_第6张图片

  • 现在在minHeap中就变成下图这样:
    然后再次poll()堆顶元素entry<“i”,2>,因为剩下的节点只有一个了所以不再需要向下调整。所以在前面的分析步骤2中的第2种情况,minHeap的poll()次序为entry<“coding”,1>,entry<“i”,2>,entry<“love”,2>,存入list后list中的元素是这样排列的:entry<“coding”,1>,entry<“i”,2>,entry<“love”,2>。在第四个步骤时:使用Collections.reverse()方法把list转置得到的答案就是实际的输出[love, i, coding],把最开始的代码拿来测试就可以发现,哈希的结果确实是步骤二的第二步这种情况。
    【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue_第7张图片

  • 那为什么会出现这种情况?最根本的原因其实是在第一次poll()时,删除节点是entry<“coding”,1>,右节点是entry<“i”,2>,左节点是entry<“love”,2>。当走到if (key.compareTo((E) c) <= 0)这一步时,问题来了,此时key是x=entry<“i”,2>,c是entry<“love”,2>,二者的value相等,就直接就break了,并没有再根据key的从小到大排列。因为题目要求频率不等时,频率从大到小排序,频率相等时,要以字典从小到大排序。这里就是不满足频率相等时,要以字典从小到大排序这个条件。简单来说错误原因就是:出堆操作,向下调整调用compareTo方法时使用比较器,但是我们传给PriorityQueue的比较器不完整。
    【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue_第8张图片

  • 那怎么解决?简单,因为走到当走到if (key.compareTo((E) c) <= 0)这一步时,会调用我们传入的比较器,而错误产生的原因是当value相等时直接就break了,那我们在比较器中就加上value相等时用key比较的判断,见下图:
    【Java 数据结构与算法】-TopK+Map题前K个高频单词+PriorityQueue_第9张图片

4、正确的代码

  • 把有缺陷的代码中的定义优先级队列minHeap时传入比较器的三行注释代码放出,即可正常保证答案正确。

你可能感兴趣的:(java,开发语言,leetcode,数据结构)