数组中的第K个最大元素

目录

一、问题描述

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。请注意,这里要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

题目要求必须设计并实现时间复杂度为 O(n) 的算法解决此问题。

示例 1:

输入: [3,2,1,5,6,4], k = 2
输出: 5

示例 2:

输入: [3,2,3,1,2,4,5,5,6], k = 4
输出: 4

二、解题思路

对于寻找数组中第K大元素的问题,有几种常见的解法:

1. 排序法

最直观的方法是将数组排序后,直接返回第k大的元素:

Arrays.sort(nums);
return nums[nums.length - k];

复杂度分析

  • 时间复杂度:O(n log n),不满足题目要求的O(n)
  • 空间复杂度:O(1) 或 O(log n),取决于排序算法的实现

2. 堆排序法

维护一个大小为k的最小堆,遍历数组,将元素加入堆中:

PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);
for (int num : nums) {
    minHeap.offer(num);
    if (minHeap.size() > k) {
        minHeap.poll();
    }
}
return minHeap.peek();

复杂度分析

  • 时间复杂度:O(n log k),不满足题目要求的O(n)
  • 空间复杂度:O(k)

3. 快速选择算法

快速选择算法是基于快速排序的分治算法,但只处理包含目标元素的那一部分,可以达到平均O(n)的时间复杂度。这是解决本题的最优方法。

三、快速选择算法详解

1. 算法原理

快速选择算法的核心思想是:

  1. 选择一个基准元素(pivot)
  2. 将数组分为两部分:小于基准的元素和大于基准的元素
  3. 根据基准元素的位置与目标位置的关系,决定在哪一部分继续查找

对于第k大元素,我们可以将其转换为查找第(n-k)小的元素,其中n是数组长度。

2. 分区操作

分区操作是快速选择算法的核心,其目的是将数组分为两部分:

  • 左边的元素都小于等于基准元素
  • 右边的元素都大于基准元素

分区操作的步骤:

  1. 选择一个基准元素(通常选择最右边的元素)
  2. 使用两个指针i和j:
    • i指向小于等于基准元素区域的边界
    • j用于遍历数组
  3. 当nums[j] <= pivot时,交换nums[i]和nums[j],并将i向右移动一步
  4. 最后,将基准元素放到正确的位置(i与right交换)
  5. 返回基准元素的位置i

3. 算法流程

快速选择算法的整体流程:

  1. 调用分区操作,获取基准元素的位置pivotIndex
  2. 比较pivotIndex与目标位置k:
    • 如果pivotIndex == k,找到了目标元素,返回nums[pivotIndex]
    • 如果pivotIndex < k,在右子数组中继续查找
    • 如果pivotIndex > k,在左子数组中继续查找
  3. 递归调用,直到找到目标元素

4. 代码实现

public int findKthLargest(int[] nums, int k) {
    // 转换为查找第(n-k)小的元素的索引
    int targetIndex = nums.length - k;
    return quickSelect(nums, 0, nums.length - 1, targetIndex);
}

private int quickSelect(int[] nums, int left, int right, int k) {
    // 分区操作,返回基准元素的位置
    int pivotIndex = partition(nums, left, right);
    
    if (pivotIndex == k) {
        // 找到了第k小的元素
        return nums[pivotIndex];
    } else if (pivotIndex < k) {
        // 第k小的元素在右子数组
        return quickSelect(nums, pivotIndex + 1, right, k);
    } else {
        // 第k小的元素在左子数组
        return quickSelect(nums, left, pivotIndex - 1, k);
    }
}

private int partition(int[] nums, int left, int right) {
    //可以先 随机选择一个元素作为基准,避免最坏情况
    //int randomIndex = left + (int) (Math.random() * (right - left + 1));
    //swap(nums, randomIndex, right);
    
    // 选择最右边的元素作为基准
    int pivot = nums[right];
    int i = left;

    
    // 将小于基准的元素放到左边
    for (int j = left; j < right; j++) {
        if (nums[j] <= pivot) {
            swap(nums, i, j);
            i++;
        }
    }
    
    // 将基准放到正确位置
    swap(nums, i, right);
    
    return i; // 返回基准位置
}

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

四、图解快速选择过程

1. 示例数组

以数组 [3,2,1,5,6,4] 为例,查找第2大的元素(即第5小的元素,targetIndex = 6 - 2 = 4):

初始状态:

数组: [3, 2, 1, 5, 6, 4]
       ↑              ↑
     left           right
     i=0           pivot=4

2. 第一次分区

选择基准元素 pivot = 4:

  1. 遍历数组,j从left开始:

    • j=0: nums[0]=3 <= pivot, 交换nums[0]和nums[0](实际不变),i++
    数组: [3, 2, 1, 5, 6, 4]
           ↑           
         i=1,j=0      
    
    • j=1: nums[1]=2 <= pivot, 交换nums[1]和nums[1](实际不变),i++
    数组: [3, 2, 1, 5, 6, 4]
              ↑           
            i=2,j=1      
    
    • j=2: nums[2]=1 <= pivot, 交换nums[2]和nums[2](实际不变),i++
    数组: [3, 2, 1, 5, 6, 4]
                 ↑           
               i=3,j=2      
    
    • j=3: nums[3]=5 > pivot, 不交换,i不变
    数组: [3, 2, 1, 5, 6, 4]
                 ↑  ↑           
               i=3  j=3      
    
    • j=4: nums[4]=6 > pivot, 不交换,i不变
    数组: [3, 2, 1, 5, 6, 4]
                 ↑     ↑           
               i=3     j=4      
    
  2. 交换nums[i]和nums[right],将基准元素放到正确位置:

    数组: [3, 2, 1, 4, 6, 5]
                 ↑           
               i=3 (pivotIndex)      
    
  3. 比较pivotIndex(3)与targetIndex(4):

    • pivotIndex < targetIndex,在右子数组中继续查找

