热题100 - 347. 前 K 个高频元素

79 阅读9分钟

题目描述:

给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。

示例 1:

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

示例 2:

输入: nums = [1], k = 1
输出: [1]

提示:

  • 1 <= nums.length <= 105
  • k 的取值范围是 [1, 数组中不相同的元素的个数]
  • 题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的

进阶: 你所设计算法的时间复杂度 必须 优于 O(n log n) ,其中 n **是数组大小。

思路:

题目要求的时间复杂度是允许使用库排序算法的,所以第一个实现的思路是使用Map统计频率,然后用List排序频率,用Set装top k的频率,最后遍历map找到对应的key,返回int数组。

实现一:暴力解法

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        Map<Integer, Integer> numMap = new HashMap<>();
        for (int i : nums) {
            numMap.put(i, numMap.getOrDefault(i, 0) + 1);
        }
        List<Integer> numFreq = new ArrayList<>(numMap.values());
        numFreq.sort((a, b) -> b - a);
        Set<Integer> s = new HashSet<>();
        for (int i = 0; i < k; i++) {
            s.add(numFreq.get(i));
        }
        int[] result = new int[k];
        int i = 0;
        // numMap.forEach((key, value) -> {
        //     if (s.contains(value)) {
        //         result[i] = key;
        //         i++;
        //     }
        // });
        for (int key : numMap.keySet()) {
            if (s.contains(numMap.get(key))) {
                result[i] = key;
                i++;
            }
        }
        return result;
    }
}

时间复杂度就是O(nlogn)。这里面注意不能使用map.forEach()的方式遍历键值对,因为要改i的值的,除非使用原子Int,但是那个我记不住应该。所以最后还是用了传统的for each遍历keySet()了。另外Set可以作为ArrayList<>()的构造器参数创建List,另外map是有个values()方法的。我就记得keySet()了。

实现二:使用最小堆PriorityQueue

前K个高频元素的频率值,回到了之前的“第K大的数”的解法。使用Java内置的最小堆类实现。

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        Map<Integer, Integer> m = new HashMap<>();
        for (int i: nums) {
            m.put(i, m.getOrDefault(i, 0) + 1);
        }
        PriorityQueue<Entry> p = new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
        for (int i: m.keySet()) {
            if (p.size() < k) {
                p.offer(new Entry(i, m.get(i)));
            } else if (m.get(i) > p.peek().getValue()) {
                p.poll();
                p.offer(new Entry(i, m.get(i)));
            }
        }
        int [] res = new int[k];
        int i = 0;
        for (Entry e: p) {
            res[i] = e.getKey();
            i++;
        }
        return res;
    }
}

class Entry {
    int key;
    int value;
    Entry(int k, int v) {
        this.key = k;
        this.value = v;
    }

    public int getKey() {
        return this.key;
    }

    public int getValue() {
        return this.value;
    }
}

该算法的时间和空间复杂度分析如下:

时间复杂度:

  1. 统计频率:遍历数组 nums 构建哈希表,时间复杂度为 O(n),其中 n 是数组长度。
  2. 维护堆:遍历哈希表中的 m 个不同元素(最多为 n),每次堆操作(插入或删除)的时间复杂度为 O(logk)。总时间复杂度为 O(m logk),最坏情况下 m = n,即 O(n logk)
  3. 结果转换:将堆中元素输出为数组,时间复杂度 O(k)

综上,总时间复杂度为 O(n + n logk) = O(n logk)

空间复杂度:

  1. 哈希表:存储元素频率,最坏情况需要 O(n) 空间。
  2. 优先队列:存储 k 个元素,占用 O(k) 空间。
  3. 结果数组:占用 O(k) 空间。

总空间复杂度为 O(n + k)

关键点:

  • 使用最小堆优化,确保堆操作的时间复杂度仅与 k 相关。
  • 哈希表统计频率是线性时间和空间复杂度。
  • k 远小于 n 时,堆方法的效率明显优于全局排序(O(n logn))。

实现三:手写最小堆

主要工作量体现在最小堆类的实现。需要实现四个核心方法:swim, sink, insert, removeHead,以及辅助方法swap,查看top的方法。其中sink的实现之前犯过错,但是现在已经熟练了。

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        Map<Integer, Integer> m = new HashMap<>();
        for (int i : nums) {
            m.put(i, m.getOrDefault(i, 0)+1);
        }
        MinHeap heap = new MinHeap(k);
        for (int i:m.keySet()) {
            if (heap.size < k) {
                heap.insert(new Entry(i, m.get(i)));
            } else {
                if (m.get(i) > heap.top()) {
                    heap.removeHead();
                    heap.insert(new Entry(i, m.get(i)));
                }
            }
        }
        int[] res = new int[k];
        int i = 0;
        for (Entry en: heap.e) {
            res[i] = en.key;
            i++;
        }
        return res;
    }
}

