【LeetCode Hot100 刷题日记 (75/100)】347. 前 K 个高频元素 —— 堆与快速选择双解法深度剖析🧠

7 阅读7分钟

📌 题目链接:347. 前 K 个高频元素 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:哈希表、堆(优先队列)、快速选择、分治

⏱️ 目标时间复杂度:优于 O(n log n) ,理想为 O(n)

💾 空间复杂度:O(n)


第 347 题「前 K 个高频元素」 是一道极具代表性的“Top-K”问题。它不仅考察你对哈希表排序结构的掌握,更深入检验你对时间复杂度优化的理解——尤其是在面试中被频繁追问:“如何做到优于 O(n log n)?”

本题是Top-K 问题的经典模板,掌握它,就等于掌握了处理“频率统计 + 排名筛选”类问题的核心思想。本文将从两种主流解法(堆 & 快速选择)出发,深入讲解其原理、代码实现、复杂度分析及面试要点。


📊 题目分析

给定一个整数数组 nums 和整数 k,要求返回出现频率前 k 高的元素(顺序任意)。

关键约束:

  • 必须设计时间复杂度优于 O(n log n) 的算法 → 暗示我们使用 堆(O(n log k))快速选择(平均 O(n))

💡 核心洞察
这不是简单的排序问题,而是在无序数据中找 Top-K。这类问题有两大经典解法:

  1. 维护大小为 k 的小顶堆(适合 k 较小)
  2. 基于快速排序的分区思想(Quickselect) (适合追求线性时间)

⚙️ 核心算法及代码讲解

✅ 方法一:小顶堆(优先队列)—— 稳定 O(n log k)

📌 算法思想

  1. 统计频次:用 unordered_map<int, int> 记录每个数字的出现次数。

  2. 维护小顶堆:堆中只保留当前频率最高的 k 个元素。

    • 若堆未满(size < k),直接入堆;
    • 若堆已满,且当前元素频率 > 堆顶(最小值),则弹出堆顶,压入当前元素。
  3. 最终堆中即为答案(注意:堆中顺序无关,题目允许任意顺序)。

🎯 为什么用小顶堆?
因为我们关心的是“前 k 大”,而小顶堆的堆顶是最小值,正好可以快速判断是否淘汰当前候选。

💻 C++ 代码(带详细行注释)

// 小顶堆比较函数:按频次升序(堆顶为最小频次)
static bool cmp(pair<int, int>& a, pair<int, int>& b) {
    return a.second > b.second; // 注意:priority_queue 默认是大顶堆,所以反向比较
}

vector<int> topKFrequent(vector<int>& nums, int k) {
    // Step 1: 统计频次
    unordered_map<int, int> freq;
    for (int num : nums) {
        freq[num]++;
    }

    // Step 2: 构建小顶堆(大小为 k)
    // 使用 decltype(&cmp) 指定自定义比较器类型
    priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(&cmp)> pq(cmp);

    for (auto& [num, count] : freq) {
        if (pq.size() < k) {
            pq.emplace(num, count); // 堆未满,直接加入
        } else if (pq.top().second < count) {
            pq.pop();               // 弹出频次最小的
            pq.emplace(num, count); // 加入当前更高频次元素
        }
    }

    // Step 3: 提取结果
    vector<int> result;
    while (!pq.empty()) {
        result.push_back(pq.top().first);
        pq.pop();
    }
    return result;
}

面试加分点

  • 明确说明为何不用大顶堆(会存所有元素,O(n log n));
  • 强调 O(n log k) 在 k << n 时远优于排序;
  • 提及 priority_queue 底层是 vector + heapify,空间局部性好。

✅ 方法二:快速选择(Quickselect)—— 平均 O(n)

📌 算法思想

基于快速排序的分区(Partition)思想,但只递归处理包含目标的一侧,从而将平均时间复杂度降至 O(n)。

步骤:

  1. 同样先统计频次,转为 vector<pair<int, int>>(值, 频次)。

  2. 对该 vector 按频次降序进行 Quickselect:

    • 随机选 pivot,分区使得左侧 ≥ pivot,右侧 < pivot;
    • 若左侧元素个数 ≥ k,则 Top-K 全在左侧,递归左半;
    • 否则,左侧全要,并在右侧找剩下的 k - left_size 个。

