在这里插入图片描述


1. 核心思想:分治与分区

快排的核心在于 Partition(分区) 操作:

  1. 选基准 (Pivot): 从序列中挑出一个元素作为“基准”。
  2. 分区 (Partition): 重新排序序列,所有比基准小的元素摆放在基准前面,大的摆在后面。
  3. 递归 (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)

它的通俗定义是:以一个“基准点”为界,把数组像排队一样重新整队。 具体来说,它实现了以下三件事:

  1. 分堆:把所有小于等于基准(pivot)的数挪到左边。
  2. 归位:把基准(pivot)放到它最终应该在的正确位置上。
  3. 确界:返回基准的下标,告诉主函数:“这个位置已经排好了,以此为界切开,去排左边和右边吧。”

这个问题困扰很多人是正常的,因为 i = low - 1 看起来像是在操作“区间外面”的内存。

其实,i 并不是一个普通的下标,它是一个**“计数器”或者说“边界墙”**。

1. 核心原因:它是“空集”的表达方式

在还没开始比较之前,“小于等于枢轴的区域”里一个元素都没有。

  • 如果你设置 i = low,意味着你默认“第 low 个位置已经是小数了”。
  • 如果设置 i = low - 1,则代表:“目前还没有发现任何小数,这个区域的右边界在起始位置的左边(即:空集)。”

2. 逻辑上的“先加后换”

代码里的逻辑顺序是:

  1. 发现一个小元素。
  2. 把墙 i 向右推一格i += 1)。
  3. 把元素换到墙内。

如果你设置 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. 形象比喻:插旗子

想象你在一个工地上(区间 lowhigh),你要用绳子围出一块地给“小数字”住。

  • 刚开始: 你手里拿着绳子的木桩,站在工地大门外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 分区方案。我们可以把 ij 想象成两个角色:

  • j (侦察兵):从头到尾扫描每一个元素。
  • i (分界线):始终指向“已知的小于等于 pivot 区域”的最后一个位置。
  • 过程:当 j 发现一个小的数时,i 向右挪一步(腾出位置),然后把 j 发现的小数和 i 位置的数交换。
  • 最后:把一直呆在末尾的 pivot 换到 i + 1 的位置,这样它左边全是小的,右边全是大的。

2. 能不能选取第一个元素作为基准?

答案是:绝对可以! 如果你想把 pivot 设定为 arr[low](第一个元素),代码逻辑只需要做一点点微调。

方案 A:直接改逻辑(稍微复杂,容易出错)

你需要改变 ij 的起始方向或者判断逻辑。

方案 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)中,我们并没有真的把数组切断或者复印一份。

  • 原数组是那一排连续的内存空间。
  • 子数组只是我们通过 lowhigh 这两个坐标,在原数组上画了一个框
  • 框里的内容就是我们要处理的“子数组”。框外的内容,在这个函数的这一层级里,我们假装看不见。

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 个同学就是子数组
老师并没有把他们带到另一个教室,他们还在原来的位置附近活动,只是老师现在的注意力(lowhigh)只在他们身上。

理解了“子数组”的概念,你对代码中 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):这是真正干活的。它必须带上 lowhigh,因为只有这样它才能知道自己在处理哪一个子数组

2. 为什么初始化要传 0n-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. 形象化理解:切蛋糕

想象你在切一块很长的奶油蛋糕:

  1. 初始化 (0, n-1):老师对你说:“把整块蛋糕切好。” 这里的 0 是蛋糕左端,n-1 是蛋糕右端。
  2. 第一次切开:你从中间切了一刀(partition),现在蛋糕变成了左半段右半段
  3. 递归调用:老师接着说:“现在去切左半段。”
  • 此时,对于左边那段来说,它的起始位置还是之前的 low
  • 但它的结束位置变成了刚才切口左边的那一点,即 pivot_index - 1

如果不传入参数,函数每次只能看到一整块蛋糕,它就永远没法只针对其中的“某一段”进行精细操作了。


总结

  • lowhigh 必须初始化:因为递归需要明确的“工作边界”。
  • 不传 low/high 的写法:只能像之前那个 Pythonic 写法一样,不停地切开数组并产生新数组。那样的代价是频繁申请内存,性能较差。
  • 原地排序的精髓:就是通过不断改变 lowhigh 的数值,在同一块内存空间里完成所有排序。

现在你能理解为什么在递归调用时,参数变成了 (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]),而你总是选最后一个数作为基准:

  1. 第一轮基准是 5,分区后左边有 4 个数,右边 0 个。
  2. 第二轮基准是 4,分区后左边有 3 个数,右边 0 个。
  3. …以此类推。

在这里插入图片描述


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

在快速排序中,lowhigh 是分治算法的核心控制变量。它们代表的是当前正在处理的数组子区间的“下标范围”

简单来说,快排不是每次都处理整个数组,而是把大数组切成小段。lowhigh 就是用来标记当前这段“小数组”的开头和结尾


1. 变量含义详解

  • low (左边界索引): 当前待排序子区间的第一个元素的下标。
  • high (右边界索引): 当前待排序子区间的最后一个元素的下标。

2. 为什么需要这两个参数?

快速排序是原地排序 (In-place)。它并不创建新的数组,而是在原始数组上通过交换元素来排序。

  • 如果不传 lowhigh 函数就不知道该处理数组的哪一部分。
  • 有了它们: 函数可以精确地知道:“哦,我现在只需要管从第 low 到第 high 这一块地方,外面的不用动。”

3. 代码逻辑中的具体体现

在你的代码中,它们有三个关键用途:

A. 确定随机基准的范围
rand_idx = random.randint(low, high)

这保证了随机抽取的基准(Pivot)一定是从当前处理的这一段里选出来的,而不是从整个数组里乱选。

B. ij 指针的初始化
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。

  1. 第一次调用: low=0, high=4。处理全量数据。
  2. 分区后: 假设 40 是基准,分成了 [10, 30][80, 90]
  3. 递归左边: 调用时 low=0, high=1。此时函数里的逻辑只会在 arr[0]arr[1] 之间发生。
  4. 递归右边: 调用时 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 面试避坑指南

  1. 接口友好性: 面试官要求主函数只传 arr,是因为在实际调用中,用户不应该关心 lowhigh。这种封装思想在编写库代码时非常重要。

  2. Top-K 变种:
    AI 岗最常考的其实是快排的变种——快速选择 (Quick Select)。用来在 时间内找到数组中第 大的数(不需要完全排序)。

在这里插入图片描述
在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