📌 题目链接:215. 数组中的第K个最大元素 - 力扣(LeetCode)
🔍 难度:中等 | 🏷️ 标签:数组、分治、堆、快速选择
⏱️ 目标时间复杂度:O(n) (期望)
💾 空间复杂度:O(log n) (递归栈,期望)
🧠 题目分析
给定一个整数数组 nums 和整数 k,要求返回数组中第 k 个最大的元素。注意:
- 不是第 k 个不同的最大元素;
- 要求设计 O(n) 时间复杂度的算法(暗示不能用普通排序 O(n log n));
- 元素范围和数组长度均在合理范围内(
1 <= k <= nums.length <= 1e5)。
📌 关键理解:
“第 k 个最大” = “排序后从大到小第 k 个” = “从小到大排序后的第 n - k 个(0-indexed)”。
例如:
[3,2,1,5,6,4], k=2 → 排序后[1,2,3,4,5,6]→ 第 2 大是5→ 对应索引6 - 2 = 4。
⚙️ 核心算法及代码讲解
本题有两大主流解法,均需掌握,尤其在面试中常被追问:
✅ 方法一:快速选择(Quickselect)——期望 O(n)
核心思想:基于快速排序的 分治 + 剪枝 思想,但只递归处理包含目标的一侧,从而将平均时间复杂度降至 O(n) 。
📌 快速选择 vs 快速排序
| 特性 | 快速排序 | 快速选择 |
|---|---|---|
| 目标 | 完全排序 | 找第 k 小/大元素 |
| 递归 | 左右子数组都递归 | 只递归一侧 |
| 时间复杂度 | O(n log n) 平均 | O(n) 期望 |
| 是否稳定 | 否 | 否 |
🔄 划分(Partition)过程详解
使用 双指针 + 哨兵(pivot) 实现经典 Hoare 划分:
-
选
nums[l]作为 pivot(也可随机选,但本题用首元素即可); -
i从左向右找 ≥ pivot 的元素; -
j从右向左找 ≤ pivot 的元素; -
若
i < j,交换nums[i]和nums[j]; -
最终
j是划分点,满足:nums[l..j] ≤ pivotnums[j+1..r] ≥ pivot
💡 注意:Hoare 划分的返回值是
j,不是 pivot 的最终位置!但能保证左右区间性质。
🧩 为什么能用 j 判断递归方向?
我们要找的是 第 n - k 小的元素(0-indexed) ,记为 target = n - k。
- 若
target <= j→ 目标在左半区[l, j] - 否则 → 目标在右半区
[j+1, r]
📜 C++ 核心代码(带逐行注释)
int quickselect(vector<int> &nums, int l, int r, int k) {
// 基线条件:区间只有一个元素
if (l == r) return nums[k];
// 选择左端点作为 pivot
int partition = nums[l];
int i = l - 1, j = r + 1; // 双指针初始化(注意边界)
// Hoare 划分
while (i < j) {
do i++; while (nums[i] < partition); // 找左边 ≥ pivot 的
do j--; while (nums[j] > partition); // 找右边 ≤ pivot 的
if (i < j)
swap(nums[i], nums[j]); // 交换,使左侧 ≤ pivot,右侧 ≥ pivot
}
// 此时 [l, j] ≤ pivot, [j+1, r] ≥ pivot
// 根据目标位置 k 决定递归哪一侧
if (k <= j)
return quickselect(nums, l, j, k); // 目标在左半区
else
return quickselect(nums, j + 1, r, k); // 目标在右半区
}
⚠️ 面试高频问题:
为什么不用 Lomuto 划分?→ Hoare 划分更高效,交换次数少,且天然支持重复元素。
最坏情况是什么?→ 每次 pivot 都是最小或最大值(如已排序数组),退化为 O(n²)。
如何避免最坏情况?→ 随机化 pivot(面试加分项!):
int randomIndex = l + rand() % (r - l + 1); swap(nums[l], nums[randomIndex]);
✅ 方法二:堆排序(Heap Select)——O(n log k) 或 O(n log n)
核心思想:维护一个大小为 k 的最小堆,遍历数组,堆顶即为第 k 大。
📌 两种堆策略对比
| 策略 | 堆类型 | 时间复杂度 | 空间 | 适用场景 |
|---|---|---|---|---|
| 维护 k 大元素 | 最小堆(size=k) | O(n log k) | O(k) | k << n 时更优 |
| 全排序取 top-k | 最大堆(size=n) | O(n + k log n) ≈ O(n log n) | O(1)(原地) | 通用,但不如快速选择快 |
💡 面试建议:优先讲 最小堆 O(n log k) 解法,因其更高效且体现优化思维!
🧱 堆实现要点(手写堆必考!)
- 建堆:从最后一个非叶子节点(
n/2 - 1)向上调整; - 调整(heapify) :比较父节点与左右孩子,交换后递归;
- 删除堆顶:将末尾元素移到堆顶,再 heapify。
📜 C++ 堆排序
void maxHeapify(vector<int>& a, int i, int heapSize) {
int l = i * 2 + 1, r = i * 2 + 2, largest = i;
if (l < heapSize && a[l] > a[largest]) largest = l;
if (r < heapSize && a[r] > a[largest]) largest = r;
if (largest != i) {
swap(a[i], a[largest]);
maxHeapify(a, largest, heapSize); // 递归调整子树
}
}
void buildMaxHeap(vector<int>& a, int heapSize) {
// 从最后一个非叶子节点开始建堆
for (int i = heapSize / 2 - 1; i >= 0; --i) {
maxHeapify(a, i, heapSize);
}
}
int findKthLargest(vector<int>& nums, int k) {
int heapSize = nums.size();
buildMaxHeap(nums, heapSize); // 建大根堆
// 删除 k-1 次最大值
for (int i = nums.size() - 1; i >= nums.size() - k + 1; --i) {
swap(nums[0], nums[i]); // 将堆顶移到末尾
--heapSize;
maxHeapify(nums, 0, heapSize); // 重新调整堆
}
return nums[0]; // 堆顶即第 k 大
}
⚠️ 注意:此解法时间复杂度为 O(n log n) ,不满足题目 O(n) 要求,但仍是重要备选方案!
🧭 解题思路(分步拆解)
快速选择法步骤:
-
明确目标索引:第 k 大 → 第
n - k小(0-indexed); -
实现划分函数:使用 Hoare 双指针划分,返回分割点
j; -
递归剪枝:
- 若
target <= j→ 在左半区[l, j]查找; - 否则 → 在右半区
[j+1, r]查找;
- 若
-
基线条件:当
l == r时,直接返回nums[k]。
堆方法步骤(最小堆优化版):
-
初始化一个空的最小堆;
-
遍历数组:
- 若堆 size < k,直接 push;
- 否则,若当前元素 > 堆顶,则 pop 堆顶并 push 当前元素;
-
遍历结束后,堆顶即为第 k 大元素。
✅ 推荐面试回答顺序:先说快速选择(满足 O(n)),再说堆方法(更稳定,适合流数据)。
📊 算法分析
| 方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 是否原地 | 面试推荐度 |
|---|---|---|---|---|---|
| 快速选择 | O(n) 期望 O(n²) 最坏 | O(log n) 期望 | ❌ | ✅ | ⭐⭐⭐⭐⭐ |
| 最小堆(size=k) | O(n log k) | O(k) | ✅ | ❌ | ⭐⭐⭐⭐ |
| 最大堆(全排序) | O(n log n) | O(1) | ❌ | ✅ | ⭐⭐ |
💡 何时用哪种?
- 要求 严格 O(n) → 快速选择(加随机化);
- 数据流 or k 很小 → 最小堆;
- 不允许修改原数组 → 堆(快速选择会打乱原数组!)。
💻 代码
C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
class Solution {
public:
int quickselect(vector<int> &nums, int l, int r, int k) {
if (l == r)
return nums[k];
int partition = nums[l], i = l - 1, j = r + 1;
while (i < j) {
do i++; while (nums[i] < partition);
do j--; while (nums[j] > partition);
if (i < j)
swap(nums[i], nums[j]);
}
if (k <= j)return quickselect(nums, l, j, k);
else return quickselect(nums, j + 1, r, k);
}
int findKthLargest(vector<int> &nums, int k) {
int n = nums.size();
return quickselect(nums, 0, n - 1, n - k);
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
Solution sol;
vector<int> nums1 = {3,2,1,5,6,4};
cout << sol.findKthLargest(nums1, 2) << "\n"; // 输出: 5
vector<int> nums2 = {3,2,3,1,2,4,5,5,6};
cout << sol.findKthLargest(nums2, 4) << "\n"; // 输出: 4
return 0;
}
JavaScript
var findKthLargest = function(nums, k) {
const n = nums.length;
function quickselect(l, r, k) {
if (l === r) return nums[k];
const pivot = nums[l];
let i = l - 1, j = r + 1;
while (i < j) {
do { i++; } while (nums[i] < pivot);
do { j--; } while (nums[j] > pivot);
if (i < j) {
[nums[i], nums[j]] = [nums[j], nums[i]];
}
}
if (k <= j) {
return quickselect(l, j, k);
} else {
return quickselect(j + 1, r, k);
}
}
return quickselect(0, n - 1, n - k);
};
// 测试
console.log(findKthLargest([3,2,1,5,6,4], 2)); // 5
console.log(findKthLargest([3,2,3,1,2,4,5,5,6], 4)); // 4
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!