⚠️ 注意:本题要的是“前 k 大”,所以分区条件是 >= pivot 放左边。

💻 C++ 代码(带详细行注释)

void quickSelect(vector<pair<int, int>>& arr, int left, int right, vector<int>& result, int k) {
    // 随机选择 pivot,避免最坏情况
    int randIndex = rand() % (right - left + 1) + left;
    swap(arr[left], arr[randIndex]);

    int pivotFreq = arr[left].second;
    int index = left; // index 指向最后一个小于 pivot 的位置

    // 分区:将频次 >= pivot 的移到左边
    for (int i = left + 1; i <= right; ++i) {
        if (arr[i].second >= pivotFreq) {
            swap(arr[++index], arr[i]);
        }
    }
    swap(arr[left], arr[index]); // 将 pivot 放到正确位置

    int leftSize = index - left + 1; // 左侧(含 pivot)元素个数

    if (k <= leftSize) {
        // Top-K 全在左侧(包括 pivot)
        if (k == leftSize) {
            // 正好取完,全部加入
            for (int i = left; i <= index; ++i) {
                result.push_back(arr[i].first);
            }
        } else {
            // 还没取够,继续在左侧找
            quickSelect(arr, left, index - 1, result, k);
        }
    } else {
        // 左侧全部都要,并在右侧找剩下的
        for (int i = left; i <= index; ++i) {
            result.push_back(arr[i].first);
        }
        quickSelect(arr, index + 1, right, result, k - leftSize);
    }
}

vector<int> topKFrequent(vector<int>& nums, int k) {
    unordered_map<int, int> freq;
    for (int num : nums) freq[num]++;

    vector<pair<int, int>> items(freq.begin(), freq.end());
    vector<int> result;
    quickSelect(items, 0, items.size() - 1, result, k);
    return result;
}

面试加分点

  • 解释 Quickselect 与 Quicksort 的区别(只递归一侧);
  • 强调 随机化 pivot 避免 O(n²) 最坏情况;
  • 指出 空间复杂度 O(log n) (递归栈),优于堆的 O(k)?其实两者都是 O(n),但常数不同;
  • 对比两种方法适用场景:k 小用堆,k 接近 n 用快选

🧭 解题思路(分步拆解)

  1. 频次统计
    → 遍历数组,用哈希表记录每个元素出现次数。O(n) 时间,O(n) 空间

  2. 选择 Top-K 策略

    • 方案 A(堆) :适合 k 较小(如 k=10,n=1e5),内存友好;
    • 方案 B(快选) :追求理论最优 O(n),但常数较大,且需额外空间存 pair。
  3. 构建结果
    → 从堆或快选结果中提取元素值(忽略频次),返回即可。


📈 算法分析

方法时间复杂度空间复杂度稳定性面试推荐度
O(n log k)O(n + k)稳定⭐⭐⭐⭐☆
快选平均 O(n),最坏 O(n²)O(n)不稳定⭐⭐⭐⭐

📌 面试建议

  • 先写堆解法(代码简洁、稳定、易解释);
  • 再提快选作为优化方向,展示深度;
  • 若面试官问“能否 O(1) 空间?”,可答:不能,因为需要统计频次(至少 O(n) 空间)。

💻 完整代码

C++ 版本

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// 方法一:小顶堆
class SolutionHeap {
public:
    static bool cmp(pair<int, int>& a, pair<int, int>& b) {
        return a.second > b.second;
    }

    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> freq;
        for (int num : nums) freq[num]++;

        priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(&cmp)> pq(cmp);
        for (auto& [num, count] : freq) {
            if (pq.size() < k) {
                pq.emplace(num, count);
            } else if (pq.top().second < count) {
                pq.pop();
                pq.emplace(num, count);
            }
        }

        vector<int> res;
        while (!pq.empty()) {
            res.push_back(pq.top().first);
            pq.pop();
        }
        return res;
    }
};