class MinHeap {
    int size;
    Entry[] e;

    int top() {
        return e[0].value;
    }

    MinHeap(int k) {
        this.size = 0;
        this.e = new Entry[k];
    }

     // swim, sink, removeHead, insert
    void swim(int index) {
        int i = index;
        while(i > 0 && (i-1)/2 >=0 && e[i].value < e[(i-1)/2].value) {
            swap(i, (i-1)/2);
            i = (i-1)/2;
        }
    }

    void sink(int index) {
        int i = index;
        int left = i*2 + 1;
        while (left < size) {
            int smaller = left;
            if (left+1 < e.length && e[left].value >= e[left+1].value) {
                smaller = left + 1;
            }
            if (e[i].value < e[smaller].value) break;
            swap(i, smaller);
            i = smaller;
            left = smaller * 2 + 1;
        }
    }

    void removeHead() {
        e[0] = e[size-1];
        size--;
        sink(0);
    }

    void insert(Entry ee) {
        e[size] = ee;
        swim(size);
        size++;
    }

    void swap(int i, int j) {
        Entry temp = e[i];
        e[i] = e[j];
        e[j] = temp;
    }
}

class Entry {
    int key;
    int value;
    Entry(int k, int v) {
        this.key = k;
        this.value = v;
    }
}

这段代码的时间复杂度和空间复杂度分析如下:

