题目描述

✅ 215. 数组中的第 K 个最大元素

image-20260928181921546

题意分析

要找的是数组按从大到小排列后的第 k 个元素,重复值按出现次数分别占据名次,不能先去重。题目保证数组非空,且 1 <= k <= n,因此答案一定存在。

排序后取值可以得到答案,但会确定所有元素的顺序,而题目只关心一个位置。把问题转成升序下标后,前面有 n - k 个元素的位置就是第 k 大,即 target = n - k。

两种思路都可以避免完整排序:小顶堆只保留最大的 k 个候选,快速选择只继续处理包含目标下标的区间。小顶堆便于理解和处理持续到来的数据,但不满足题目线性时间的要求;随机快速选择的期望时间为 $O(n)$,最坏时间仍可能达到 $O(n^2)$。

解法一:小顶堆

核心思路

[!blue]

只需要知道最大的 k 个元素中最小的是谁:这个数前面恰好可以排下另外 k - 1 个保留元素,所以它就是第 k 大。为方便淘汰较小的候选,用小顶堆把当前保留元素的最小值放在堆顶。

遍历时先把当前值加入堆。如果元素数量超过 k,就弹出堆顶,留下其中最大的 k 个。处理不足 k 个元素时全部保留,达到 k 个后,堆中始终保存已遍历部分最大的 k 个元素。

为什么已经淘汰的值不需要再考虑?它被淘汰时,至少已有 k 个保留元素不小于它;后续加入新元素只可能把这些候选替换成更大的值,不会使它重新成为必须保留的元素。遍历结束后,堆顶就是整个数组的第 k 大,重复值也会作为独立元素参与比较和保留。

解题步骤

  1. 创建一个空的小顶堆。
  2. 遍历数组,将当前元素加入堆。
  3. 如果堆中元素超过 k 个,弹出堆顶,淘汰当前最小值。
  4. 遍历结束,返回堆顶。

代码实现

class Solution {
    public int findKthLargest(int[] nums, int k) {
        PriorityQueue<Integer> heap = new PriorityQueue<>();

        for (int num : nums) {
            heap.offer(num);

            // 多于名额时淘汰最小值,堆中只保留最大的 k 次出现。
            if (heap.size() > k) {
                heap.poll();
            }
        }

        return heap.peek();
    }
}
import "container/heap"

type MinHeap []int

func (h MinHeap) Len() int { return len(h) }

func (h MinHeap) Less(i, j int) bool { return h[i] < h[j] }

func (h MinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }

func (h *MinHeap) Push(x any) { *h = append(*h, x.(int)) }

func (h *MinHeap) Pop() any {
    old := *h
    x := old[len(old)-1]
    *h = old[:len(old)-1]
    return x
}

func findKthLargest(nums []int, k int) int {
    h := &MinHeap{}
    for _, num := range nums {
        heap.Push(h, num)
        // 多于名额时淘汰最小值,堆中只保留最大的 k 次出现。
        if h.Len() > k {
            heap.Pop(h)
        }
    }
    return (*h)[0]
}

复杂度分析

  • 时间复杂度:$O(n \log k)$,每个元素入堆一次,堆大小最多为 k + 1;k = 1 时为 $O(n)$。
  • 空间复杂度:$O(k)$,用于保存堆中的元素。

关键点总结

[!green]

  • 保留最大的 k 个,淘汰最小的:因此使用小顶堆,堆顶就是保留集合中排名最后的元素。
  • 重复值分别入堆:排名按元素个数计算,不能把堆替换成去重集合。
  • Go 的 Pop 返回末尾元素:heap.Pop(h) 会先把堆顶换到末尾并调整堆,再调用自定义的 Pop;不要在该方法里直接删除下标 0。

解法二:三路分区快速选择

核心思路

[!blue]

快速选择借用快速排序的分区,但只处理答案所在的一侧。若当前区间已经分成“小于基准、等于基准、大于基准”三段,那么左段的所有值都不大于中段,中段的所有值都不大于右段;各段内部是否有序,不影响目标下标落在哪一段。

在当前区间 [left, right] 随机选一个值作为 pivot。用 lt、i、gt 维护四个范围:[left, lt) 小于基准,[lt, i) 等于基准,[i, gt] 尚未检查,(gt, right] 大于基准。开始时没有已分类的元素,整个区间都待检查。

当前值小于基准时,把它与 lt 交换,让它进入左段,再同时推进 lt 和 i。当 lt < i 时,换回来的值原本就在等值段中,已经确定等于基准;当两者相等时只是原地交换,因此这两种情况都不需要重新检查当前位置。

当前值大于基准时,把它与 gt 交换,再缩小 gt,让这个较大值进入右段。此时从右侧换回来的值尚未检查,所以 i 必须停在原地。当前值等于基准时,直接推进 i,把它纳入等值段。每一步都会使待检查区间减少一个元素。

当 i > gt,所有元素都已分类,等值段正好是 [lt, gt]。如果 target 落在其中,这些位置的值都等于 pivot,可以直接返回;如果 target < lt,答案只可能在左段;如果 target > gt,答案只可能在右段。保留下来的区间始终包含目标下标,其他区间无需继续排序。