// 方法二:快速选择
class SolutionQuickSelect {
public:
    void quickSelect(vector<pair<int, int>>& arr, int left, int right, vector<int>& result, int k) {
        int randIndex = rand() % (right - left + 1) + left;
        swap(arr[left], arr[randIndex]);

        int pivotFreq = arr[left].second;
        int index = left;
        for (int i = left + 1; i <= right; ++i) {
            if (arr[i].second >= pivotFreq) {
                swap(arr[++index], arr[i]);
            }
        }
        swap(arr[left], arr[index]);

        int leftSize = index - left + 1;
        if (k <= leftSize) {
            if (k == leftSize) {
                for (int i = left; i <= index; ++i) result.push_back(arr[i].first);
            } else {
                quickSelect(arr, left, index - 1, result, k);
            }
        } else {
            for (int i = left; i <= index; ++i) result.push_back(arr[i].first);
            quickSelect(arr, index + 1, right, result, k - leftSize);
        }
    }

    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> freq;
        for (int num : nums) freq[num]++;

        vector<pair<int, int>> items(freq.begin(), freq.end());
        vector<int> result;
        quickSelect(items, 0, items.size() - 1, result, k);
        return result;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    SolutionHeap sol;
    vector<int> nums1 = {1,1,1,2,2,3};
    auto res1 = sol.topKFrequent(nums1, 2);
    for (int x : res1) cout << x << " "; // 输出: 2 1 或 1 2
    cout << "\n";

    vector<int> nums2 = {1};
    auto res2 = sol.topKFrequent(nums2, 1);
    cout << res2[0] << "\n"; // 输出: 1

    vector<int> nums3 = {1,2,1,2,1,2,3,1,3,2};
    auto res3 = sol.topKFrequent(nums3, 2);
    for (int x : res3) cout << x << " "; // 输出: 1 2
    cout << "\n";

    return 0;
}

JavaScript 版本

// 方法一:小顶堆(用 MinHeap 模拟)
class MinHeap {
    constructor(compareFn = (a, b) => a - b) {
        this.heap = [];
        this.compare = compareFn;
    }
    size() { return this.heap.length; }
    peek() { return this.heap[0]; }
    push(val) {
        this.heap.push(val);
        this.bubbleUp();
    }
    pop() {
        if (this.size() === 0) return null;
        if (this.size() === 1) return this.heap.pop();
        const top = this.heap[0];
        this.heap[0] = this.heap.pop();
        this.bubbleDown();
        return top;
    }
    bubbleUp() {
        let idx = this.size() - 1;
        while (idx > 0) {
            const parentIdx = Math.floor((idx - 1) / 2);
            if (this.compare(this.heap[idx], this.heap[parentIdx]) >= 0) break;
            [this.heap[idx], this.heap[parentIdx]] = [this.heap[parentIdx], this.heap[idx]];
            idx = parentIdx;
        }
    }
    bubbleDown() {
        let idx = 0;
        while (true) {
            const left = 2 * idx + 1;
            const right = 2 * idx + 2;
            let smallest = idx;
            if (left < this.size() && this.compare(this.heap[left], this.heap[smallest]) < 0)
                smallest = left;
            if (right < this.size() && this.compare(this.heap[right], this.heap[smallest]) < 0)
                smallest = right;
            if (smallest === idx) break;
            [this.heap[idx], this.heap[smallest]] = [this.heap[smallest], this.heap[idx]];
            idx = smallest;
        }
    }
}

var topKFrequent = function(nums, k) {
    const freq = new Map();
    for (const num of nums) {
        freq.set(num, (freq.get(num) || 0) + 1);
    }

    const minHeap = new MinHeap((a, b) => a[1] - b[1]); // 按频次升序
    for (const [num, count] of freq) {
        if (minHeap.size() < k) {
            minHeap.push([num, count]);
        } else if (minHeap.peek()[1] < count) {
            minHeap.pop();
            minHeap.push([num, count]);
        }
    }

    const result = [];
    while (minHeap.size() > 0) {
        result.push(minHeap.pop()[0]);
    }
    return result;
};

// 测试
console.log(topKFrequent([1,1,1,2,2,3], 2)); // [1, 2] 或 [2, 1]
console.log(topKFrequent([1], 1)); // [1]
console.log(topKFrequent([1,2,1,2,1,2,3,1,3,2], 2)); // [1, 2]

🌟 结语 & 下期预告

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!