Leetcode【TopK】| 347. 前 K 个高频元素

本文介绍了如何解决LeetCode中的「TopK」问题,提供了4种方法:哈希排序、快排变形、堆和二叉搜索树。其中,快排变形能在O(N)时间内高效解决,堆和二叉搜索树则能在O(NlogK)时间内完成,而计数排序(桶排序)在数据范围有限时可实现线性时间复杂度。

题目

给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
示例 1:
 输入: nums = [1,1,1,2,2,3], k = 2
 输出: [1,2]
示例 2:
 输入: nums = [1], k = 1
 输出: [1]
提示:
 1. 你可以假设给定的 k 总是合理的,且 1 ≤ k ≤ 数组中不相同的元素的个数。
 2. 你的算法的时间复杂度必须优于 O(n log n) , n 是数组的大小。
 3. 题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的。
 4. 你可以按任意顺序返回答案。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/top-k-frequent-elements

解题

哈希排序

自己首先的思路就是用一个哈希表存储每个元素以及它们在数组中出现的次数,然后将该哈希依照次数进行排序,然后再依次输出。(重点:哈希表中比较器排序的写法)

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        int[] res = new int[k];
        //建立一个按value排序的HashMap
        Map<Integer, Integer> map = new HashMap<Integer, Integer>();//key保存 数组元素值,value保存该值出现次数
        for (int num : nums) {
            map.put(num, map.getOrDefault(num, 0) + 1);
        }
        //比较器 list将map中的每个<key,value>即为一个entry保存为一个list
        List<Map.Entry<Integer,Integer>> list = new ArrayList<Map.Entry<Integer,Integer>>(map.entrySet());
        Collections.sort(list, new Comparator<Map.Entry<Integer, Integer>>(){
            //降序排序
            public int compare(Map.Entry<Integer, Integer> o1, Map.Entry<Integer, Integer> o2){
                return o2.getValue().compareTo(o1.getValue());
            }
        });
        //输出map中前k个
        //写法1
        //int i = 0;
        //for(Map.Entry<Integer, Integer> mapping:list){
        //    if(i == k){
        //        break;
        //    }else{
        //        res[i] = mapping.getKey();
        //    }
        //    i++;   
        //}
        //写法2
        for(int i = 0 ; i < k; i++){
            res[i] = list.get(i).getKey();
        }
        return res;
    }
}

在这里插入图片描述

TopK问题解法

参考以及代码来源: 4 种方法秒杀 TopK(计数排序/快排变形/堆/二叉搜索树)
其实是几种比较优秀的排序算法的应用,对排序算法不了解可以先看这篇:十种排序算法详解&Java实现
TopK问题,不管是求前K大/前K小/第K大/第K小,都有以下四种不错的方法:

  1. O(N):用快排变形最最高效解决TopK问题
  2. O(NlogK):大根堆(前K小)/小根堆(前K大)
  3. O(NlogK):二叉搜索树
  4. O(N):对于数据范围有限的情况下,可以用计数排序O(N)高效解决。

其中的很多方法其实在求TopK问题时,是不需要对整个序列进行排序的,应用一些较快的排序思想,在排序过程中就可得到结果,而不需全部排序完毕。

快排变形

因为每次快排是选出一个数(一般从第一个数开始)经过一系列交换找到该数在整个序列中所在的位置,对整个序列进行一次切分,结果为该数左边为比该数小的元素,右边为比该数大的元素。所以在解决TopK问题时,如果这次快排选出的数正好是第K个元素,结果已经出来了;如果不是根据两者的大小关系,看下次切分从哪个范围内选择数值即下次切分右段还是左段。

class Solution {
    class Pair{
        int num;
        int freq;
        public Pair(int num, int freq){
            this.num = num;
            this.freq = freq;
        }
    }
    public int[] topKFrequent(int[] nums, int k) {
        //统计每个数出现的次数
        Map<Integer, Integer> counterMap = IntStream.of(nums).boxed().collect(Collectors.toMap(e -> e, e -> 1, Integer::sum));
        //构造Pair数组,Pair.num表示数值,Pair.freq表示数字出现的次数
        Pair[] pairs = IntStream.of(nums).distinct().boxed().map(num -> new Pair(num, counterMap.get(num))).toArray(Pair[]::new);
        //用快排思想 获取Pair数组中频次最大的k个元素,即下标为0到k-1
        Pair[] topKpairs = quickSelect(pairs,0,pairs.length-1,k-1);
        //返回结果
        int[] res = new int[k];
        int idx = 0;
        for(Pair pair: topKpairs){
            res[idx++] = pair.num;
        }
        return res;
    }
    //快排思想 选出topk
    private Pair[] quickSelect(Pair[] pair, int l, int r, int idx){
        if(l>r){
            return new Pair[0];
        }
        //快排切分后,j左边的数组出现的次数都>=pair[j].freq,右边的数字出现次数都小于等于j出现次数
        int j = partition(pair,l,r);//一次快排切分,选出当前该关键字所在位置,即j 左大 右小
        if(j == idx){//当前切分关键字正好是topk的分界点,则返回其左边所有元素+其本身就是前k个元素即可
            return Arrays.copyOf(pair,idx+1);//copyOf的下标右边界 不包含所以是idx+1
        }
        //否则 根据j与idx的关系判断继续切分左段还是右段
        return j<idx?quickSelect(pair,j+1,r,idx):quickSelect(pair,l,j-1,idx);
    }
    //一次快速排序,即快排切分
    private int partition(Pair[] pair, int l, int r){
        Pair v = pair[l];
        int i = l, j = r+1;
        while(true){
            while(++i<=r && pair[i].freq > v.freq);
            while(--j>-r && pair[j].freq < v.freq);
            if(i>=j) break;
            //交换位置
            Pair tmp = pair[i];
            pair[i] = pair[j];
            pair[j] = tmp;
        }
        pair[l] = pair[j];
        pair[j] = v;
        return j;
    }
}

