☰
LeetCode 215题详解:快速选择算法原理与C++实现
2026/10/23 9:05:14 网站建设 项目流程

1. 为什么第215题是“背了又忘”的高频题

如果你刷过一段时间的算法题,大概率对这道题有印象:给定一个整数数组nums和一个整数k,要求返回数组中第 k 个最大的元素。乍一看这就是“排序然后取下标”的事,但 Leetcode 215 之所以被反复拿出来讲,恰恰是因为它考察的不是你会不会调排序接口,而是你有没有理解快速排序内部的分治本质。

我第一次做这道题时也走了弯路:直接sort(nums.begin(), nums.end()),然后返回nums[nums.size() - k]。提交能过,但总觉得这事没完。因为面试官大概率会追问一句:如果数组特别大,内存放不下全量排序,或者数据是流式的,只允许遍历一次,你还能怎么做?这道题的真正价值在于引出两个经典思路——堆(优先队列)和快速选择(Quick Select),其中快速选择就是“单边分治版的快速排序”,理解了它,你对快速排序的理解才算真正上了一个台阶。

这篇文章我准备从零开始拆解:先讲清楚第 215 题的目标与边界,然后进入快速排序的分治模型,说明快速选择为什么能平均 O(n) 完成查找,再给出完整可运行的 C++ 实现,最后专门聊一聊那些坑——尤其是基准值选择和退化问题。全程用 C++ 代码说话,尽量还原我在实际做题和模拟面试时的思考过程。

2. 先厘清题目目标:第 k 大到底对应哪个位置

2.1 从“排序后取下标”的直觉说起

题目给的是“第 k 个最大的元素”。注意这个表述和“第 k 个最小”容易混淆。一个简单的换算关系是:如果要找第 k 大,其实就是升序排序后从右往左数第 k 个,也就是下标n - k的元素。

我记得自己第一次提交时写的是sort(nums.begin(), nums.end()); return nums[nums.size() - k];输出正确,但后来面试官问我“为什么是n - k”时,我愣了一下才反应过来。其实想清楚就很简单:数组升序后,最大的元素在n - 1,第二大的在n - 2,依此类推,第 k 大的就是n - k。这里千万不能把“第 k 大”当成“从左边数的第 k 个”,那是第 k 小的位置。

2.2 暴力解法的复杂度边界

排序法的时间复杂度是 O(n log n),空间复杂度取决于排序实现(std::sort通常是 O(log n) 栈空间)。对于 Leetcode 215 的常规数据规模来说,这当然能过。但如果你在面试中只给出排序解法,往往会被追问“能不能优化”,因为题目真正想要的并不是全量排序,而是一个能更快定位到目标的方案。

同理,用小顶堆维护大小为 k 的窗口,遍历完数组后堆顶就是第 k 大,复杂度是 O(n log k)。这种方案适合数据量极大、无法一次性全部载入内存的场景,因为只需要流式处理每个元素。它的空间复杂度是 O(k),当 k 远小于 n 时非常实用。本文重点讨论的是快速选择方案,它在时间均摊上更优,而且它从头到尾都在复用快速排序的partition过程——理解了它,等于同时复习了快速排序。

3. 快速排序的分治模型,快速选择就是它的“单边剪枝版”

3.1 partition 的本质是什么

要讲快速选择,必须先讲 partition。快速排序的核心动作不是“交换”,而是“选定一个基准元素,然后通过一系列交换,把数组分成两部分:左边都不大于基准值,右边都不小于基准值”。在 partition 结束的这一刻,基准值所在的位置就是它在最终有序序列中的位置。

举个例子。假设数组是[3, 1, 4, 1, 5, 9, 2, 6],我们随便选一个基准,比如取最后一个元素 6,partition 完毕后,6 会移动到一个位置pos,使得[0, pos)区间内的元素全部 <= 6,(pos, n-1]区间内的元素全部 >= 6。不管后续怎么继续排序,6 永远停在这个位置上了。这就是快速排序“每次确定一个元素的最终位置”的核心机制。

