LeetCode 记录-215. 数组中的第K个最大元素数

118 阅读3分钟

LeetCode 记录-215. 数组中的第K个最大元素数

我的解法

思路

image.png

我的想法就是降序排序后取出k位置的值。但这显然不能满足O(n)O(n)的要求,因为排序的时间复杂度是O(nlogn)O(nlogn)


官方解法 1: 基于快速排序的选择方法

思路

我们知道,快速排序是一个典型的分治算法。大致过程是:随机选择数组中的一个数,将大于、小于这个数的数各自划分在两边,然后对这两个划分继续上述过程,最后合并起来,就可以在O(nlogn)O(nlogn)的平均时间复杂度下排序好数组。

然而这并不满足O(n)O(n)的时间复杂度。但我们可以在快速排序的基础上进行优化,它就是「快速选择」算法:在快速排序算法中,会随机选择一个数(假设下标为idx),我们将比这个数大的划分在左侧,小的划分在右侧,那么这个数就是该数组中第idx+1个最大的数。然后我们可以通过判断idx+1和k的大小,继续选择在左分区还是右分区中继续「快速选择」算法,直到找到第k个最大的数。

代码

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function (nums, k) {
  return quickSort(nums, k, 0, nums.length - 1);
};

const quickSort = (nums, k, left, right) => {
  const index = Math.floor(Math.random() * (right - left + 1)) + left;
  const flag = nums[index];
  nums[index] = nums[left];
  let i = left, j = right;
  while (i < j) {
    while (i < j && nums[j] <= flag) j--;
    nums[i] = nums[j];
    while (i < j && nums[i] >= flag) i++;
    nums[j] = nums[i];
  }

  nums[i] = flag;
  if (i === k - 1) {
    return flag;
  } else if (i < k - 1) {
    return quickSort(nums, k, i + 1, right);
  } else {
    return quickSort(nums, k, left, i - 1)
  }
}

复杂度分析

时间复杂度

O(n)O(n),引入随机后,「快速选择」算法的时间复杂度为O(n)O(n),证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。

空间复杂度

O(logn)O(logn),递归使用栈空间的空间代价的期望为O(logn)O(logn)

官方解法 2: 基于堆排序的选择方法

思路

我们可以用堆排序来解决这个问题:首先建立一个大顶堆,做k-1次删除操作后,在堆顶的元素就是我们要的答案。

代码

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function (nums, k) {
  let heapSize = nums.length;
  buildMaxHeap(nums, heapSize);
  for (let i = nums.length - 1; i >= nums.length - k + 1; --i) {
    swap(nums, 0, i);
    --heapSize;
    maxHeapify(nums, 0, heapSize);
  }
  console.log(nums[0]);
  return nums[0];
};

var buildMaxHeap = (nums, heapSize) => {
  for (let i = Math.floor(heapSize / 2); i >= 0; --i) {
    maxHeapify(nums, i, heapSize);
  }
}

var maxHeapify = (nums, i, heapSize) => {
  let l = i * 2 + 1, r = i * 2 + 2, largest = i;
  if (l < heapSize && nums[l] > nums[largest]) {
    largest = l;
  }
  if (r < heapSize && nums[r] > nums[largest]) {
    largest = r;
  }
  if (largest !== i) {
    swap(nums, i, largest);
    maxHeapify(nums, largest, heapSize);
  }
}

var swap = (nums, i, j) => {
  const temp = nums[i];
  nums[i] = nums[j];
  nums[j] = temp;
}

复杂度分析

时间复杂度

O(nlogn)O(nlogn),建堆的时间代价是O(n)O(n),删除的总代价为O(klogn)O(klogn),因为k<nk < n, 故渐进时间复杂度为O(n+klogn)=O(nlogn)O(n+klogn)=O(nlogn)

空间复杂度

O(logn)O(logn),递归使用栈空间的空间代价的期望为O(logn)O(logn)


衍生知识点-

定义

堆是一种特殊的树,只要满足下面两个条件,它就是一个堆:

  1. 堆是一颗完全二叉树
  2. 堆中的某个节点总是不大于(或不小于)其父节点的值

其中,我们把根节点最大的堆叫大顶堆,根节点最小的堆叫小顶堆。

如何通过堆来排序?

给定一个待排序的数列,进行如下操作:

  1. 建堆:将数列建成一个堆
  2. 输出:删除根节点,将其输出,然后将最后一个叶子节点移动到根节点
  3. 调整:移动之后的树可能不满足堆的性质,此时需要进行一轮调整,使其成为一个新堆
  4. 不断进行步骤2,3就可以得到排好的数列

建堆:从上往下建堆vs.从下往上建堆

这里只写结论,具体逻辑和证明过程请看:zhuanlan.zhihu.com/p/341249538

  1. 从上往下建堆的时间复杂度为O(nlogn)O(nlogn)
  2. 从下往上建堆的时间复杂度为O(n)O(n)

输出和调整

一次调整的时间复杂度为:O(logn)O(logn),最坏情况下,删除根节点后,堆高为hh',将最后一个节点置到根节点后,最多会下降h1h'-1次。

总体时间复杂度:O(nlogn)O(nlogn)

image.png