在这里插入图片描述

思路:维护一个大小为k的小根堆,这样堆顶一直都是当前堆内元素的最小值,这样依次比较的时候有大于堆顶元素的直接将堆顶元素替换掉再调整堆保证其满足堆性质,这样比较完所有元素后最终留在堆里的k个元素就是最大的k个。(重点:优先级队列priorityqueue的应用)

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        // 统计每个数字出现的次数
        Map<Integer, Integer> counter = IntStream.of(nums).boxed().collect(Collectors.toMap(e -> e, e -> 1, Integer::sum));
        // 定义小根堆,根据数字频率自小到大排序
        Queue<Integer> pq = new PriorityQueue<>((v1, v2) -> counter.get(v1) - counter.get(v2));
        // 遍历数组,维护一个大小为 k 的小根堆:
        // 不足 k 个直接将当前数字加入到堆中;否则判断堆中的最小次数是否小于当前数字的出现次数,若是,则删掉堆中出现次数最少的一个数字,将当前数字加入堆中。
        counter.forEach((num, cnt) -> {
            if (pq.size() < k) {
                pq.offer(num);
            } else if (counter.get(pq.peek()) < cnt) {
                pq.poll();
                pq.offer(num);
            }
        });
        // 构造返回结果
        int[] res = new int[k];
        int idx = 0;
        for (int num: pq) {
            res[idx++] = num;
        }
        return res;
    }
}

在这里插入图片描述

二叉搜索树

和最小堆的思想类似,只是这里使用k个元素的二叉搜索树来代替堆这个数据结构。堆的特点是堆顶是最小值/最大值,二叉搜索树的特点是树的节点是有顺序的,方便查找,所以也方便查找出当前树中的最小元素,因此遍历一遍整个数组的元素与其频次,通过维护一个k个元素的二叉搜索树,每次与树中最小元素比较与交换,最终留在树中的所有元素就是top。

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        // 统计每个数字出现的次数
        Map<Integer, Integer> counterMap = IntStream.of(nums).boxed().collect(Collectors.toMap(e -> e, e -> 1, Integer::sum));
        // 定义二叉搜索树:key 是数字出现的次数,value 是出现了key次的数字列表。
        TreeMap<Integer, List<Integer>> treeMap = new TreeMap<>();
        // 维护一个有 k 个数字的二叉搜索树:
        // 不足 k 个直接将当前数字加入到树中;否则判断当前树中的最小次数是否小于当前数字的出现次数,若是,则删掉树中出现次数最少的一个数字,将当前数字加入树中。
        int count = 0;
        for(Map.Entry<Integer, Integer> entry: counterMap.entrySet()) {
            int num = entry.getKey();
            int cnt = entry.getValue();
            if (count < k) {
                treeMap.computeIfAbsent(cnt, ArrayList::new).add(num);
                count++;
            } else {
                Map.Entry<Integer, List<Integer>> firstEntry = treeMap.firstEntry();
                if (cnt > firstEntry.getKey()) {
                    treeMap.computeIfAbsent(cnt, ArrayList::new).add(num);
                    List<Integer> list = firstEntry.getValue();
                    if (list.size() == 1) {
                        treeMap.pollFirstEntry();
                    } else {
                        list.remove(list.size() - 1);
                    }
                }
            }
        }
        // 构造返回结果
        int[] res = new int[k];
        int idx = 0;
        for (List<Integer> list: treeMap.values()) {
            for (int num: list) {
                res[idx++] = num;
            }
        }
        return res;
    }
}

在这里插入图片描述

计数排序(桶排序)

这个方法的核心思想是:将需要排序的数值即这道题中的频次对应一个数组的下标来存储。 该数组的值对应的就是元素值,下标就是该元素出现频次,因为数组下标本就是递增,所以排序自然完成,直接输出即可。
因为该题的频次是能确定范围的,最大就是nums数组的总长,所以需要排序的数值范围能确定,所以频次数组的大小/下标最大也能确定。所以这种方法适用于需要排序的数值的范围能确定以及数值范围比较小的情况。

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        // 统计每个数字出现的次数
        Map<Integer, Integer> counterMap = IntStream.of(nums).boxed().collect(Collectors.toMap(e -> e, e -> 1, Integer::sum));
        // 一个数字最多出现 nums.length 次,因此定义一个长度为 nums.length + 1 的数组,freqList[i] 中存储出现次数为 i 的所有数字。
        List<Integer>[] freqList = new List[nums.length + 1];
        for (int i = 0; i < freqList.length; i++) {
            freqList[i] = new ArrayList<>();
        }
        counterMap.forEach((num, freq) -> {
            freqList[freq].add(num);
        });
        // 按照出现频次,从大到小遍历频次数组,构造返回结果。
        int[] res = new int[k];
        int idx = 0;
        for (int freq = freqList.length - 1; freq > 0; freq--) {
            for (int num: freqList[freq]) {
                res[idx++] = num;
                if (idx == k) {
                    return res;
                }
            }
        }
        return res;
    }
}

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值