【大白笔记】快速排序
left = []for x in arr: # 遍历数组里的每一个数if x < pivot: # 如果这个数比基准小left.append(x) # 把它放进左边的小篮子里我不原地交换数据了,我直接准备三个新篮子(左、中、右),按大小分拣。在算法效率上,它们面临同样的风险如果数组已经是正序的,选第一个做基准会产生最差情况。如果数组已经是逆序的,选最后一个做基准也会产生最差情况。“既然选第一个和

1. 核心思想:分治与分区
快排的核心在于 Partition(分区) 操作:
- 选基准 (Pivot): 从序列中挑出一个元素作为“基准”。
- 分区 (Partition): 重新排序序列,所有比基准小的元素摆放在基准前面,大的摆在后面。
- 递归 (Recursive): 递归地对左右两个子序列进行快排。
方法一:Pythonic 简洁版(利用列表推导式)
这种写法极其简洁,非常适合在白板上手撕,因为它直接体现了“分治”的思想。
def quick_sort(arr):
# 基准情况:如果数组长度小于等于 1,直接返回
if len(arr) <= 1:
return arr
# 选定基准(Pivot),这里选中间值可以一定程度规避有序数组问题
pivot = arr[len(arr) // 2]
# 利用列表推导式进行分区
left = [x for x in arr if x < pivot] # 比基准小的
middle = [x for x in arr if x == pivot] # 与基准相等的(处理重复元素)
right = [x for x in arr if x > pivot] # 比基准大的
# 递归调用并拼接
return quick_sort(left) + middle + quick_sort(right)
1. 什么是列表推导式?
这行代码:left = [x for x in arr if x < pivot]
其实等同于下面这段普通的循环:
left = []
for x in arr: # 遍历数组里的每一个数
if x < pivot: # 如果这个数比基准小
left.append(x) # 把它放进左边的小篮子里
这种写法的核心思路是:我不原地交换数据了,我直接准备三个新篮子(左、中、右),按大小分拣。
为什么 AI 面试里这个写法很“取巧”?
这种写法被称为 “Non-in-place Quick Sort” (非原地快排)。
-
优点(手写神器): * 超级好写:你不需要管理
low,high,i,j这些容易出错的索引。 -
处理重复值极其优雅:通过
middle篮子,一次性解决了数组里有大量相同数字的问题(这是标准快排的一个难点)。 -
缺点(面试官可能会挑刺):
-
空间换时间:因为它每次递归都创建了新的列表(
left,right),所以它消耗的内存比原地交换版要多。
4. 关键点:递归的出口
代码的第一段:
if len(arr) <= 1:
return arr
这就像是递归的“刹车”。当篮子里只剩 1 个数或者没数时,说明不用再排了,直接原样返回。如果没有这行,程序会陷入无限死循环。
面试小贴士:
如果面试官问:“你这个写法空间复杂度挺高啊?”
你可以这样回答:
“是的,这个版本利用 Python 的特性,代码可读性更强。在处理小规模数据或对内存不敏感的场景下非常高效。如果需要处理海量数据,我可以改写成基于指针的原地排序版本以节省空间。”(这样显得你既懂高级语法,又懂底层优化!)
2. 手撕代码实现 (Python)
面试时建议写这种原地排序 (In-place) 的版本,因为它空间复杂度更低,更能体现算法功底。
def quick_sort(arr, low, high):
if low < high:
# 获取分区点索引
pivot_index = partition(arr, low, high)
# 递归排序左半部分
quick_sort(arr, low, pivot_index - 1)
# 递归排序右半部分
quick_sort(arr, pivot_index + 1, high)
def partition(arr, low, high):
# 选择最右边的元素作为基准 (Pivot)
pivot = arr[high]
# i 是“较小元素区域”的边界指针
i = low - 1
for j in range(low, high):
# 如果当前元素小于或等于基准
if arr[j] <= pivot:
i += 1
# 交换到较小区域
arr[i], arr[j] = arr[j], arr[i]
# 最后将基准元素换到中间(i+1 的位置)
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
这个 partition 函数实现的是快速排序中最核心的功能:原地分区 (In-place Partitioning)。
它的通俗定义是:以一个“基准点”为界,把数组像排队一样重新整队。 具体来说,它实现了以下三件事:
- 分堆:把所有小于等于基准(pivot)的数挪到左边。
- 归位:把基准(pivot)放到它最终应该在的正确位置上。
- 确界:返回基准的下标,告诉主函数:“这个位置已经排好了,以此为界切开,去排左边和右边吧。”
这个问题困扰很多人是正常的,因为 i = low - 1 看起来像是在操作“区间外面”的内存。
其实,i 并不是一个普通的下标,它是一个**“计数器”或者说“边界墙”**。
1. 核心原因:它是“空集”的表达方式
在还没开始比较之前,“小于等于枢轴的区域”里一个元素都没有。
- 如果你设置
i = low,意味着你默认“第low个位置已经是小数了”。 - 如果设置
i = low - 1,则代表:“目前还没有发现任何小数,这个区域的右边界在起始位置的左边(即:空集)。”
2. 逻辑上的“先加后换”
代码里的逻辑顺序是:
- 发现一个小元素。
- 把墙
i向右推一格(i += 1)。 - 把元素换到墙内。
如果你设置 i = low - 1,当发现第一个小元素时:
i变成low - 1 + 1 = low。- 元素被换到了
arr[low]。完美。
如果你设置 i = low,当发现第一个小元素时:
i变成low + 1。- 元素被换到了
arr[low + 1]。糟糕,你把第一个位置arr[low]给跳过了!
3. 现场模拟:i 和 j 的追逐
假设 arr = [5, 2, 8], low = 0, high = 2, pivot = 8。
-
初始:
i = -1(即low - 1)。 -
j = 0: 发现
arr[0](5) < 8。 -
i自增变为0。 -
交换
arr[0]和arr[0]。 -
此时
i停在 0,表示“下标 0 及其左边都是小数”。 -
j = 1: 发现
arr[1](2) < 8。 -
i自增变为1。 -
交换
arr[1]和arr[1]。 -
此时
i停在 1,表示“下标 1 及其左边都是小数”。 -
结束循环: 将
pivot换到i + 1即下标 2 的位置。
4. 形象比喻:插旗子
想象你在一个工地上(区间 low 到 high),你要用绳子围出一块地给“小数字”住。
- 刚开始: 你手里拿着绳子的木桩,站在工地大门外(
low - 1)。因为里面还没人。 - 发现一个小数字: 你往工地里走一步(
i += 1),把木桩钉下,把这个小数字拉到木桩左边。 - 总结: 如果你一开始就站在大门里第一步的位置(
low),那你还没开始干活,就已经占用了第一个病人的床位,这显然不合理。
5. 换种写法你能理解吗?
其实 i = low - 1 配合 i += 1 这种写法是为了代码简洁。如果你觉得难受,可以换一种完全等价但更直观的写法:
# 这种写法 i 直接从起点开始
i = low
for j in range(low, high):
if arr[j] <= pivot:
arr[i], arr[j] = arr[j], arr[i] # 先交换
i += 1 # 交换完后,把墙往右挪,指向下一个空位
# 此时最后 pivot 换到 i 的位置即可
arr[i], arr[high] = arr[high], arr[i]
return i
这种写法 i 的含义是:“下一个存放小数的空位”。虽然这种写法更好懂,但**面试中常见的、教科书上的标准写法(Lomuto Partition)都是用 low - 1**。
面试官考你这个 low - 1,其实是在考你:“你是否理解边界的初始定义(空集状态)”。
现在回想一下,你觉得是“墙外等候法 (low-1)”更顺手,还是“占住第一个坑位 (low)”更符合你的直觉?
1. 代码逻辑拆解:i 和 j 的双指针华尔兹
这段代码使用的是经典的 Lomuto 分区方案。我们可以把 i 和 j 想象成两个角色:
j(侦察兵):从头到尾扫描每一个元素。i(分界线):始终指向“已知的小于等于 pivot 区域”的最后一个位置。- 过程:当
j发现一个小的数时,i向右挪一步(腾出位置),然后把j发现的小数和i位置的数交换。 - 最后:把一直呆在末尾的
pivot换到i + 1的位置,这样它左边全是小的,右边全是大的。
2. 能不能选取第一个元素作为基准?
答案是:绝对可以! 如果你想把 pivot 设定为 arr[low](第一个元素),代码逻辑只需要做一点点微调。
方案 A:直接改逻辑(稍微复杂,容易出错)
你需要改变 i 和 j 的起始方向或者判断逻辑。
方案 B:最聪明、最推荐的“偷懒”法(面试推荐)
这是 AI 面试中的高情商写法:既然你已经会写“以最后一个元素为基准”的代码了,那就先把第一个元素和最后一个元素交换,然后直接复用原来的逻辑。
def partition(arr, low, high):
# --- 只要加这一行,就变成了“以第一个元素为基准” ---
arr[low], arr[high] = arr[high], arr[low]
pivot = arr[high] # 现在的 high 实际上是原来的 low
i = low - 1
for j in range(low, high):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
3. 选第一个 vs 选最后一个,有区别吗?
在算法效率上,它们面临同样的风险:
- 如果数组已经是正序的,选第一个做基准会产生最差情况 。
- 如果数组已经是逆序的,选最后一个做基准也会产生最差情况 。
面试官的深度追问:
“既然选第一个和最后一个都有风险,AI 领域处理大规模不确定数据时,你会怎么选?”
满分回答: “我会使用随机化基准。即先用
random.randint(low, high)随机选一个下标,把它和high交换,再执行后续逻辑。这样可以保证算法在统计意义上的平均时间复杂度始终为 ,不会被特定分布的数据(如已排序数据)‘坑’掉。”
在算法的世界里,“子数组”(Subarray) 是一个非常核心的概念。理解了它,你就理解了为什么快速排序能处理海量数据。
之所以都叫它“子数组”,主要有以下三个层面的原因:
1. 物理上:它们共享同一块内存
在“原地排序”(In-place)中,我们并没有真的把数组切断或者复印一份。
- 原数组是那一排连续的内存空间。
- 子数组只是我们通过
low和high这两个坐标,在原数组上画了一个框。 - 框里的内容就是我们要处理的“子数组”。框外的内容,在这个函数的这一层级里,我们假装看不见。
2. 逻辑上:递归就是“大任务变小任务”
快速排序采用的是 分治法 (Divide and Conquer)。
- 大任务:给整个数组
arr[0...n-1]排序。 - 拆分:选个基准点,把大数组分成两半。
- 小任务:给左边那一小段排序,给右边那一小段排序。
这两小段对于整个数组来说是局部,所以被称为“子数组”。随着递归不断深入,子数组会越来越短,直到只剩 1 个元素(即low == high),此时任务就完成了。
3. 语义上:为了区分边界
在代码中,partition 函数和 quick_sort_inplace 函数需要反复调用自己。如果不叫子数组,我们很难描述现在的进度。
- 第一层调用:处理的是
[0, 10](整个数组)。 - 第二层调用:处理的是
[0, 4](第一个子数组)和[6, 10](第二个子数组)。 - 第三层调用:处理的是
[0, 1](子数组的子数组)…
深度理解:为什么不叫“子序列”?
面试中如果能说出这一点,会显得你计算机基础非常扎实:
- 子数组 (Subarray):必须是连续的一段。快速排序处理的就是连续的内存块,效率极高。
- 子序列 (Subsequence):可以是不连续的(比如从数组里挑第 1、3、5 个元素)。
总结
在你的代码注释里,“子数组范围”其实就是在说:“现在的任务范围”。low 是这个范围的起点,high 是这个范围的终点。
举个生活中的例子:
假设有一排学生(原数组)。
老师说:“前 5 个同学站出来排一下队。” —— 这前 5 个同学就是子数组。
老师并没有把他们带到另一个教室,他们还在原来的位置附近活动,只是老师现在的注意力(low到high)只在他们身上。
理解了“子数组”的概念,你对代码中 i = low - 1 为什么不是 i = -1 是不是也豁然开朗了?(因为在子数组里,起始位置可能是数组中间的任何地方)。
def quick_sort_inplace(arr, low, high):#low和high是数组的下标,表示要排序的子数组范围
if low < high:#递归的终止条件
pivot_index = partition(arr, low, high)##划分数组并返回枢轴元素的最终位置
quick_sort_inplace(arr, low, pivot_index - 1)
quick_sort_inplace(arr, pivot_index + 1, high)
def partition(arr, low, high):#划分函数,返回枢轴元素的最终位置,low和high是数组的下标,表示要划分的子数组范围
pivot = arr[high]#选择最后一个元素作为枢轴
i = low - 1#i指向小于等于枢轴的元素的最后一个位置
for j in range(low, high):#j遍历子数组
if arr[j] <= pivot:#如果当前元素小于等于枢轴
i += 1#将i向右移动一位,表示有一个小于等于枢轴的元素 #交换arr[i]和arr[j]
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
if __name__ == "__main__":
arr = [3,6,8,10,1,2,1]
print("原始数组:", arr)
quick_sort_inplace(arr, 0, len(arr) - 1)
print("排序后数组:", arr)
简单来说:用户调用时不想传 low/high,但算法递归时必须有 low/high。
1. 到底谁才是“主函数”?
在实际面试或工程代码中,通常会有两个函数角色:
- 对外接口 (Public Interface):这是给别人用的。用户只传一个
arr,不需要知道什么下标。 - 递归实现 (Internal Recursive Function):这是真正干活的。它必须带上
low和high,因为只有这样它才能知道自己在处理哪一个子数组。
2. 为什么初始化要传 0 和 n-1?
当你第一次启动引擎时,你的目标是排序整个数组。
- 数组的第一个下标是
0。 - 数组的最后一个下标是
n-1(即len(arr) - 1)。
如果你不把这两个边界传进去,递归函数就不知道该从哪里开始“分拣”。
3. 标准的“封装”写法(面试推荐)
为了解决你的困惑,AI 面试时最专业的写法是将两者结合。这样你就明白了:
# --- 角色 1:真正的“主函数”(对外接口) ---
def quick_sort(arr):
n = len(arr)
# 第一次启动:范围是 0 到 n-1
_quick_sort_recursive(arr, 0, n - 1)
return arr
# --- 角色 2:内部递归函数(干苦力的) ---
def _quick_sort_recursive(arr, low, high):
if low < high:
# 分区,找到基准点
pivot_index = partition(arr, low, high)
# 【关键点】这里就开始处理“子数组”了
# 左边子数组:范围从原来的 low 到 pivot_index-1
_quick_sort_recursive(arr, low, pivot_index - 1)
# 右边子数组:范围从 pivot_index+1 到原来的 high
_quick_sort_recursive(arr, pivot_index + 1, high)
def partition(arr, low, high):
# 这里是具体的交换逻辑...(略)
...
4. 形象化理解:切蛋糕
想象你在切一块很长的奶油蛋糕:
- 初始化 (
0, n-1):老师对你说:“把整块蛋糕切好。” 这里的0是蛋糕左端,n-1是蛋糕右端。 - 第一次切开:你从中间切了一刀(
partition),现在蛋糕变成了左半段和右半段。 - 递归调用:老师接着说:“现在去切左半段。”
- 此时,对于左边那段来说,它的起始位置还是之前的
low。 - 但它的结束位置变成了刚才切口左边的那一点,即
pivot_index - 1。
如果不传入参数,函数每次只能看到一整块蛋糕,它就永远没法只针对其中的“某一段”进行精细操作了。
总结
low和high必须初始化:因为递归需要明确的“工作边界”。- 不传
low/high的写法:只能像之前那个 Pythonic 写法一样,不停地切开数组并产生新数组。那样的代价是频繁申请内存,性能较差。 - 原地排序的精髓:就是通过不断改变
low和high的数值,在同一块内存空间里完成所有排序。
现在你能理解为什么在递归调用时,参数变成了 (arr, low, pivot_index - 1) 而不再是 (arr, 0, n - 1) 了吗?
3. AI 岗位面试深度提问
面试官通常不会只让你写出代码,还会追问以下深度问题:
Q1: 为什么快排在实际应用中通常比归并排序快?
- 缓存友好: 快排是原地排序,访问的是连续内存,对 CPU 缓存(Cache)非常友好。
- 常数项小: 快排在交换元素时的比较次数通常比归并排序少。
Q2: 快排的最差情况是什么?如何优化?
- 最差情况: 当数组已经有序(正序或倒序)且每次选第一个/最后一个作为基准时,复杂度会退化到 。此时递归树变成了一条链。
- 优化方案: * 三数取中法: 取左端、中端、右端三个数,取中间值作为基准。
- 随机基准: 随机选择一个位置的数作为基准。
Q3: 快排是稳定的吗?
- 不稳定。 在交换元素的过程中,可能会改变相等元素的原始相对顺序。例如
[5, 5*, 2],经过分区后2可能会和第一个5交换。
4. 算法性能对比

这是一个非常敏锐的观察!在 AI 岗位的算法面试中,**“是否引入随机性”**往往是区分初级开发者和资深开发者的关键点。
我之前给出的代码是教科书式的标准实现。在实际工程(如 PyTorch 的底层实现或 C++ std::sort)中,通常会加入随机化逻辑。
1. 为什么“标准版”不需要随机化?
标准版是为了让你清晰地展示分治逻辑和指针移动过程。
- 默认约定: 默认取最后一个元素
arr[high]或第一个元素arr[low]为基准。 - 教学意义: 这样代码逻辑最纯粹,面试官一眼就能看出你是否掌握了
partition的核心逻辑。
2. 为什么要引入 random?(面试加分点)
如果没有随机化,快排有一个致命弱点:
如果输入数组是已经排好序的(例如 [1, 2, 3, 4, 5]),而你总是选最后一个数作为基准:
- 第一轮基准是 5,分区后左边有 4 个数,右边 0 个。
- 第二轮基准是 4,分区后左边有 3 个数,右边 0 个。
- …以此类推。

3. 加入随机化后的“手撕版”代码
在面试时,如果你能在写完基础版后,主动提出并修改为随机版,会显得你对算法鲁棒性有深入思考。
import random
def randomized_partition(arr, low, high):
# 1. 在 [low, high] 之间随机选一个索引
rand_idx = random.randint(low, high)
# 2. 将随机选到的基准交换到最后面 (与原逻辑接轨)
arr[rand_idx], arr[high] = arr[high], arr[rand_idx]
# 3. 接下来的逻辑与标准版完全一致
return partition(arr, low, high)
def partition(arr, low, high):
pivot = arr[high]
i = low - 1
for j in range(low, high):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
在快速排序中,low 和 high 是分治算法的核心控制变量。它们代表的是当前正在处理的数组子区间的“下标范围”。
简单来说,快排不是每次都处理整个数组,而是把大数组切成小段。low 和 high 就是用来标记当前这段“小数组”的开头和结尾。
1. 变量含义详解
low(左边界索引): 当前待排序子区间的第一个元素的下标。high(右边界索引): 当前待排序子区间的最后一个元素的下标。
2. 为什么需要这两个参数?
快速排序是原地排序 (In-place)。它并不创建新的数组,而是在原始数组上通过交换元素来排序。
- 如果不传
low和high: 函数就不知道该处理数组的哪一部分。 - 有了它们: 函数可以精确地知道:“哦,我现在只需要管从第
low到第high这一块地方,外面的不用动。”
3. 代码逻辑中的具体体现
在你的代码中,它们有三个关键用途:
A. 确定随机基准的范围
rand_idx = random.randint(low, high)
这保证了随机抽取的基准(Pivot)一定是从当前处理的这一段里选出来的,而不是从整个数组里乱选。
B. i 和 j 指针的初始化
i = low - 1 # i 代表“小于等于基准”区域的右边界,初始在区间左侧外面
for j in range(low, high): # j 从这段区间的开头扫到结尾(不含基准本身)
这里 j 必须从 low 开始,否则会重复处理已经排好序的其他区间。
C. 递归时的区间划分
在主逻辑(通常是调用 randomized_partition 的外层函数)中:
- 左半部分递归:
quick_sort(arr, low, pivot_index - 1) - 右半部分递归:
quick_sort(arr, pivot_index + 1, high)
4. 形象化例子
假设数组 arr = [10, 80, 30, 90, 40],长度为 5。
- 第一次调用:
low=0,high=4。处理全量数据。 - 分区后: 假设 40 是基准,分成了
[10, 30]和[80, 90]。 - 递归左边: 调用时
low=0,high=1。此时函数里的逻辑只会在arr[0]和arr[1]之间发生。 - 递归右边: 调用时
low=3,high=4。此时函数里的逻辑只会在arr[3]和arr[4]之间发生。
面试官可能会问:
“如果 low >= high 说明了什么?”
答: 说明当前区间只有一个元素或者为空,不需要再排序了,这也是递归的终止条件。
4. 深度追问:AI 岗为什么在乎这个?
在 AI 领域,我们经常处理特征向量或大规模数据集。
- 对抗性数据: 有些数据可能由于采集原因自带某种顺序(比如按时间戳排序)。如果你的快排没有随机化,处理这些数据时系统可能会卡死或超时(TLE)。
- 工程严谨性: 在分布式训练或大规模数据预处理中,算法的期望复杂度比理论最优复杂度更重要。随机化保证了无论输入是什么样的,平均表现都能维持在 。
总结
- 不加 random: 逻辑清晰,适合展示原理,但怕有序数组。
- 加 random: 工业级实现,具备抗风险能力(面试高级岗位必考)。
在 AI 面试中,如果你被要求手撕一个“主函数只有 array 参数”的快排,通常有两种写法:一种是递归式(Python 特色写法),另一种是内部封装递归函数(工程写法)。
面试官问这种写法,通常是考察你对函数封装或列表推导式的熟练度。
方法二:内部封装版(原地排序,工程标准)
如果你想向面试官展示你既懂算法底层(原地交换),又懂良好的接口设计,应该写这一种。
def quick_sort(arr):
# 内部定义真正的递归函数
def _quick_sort(items, low, high):
if low < high:
# 分区
pivot_index = partition(items, low, high)
# 递归
_quick_sort(items, low, pivot_index - 1)
_quick_sort(items, pivot_index + 1, high)
def partition(items, low, high):
# 优化:随机选取基准防止退化
import random
rand_idx = random.randint(low, high)
items[rand_idx], items[high] = items[high], items[rand_idx]
pivot = items[high]
i = low - 1
for j in range(low, high):
if items[j] <= pivot:
i += 1
items[i], items[j] = items[j], items[i]
items[i + 1], items[high] = items[high], items[i + 1]
return i + 1
# 调用内部函数,初始边界为 0 到 len-1
_quick_sort(arr, 0, len(arr) - 1)
return arr
AI 面试避坑指南
-
接口友好性: 面试官要求主函数只传
arr,是因为在实际调用中,用户不应该关心low和high。这种封装思想在编写库代码时非常重要。 -
Top-K 变种:
AI 岗最常考的其实是快排的变种——快速选择 (Quick Select)。用来在 时间内找到数组中第 大的数(不需要完全排序)。


更多推荐

所有评论(0)