3. 第二次分区

在右子数组 [6,5] 中继续查找:

数组: [3, 2, 1, 4, 6, 5]
                   ↑  ↑
                 left right
                 i=4  pivot=5
  1. 遍历数组,j从left开始:

    • j=4: nums[4]=6 > pivot, 不交换,i不变
    数组: [3, 2, 1, 4, 6, 5]
                    ↑  ↑           
                  i=4  j=4      
    
  2. 交换nums[i]和nums[right],将基准元素放到正确位置:

    数组: [3, 2, 1, 4, 5, 6]
                    ↑           
                  i=4 (pivotIndex)      
    
  3. 比较pivotIndex(4)与targetIndex(4):

    • pivotIndex == targetIndex,找到了目标元素,返回nums[4] = 5

4. 结果分析

通过两次分区操作,我们成功找到了数组中的第2大元素:5。

整个过程中,我们只处理了包含目标元素的那部分数组,而不需要对整个数组进行排序,这就是快速选择算法高效的原因。

五、算法优化

1. 随机选择基准元素

为了避免在已排序或近似排序的数组上性能下降,可以随机选择基准元素:

private int partition(int[] nums, int left, int right) {
    // 随机选择一个元素作为基准
    int randomIndex = left + (int) (Math.random() * (right - left + 1));
    swap(nums, randomIndex, right);
    
    // 其余代码不变
    int pivot = nums[right];
    // ...
}

2. 三数取中法

另一种选择基准元素的方法是三数取中法,即选择left、right和mid三个位置的元素的中间值作为基准:

private int partition(int[] nums, int left, int right) {
    int mid = left + (right - left) / 2;
    // 排序left, mid, right三个位置的元素
    if (nums[left] > nums[mid]) swap(nums, left, mid);
    if (nums[left] > nums[right]) swap(nums, left, right);
    if (nums[mid] > nums[right]) swap(nums, mid, right);
    
    // 将中间值(现在在mid位置)交换到right-1位置
    swap(nums, mid, right - 1);
    
    // 使用nums[right-1]作为基准
    int pivot = nums[right - 1];
    // ...
}

3. 非递归实现

为了避免递归调用导致的栈溢出,可以使用迭代方式实现快速选择:

public int findKthLargest(int[] nums, int k) {
    int left = 0;
    int right = nums.length - 1;
    int targetIndex = nums.length - k;
    
    while (left <= right) {
        int pivotIndex = partition(nums, left, right);
        
        if (pivotIndex == targetIndex) {
            return nums[pivotIndex];
        } else if (pivotIndex < targetIndex) {
            left = pivotIndex + 1;
        } else {
            right = pivotIndex - 1;
        }
    }
    
    return -1; // 不会执行到这里
}

六、复杂度分析

1. 时间复杂度

  • 平均情况:==O(n)==

    • 每次分区操作会将问题规模减少一半(平均情况)
    • 总时间复杂度为:T(n) = T(n/2) + O(n) = O(n)
  • 最坏情况:O(n²)

    • 如果每次都选到最大或最小元素作为基准
    • 但通过随机选择基准元素,最坏情况的概率很低

2. 空间复杂度

  • 递归实现

    • 平均情况:O(log n),递归调用栈的深度
    • 最坏情况:O(n),递归调用栈的深度
  • 迭代实现:O(1),只使用常数级别的额外空间

七、总结

问:如何在O(n)时间内找到数组中的第K大元素?

关键知识点

快速选择算法是解决第K大元素问题的最优方法,它基于快速排序的分区思想,但只处理包含目标元素的那一部分数组。通过将第K大元素转换为第(n-k+1)小元素,然后使用分区操作递归查找,可以在平均O(n)时间内找到目标元素。

深入解析

  1. 快速选择算法的核心思想

    • 利用分区操作将数组分为两部分
    • 根据基准元素位置与目标位置的关系,决定在哪一部分继续查找
    • 避免对整个数组排序,只关注包含目标元素的部分
  2. 分区操作的工作原理

    • 选择基准元素(pivot)
    • 将小于等于基准的元素放在左边
    • 将大于基准的元素放在右边
    • 返回基准元素的最终位置
  3. 常见的进阶问题

    • 如何处理有大量重复元素的数组?
      答:可以使用三路快速选择,将数组分为小于、等于和大于基准的三部分
    • 如何优化最坏情况下的性能?
      答:使用随机选择基准元素或三数取中法
    • 如果内存受限,如何处理大规模数据?
      答:可以使用迭代而非递归实现,或考虑外部排序技术