前端知库

数组中第 K 大的元素怎样找?小顶堆和快速选择的复杂度有什么区别?

小顶堆的做法是维护一个大小为 k 的最小堆,遍历时把每个数放进去,超过 k 个就弹出堆顶,最后堆顶就是第 k 大,时间 O(n log k)、空间 O(k)。快速选择借用快速排序的分区,每次只往答案所在的一侧递归,平均 O(n),最坏 O(n²),随机选基准可以避免固定输入总落到最坏情况。

参考答案

简答

第 K 大的元素,是把数组从大到小排好后排在第 k 位的那个数,重复的数各算一次,[3,2,3,1,2,4,5,5,6] 里第 4 大是 4。最直接的做法是排序后取下标 n - k,时间 O(n log n),题目希望不靠完整排序来做。

小顶堆的做法像只保留前 k 名的排行榜:堆里最多放 k 个数,堆顶是这 k 个里最小的。每来一个数就放进去,超过 k 个就把堆顶这个最小的踢掉。遍历完,堆里是最大的 k 个数,堆顶就是第 k 大。每次进出堆 O(log k),总共 O(n log k),空间 O(k)。

快速选择借用快速排序的分区:选一个基准,把比它小的放左边、大的放右边,基准落到的下标就是它排好序后的位置。如果正好是 n - k 就找到了,否则只往答案所在的那一侧继续,另一侧直接丢掉。每轮平均扔掉一半,总共平均 O(n)。

要注意,快速选择最坏是 O(n²),基准总选到最大或最小值时每轮只排除一个数,所以基准要随机选。它还会原地打乱数组。堆的做法适合数据流式到达、内存只放得下 k 个的情况;快速选择适合数组已经在内存里、只求一次的情况。

两种写法

MinHeap 只写了用得到的方法,push 往上浮、pop 往下沉。快速选择用的是 Lomuto 分区,看 quickSelect 里怎样根据基准的位置决定往哪边走。

JavaScript
class MinHeap {
  constructor() {
    this.items = [];
  }
  get size() {
    return this.items.length;
  }
  peek() {
    return this.items[0];
  }
  push(value) {
    const a = this.items;
    a.push(value);
    let i = a.length - 1;
    while (i > 0) {
      const parent = (i - 1) >> 1;
      if (a[parent] <= a[i]) break;
      [a[parent], a[i]] = [a[i], a[parent]];
      i = parent;
    }
  }
  pop() {
    const a = this.items;
    const top = a[0];
    const last = a.pop();
    if (a.length) {
      a[0] = last;
      let i = 0;
      while (true) {
        let smallest = i;
        for (const child of [2 * i + 1, 2 * i + 2]) {
          if (child < a.length && a[child] < a[smallest]) smallest = child;
        }
        if (smallest === i) break;
        [a[smallest], a[i]] = [a[i], a[smallest]];
        i = smallest;
      }
    }
    return top;
  }
}

function findKthLargestByHeap(nums, k) {
  const heap = new MinHeap();
  for (const num of nums) {
    heap.push(num);
    if (heap.size > k) heap.pop();
  }
  return heap.peek();
}

function partition(a, lo, hi) {
  const r = lo + Math.floor(Math.random() * (hi - lo + 1));
  [a[r], a[hi]] = [a[hi], a[r]];
  const pivot = a[hi];
  let store = lo;
  for (let i = lo; i < hi; i++) {
    if (a[i] < pivot) {
      [a[i], a[store]] = [a[store], a[i]];
      store++;
    }
  }
  [a[store], a[hi]] = [a[hi], a[store]];
  return store;
}

function findKthLargestByQuickSelect(nums, k) {
  const a = [...nums];
  const target = a.length - k;
  let lo = 0;
  let hi = a.length - 1;
  while (true) {
    const p = partition(a, lo, hi);
    if (p === target) return a[p];
    if (p < target) lo = p + 1;
    else hi = p - 1;
  }
}

console.log(findKthLargestByHeap([3, 2, 1, 5, 6, 4], 2)); // 5
console.log(findKthLargestByHeap([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 4
console.log(findKthLargestByQuickSelect([3, 2, 1, 5, 6, 4], 2)); // 5
console.log(findKthLargestByQuickSelect([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 4
console.log(findKthLargestByQuickSelect([7], 1)); // 7

为什么找第 k 大要用小顶堆,不是大顶堆?堆里要留下的是最大的 k 个数,需要随时踢掉的是这 k 个里最小的那个,小顶堆的堆顶正好就是它,O(1) 能看到,O(log k) 能删掉。用大顶堆也能做,但要把 n 个数全放进去再弹 k − 1 次,空间是 O(n)。

快速选择里 target = a.length - k 是怎么来的?从小到大排好序后,第 1 大在下标 n - 1,第 k 大在下标 n - k。partition 返回基准最终的下标,它左边都比它小,右边都不小于它,所以它就在排好序以后的位置上。基准下标比目标小,答案只能在右边;比目标大,答案只能在左边。

示例里的快速选择用了 Math.random,每次运行分区的过程不同,但返回的值是确定的,因为第 k 大的数只有一个值。函数先复制了数组,避免原地交换改掉调用方的数据。

复杂度从哪来

快速选择平均 O(n) 是这么算的:基准随机时,每轮平均能把范围缩小到原来的一半左右,各轮的扫描量是 n、n/2、n/4……加起来不超过 2n。快速排序要两边都递归,每层都要扫完整个数组,所以是 O(n log n);快速选择只走一边,省掉了这个 log。最坏情况是每轮基准都选到当前范围的最值,扫描量是 n、n − 1、n − 2……加起来 O(n²)。

还有一点:Lomuto 分区遇到大量重复值时,等于基准的数全都分到同一边,全是相同的值时会退化。处理方法是三路分区,把等于基准的数单独归为中间一段,目标下标落在这一段里就直接返回。

堆的做法时间稳定在 O(n log k),k 很小时接近 O(n)。小顶堆的完整实现和插入、删除的过程见《前端任务怎样按优先级排队?二叉堆为什么不需要每次完整排序?》,快速排序的分区见《快速排序和归并排序怎样实现?时间复杂度和稳定性有什么区别?》。

常见追问

直接 sort 再取值可以吗?

面试里先说出来没问题,时间 O(n log n),代码最短。要记得传比较函数,nums.sort((a, b) => b - a)[k - 1],因为 MDN 说明不传比较函数时 sort 会按字符串比较,[1, 10, 9].sort() 得到的是 [1, 10, 9],不是按数值排好的 [1, 9, 10]。还要注意 sort 会修改原数组。

要求返回前 k 大的所有元素呢?

小顶堆跑完以后,堆里剩下的就是前 k 大,只是没有排序,需要的话再排一下,O(k log k)。快速选择找到下标 n - k 时,右边的部分也正好是前 k 大的数。

数据是源源不断到来的,怎么随时给出第 k 大?

用小顶堆。每来一个数就 push,超过 k 个就 pop,任何时候堆顶都是目前为止的第 k 大,空间只要 O(k)。快速选择需要整个数组都在手里,不适合这种场景。