LeetCode-912:排序数组,手写快排和归并排序,面试必须能默写的两个模板
本题在线练习:LeetCode 912. 排序数组 — 在线练习(免费 · 无需登录 · AI 辅助)
配套刷题网站 Zero2Leetcode —— 内置本地 OJ + AI 教练,零门槛开刷 Hot 100。
题目概述
给定一个整数数组 nums,将其升序排列后返回。
要求时间复杂度为 O(n log n),不能使用内置排序函数。
例如:
- 输入:
nums = [5,2,3,1]→ 输出:[1,2,3,5] - 输入:
nums = [5,1,1,2,0,0]→ 输出:[0,0,1,1,2,5]
这道题看起来简单,但它是手写排序算法的标准考察题。字节面试中常考手写 partition,要求非常熟练。面试官通常要求你现场写出快速排序或归并排序,而不是调用 sort()。
核心思路
这道题有两种主流解法:
-
快速排序(Quicksort):选一个基准值(pivot),把数组分成"比 pivot 小"和"比 pivot 大"两部分,然后递归排序。平均 O(n log n),但最坏情况 O(n^2),需要随机化 pivot 来避免。
-
归并排序(Mergesort):把数组从中间切成两半,分别排序,然后合并两个有序数组。稳定保证 O(n log n),但需要额外空间。
两种方法都需要掌握。下面分别实现。
解法一:快速排序(随机化 pivot)
为什么需要随机化 pivot?
如果每次都选固定位置(比如第一个或最后一个元素)作为 pivot,当输入数组已经有序或接近有序时,partition 每次只能切掉一个元素,递归深度退化为 O(n),总时间退化为 O(n^2)。
随机选择 pivot,期望情况下每次都能把数组切成大致相等的两半,保证平均 O(n log n)。
代码
import random
from typing import List
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
def quicksort(left: int, right: int) -> None:
if left >= right:
return
# 随机选 pivot,避免最坏情况
pivot_idx = random.randint(left, right)
nums[pivot_idx], nums[right] = nums[right], nums[pivot_idx]
pivot = nums[right]
# Lomuto partition
store = left
for i in range(left, right):
if nums[i] < pivot:
nums[store], nums[i] = nums[i], nums[store]
store += 1
nums[store], nums[right] = nums[right], nums[store]
quicksort(left, store - 1)
quicksort(store + 1, right)
quicksort(0, len(nums) - 1)
return nums
逐行拆解
递归出口:
if left >= right:
return
只剩 0 个或 1 个元素时,不需要排序。
随机选 pivot 并放到末尾:
pivot_idx = random.randint(left, right)
nums[pivot_idx], nums[right] = nums[right], nums[pivot_idx]
pivot = nums[right]
随机选一个位置作为 pivot,把它交换到最右边。这样 partition 的逻辑可以统一:扫描 [left, right-1],pivot 固定在 right 位置。
Lomuto partition:
store = left
for i in range(left, right):
if nums[i] < pivot:
nums[store], nums[i] = nums[i], nums[store]
store += 1
nums[store], nums[right] = nums[right], nums[store]
store 指向"下一个应该放小于 pivot 元素"的位置。扫描过程中:
- 如果
nums[i] < pivot,把它换到store位置,store右移 - 扫描完后,
nums[store]的位置就是 pivot 的最终归宿,把 pivot 换回来
partition 完成后:
[left, store-1]的元素都 < pivotnums[store]== pivot[store+1, right]的元素都 >= pivot
递归排序两侧:
quicksort(left, store - 1)
quicksort(store + 1, right)
pivot 已经在正确位置,不需要再动。只递归处理左右两部分。
手动模拟
以 nums = [5, 2, 3, 1] 为例:
第 1 轮:left=0, right=3
假设随机到 pivot_idx=2,pivot=3。交换后数组变为 [5, 2, 1, 3](pivot 3 在末尾)。
Lomuto partition(store=0,扫描 i=0,1,2):
| i | nums[i] | < pivot(3)? | 操作 | store | 数组状态 |
|---|---|---|---|---|---|
| 0 | 5 | 否 | 不动 | 0 | [5, 2, 1, 3] |
| 1 | 2 | 是 | swap(0,1) | 1 | [2, 5, 1, 3] |
| 2 | 1 | 是 | swap(1,2) | 2 | [2, 1, 5, 3] |
把 pivot 放回:swap(store=2, right=3) → [2, 1, 3, 5]
pivot 3 在下标 2,位置正确。
递归左半 [2, 1]:排序后 → [1, 2]
递归右半 [5]:只有一个元素,不需要排序。
最终结果:[1, 2, 3, 5]。
解法二:归并排序(稳定 O(n log n))
思路
归并排序的核心是"分治":
- 分:把数组从中间切成两半
- 治:递归排序左半部分和右半部分
- 合:把两个有序数组合并成一个有序数组
合并过程用双指针,依次比较两个有序数组的头部元素,把较小的放入结果。
代码
from typing import List
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
def mergesort(arr: List[int]) -> List[int]:
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = mergesort(arr[:mid])
right = mergesort(arr[mid:])
return merge(left, right)
def merge(a: List[int], b: List[int]) -> List[int]:
result = []
i = j = 0
while i < len(a) and j < len(b):
if a[i] <= b[j]:
result.append(a[i])
i += 1
else:
result.append(b[j])
j += 1
result.extend(a[i:])
result.extend(b[j:])
return result
return mergesort(nums)
逐行拆解
递归出口:
if len(arr) <= 1:
return arr
空数组或只有一个元素的数组,天然有序,直接返回。
分割:
mid = len(arr) // 2
left = mergesort(arr[:mid])
right = mergesort(arr[mid:])
从中间切开,递归排序左右两半。切片 arr[:mid] 和 arr[mid:] 创建了新数组,所以归并排序不是原地排序。
合并两个有序数组:
i = j = 0
while i < len(a) and j < len(b):
if a[i] <= b[j]:
result.append(a[i])
i += 1
else:
result.append(b[j])
j += 1
result.extend(a[i:])
result.extend(b[j:])
双指针同时扫描两个有序数组,每次取较小的放入结果。某一边先扫完后,另一边剩余元素直接追加(它们本身已经有序且都比 result 中已有的元素大)。
注意用 <= 而不是 <,这保证了归并排序的稳定性——相等元素保持原来的先后顺序。
手动模拟
以 nums = [5, 2, 3, 1] 为例:
[5, 2, 3, 1]
/ \
[5, 2] [3, 1]
/ \ / \
[5] [2] [3] [1]
\ / \ /
[2, 5] [1, 3]
\ /
[1, 2, 3, 5]
合并 [5] 和 [2]:比较 5 和 2,2 小 → [2];剩余 [5] 追加 → [2, 5]
合并 [3] 和 [1]:比较 3 和 1,1 小 → [1];剩余 [3] 追加 → [1, 3]
合并 [2, 5] 和 [1, 3]:
| 步骤 | a[i] | b[j] | 取谁 | result |
|---|---|---|---|---|
| 1 | 2 | 1 | b[0]=1 | [1] |
| 2 | 2 | 3 | a[0]=2 | [1, 2] |
| 3 | 5 | 3 | b[1]=3 | [1, 2, 3] |
a 剩余 [5] 追加 → [1, 2, 3, 5]。
快速排序 vs 归并排序:怎么选?
| 对比维度 | 快速排序 | 归并排序 |
|---|---|---|
| 平均时间 | O(n log n) | O(n log n) |
| 最坏时间 | O(n^2)(随机化后极少发生) | O(n log n)(稳定保证) |
| 空间 | O(log n)(递归栈) | O(n)(合并时需要额外数组) |
| 稳定性 | 不稳定 | 稳定 |
| 实际速度 | 通常更快(常数小,缓存友好) | 常数稍大 |
面试中的建议:
- 如果面试官要求 O(n log n) 且不能最坏退化,写归并排序更安全
- 如果面试官问"怎么优化快排",答案是随机化 pivot
- 字节面试常考手写 partition,需要非常熟练。把 Lomuto partition 的模板背下来,面试时直接写
复杂度分析
快速排序
- 平均时间复杂度:O(n log n)
- 最坏时间复杂度:O(n^2)(随机化后概率极低)
- 空间复杂度:O(log n)(递归栈深度,平均情况)
归并排序
- 时间复杂度:O(n log n)(最好、最坏、平均都一样)
- 空间复杂度:O(n)(合并时需要创建临时数组)
总结
这道题是排序算法的基础考察,面试中几乎必考。两种解法都要掌握:
快速排序的关键点:
- 随机选 pivot,避免有序输入导致 O(n^2) 退化
- Lomuto partition 模板:
store指针记录分界线,扫描一遍完成划分 - partition 完成后 pivot 在最终位置,递归处理两侧
归并排序的关键点:
- 先递归拆分到最小,再两两合并
- 合并用双指针,
<=保证稳定性 - 不是原地排序,需要额外 O(n) 空间
面试时建议先问清面试官想看哪种排序。如果没有特殊要求,快速排序(带随机化)是最常见的选择;如果强调稳定性或最坏复杂度,就写归并排序。

浙公网安备 33010602011771号