标准循环不变量是这样的:维护一个区间[0, i]表示已处理的小于等于基准的部分,一个游标j从区间的左边界往右扫描,遇到小于等于基准的元素就把它交换到前面来。我在 C++ 里最常用的是“挖坑填数法”或“交换法”,写法不同,但本质一致。快速选择算法用的 partition 不仅负责把数组划分开,更关键的是它返回了基准的最终下标。

3.2 为什么快速选择只需要递归一侧

普通快速排序在 partition 之后,要对基准左边和右边两个子区间分别递归排序,时间复杂度期望是 O(n log n)。但第 215 题只要求找第 k 大,这意味着我们只关心目标下标target = n - k位置的元素,其他位置的顺序根本不需要完全排好。

于是快速选择就来了:做一次 partition 后,拿到基准的最终位置pos。如果pos == target,那基准值恰好就是我们要找的第 k 大元素,直接返回;如果pos < target,说明目标在右半边,只需要递归处理[pos + 1, right];如果pos > target,说明目标在左半边,只需要递归处理[left, pos - 1]。

这个“只走一侧”就是剪枝。普通快排要递归两侧,而快速选择每次递归只处理一侧,从直观上就比快排快。我最初觉得这只是“少了一半的工作量”,但后来算复杂度才发现,它带来的收益是巨大的——均摊从 O(n log n) 直接降到了 O(n)。

3.3 均摊 O(n) 的几何级数直观证明

这里用一个很感性的方式推导复杂度,方便记忆。假设 partition 非常理想,每次都能把区间大致对半分。第一次处理的规模是 n,第二次大概是 n/2,第三次是 n/4……总工作量近似为 n + n/2 + n/4 + ... = 2n,所以期望复杂度是 O(n)。

这个推导虽然省略了细节,但能帮我们快速理解为什么“每次只处理一半”比“每次处理完整的两半”划算。完整快排的工作量模型是每层都处理 n 个元素,一共 log n 层,所以是 O(n log n);而快速选择每层处理 n/2^k 个元素,呈几何级数缩减,加起来就是收敛的 O(n)。

注意:这里的 O(n) 是“平均期望复杂度”,不是严格的最坏情况。如果 partition 每次选择的基准都恰好把数组分成极度不平衡的两部分,比如每次都取到最小值或最大值,那递归深度会变成 n,复杂度会退化成 O(n^2)。这个退化问题我会在第 6 部分专门展开,也是面试官最喜欢追问的细节点。

4. C++ 实现快速选择的完整代码与逐步推演

4.1 最朴素的版本:固定取最右元素为基准

我先把最快能跑的版本写出来,代码尽量贴近我平时做题的风格,注释会很详细。

#include <vector> #include <algorithm> using namespace std; class Solution { public: int findKthLargest(vector<int>& nums, int k) { int n = nums.size(); int target = n - k; // 第 k 大 = 升序后的第 n-k 个位置 int left = 0, right = n - 1; while (true) { int pos = partition(nums, left, right); if (pos == target) { return nums[pos]; } else if (pos < target) { left = pos + 1; } else { right = pos - 1; } } } private: int partition(vector<int>& nums, int left, int right) { int pivot = nums[right]; int i = left; // i 指向当前已确认 <= pivot 的区间末尾的下一个位置 for (int j = left; j < right; ++j) { if (nums[j] <= pivot) { swap(nums[i], nums[j]); ++i; } } swap(nums[i], nums[right]); return i; } };

这里的partition是经典的“循环内交换”写法:i左边全是小于等于 pivot 的元素,j负责向右扫描,发现符合条件的元素就换到i的位置,然后i后移。扫描结束之后,把 pivot 从最右边换到i的位置,这样 pivot 就落在了它最终应该在的位置上。

findKthLargest用的是循环而非递归,因为每一轮 partition 之后目标区间的边界就更新了,循环改写非常自然。这种写法也避免了递归栈溢出的隐患,实际跑 Leetcode 大数组时更安全。

