图 1:本文封面,点明题目与「不排完也能定位」的主线。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。「数组里第 K 大的元素」几乎人人都会第一反应「排序再取」,面试官真正想听的是后半句:能不能不排完?
想象面试现场,面试官写下:给定无序数组和整数 k,返回第 k 大的元素;追问——比 O(n log n) 更好能做到吗?
第一步,审题:先立正确性基准。
排序后取第 k 个,时间 O(n log n)、空间看排序实现,这个答案必须先说——它是你的正确性兜底,也是衬托更优解的背景板。然后点破关键观察:排序把 n 个元素的名次全部算出来了,但我们只要其中一个名次。为一个问题付出 n 个问题的代价,浪费就在这里。真正的问题是:能不能只算出「第 k 大」这一个名次?
第二步,核心直觉:分区一次,名次落一半。
借快排的分区操作:随机挑一个基准,把比它大的挪到左边、比它小的挪到右边。分区结束后,基准元素的位置就是它的最终名次——左边恰好有 p 个比它大的数,它就是第 p+1 大。于是目标名次落在左边就只在左半区继续找,落在右边就只在右半区继续找,另一半直接扔掉。每一轮问题规模大约减半,总比较次数约 n + n/2 + n/4,加起来不超过 2n,平均时间复杂度 O(n)。
这就是快速选择(quickselect):快排的分区,加上「只进一半」的贪心。
第三步,两个必须交代的细节。
一,最坏情况是 O(n²)——每次基准都恰好是最值,规模只减一。工程上的标准解法是随机选基准,让最坏情况成为概率事件而非输入事件;对已有序的输入尤其重要。二,「第 k 大」默认按去重后的名次理解还是按下标理解,面试时要先问清。下面的实现按「排序后下标」理解,允许重复值占名次。
图 2:两轮分区定位第 2 大,以及全排序与快速选择的复杂度对比。
第四步,写出可运行的代码。
下面这份实现包含随机基准的快速选择与全排序复核,用 Node.js 22.15.0 直接运行(node quickselect.js)即可:
'use strict';
// 快速选择:在未完全排序的数组上找第 k 大(k 从 1 开始)
function quickSelect(nums, k) {
const arr = [...nums];
const target = arr.length - k; // 第 k 大 = 升序下标 length - k
let lo = 0;
let hi = arr.length - 1;
while (lo <= hi) {
const p = partition(arr, lo, hi);
if (p === target) return arr[p];
if (p < target) lo = p + 1;
else hi = p - 1;
}
return undefined; // k 越界时兜底
}
// Lomuto 分区:随机取基准换到末尾,把比基准小的挪到左边,返回基准最终位置
function partition(arr, lo, hi) {
const r = lo + Math.floor(Math.random() * (hi - lo + 1)); // 随机选基准,规避最坏情况
[arr[r], arr[hi]] = [arr[hi], arr[r]];
const pivot = arr[hi];
let i = lo;
for (let j = lo; j < hi; j++) {
if (arr[j] < pivot) {
[arr[i], arr[j]] = [arr[j], arr[i]];
i++;
}
}
[arr[i], arr[hi]] = [arr[hi], arr[i]];
return i;
}
const cases = [
{ nums: [5, 2, 9, 1, 7, 3, 8], k: 2 },
{ nums: [3, 2, 1, 5, 6, 4], k: 2 },
{ nums: [3, 2, 3, 1, 2, 4, 5, 5, 6], k: 4 },
{ nums: [1], k: 1 },
{ nums: [7, 6, 5, 4, 3, 2, 1], k: 7 },
];
for (const { nums, k } of cases) {
const fast = quickSelect(nums, k);
const check = [...nums].sort((a, b) => b - a)[k - 1]; // 全排序复核
console.log(`[${nums}] 第 ${k} 大 -> 快速选择 ${fast},全排序复核 ${check}`);
if (fast !== check) throw new Error('两种方法结果不一致');
}
实测输出如下:
[5,2,9,1,7,3,8] 第 2 大 -> 快速选择 8,全排序复核 8
[3,2,1,5,6,4] 第 2 大 -> 快速选择 5,全排序复核 5
[3,2,3,1,2,4,5,5,6] 第 4 大 -> 快速选择 4,全排序复核 4
[1] 第 1 大 -> 快速选择 1,全排序复核 1
[7,6,5,4,3,2,1] 第 7 大 -> 快速选择 1,全排序复核 1
五个用例两种方法全部一致,覆盖了重复元素、单元素、完全逆序三个边界。
易错点清单。
一,只会排序取第 k 个,讲不出「只算一个名次」的优化动机。二,分区后两半边都递归——那就退化成了快排,丢掉 O(n) 的核心。三,固定取首元素作基准,被有序输入打成 O(n²) 还不知道为什么。四,k 与下标的换算搞错——第 k 大对应升序下标 length 减 k,写反了结果全错。五,不与面试官确认重复元素的口径。
回到面试现场,建议的答题节奏:先给排序法作为基准并指出浪费,再讲「分区一次名次落一半」的直觉,然后写出循环版快速选择,最后主动交代最坏情况与随机基准。top-k 采样、粗排选候选、实时榜单,背后都是这同一个「部分选择代替全排序」的思想。
如果面试官把题目改成「数据流里持续求第 K 大,数组会无限增长」,快速选择还合用吗?你会换成什么结构?欢迎写出你的思路。
参考资料
- Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,
Array.prototype.sort、解构赋值等行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/