如果你在力扣上刷到第75题“颜色分类”,第一反应大概率是“这不就是排序吗”——但等你真去提交,就会发现事情没那么简单。这道题不能用库函数、要求原地修改、还希望你用一趟扫描加常数空间解决,本质上考的是快速排序里的分区思想(partition),更准确地说,是三路快排的“荷兰国旗”写法。而紧接着的第912题“排序数组”,直接逼着你手写一遍完整可用的快速排序,不能调 sort(),不能偷懒。这两道题放在一起刷,正好能把分治思想从“看懂”变成“会用”。
这篇文章就是围绕这两道题写的实战拆解。适合正在刷力扣热题100、准备算法面试,或者学过快排但从来没自己手写过一遍完整实现的人。我会把荷兰国旗分区的执行过程逐行讲透,把快排的递归结构拆开看,再聊复杂度、边界条件和刷题顺序。读完你不仅能过这两道题,拿到215、347、148这些变体题时,也会知道从哪里下手。
1. 颜色分类的隐藏考点:从荷兰国旗到三指针分区
1.1 题目背后的三类解法对比
题目给了你一个只包含 0、1、2 的数组,分别代表红色、白色、蓝色,要求原地排序,让所有 0 在前、1 居中、2 在后。不能调库函数,进阶要求是“仅使用常数空间的一趟扫描算法”。
我第一次见到这题时,脑子里冒出来的是计数排序:扫一遍统计 0、1、2 的出现次数,再按次数把数组重写一遍。这个做法的时间复杂度是 O(n),空间复杂度是 O(1),完全是合法解。
但为什么面试官通常不满意这个答案?因为“扫描两遍”这件事,在工程里意味着你必须遍历两次数组。第一遍统计,第二遍回填。而一趟扫描的方案,只需要遍历一次,在遍历过程中通过交换把每个元素放到该去的位置。这听起来只是常数级的时间差异,但在题目设计者眼里,这代表你有没有真正理解“分区”这个动作,而不是仅仅会用排序。
| 解法 | 时间复杂度 | 空间复杂度 | 遍历次数 | 是否原地 |
|---|---|---|---|---|
| 计数排序 | O(n) | O(1) | 两趟 | 是 |
| 普通双指针 | O(n) | O(1) | 一趟 | 是 |
| 三指针(荷兰国旗) | O(n) | O(1) | 一趟 | 是 |
普通双指针也可以一趟完成,但需要先确定一个“基准值”的边界,比如先把 0 归位,再处理 1 和 2。这要两轮循环。三指针方案才是真正的一遍搞定,也是快速排序三向切分的雏形。
这题的官方标签是“数组、双指针、排序”,但很多人忽略了一个关键信息:它的本质,就是快排 partition 的一个特例——选定一个基准 1,把小于基准的放左边,等于基准的放中间,大于基准的放右边。你一旦看懂这个,后面做912题时,三路快排的代码就是照着这个思路扩展出来的。
1.2 三指针代码的逐行拆解
先直接给出我最终提交的版本:
python复制class Solution:
def sortColors(self, nums: List[int]) -> None:
n = len(nums)
p0 = 0
p2 = n - 1
i = 0
while i <= p2:
if nums[i] == 0:
nums[i], nums[p0] = nums[p0], nums[i]
p0 += 1
i += 1
elif nums[i] == 1:
i += 1
else: # nums[i] == 2
nums[i], nums[p2] = nums[p2], nums[i]
p2 -= 1
这里有三个指针:
p0指向下一个0应该放置的位置,初始为0。p2指向下一个2应该放置的位置,初始为n-1。i是当前扫描指针,从0往右走。
核心逻辑可以概括成一句话:i 每遇到一个数,就看它是 0、1 还是 2。是 0 就扔到左边 p0 的位置,是 2 就扔到右边 p2 的位置,是 1 就什么都不做,继续往前走。
为什么遇到 0 交换后 i 可以放心加一?因为从左边换过来的元素,要么是 0 要么是 1。p0 始终在 i 的左侧或等于 i 的位置,它指向的区域已经被扫描过了,所以换过来的元素不可能大于 1,不需要再检查。
为什么遇到 2 交换后 i 不能直接加一?因为从 p2 换过来的元素我们没有见过,它可能是 0,也可能是 1,还可能是 2。如果直接跳过,就会漏处理。所以 while 循环要在这里继续检查 nums[i] 的新值。
1.3 双指针解法的一个补充视角
除了三指针,还有一种思路是先把 0 归位,再把 1 归位。代码如下:
python复制class Solution:
def sortColors(self, nums: List[int]) -> None:
n = len(nums)
p = 0
for i in range(n):
if nums[i] == 0:
nums[i], nums[p] = nums[p], nums[i]
p += 1
for i in range(p, n):
if nums[i] == 1:
nums[i], nums[p] = nums[p], nums[i]
p += 1
这个方案是两趟扫描,但代码极好理解。第一趟把所有 0 换到头部,第二趟把剩下的 1 换到 0 区间的后面。2 自然就沉到尾部了。
它比三指针好懂,但有个缺陷:0 换过去的时候,会把原本在左部的 1 交换到中间来,第二轮需要再检查一次。所以两轮循环是必须的。三指针方案本质上是在一轮内同时完成这两个任务,代价是逻辑稍微绕一点。
我自己的建议是:第一步先把同一轮的双指针版本写对,能 AC;然后趁热打铁,把三指针版本也默写一遍。因为后面学快排时,三路快排的核心就是三指针,你提前在75题里把 lt、i、gt 三个指针的移动规则摸熟了,912题的三路快排会非常顺畅。
1.4 为什么说这题就是快排的一次划分
快速排序的 partition 有很多种写法:单边循环法(Lomuto)、双边循环法(Hoare),以及三向切分。常规的快排是“小于基准放一边,大于基准放另一边”,而三向切分是“小于基准、等于基准、大于基准”三块区域。
颜色分类有一个天然的优势:基准值已经定死了,就是 1。所有 0 是小于基准的,所有 1 是等于基准的,所有 2 是大于基准的。不需要你做任何数值比较以外的判断。
所以一次 partition 做完,整个数组就排好了。而对一个包含大量重复元素的随机数组做三路快排时,每一层递归里,等于基准值的那一堆元素,都可以原地不动、不再参与递归。这就是三路快排在处理海量重复数据时,能接近 O(n) 的原因。
这个认知很重要。你先在颜色分类里看到了“一次划分就完成排序”的特例,再去看排序数组的三路快排,就容易理解为什么我们要把等于 pivot 的区域单独隔出来,而不是让它们继续参与左右递归。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序数组:手写快速排序的三种姿势
2.1 手写快排前,先把分治的三步走想明白
912题“排序数组”的要求简单粗暴:给你一个整数数组,升序排序,不能调库函数。时间要求是 O(n log n),空间尽量小。这就意味着冒泡、插入、选择这些 O(n²) 的排序在较大数据量下会超时。归并排序可以过,但需要额外的 O(n) 空间。真正符合“平均 O(n log n)、原地排序、空间开销小”这三个条件的,最典型的就是快速排序。
你要是问我面试时最常考什么,我的观察是:手写快排出现的频率远高于手写归并。原因很简单,快排的 partition 本身就是很多题的解题核心(快速选择、TopK、颜色分类都有它的影子),一道面试题往往同时考察了递归、分治、指针操作和边界处理,信息密度非常高。
写快排之前,脑子里一定要有分治三步走:
- 分解:选一个基准
pivot,把数组分成三块(或两块)——小于基准的、等于基准的、大于基准的。 - 解决:递归处理小于基准的部分和大于基准的部分。
- 合并:这一步对快排来说什么都不用做,因为所有交换都在原数组上完成,分区结束后数组已经部分有序,递归完全结束后整体有序。
归并排序的分治是“先递归排序左右子数组,然后合并”,所以它需要一个额外的临时数组。快排的分治是“先分区,再递归”,不需要额外数组,省空间的代价是排序不稳定。什么是稳定?就是相同的两个元素,排序后相对顺序保持不变。快排做不到,归并可以。这一点面试里经常被追问,提前记住。
2.2 经典快排:先按最简单的模板跑通
第一版可以先写最简单的分区逻辑:每次取子区间的最后一个元素当 pivot,然后用两个指针把数组分成“小于等于 pivot”和“大于 pivot”两部分。
我最早用的模板是这样的:
python复制class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
def partition(l, r):
pivot = nums[r]
i = l - 1
for j in range(l, r):
if nums[j] <= pivot:
i += 1
nums[i], nums[j] = nums[j], nums[i]
nums[i + 1], nums[r] = nums[r], nums[i + 1]
return i + 1
def quick_sort(l, r):
if l >= r:
return
p = partition(l, r)
quick_sort(l, p - 1)
quick_sort(p + 1, r)
quick_sort(0, len(nums) - 1)
return nums
这个模板对应的是 Lomuto 分区法。它的核心是维护一个“小于等于 pivot 的区域”,i 指向这个区域的最后一个位置。从左到右扫描,遇到比 pivot 小的元素,就扩大这个区域,把当前元素换进来。扫描结束后,把 pivot 换到区域后一格,pivot 就到达了最终位置。
这个写法的优点是代码短、不容易写错,特别适合第一次手写。缺点是如果数组里重复元素很多,或者数组已经有序,性能会退化得很厉害。
同样,Hoare 分区法是另一种经典写法:
python复制def partition(l, r):
pivot = nums[l]
i, j = l, r
while i < j:
while i < j and nums[j] >= pivot:
j -= 1
nums[i] = nums[j]
while i < j and nums[i] <= pivot:
i += 1
nums[j] = nums[i]
nums[i] = pivot
return i
Hoare 的写法是从两端交替扫描,把找到的逆序元素填到另一侧的坑里,最后把 pivot 放回 i 和 j 相遇的位置。这种写法的交换次数更少,跑起来通常比 Lomuto 快一点,但坑也多——边界条件、while 里的等号处理,稍不留神就会死循环或者越界。
我个人建议第一版先用 Lomuto 跑通,感受一下递归流程,然后再切换成 Hoare,防止面试时只会一种写法、换个人称就写不出来。
2.3 随机化 pivot:用很小的代价换掉最坏情况
上面两种写法在挑 pivot 时都选了固定位置:最后一个或第一个。这有一个致命问题:如果数组已经有序(升序或降序),每次分区都只分出一个元素,递归深度会变成 n,时间复杂度直接退化成 O(n²)。
你可能会觉得“力扣的测试数据怎么会那么坏”,但实际上912题的测试数据里就有近乎有序的用例,还有一些精心构造的重复数据。单纯用固定 pivot 可能直接超时。
解决办法很简单:随机从区间里取一个位置当 pivot,和最后一个元素(或第一个元素)交换,后续逻辑完全不变。
python复制import random
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
def quick_sort(l, r):
if l >= r:
return
p = random.randint(l, r)
nums[p], nums[r] = nums[r], nums[p]
pivot = nums[r]
i = l - 1
for j in range(l, r):
if nums[j] <= pivot:
i += 1
nums[i], nums[j] = nums[j], nums[i]
nums[i + 1], nums[r] = nums[r], nums[i + 1]
pos = i + 1
quick_sort(l, pos - 1)
quick_sort(pos + 1, r)
quick_sort(0, len(nums) - 1)
return nums
这几乎就是标准的“随机化快排”。随机选 pivot 并不能保证每次都不踩到最坏情况,但它把“遇到最坏情况”的概率降到了极低。在算法面试中,这已经是公认的实践,面试官问到“怎么优化快排”时,能脱口说出“随机化选 pivot”,而不是只会背模板,是很加分的。
2.4 三路快排:处理海量重复元素的关键
有了75题的底子,三路快排其实是顺水推舟。它的思想是把数组分成三块:[l, lt-1] 是小于 pivot 的,[lt, gt] 是等于 pivot 的,[gt+1, r] 是大于 pivot 的。递归时只需要处理左右两块,中间那堆等于 pivot 的元素直接跳过。
python复制import random
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
def quick_sort_3way(l, r):
if l >= r:
return
p = random.randint(l, r)
pivot = nums[p]
lt, i, gt = l, l, r
while i <= gt:
if nums[i] < pivot:
nums[i], nums[lt] = nums[lt], nums[i]
lt += 1
i += 1
elif nums[i] > pivot:
nums[i], nums[gt] = nums[gt], nums[i]
gt -= 1
else:
i += 1
quick_sort_3way(l, lt - 1)
quick_sort_3way(gt + 1, r)
quick_sort_3way(0, len(nums) - 1)
return nums
对比一下75题的三指针,你会惊讶地发现结构几乎相同。75题里 pivot 是隐性的 1,这里的 pivot 是随机选的;75题里要排的元素只有三种值,这里可能有成百上千种值,但每一层递归只需要把“等于 pivot”的那批元素从后续递归中剔除。
为什么要用三路快排处理重复元素?举个极端例子:数组里有10万个元素,其中9万个都是同一个数。经典快排把所有等于 pivot 的元素分散到左右两个子区间里,每次递归都要重复处理这些“相等元素”,效率很低;而三路快排第一次分区就会把那9万个相同元素全部归位,剩下的递归只处理其余1万个不同元素。
在912题的实际测试中,如果输入的测试数据包含大量重复元素,三路快排的运行时间通常明显优于经典快排。这也是为什么很多高级排序库(比如Java的 Arrays.sort() 对基本类型数组)在元素数量较大、重复较多时,会选择三路快排作为底层实现。
3. 从递归树看复杂度:O(n log n)和O(n²)之间只差一个pivot
3.1 理想折半时的时间复杂度推导
很多人背下了“快排平均时间复杂度是O(n log n)”,但问他为什么,说不上来。我建议从递归树的角度理解,一旦你画出这棵树,这个复杂度会变得非常直观。
假设每次分区都恰好把数组分成两半。第一层处理整个数组,需要做大小为 n 的分区比较,复杂度 O(n)。第二层有两个子数组,各 n/2,合计 O(n)。第三层是四个子数组,各 n/4,合计还是 O(n)。
递归树一共有多少层?因为每次规模折半,所以层数大约是 log₂n。每层的总工作量为 O(n)。总复杂度就是 层数 × 每层工作量 = O(n log n)。
这和归并排序的推导完全一致。归并排序保证了每次对半切,所以无论如何都是 O(n log n);快排的切分比例取决于 pivot 选得好不好,因此是“平均 O(n log n)、最坏 O(n²)”。
3.2 有序数组的最坏情况:退化成等差数列求和
最坏情况怎么触发的?你选固定的 pivot(比如最右元素),而数组恰好是升序排列的。比如 [1,2,3,4,5]。取最右元素 5 当 pivot,分区后 5 到了最右边,左边剩 [1,2,3,4]。下一轮又取最右元素 4 当 pivot……每一轮只排好一个元素,递归树变成了一根链,深度是 n。
每层的工作量分别是 n, n-1, n-2, ..., 1,加起来是 n(n-1)/2,也就是 O(n²)。
这就是为什么固定选 pivot 的快排,在数据近似有序时会慢到让人怀疑人生。而随机化方案会把这个概率打散,因为 pivot 在数组中的位置是随机的,几乎不可能每一层都选中极值。
顺带一提,面试里如果被问到“快排什么时候最慢”,标准答案有两类:一是数组本身已经有序/逆序,并且 pivot 固定取端点;二是数组中大量元素重复,并且分区算法把相等元素全部丢到同一侧。前者用随机化 pivot 解决,后者用三路快排解决。
3.3 空间复杂度、递归深度与栈溢出防护
快排的递归调用是在系统栈上进行的,所以它的额外空间主要是递归栈的开销。平均情况下,递归树高度是 log n,空间复杂度 O(log n);最坏情况下是 n,空间复杂度 O(n)。
在力扣的912题里,nums 长度最大是5万(旧版)或10万(新版),如果递归深度达到5万层,Python的默认递归深度(1000)早就栈溢出了。
怎么防?随机化 pivot 可以规避绝大多数情况,但还有一个更保险的工程化手段——只递归短的那半部分,长的部分在循环里继续处理。这样可以把递归深度压到 O(log n) 级别,也就是所谓的“尾递归优化”的精简版。思路是:每次分区得到 pos 后,判断左右两边哪个子区间更短,递归处理短的,然后 l 或 r 指向长的区间,继续循环。
python复制def quick_sort(lo, hi):
while lo < hi:
p = partition(lo, hi)
if p - lo < hi - p:
quick_sort(lo, p - 1)
lo = p + 1
else:
quick_sort(p + 1, hi)
hi = p - 1
这个操作在你日常刷题时未必用得上,但在面试里如果你能主动提出来“为了防止递归栈过深,我会优先递归短的子区间,把长的子区间留在循环中处理”,面试官一般会眼前一亮。这属于那种“知道的人不多、一说就显专业”的知识点。
4. 两道题连刷的实战排坑与后续延伸
4.1 颜色分类最容易错的三个细节
我在刷75题时反复栽过跟头的地方有三个,都记一下。
第一个是 while 的循环条件。很多人写 while i < n,这能过一些用例,但如果你在交换 2 的时候,i 和 p2 已经相遇,再往后 i 会跑到已经排好的 2 区域里,造成误交换。正确条件是 while i <= p2。因为 p2 右边全都是已经处理好的 2,i 一旦越过 p2,说明中间区域已经全部扫描完成。
第二个是遇到 0 时 i 要移动,遇到 2 时 i 不移动。这个不对称性很多人一开始记不住。原因我在前面提过:左边换过来的是已经检查过的元素,右边换过来的是还没检查过的元素。如果你在这两个分支上都写 i += 1,遇到 [2, 0, 1] 这种输入,第一个 2 会和末尾的 1 交换,然后 i 加一跳过 1,数组变成 [1, 0, 2],之后 i 走到 0 时会出问题。
第三个是 p0 和 i 的初始关系。p0 从 0 开始,i 也从 0 开始。当数组首位就是 0 时,交换 nums[i] 和 nums[p0] 是同一个位置的交换,看起来毫无意义,但这是保证逻辑完整性的必要动作。有些优化版本会加一个 if 判断避免自交换,但力扣上不必纠结这种微优化,代码可读性更重要。
4.2 手写快排时我反复确认的四个边界
写912题的快排,最容易出 bug 的地方集中在四个边界上。
第一,递归的结束条件。必须是 if l >= r: return,不能只写 if l == r。因为当 l > r 时(比如 partition 返回的 pos 恰好等于 l,那么右侧递归的 pos+1 就会大于 r),缺少 > 判断会导致无限递归或数组越界。
第二,partition 里扫描指针的移动。以 Hoare 写法的内层 while 为例,条件里的等号非常关键:nums[j] >= pivot 里的等号能保证所有等于 pivot 的元素都跳过,让两个指针不会因为相等的元素而卡住形成死循环。但如果你两边都加了等号,i 和 j 可能同时停在等于 pivot 的位置,导致循环无法结束。正确做法是让两边保持“一边大于等于、一边小于等于”的非对称条件。
第三,随机 pivot 与区间边界交换之后,pivot 值必须保存在局部变量里。因为你交换后,pivot 所在的位置可能会被子数组内的元素覆盖,如果你之后还去 nums[p] 取 pivot,取到的可能已经是交换过后的另一个值了。
第四,递归区间不要包含已经归位的 pivot。partition 返回的位置 pos 对应的元素已经在正确位置上,左右递归应该是 [l, pos-1] 和 [pos+1, r]。如果写成了 [l, pos],那 pivot 会反复参与排序,逻辑上不会死循环,但效率会差很多。
4.3 把分区思想迁移到215题“数组中的第K个最大元素”
刷完75和912,强烈建议马上去做215题。这题要求找出数组中第 K 个最大的元素,典型的 TopK 问题。能用的方法很多:堆排序、快速选择、甚至直接用 sort。但如果你刚写完快排,你会发现快速选择就是快排 partition 的“懒人版”。
核心思想是:一次 partition 之后,pivot 已经到达了它最终的位置。如果这个位置正好是 n - k,那 pivot 本身就是答案。如果这个位置小于 n - k,说明第 K 大的元素在右半段,只需要在右半段继续找;否则在左半段继续找。也就是说,不需要两边递归,只需要深入一侧。
python复制class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
n = len(nums)
target = n - k
l, r = 0, n - 1
while l <= r:
pivot = nums[r]
i = l - 1
for j in range(l, r):
if nums[j] <= pivot:
i += 1
nums[i], nums[j] = nums[j], nums[i]
i += 1
nums[i], nums[r] = nums[r], nums[i]
if i == target:
return nums[i]
elif i < target:
l = i + 1
else:
r = i - 1
return -1
这个快速选择的平均时间复杂度是 O(n),因为它每一轮只需要处理大约一半的元素:n + n/2 + n/4 + ... ≈ 2n。最坏情况同样是 O(n²),用随机 pivot 可以规避。这道题能帮你把“分治”从排序扩展到“查找”场景,理解的深度会完全不一样。
4.4 给刷题新手的顺序建议与复盘日志
最后聊一下刷题顺序的问题。很多新手一上来就刷“热题HOT 100”,从第一题“两数之和”开始,刷到第几十题就断了。我的建议是先把排序和分治这类“基础算法骨架”题刷扎实,再碰其他题目。
具体顺序上,我建议这样排:
- 先做75题颜色分类,把三指针分区跑通。这题短小精悍,非常适合作为“分区思想”的入门题。
- 再做912题排序数组,分别用经典快排、随机化快排、三路快排提交一遍。对比三种写法在运行时间上的差异。
- 接着做215题数组中的第K个最大元素,体会一遍快排的
partition如何直接用来查找。 - 然后可以延伸到148题排序链表,这题用归并排序是正统解法,正好帮你对比“分治在数组和链表上的不同实现”。
- 最后可以做347题前K个高频元素,这题可以用堆,也可以用快速选择,两种思路都值得实现一遍。
复盘时,给每一道题记录三个东西:我的第一版解法是什么、通过了没有、如果超时或报错,问题出在哪。用不了多长时间,但比闷头刷十道题管用得多。
我个人在刷这三道题时的体会是:快排的难点从来不是背代码,而是理解三个“为什么”——为什么要随机选 pivot,为什么三路快排能应对重复元素,为什么 partition 返回的位置可以直接用来找第 K 大。这三个问题想通了,你的分治基础基本上就立住了。接下来再碰到任何跟排序、查找、TopK 相关的题,你都会有一种“这题我见过”的踏实感。