4.2 递归版本的写法对比

我有时候在面试中也会直接写递归版,因为它更贴近快速排序的思考方式。贴上递归版的实现:

int quickSelect(vector<int>& nums, int left, int right, int target) { int pos = partition(nums, left, right); if (pos == target) return nums[pos]; if (pos < target) return quickSelect(nums, pos + 1, right, target); return quickSelect(nums, left, pos - 1, target); } int findKthLargestRecursive(vector<int>& nums, int k) { int n = nums.size(); return quickSelect(nums, 0, n - 1, n - k); }

递归版和循环版本质上没有区别,都是一个“一直收敛区间直到找到目标位置”的过程。面试中我会优先写递归版,因为代码更紧凑,也更容易向面试官解释思路;但心里知道循环版是迭代版本,连递归栈都不需要,遇到极端数据更稳。

4.3 手动走一遍 partition,看清楚下标怎么变

光看代码可能还不够直观,我手动走一个例子。数组[3, 2, 1, 5, 6, 4],目标k = 2,所以target = 6 - 2 = 4,也就是要找升序后下标 4 的元素。

第一轮 partition,left=0,right=5,pivot 取nums[5]=4。扫描过程如下:

  • 初始i=0,j从 0 开始。
  • j=0,nums[0]=3 <= 4,swap(nums[0], nums[0]),i 变 1。
  • j=1,nums[1]=2 <= 4,swap(nums[1], nums[1]),i 变 2。
  • j=2,nums[2]=1 <= 4,swap(nums[2], nums[2]),i 变 3。
  • j=3,nums[3]=5 > 4,跳过。
  • j=4,nums[4]=6 > 4,跳过。
  • 扫描结束,swap(nums[3], nums[5]),数组变成[3, 2, 1, 4, 6, 5],返回 pos=3。

现在pos=3 < target=4,说明第 4 位置的元素在右半边[4, 5]。下一轮只处理[4, 5]区间:数组是[3, 2, 1, 4, 6, 5],left=4, right=5,pivot 取nums[5]=5。扫描中nums[4]=6 > 5,跳过,结束后 swap(nums[4], nums[5]),得到[3, 2, 1, 4, 5, 6],pos=4。

刚好pos == target,返回nums[4]=5。手动走一遍,你就能很清楚地看到:第二轮只处理了两个元素,整个数组左侧的[3, 2, 1, 4]完全没动。这就是“剪枝”的直观体现。

5. 基准值选择的工程化改进:随机化与三数取中

5.1 固定取最右元素的劣化场景

前面的朴素版取nums[right]作为基准,这在某些特殊输入上会非常吃亏。假设输入数组已经是一个升序数组,比如[1, 2, 3, 4, 5, 6],每次取最右元素作为 pivot,它恰好是整个区间的最大值。这样 partition 完成后,pivot 被放到最右端,左半部分几乎包含所有其他元素,每次递归几乎只能排除一个元素,复杂度直接退化到 O(n^2)。

这种情况在 Leetcode 的测试数据中确实存在,例如某些精心构造的例子专门让你“用最右基准”时超时。这也是我在实际提交中碰到过一次超时后真正重视起来的问题——光会写朴素版是不够的,必须在基准选择上做文章。

5.2 随机化基准:最简单的工程解法

最常用且最容易实现的优化就是随机选择基准。方法是生成一个[left, right]范围内的随机下标,然后把该位置的元素和nums[right]交换,之后的逻辑完全不变。这样任何固定的数据分布都不可能稳定地让 partition 每次都取到最坏情况,期望复杂度就被“打散”到了 O(n log n)(快排)或 O(n)(快选)。

C++ 实现时可以用<random>库,也可以直接用rand(),但要注意不同编译器下rand()的可移植性。我一般用std::mt19937配合uniform_int_distribution,这样随机性更均匀,向量子化的担心也少一些。

