题目描述

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

image-20260929005755627

题意分析

返回数组降序排列后第 k 个位置上的值。相同值的每次出现都占一个名次,所以不能先去重;题目保证 1 <= k <= nums.length。

只求一个名次,无需排好整个数组。可以保存当前最大的 k 个候选,也可以通过分区不断缩小目标所在范围。下面保留最小堆和三路快速选择两种实现,分别说明它们的状态与复杂度。

解法一:大小为 k 的最小堆

核心思路

[!blue]

如果始终保留已扫描元素中最大的 k 个,扫描结束后,这些候选中最小的值就是第 k 大。因此使用最小堆,让堆顶直接暴露最容易被淘汰的那个候选。

每读入一个元素,先入堆;若数量超过 k,再弹出堆顶。此前没有进入前 k 大的元素,已经有至少 k 个元素不小于它,加入新元素后也不需要重新考虑。新的候选只可能是旧堆中元素和新元素,从中删去最小者,便继续保留最大的 k 个。

所以每轮结束后,堆中都恰好保存已扫描前缀中最大的 min(k,已扫描数量) 个元素。重复值作为多次出现分别入堆,仍按次数占位。最终堆大小为 k,读取堆顶就是答案。

解题步骤

  1. 建立空的最小堆。
  2. 遍历 nums,将当前值入堆;若堆大小大于 k,弹出最小值。
  3. 扫描结束后读取堆顶。

k == 1 时堆始终保留当前最大值;k == n 时最终保留全部元素,堆顶就是最小值。Go 的底层 Pop 方法删除切片末项,是因为 container/heap 已先将待弹出的堆顶换到末尾,再调用这个方法。

代码实现

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

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

            if (minHeap.size() > k) {
                // 堆中始终只保留当前最大的 k 个数,堆顶就是这 k 个数里最小的。
                minHeap.poll();
            }
        }

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

type MinHeap []int

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

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

func (h MinHeap) Swap(i int, 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
    last := old[len(old)-1]
    *h = old[:len(old)-1]
    return last
}

func findKthLargest(nums []int, k int) int {
    minHeap := &MinHeap{}
    heap.Init(minHeap)

    for _, num := range nums {
        heap.Push(minHeap, num)
        if minHeap.Len() > k {
            // 堆中始终只保留当前最大的 k 个数,堆顶就是这 k 个数里最小的。
            heap.Pop(minHeap)
        }
    }

    return (*minHeap)[0]
}

复杂度分析

  • 时间复杂度:$O(n\log(k+1))$,每个元素入堆一次、最多弹出一次,堆大小最多为 k+1。
  • 空间复杂度:$O(k)$,只保存有限个候选,不修改输入数组。

关键点总结

[!green]

  • 要保留最大的 k 个,就让最小候选处在堆顶,便于超限时淘汰。
  • 不变量在每次插入及可能的删除完成后成立,不能只按已经扫描的元素个数猜堆内内容。
  • 只需最终第 k 大的值,不需要再将堆中元素排成有序数组。

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

核心思路

[!blue]

按降序排名,第 k 大对应下标 target = k-1。分区只需确认目标应该在哪一段,不必整理其他段内部的顺序。

在当前区间选择 pivot,用三个指针维护四个部分:[left,greater) 大于基准,[greater,idx) 等于基准,[idx,less] 尚未检查,(less,right] 小于基准。

若 nums[idx] > pivot,与 greater 交换,将较大值放到左区,再同时推进 greater 和 idx。两指针不同时,换回的是已检查的等值元素;两指针相同时只是原位交换,因此都可以继续前进。

若 nums[idx] < pivot,与 less 交换,将较小值放到右区,再将 less 左移。换回的元素来自未知部分,还必须检查,所以 idx 不动。相等时则只推进 idx,扩大等值区。

每步都减少一个未知位置。分区结束后,[greater,less] 全部等于基准,左侧都更大、右侧都更小,所以中段恰好占据这些相等元素应有的降序排名范围。

若 target < greater,只保留左段;若 target > less,只保留右段;否则目标已在等值段中,返回该值。目标下标始终使用原数组的绝对下标,不随范围缩小而重新计算。基准来自当前区间,等值段一定非空,所以未命中时范围也必定缩短。

