【LeetCode Hot100 刷题日记 (74/100)】215. 数组中的第K个最大元素——快速选择 & 堆排序 🎯

4 阅读6分钟

📌 题目链接: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] ≤ pivot
    • nums[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) 要求,但仍是重要备选方案!


🧭 解题思路(分步拆解)

快速选择法步骤:

  1. 明确目标索引:第 k 大 → 第 n - k 小(0-indexed);

  2. 实现划分函数:使用 Hoare 双指针划分,返回分割点 j

  3. 递归剪枝

    • target <= j → 在左半区 [l, j] 查找;
    • 否则 → 在右半区 [j+1, r] 查找;
  4. 基线条件:当 l == r 时,直接返回 nums[k]

堆方法步骤(最小堆优化版):

  1. 初始化一个空的最小堆

  2. 遍历数组:

    • 若堆 size < k,直接 push;
    • 否则,若当前元素 > 堆顶,则 pop 堆顶并 push 当前元素;
  3. 遍历结束后,堆顶即为第 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!💪

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