基准值取自当前区间,等值段一定非空,所以每次未命中时都能严格缩小范围。用三路分区一次跳过所有等于基准的元素,也避免了大量重复值被一轮轮单独处理;随机选择基准则降低了连续出现极不均衡划分的概率。

解题步骤

  1. 计算升序目标下标 target = n - k,初始化 left = 0、right = n - 1。
  2. 从 [left, right] 随机选取并保存基准值,初始化 lt = left、i = left、gt = right。
  3. 当 i <= gt 时,按当前值与基准的大小关系执行交换和移动,直到待检查区间为空。
  4. 若 target < lt,令 right = lt - 1;若 target > gt,令 left = gt + 1,重新对保留区间分区。
  5. 若 lt <= target <= gt,返回 nums[target]。target 始终是原数组中的绝对下标,缩小区间后不需要重新计算。

代码实现

class Solution {
    public int findKthLargest(int[] nums, int k) {
        // 统一成升序目标下标,重复值仍分别占据名次。
        int target = nums.length - k;
        int left = 0;
        int right = nums.length - 1;

        while (left <= right) {
            int pivot =
                    nums[java.util.concurrent.ThreadLocalRandom.current().nextInt(left, right + 1)];
            int lt = left;
            int i = left;
            int gt = right;

            while (i <= gt) {
                if (nums[i] < pivot) {
                    swap(nums, lt++, i++);
                } else if (nums[i] > pivot) {
                    // 右侧换回的值还未分类,本轮不能推进 i。
                    swap(nums, i, gt--);
                } else {
                    i++;
                }
            }

            // 只继续包含目标的区间,等值段命中时即可直接返回。
            if (target < lt) {
                right = lt - 1;
            } else if (target > gt) {
                left = gt + 1;
            } else {
                return nums[target];
            }
        }

        throw new IllegalStateException();
    }

    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];

        nums[i] = nums[j];
        nums[j] = temp;
    }
}
import "math/rand"

func findKthLargest(nums []int, k int) int {
    // 统一成升序目标下标,重复值仍分别占据名次。
    target := len(nums) - k
    left, right := 0, len(nums)-1

    for left <= right {
        pivot := nums[left+rand.Intn(right-left+1)]
        lt, i, gt := left, left, right

        for i <= gt {
            if nums[i] < pivot {
                nums[lt], nums[i] = nums[i], nums[lt]
                lt++
                i++
            } else if nums[i] > pivot {
                // 右侧换回的值还未分类,本轮不能推进 i。
                nums[i], nums[gt] = nums[gt], nums[i]
                gt--
            } else {
                i++
            }
        }

        // 只继续包含目标的区间,等值段命中时即可直接返回。
        if target < lt {
            right = lt - 1
        } else if target > gt {
            left = gt + 1
        } else {
            return nums[target]
        }
    }
    panic("unreachable")
}

复杂度分析

  • 时间复杂度:期望 $O(n)$,单次分区只扫描当前区间,之后仅保留一侧;随机基准使剩余规模在期望意义下持续缩小,各轮扫描量的期望总和为线性量级。若连续选到极端基准,每次仅减少少量元素,最坏仍为 $O(n^2)$。
  • 空间复杂度:$O(1)$,在原数组中交换元素,并通过循环缩小区间,不使用递归栈。

关键点总结

[!green]

  • 下标统一用升序:第 1 大对应 n - 1,第 n 大对应 0,因此目标下标始终是 n - k。
  • 只保留一侧:分区后另一侧不可能包含目标,无需继续排序;等值段覆盖目标时可立即结束。
  • 重复值不单独递归处理:全相等数组经过一轮分区即可返回。随机基准改善划分的期望表现,但不消除最坏情况。

易错点总结

[!yellow]

  • 堆的方向不能写反:大小为 k 的小顶堆保留最大的 k 个数;同样大小的大顶堆会保留最小的 k 个数。
  • 目标下标不能混用:快速选择按升序分区,第 k 大对应 n - k;k - 1 是降序排列的下标,不能套入这里。
  • 不能去重:每次出现都占一个名次,去重会改变第 k 大的含义。
  • 与右侧交换后不能递增 i:从 gt 换来的元素还没有检查,必须留在当前位置继续判断。
  • 基准要保存为值:分区会不断交换元素,不能一边交换一边从最初的基准下标重新读取。
  • 区间更新要排除等值段:使用 lt - 1 和 gt + 1,避免重复处理已经确定的部分;该实现会改变原数组顺序。

相似题目

题目 难度 关联与区别
703. 数据流中的第 K 大元素 简单 原题持续加入数据并查询第k大,本题只做一次选择,可用快速选择或堆。
347. 前 K 个高频元素 中等 同样挑选TopK,原题排名依据为元素频次,本题依据数值大小。
692. 前K个高频单词 中等 用大小受限的堆保留排名靠前的候选;本题以数值为排序依据选择第 k 大,该题以单词频次和字典序联合排序。
973. 最接近原点的 K 个点 中等 用大小受限的堆保留排名靠前的候选;本题以数值为排序依据选择第 k 大,该题以到原点的平方距离为排序依据。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/17103515
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!