#include <random> int randomPartition(vector<int>& nums, int left, int right) { static mt19937 gen(random_device{}()); uniform_int_distribution<int> dist(left, right); int pivotIndex = dist(gen); swap(nums[pivotIndex], nums[right]); return partition(nums, left, right); }

5.3 三数取中:不依赖随机数也有效的策略

如果你不想依赖随机数生成器,另一个经典方案是三数取中(median-of-three):取nums[left]、nums[mid]、nums[right]三者的中间值作为基准。这个策略对近似有序的数据特别有效,因为它避免了取到最大值或最小值的极端情况。

int medianOfThree(vector<int>& nums, int left, int right) { int mid = left + (right - left) / 2; if (nums[left] > nums[mid]) swap(nums[left], nums[mid]); if (nums[left] > nums[right]) swap(nums[left], nums[right]); if (nums[mid] > nums[right]) swap(nums[mid], nums[right]); // 此时 nums[mid] 是三者中位数 swap(nums[mid], nums[right]); return partition(nums, left, right); }

实际上,很多 C++ 标准库的排序实现都会综合使用多种优化策略。我们在刷题层面,用随机化就足以应对 Leetcode 的测试了。不过三数取中在某些嵌入式或线上环境(不允许引入随机数)中更实用,值得掌握。

5.4 双指针交换法 partition 的另一种写法

除了前面那种“快慢指针”式的 partition,还有一种常见写法是双指针从两端向中间走:左指针找大于 pivot 的元素,右指针找小于 pivot 的元素,找到后交换。这种写法在某些场景下交换次数更少,读起来也更符合“左右开弓”的直觉。

int partitionTwoPointer(vector<int>& nums, int left, int right) { int pivot = nums[left]; while (left < right) { while (left < right && nums[right] >= pivot) --right; nums[left] = nums[right]; while (left < right && nums[left] <= pivot) ++left; nums[left] = nums[right]; // 你可能会觉得这行有 bug,仔细看,这是挖坑填数的核心 } // 实际标准写法的最后一步是 nums[left] = pivot nums[left] = pivot; return left; }

等等,上面这段我故意留了个小坑,因为挖坑填数法很容易写错。正确版本应该是:先用一个变量保存 pivot,然后右指针找到小于 pivot 的数字填到左指针的位置,左指针找到大于 pivot 的数字填到右指针的位置,最后左右指针相遇时把 pivot 填回去。我平时更推荐快慢指针的写法,因为它不容易出错、更好调试。双指针写法适合你已经对 partition 非常熟练之后再去掌握,不然容易把自己绕晕。

6. 那些年我踩过的坑:重复元素、边界条件和复杂度

6.1 重复元素会造成死循环吗

一个常见问题是:如果数组里大量重复元素,比如[2, 2, 2, 2, 2],partition 会不会陷入死循环?答案是:如果你用的是“小于等于 pivot 换到前面”的写法,重复元素会被均匀地分布在 pivot 两侧,partition 照样能正常返回一个位置。我的实现里用的是nums[j] <= pivot这个条件,所以重复元素不会导致死循环。

但如果你的 partition 写成“只有小于 pivot 才交换,等于不交换”,重复元素就可能全部堆在 pivot 一侧,导致区间划分极度不平衡。所以在实现中要留意这个细节:当 pivot 存在大量重复值时,等值元素必须能被分配到两侧,而不是总停留在同一侧。

6.2left <= right还是left < right

循环版快速选择的边界条件是很多人容易写错的地方。我的习惯是:

  • 初始区间是[0, n-1],只要left <= right就继续循环;
  • 在left > right的情况下理论上不该出现,因为一定存在一个target位置在[0, n-1]范围内,循环迟早会收敛到pos == target;
  • 但如果你写的是while (left < right),那可能在left == right时遗漏最后的检查。稳妥起见,我推荐用while (true)并在内部区分三种情况,这样最直观,也不会出边界错误。

递归版的边界条件同样如此:如果pos < target就处理右边,如果pos > target就处理左边,递归调用一定能保证最终收敛,因为区间每次都在缩小。

6.3 最坏情况 O(n^2) 的典型输入

我前面提到升序数组会绑架固定取末尾 pivot 的写法,这里再多说一句:不仅是升序,降序数组取末尾元素同样会退化成 O(n^2)。如果排序算法的面试题考察你“是否了解快排的弱点”,这类退化输入几乎是必问的。

随机化可以极大降低最坏情况的概率,但理论上随机化也不能完全消除最坏情况。工程上还有一种更彻底的思路:BFPRT(中位数的中位数)算法,它通过精心挑选 pivot 保证严格 O(n) 最坏复杂度,但实现复杂度高,常数项也大,在刷题场景很少用。知道这个概念就足够了。

6.4 实测中的一次超时经历

我自己第一次提交第 215 题时,用的是固定最右 pivot 的朴素版,结果在一个大数组用例上直接超时。当时的第一反应是“怎么可能 O(n) 也会超时”,后来一分析才发现,那个用例很可能是特意构造的近乎有序的数据,让朴素版退化成了 O(n^2)。

换成随机化版本后,提交立刻就过了。那次之后我在所有涉及快排/快选的题目里都会默认使用随机基准,不再纠结“是不是这个用例没那么坏”。这个教训也提醒我:分析复杂度一定要考虑最坏情况,不能只盯着平均情况自作多情。

7. 从第 215 题延伸出去的三个思考

7.1 堆方案与快速选择的对比选型

除了快速选择,第 215 题的另一个经典解法是小顶堆。我用一张表把两种方案的适用场景梳理一下:

方案时间复杂度空间复杂度适用场景
排序后取下标O(n log n)O(log n)代码最短,但只适合小数据
小顶堆O(n log k)O(k)数据流/超大数组,只需要维护前 k 个
快速选择期望 O(n),最坏 O(n^2)O(log n)一次性数组,追求速度,内存可容纳全部数据

如果数据是流式的,或者 n 无限大而 k 较小,堆方案是首选。如果数据已经全部在内存里,且你不想引入额外 O(k) 的开销,快速选择更合适。我面试时通常会先说“两种都可以”,然后再根据面试官给出的约束条件选一个往下展开,这比一上来就写代码更能体现思考层次。

7.2 标准库函数std::nth_element的底层就是快选

C++ 标准库提供了一个直接可用的函数std::nth_element,它的语义就是把第 n 个位置上的元素放到它排序后应该在的位置,左边都小于等于它,右边都大于等于它——本质上就是快速选择。它的平均复杂度也是 O(n)。

在允许使用标准库的工程场景中,我会直接用它来替代手写快速选择,因为标准库实现针对当前编译器和指令集做了很多优化,性能往往比自己写的强。但作为面试准备,你还是得能手写底层逻辑,不然面试官问一句“nth_element怎么实现的”就直接露怯了。

std::nth_element(nums.begin(), nums.begin() + n - k, nums.end()); return nums[n - k];

7.3 从“找第 k 大”到“找 Top K”的转化

第 215 题解决了“第 k 大”的定位问题,而 Top K 问题经常会要求返回前 k 个最大(或最小)的元素,不要求这 k 个元素内部有序。此时快速选择同样适用:做一次快选把target = n - k的元素定位好,它左侧所有元素就是前 k 个最大(无序)。复杂度依然是 O(n),而最简单的排序方案需要 O(n log n)。

如果你要求返回的前 k 个元素必须有序,那可以在快选之后对左侧区间再排一次序,总复杂度 O(n + k log k),这在 k 远小于 n 时比全排序划算很多。这个组合思路在真实业务中也非常常见,比如从海量日志中找到最热的 k 个关键词。

8. 几个亲手验证过的代码细节与建议

8.1 使用swap前别忘记包含头文件

因为第 215 题代码很短,很多人会顺手省略头文件,结果在本地编译时报错swap不是标准库函数。其实只要包含<vector>,有些编译器会顺带把std::swap暴露出来,但不保证所有环境都这样。稳妥做法是显式包含<algorithm>或<utility>,这样在任何标准 C++ 编译器下都能跑。

8.2 注意n - k下标的隐式类型问题

nums.size()返回的是size_t,是无符号整数;k是int。如果直接写int target = nums.size() - k;,当nums.size()小于k时会发生无符号整数下溢,变成一个巨大的正数。虽然题目保证k合法,但如果你把代码内部复用到其他场景,这里就是一个隐患。我习惯先转成int n = nums.size();再计算,避免混用无符号和有符号数。

8.3 递归栈溢出不是玩笑

虽然快速选择的期望递归深度是 O(log n),但在极端退化情况下深度会变成 O(n)。如果你在面试中写递归版,然后面试官问“如果输入特别糟糕怎么办”,随机化是答案之一,但更好的预防方案是直接用循环版本。我平时给出的推荐版本就是第 4 部分的while(true)循环代码,因为它没有递归栈风险,也不会在极端输入下爆栈。

8.4 关于“等号”写在哪里,我想多说两句

partition 里的比较条件nums[j] <= pivot和nums[j] < pivot看似差别不大,但影响不小。使用<时,等于 pivot 的元素不会通过交换进入前段,导致 pivot 的等值元素全挤到某一侧,遇到大量重复元素时容易让区间划分失衡。使用<=是最稳妥的选择,这也是我推荐用<=的原因。

如果想再优化一点,可以使用类似三路快排的思路,把等于 pivot 的元素单独放在中间区,这样重复元素多时的性能也不会退化,但代码复杂度会高不少。第 215 题用双路划分已经足够了。

8.5 做题时的几个常见误区总结

  • 把第 k 大当成第 k 小,直接返回nums[k],结果错误;
  • 忘记把k转换成下标n - k,导致始终在错误区间查找;
  • partition 写完没有把 pivot 放回最终位置,导致后续区间划分错乱;
  • 递归版本在left > right时没有保护,出现越界访问。这些错误我一个一个都犯过,写这篇文章时反复对照代码检查,才意识到基本都是“边界条件不清晰”和“枢轴最终位置理解不到位”这两个原因。

9. 最后分享一点个人做题心得

第 215 题是我很早就想透彻的一道题,因为它串联了排序、分治、复杂度分析、随机化这几个非常重要的算法主题。复盘下来,我觉得最值得记住的一句话是:不要只背快速选择的代码,要理解“为什么每次只需要递归一侧”。这个“单侧递归”的思想还能用在很多地方,比如二分查找的变体、矩阵中的搜索、甚至某些分治优化问题上。

如果你现在正在刷 Leetcode 备战面试,我的建议是:这道题至少手写三遍。第一遍直接写朴素版,跑通后故意用近乎有序的大数据测试一次,感受一下超时;第二遍改成随机化版本,对比两种写法的时间差异;第三遍把循环版和递归版都写一遍,确保自己能在白板上不假思索地写出来。这样三轮下来,你对 partition 和复杂度模型的理解会比单纯背代码深得多。

另外,如果你在面试中被问到这道题,除了给出解法之外,可以主动提一下“堆方案适合流式数据”“随机化消除最坏依赖”“std::nth_element的底层就是快选”这三个扩展点。这会让面试官觉得你不只是会刷题,而是对工程选型有真实的思考。我在模拟面试中带过几个同学,能说到这一层的,印象分往往明显更好。

最后一个小技巧:快速选择不仅能解决第 215 题,还经常被用在“找中位数”“寻找第 k 小的距离对”等问题中。如果你把 partition 的代码封装成一个内部工具函数,遇到这些变体题时就能直接复用,省下很多调试时间。祝你能在一次次的“手动走数组”中,真正把快速排序的分治直觉内化成自己的算法肌肉记忆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询