解题步骤

  1. 设置 target = k-1,初始待选择范围为整个数组。
  2. 取当前中间位置的值为基准,初始化 greater = idx = left、less = right。
  3. 在 idx <= less 时,按大于、小于、等于基准执行对应交换与指针更新。
  4. 根据目标与等值区间的位置关系,收缩到一侧,或直接返回答案。

三路分区会一次处理整段重复值,全相等时一轮就能结束。代码选的是“中间位置上的元素”,并不保证这个值接近中位数,因而仍可能产生很不平衡的分区。

代码实现

class Solution {
    public int findKthLargest(int[] nums, int k) {
        int target = k - 1;
        int left = 0;
        int right = nums.length - 1;

        while (left <= right) {
            int[] equalRange = partition(nums, left, right);

            if (target < equalRange[0]) {
                right = equalRange[0] - 1;
            } else if (target > equalRange[1]) {
                left = equalRange[1] + 1;
            } else {
                return nums[target];
            }
        }

        return -1;
    }

    private int[] partition(int[] nums, int left, int right) {
        int pivot = nums[left + (right - left) / 2];
        int greater = left;
        int idx = left;
        int less = right;

        while (idx <= less) {
            if (nums[idx] > pivot) {
                // greater 左侧都大于 pivot,等值区间会被留在中间。
                swap(nums, greater, idx);
                greater++;
                idx++;
            } else if (nums[idx] < pivot) {
                swap(nums, idx, less);
                less--;
            } else {
                idx++;
            }
        }

        return new int[] {
            greater,
            less
        };
    }

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

        nums[i] = nums[j];
        nums[j] = temp;
    }
}
func findKthLargest(nums []int, k int) int {
    target := k - 1
    left := 0
    right := len(nums) - 1

    for left <= right {
        equalLeft, equalRight := partition(nums, left, right)
        if target < equalLeft {
            right = equalLeft - 1
        } else if target > equalRight {
            left = equalRight + 1
        } else {
            return nums[target]
        }
    }

    return -1
}

func partition(nums []int, left int, right int) (int, int) {
    pivot := nums[left+(right-left)/2]
    greater := left
    idx := left
    less := right

    for idx <= less {
        if nums[idx] > pivot {
            // greater 左侧都大于 pivot,等值区间会被留在中间。
            nums[greater], nums[idx] = nums[idx], nums[greater]
            greater++
            idx++
        } else if nums[idx] < pivot {
            nums[idx], nums[less] = nums[less], nums[idx]
            less--
        } else {
            idx++
        }
    }

    return greater, less
}

复杂度分析

  • 时间复杂度:按随机排列输入进行平均分析为 $O(n)$,最坏为 $O(n^2)$。一轮分区与当前区间长度成正比;范围较均衡地缩小时,总扫描量呈几何级数;每轮只排除很少元素时则退化为平方级。当前代码固定选择中间位置,本身没有随机化期望保证。
  • 空间复杂度:$O(1)$,原地交换并迭代收缩范围;Java 每轮返回的边界数组也只有固定两项。

关键点总结

[!green]

  • 降序分区配合目标下标 k-1,分区方向和排名定义必须一致。
  • 四段状态明确区分已知大于、已知等于、未知和已知小于,指针更新来自这些含义。
  • 与右端交换后不推进扫描指针,是为了检查换回来的未知元素。
  • 三路分区消除了反复搜索等值元素的需要,但固定基准仍不保证所有输入下线性时间。

解法对比:

最小堆提供 $O(n\log(k+1))$ 的时间上界,使用 $O(k)$ 空间,不修改输入,也能逐个接收元素。

快速选择使用常数额外空间,但会打乱原数组。平均表现较好,当前确定性基准的最坏时间仍为 $O(n^2)$;这与只在目标一侧继续搜索并不矛盾。

易错点总结

[!yellow]

  • 第 k 大按出现次数计,不能把输入当成不同值的集合。
  • 最小堆保留最大的 k 个,弹出的应是超额候选中最小的值。
  • 三路分区与右端交换后立即增加 idx,会跳过尚未分类的元素。
  • 固定取中间位置作为基准,不等于每轮都取得中位数,也不能据此承诺最坏线性时间。

相似题目

题目 难度 关联与区别
703. 数据流中的第 K 大元素 简单 原题持续加入数据并查询第k大,本题只做一次选择,可用快速选择或堆。
347. 前 K 个高频元素 中等 同样挑选TopK,原题排名依据为元素频次,本题依据数值大小。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/36999628
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!