LEETCODE 347Medium

前 K 个高频元素

频次最大不超过 n,把它当下标开桶,排序这一步就被计数替代了——O(n) 拿到前 K 高频。

问题拆解

给一个数组和整数 k,返回出现频率最高的 k 个元素,题目保证答案唯一,且要求优于 O(n log n)

第一步没有悬念:哈希表统计每个数出现几次。有分歧的是第二步——按频次取前 k 个。全量排序是 O(n log n),恰好撞在题目禁止的线上;小顶堆维护 k 个候选能到 O(n log k),已经达标,也是数据流场景下的标准答案。

但这题还能再进一步。注意频次的取值范围天然有界:一个数最多出现 n 次。值域有界的量就能当数组下标用——按频次开 n+1 个桶,把每个数扔进它频次对应的桶里,再从高频端往回收集,凑够 k 个即止。整个过程没有比较排序,纯 O(n)

排序之所以要 O(n log n),是因为比较排序对值域一无所知;一旦发现待排的量有界(频次 ≤ n),计数 / 桶就能把 log 直接抹掉。

频次当下标的桶排序

public int[] topKFrequent(int[] nums, int k) {
    Map<Integer, Integer> count = new HashMap<>();
    for (int num : nums) {
        count.merge(num, 1, Integer::sum);
    }
    // 桶下标 = 频次,最大频次不超过 nums.length
    List<List<Integer>> buckets = new ArrayList<>();
    for (int i = 0; i <= nums.length; i++) {
        buckets.add(new ArrayList<>());
    }
    for (Map.Entry<Integer, Integer> e : count.entrySet()) {
        buckets.get(e.getValue()).add(e.getKey());
    }
    int[] result = new int[k];
    int idx = 0;
    for (int freq = nums.length; freq >= 0 && idx < k; freq--) {
        for (int num : buckets.get(freq)) {
            result[idx++] = num; // 从高频桶往低频桶收集
            if (idx == k) break;
        }
    }
    return result;
}
def topKFrequent(nums: List[int], k: int) -> List[int]:
    count = Counter(nums)
    # 桶下标 = 频次,最大频次不超过 len(nums)
    buckets = [[] for _ in range(len(nums) + 1)]
    for num, freq in count.items():
        buckets[freq].append(num)

    result = []
    for freq in range(len(nums), 0, -1):  # 从高频桶往低频桶收集
        for num in buckets[freq]:
            result.append(num)
            if len(result) == k:
                return result
    return result
func topKFrequent(nums []int, k int) []int {
    count := map[int]int{}
    for _, num := range nums {
        count[num]++
    }
    // 桶下标 = 频次,最大频次不超过 len(nums)
    buckets := make([][]int, len(nums)+1)
    for num, freq := range count {
        buckets[freq] = append(buckets[freq], num)
    }
    result := make([]int, 0, k)
    for freq := len(nums); freq > 0 && len(result) < k; freq-- {
        for _, num := range buckets[freq] { // 从高频桶往低频桶收集
            result = append(result, num)
            if len(result) == k {
                break
            }
        }
    }
    return result
}
pub fn top_k_frequent(nums: Vec<i32>, k: i32) -> Vec<i32> {
    let mut count = std::collections::HashMap::new();
    for &num in &nums {
        *count.entry(num).or_insert(0usize) += 1;
    }
    // 桶下标 = 频次,最大频次不超过 nums.len()
    let mut buckets: Vec<Vec<i32>> = vec![Vec::new(); nums.len() + 1];
    for (num, freq) in count {
        buckets[freq].push(num);
    }
    let mut result = Vec::with_capacity(k as usize);
    for bucket in buckets.iter().rev() { // 从高频桶往低频桶收集
        for &num in bucket {
            result.push(num);
            if result.len() == k as usize {
                return result;
            }
        }
    }
    result
}

两个细节别踩:桶要开 n + 1 个——数组全是同一个数时频次恰好为 n,开 n 个就越界;收集必须从最高频次倒着走,正着走拿到的是频率最低的 k 个。另外同一个桶里可能装着多个频次相同的数,凑够 k 就要立刻停,题目保证答案唯一,所以不会在“取谁不取谁”上产生歧义。

如果场景换成数据一批批到来、或者 n 大到桶数组开不下,小顶堆的 O(n log k) 解法更合适:堆里始终只留 k 个候选,新频次比堆顶大就换掉堆顶——一句话的事,这里就不铺第二组代码了。

复杂度

指标 复杂度 原因
时间 O(n) 计数、进桶、收集各扫一遍,没有比较排序
空间 O(n) 哈希表加 n+1 个桶

可以迁移的模式

  • “按某个量排序”之前先问一句:这个量的值域有界吗?有界就考虑计数 / 桶,把 log 抹掉;
  • 频次、年龄、评分这类天然有上限的量,最适合当数组下标;
  • Top-K 的两条路线要分场景:离线且值域有界用桶 O(n),流式或 k 远小于 n 用堆 O(n log k)

把“排序”换成“分桶”,是频次类统计题最常见的一次降维。