时间复杂度分析:

  1. 统计频率:遍历数组nums,使用哈希表统计每个元素的频率。时间复杂度为O(n),其中n是输入数组的长度。
  2. 构建最小堆
    • 遍历哈希表的m个不同元素(m ≤ n)。
    • 每次插入或删除堆顶元素的时间复杂度为O(log k),其中k是堆的大小。
    • 最坏情况下,所有`m元素都需要插入堆,时间复杂度为O(m log k)
    • 由于m在最坏情况下等于n,这部分的时间复杂度为O(n log k)
  3. 生成结果数组:将堆中的元素输出,时间复杂度为O(k),可忽略。

综上,总时间复杂度为 O(n + n log k) = O(n log k)

空间复杂度分析:

  1. 哈希表:存储m个不同元素的频率,占用**O(n)**空间(最坏情况所有元素不同)。
  2. 最小堆:存储k个元素,占用**O(k)**空间。
  3. 结果数组:占用**O(k)**空间,通常不计入空间复杂度。

综上,总空间复杂度为 O(n)(哈希表占主导)。

总结:

时间复杂度O(n log k)空间复杂度O(n)

实现四:快速选择

快速选择的思想是用把数组分成三段,分别是大于pivot,等于pivot,以及小于pivot。然后根据各个区间的长度与k比较,决定下一步该怎么递归。

这里因为需要所有前k个值,所以快选返回的值应该是下标。

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        Map<Integer, Integer> m = new HashMap<>();
        for (int i: nums) {
            m.put(i, m.getOrDefault(i, 0) + 1);
        }
        Entry[] e = new Entry[m.size()];
        int j = 0;
        for (int i: m.keySet()) {
            e[j] = new Entry(i, m.get(i));
            j++;
        }
        int index = quickSelect(e, 0, e.length-1, k);
        int[] res = new int[k];
        for (int x = 0; x < k; x++) {
            res[x] = e[x].key;
        }
        return res;
    }

    private int quickSelect(Entry[] e, int left, int right, int k) {
        if (left == right) {
            return left;  // this time we return index.
        }

        Random r = new Random();
        int pivot = r.nextInt(right - left + 1) + left;
        int pivotVal = e[pivot].value;

        int i = left;
        int lt = left;
        int gt = right;

        while (i <= gt) {
            if (e[i].value > pivotVal) {
                swap(e, lt, i);
                i++;
                lt++;
            } else if (e[i].value < pivotVal) {
                swap(e, i, gt);
                gt--;
            } else {
                i++;
            }
        }
        // [left, x, x, lt, xxxxx, gt,x, x, x ,right]

        int m = lt - left; // [left, lt)
        int mid = gt - lt + 1; // [lt, gt]

        if (k <= m) {
            return quickSelect(e, left, lt-1, k); // [left, lt-1]0
        } else if (k <= m + mid) {
            return k-1; // different right? cause want k values.
        } else {
            return quickSelect(e, gt+1, right, k-(m+mid)); // [gt+1, right]
        }

    }

    private void swap(Entry[] e, int i, int j)  {
        Entry temp = e[i];
        e[i] = e[j];
        e[j] = temp;
    }
}

class Entry {
    int key;
    int value;
    Entry(int k, int v) {
        this.key = k;
        this.value = v;
    }
}

这段代码为平均情况O(n),最坏情况O(n²);空间复杂度为O(n)。

时间复杂度分析:

  1. 统计频率(哈希表构建):
    遍历数组nums统计频率,时间复杂度为O(n),其中n为输入数组长度。

  2. 构建Entry数组:
    将哈希表中的元素转换为Entry对象,O(m),m为不同元素的数量(m ≤ n)。

  3. 快速选择(QuickSelect):

    • 平均情况: 每次随机选择pivot将数组大致分为两半,递归处理其中一半,时间复杂度为O(m)。
    • 最坏情况: 若每次pivot都选到极值(如最小/最大值),导致每次仅排除一个元素,时间复杂度退化为O(m²)。由于使用随机化pivot,最坏情况概率极低。
  4. 提取结果:
    将前k个元素复制到结果数组,时间复杂度为O(k)。

综上:

  • 平均时间复杂度: O(n)(哈希统计O(n)主导)
  • 最坏时间复杂度: O(n²)(当m ≈ n时,快速选择退化为O(n²))

空间复杂度分析:

  1. 哈希表: 存储最多m个键值对,空间为O(m)。
  2. Entry数组: 存储m个元素,空间为O(m)。
  3. 递归栈: 快速选择递归深度平均为O(log m),最坏为O(m)。

总空间复杂度: O(m)(哈希表+Entry数组) + O(m)(最坏递归栈) = O(m)。当所有元素不同时(m = n),空间复杂度为O(n)。

DS老师的批注:

为了高效解决LeetCode 347题,即找出数组中出现频率最高的前k个元素,可以采用两种主要方法:最小堆和桶排序。以下是这两种方法的详细说明及实现:

方法一:最小堆(优先队列) (实现二优化)

思路:

  1. 统计频率: 使用哈希表记录每个元素的出现次数。
  2. 维护最小堆: 使用大小为k的最小堆,确保堆顶元素是当前堆中频率最小的。遍历哈希表时,若堆未满则直接加入元素;若堆已满且当前元素频率更高,则替换堆顶。
  3. 提取结果: 将堆中元素依次弹出,由于堆顶始终是最小频率,最终结果需反转以获得频率降序(但题目不要求顺序,可直接返回)。

时间复杂度: O(n log k),其中n是数组长度。统计频率O(n),堆操作每次O(log k)。

空间复杂度: O(n),存储哈希表和堆。

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        Map<Integer, Integer> freqMap = new HashMap<>();
        for (int num : nums) {
            freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
        }
        
        PriorityQueue<Integer> heap = new PriorityQueue<>(
            (a, b) -> freqMap.get(a) - freqMap.get(b)
        );
        
        for (int num : freqMap.keySet()) {
            if (heap.size() < k) {
                heap.offer(num);
            } else if (freqMap.get(num) > freqMap.get(heap.peek())) {
                heap.poll();
                heap.offer(num);
            }
        }
        
        int[] res = new int[k];
        int i = 0;
        while (!heap.isEmpty()) {
            res[i++] = heap.poll();
        }
        return res;
    }
}

方法二:桶排序 (实现五)

思路:

  1. 统计频率: 使用哈希表记录各元素的频率。
  2. 创建频率桶: 构建一个桶数组,索引代表频率,存储对应频率的元素列表。
  3. 逆序收集元素: 从最高频率开始遍历桶数组,收集元素直到达到k个。

时间复杂度: O(n),统计频率、建桶、收集元素均为线性时间。

空间复杂度: O(n),用于哈希表和桶数组。

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        Map<Integer, Integer> freqMap = new HashMap<>();
        for (int num : nums) {
            freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
        }
        
        List<Integer>[] buckets = new List[nums.length + 1];
        for (int num : freqMap.keySet()) {
            int freq = freqMap.get(num);
            if (buckets[freq] == null) {
                buckets[freq] = new ArrayList<>();
            }
            buckets[freq].add(num);
        }
        
        List<Integer> result = new ArrayList<>();
        for (int i = buckets.length - 1; i >= 0 && result.size() < k; i--) {
            if (buckets[i] != null) {
                for (int num : buckets[i]) {
                    result.add(num);
                    if (result.size() == k) break;
                }
            }
        }
        
        int[] res = new int[k];
        for (int i = 0; i < k; i++) {
            res[i] = result.get        }
        return res;
    }
}

总结

  • 最小堆 适合处理数据流或动态数据,无需一次性知道所有元素的频率。
  • 桶排序 在数据规模较大且k较大时效率更优,时间复杂度为线性。
  • 根据具体场景选择合适的方法,前者节省空间,后者时间更优**。**