📌 题目链接: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。这类问题有两大经典解法:
- 维护大小为 k 的小顶堆(适合 k 较小)
- 基于快速排序的分区思想(Quickselect) (适合追求线性时间)
⚙️ 核心算法及代码讲解
✅ 方法一:小顶堆(优先队列)—— 稳定 O(n log k)
📌 算法思想
-
统计频次:用
unordered_map<int, int>记录每个数字的出现次数。 -
维护小顶堆:堆中只保留当前频率最高的 k 个元素。
- 若堆未满(size < k),直接入堆;
- 若堆已满,且当前元素频率 > 堆顶(最小值),则弹出堆顶,压入当前元素。
-
最终堆中即为答案(注意:堆中顺序无关,题目允许任意顺序)。
🎯 为什么用小顶堆?
因为我们关心的是“前 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)。
步骤:
-
同样先统计频次,转为
vector<pair<int, int>>(值, 频次)。 -
对该 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 用快选。
🧭 解题思路(分步拆解)
-
频次统计
→ 遍历数组,用哈希表记录每个元素出现次数。O(n) 时间,O(n) 空间。 -
选择 Top-K 策略
- 方案 A(堆) :适合 k 较小(如 k=10,n=1e5),内存友好;
- 方案 B(快选) :追求理论最优 O(n),但常数较大,且需额外空间存 pair。
-
构建结果
→ 从堆或快选结果中提取元素值(忽略频次),返回即可。
📈 算法分析
| 方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 面试推荐度 |
|---|---|---|---|---|
| 堆 | 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!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!