描述

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

示例

示例 1:

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

示例 2:

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

提示

  • 1 <= k <= nums.length <= 104
  • -104 <= nums[i] <= 104

    解题思路

方法一:基于快速排序的选择方法

我们对数组 a[lr] 做快速排序的过程是:

  • 分解: 将数组 a[l⋯r] 「划分」成两个子数组 a[l⋯q−1]、a[q+1⋯r],使得 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。其中,计算下标 q 也是「划分」过程的一部分。
  • 解决: 通过递归调用快速排序,对子数组 a[l⋯q−1] 和 a[q+1⋯r] 进行排序。
  • 合并: 因为子数组都是原址排序的,所以不需要进行合并操作,a[l⋯r] 已经有序。
  • 上文中提到的 「划分」 过程是:从子数组 a[l⋯r] 中选择任意一个元素 x 作为主元,调整子数组的元素使得左边的元素都小于等于它,右边的元素都大于等于它, x 的最终位置就是 q。

每次经过「划分」操作后,我们一定可以确定一个元素的最终位置,所以只要某次划分的 q 为倒数第 k 个下标的时候,我们就已经找到了答案。

因此我们可以改进快速排序算法来解决这个问题:在分解的过程当中,我们会对子数组进行划分,如果划分得到的 q 正好就是我们需要的下标,就直接返回 a[q];否则,如果 q 比目标下标小,就递归右子区间,否则递归左子区间。这样就可以把原来递归两个区间变成只递归一个区间,提高了时间效率。这就是「快速选择」算法。

我们知道快速排序的性能和「划分」出的子数组的长度密切相关。直观地理解如果每次规模为 n 的问题我们都划分成 1 和 n−1,每次递归的时候又向 n−1 的集合中递归,这种情况是最坏的,时间代价是 O(n2)。我们可以引入随机化来加速这个过程,它的时间代价的期望是 O(n),证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。

代码

/**
 * 快速排序
 */
class Solution {
    Random random = new Random();

    public int findKthLargest(int[] nums, int k) {
        // 要找到的元素所在索引:  前K大,即倒数索引第K个
        int index = nums.length - k;
        int right = nums.length - 1;
        int left = 0;
        return quickSelect(nums, left, right, index);
    }

    public int quickSelect(int[] nums, int left, int right, int index) {
        // 得到分区值索引q
        int q = randomPartition(nums, left, right);


        if (q == index) {
            // 如果刚好索引q就是想要的索引,则直接返回
            return nums[q];

        } else {
            // 如果不是,比较q 与 index ,确定下次要检索的区间, 要么是[q+1, right], 要么就是[left, q-1]
            return q < index ? quickSelect(nums, q + 1, right, index) : quickSelect(nums, left, q - 1, index);
        }
    }

    public int randomPartition(int[] nums, int l, int r) {
        // 1. 随机数范围: [0, r-l+1) 同时加l, 则是 [l, r+1) = [l, r] 也就是在这个[l,r] 中随机选一个索引出来
        int i = random.nextInt(r - l + 1) + l;

        // 2. 交换nums[i], nums[r], 也就是将随机数先放在[l,r]最右边nums[r]上
        swap(nums, i, r);
        return partition(nums, l, r);
    }

    public int partition(int[] nums, int l, int r) {
        // 3. 在调用当前方法的randomPartition方法中,已经确定了了随机数是nums[r]
        int x = nums[r], i = l - 1;

        // 首先比较区间在[l, r]之间, 所以nums[j]中的    l<= j <= r
        for (int j = l; j < r; ++j) {
            // 4. nums[j] 跟随机数 x 比较, 小于x的数都跟[l,r]左边区间交换,i=l-1,所以++i=l,初始索引就是l,
            if (nums[j] <= x) {
                swap(nums, ++i, j);//两两交换
            }
        }// 这个for循环操作就是将小于 x 的数都往[i, j]的左边区间设置,从而实现存在[l, i]区间,使得对应数值都 小于 x

        //5. 既然已经将<x的值都放在一边了,现在将x也就是nums[r] 跟nums[i+1]交换,从而分成两个区间[l.i+1]左, [i+2, r]右,左边区间的值都小于x
        swap(nums, i + 1, r);

        // 然后返回这个分区值
        return i + 1;
    }

    public void swap(int[] a, int i, int j) {
        int temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
}

复杂度分析

时间复杂度:O(n),如上文所述,证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。
空间复杂度:O(logn),递归使用栈空间的空间代价的期望为 O(logn)。

方法二:基于堆排序的选择方法

建立一个大根堆,做 k - 1 次删除操作后堆顶元素就是我们要找的答案。

代码

class Solution {
    public int findKthLargest(int[] nums, int k) {
        int heapSize = nums.length;
        buildMaxHeap(nums, heapSize);
        //建堆完毕后,nums【0】为最大元素。逐个删除堆顶元素,直到删除了k-1个。
        for (int i = nums.length - 1; i >= nums.length - k + 1; --i) {
            //先将堆的最后一个元素与堆顶元素交换,由于此时堆的性质被破坏,需对此时的根节点进行向下调整操作。
            swap(nums, 0, i);
            //相当于删除堆顶元素,此时长度变为nums.length-2。即下次循环的i
            --heapSize;
            maxHeapify(nums, 0, heapSize);
        }
        return nums[0];
    }

    public void buildMaxHeap(int[] a, int heapSize) {
        //从最后一个父节点位置开始调整每一个节点的子树。数组长度为heasize,因此最后一个节点的位置为heapsize-1,所以父节点的位置为heapsize-1-1/2。
        for (int i = (heapSize-2)/ 2; i >= 0; --i) {
            maxHeapify(a, i, heapSize);
        } 
    }

    public void maxHeapify(int[] a, int i, int heapSize) {      //调整当前结点和子节点的顺序。
        //left和right表示当前父节点i的两个左右子节点。
        int left = i * 2 + 1, right = i * 2 + 2, largest = i;
        //如果左子点在数组内,且比当前父节点大,则将最大值的指针指向左子点。
        if (left < heapSize && a[left] > a[largest]) {
            largest = left;
        } 
        //如果右子点在数组内,且比当前父节点大,则将最大值的指针指向右子点。
        if (right < heapSize && a[right] > a[largest]) {
            largest = right;
        }
        //如果最大值的指针不是父节点,则交换父节点和当前最大值指针指向的子节点。
        if (largest != i) {
            swap(a, i, largest);
            //由于交换了父节点和子节点,因此可能对子节点的子树造成影响,所以对子节点的子树进行调整。
            maxHeapify(a, largest, heapSize);
        }
    }

    public void swap(int[] a, int i, int j) {
        int temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
}

复杂度分析

时间复杂度:O(nlogn),建堆的时间代价是 O(n),删除的总代价是 O(klogn),因为 k空间复杂度:O(logn),即递归使用栈空间的空间代价。