快速排序一个在面试、笔试、机试中出场率极高的算法。无论是校招的算法岗还是社招的技术面手写一个正确、高效、边界清晰的快速排序几乎是检验候选人基本功的“必考题”。但很多人在面对白板或编辑器时明明理解原理却总在细节上翻车递归终止条件写错、分区Partition逻辑混乱、处理重复元素导致栈溢出…… 这篇文章不打算从零科普快速排序的原理而是直接聚焦于“如何稳定地手写出无 Bug 的快速排序代码”这一核心目标。我们将拆解快速排序的经典实现提炼出几个关键的记忆锚点和应试技巧。无论你是用 C、Java 还是 Python这些技巧都能帮你快速构建起正确的代码骨架并有效应对面试官可能提出的各种变体问题如三路快排、非递归实现、链表排序等。本文的重点是“怎么写对”和“怎么应对追问”附带代码将提供多种语言版本并分析其显存/内存占用对于递归深度和性能边界。1. 核心能力速览快速排序手写要点在深入代码之前我们先通过一个表格快速把握手写快速排序时必须掌控的几个核心维度这能帮助你在动笔前就建立清晰的蓝图。能力项说明与应试关注点时间复杂度平均 O(n log n)最坏 O(n²)。面试时必须能说出最坏情况已排序或逆序数组及如何避免随机化或三数取中。空间复杂度主要来自递归调用栈平均 O(log n)最坏 O(n)。需能解释递归深度的影响。稳定性不稳定。必须能举例说明例如对[3a, 2, 3b]排序相等的3a和3b可能换位。关键函数partition(分区函数) 是核心quickSort(递归函数) 是骨架。务必保证partition的正确性。手写易错点1. 递归终止条件 (left right)。2. 分区指针移动的边界判断。3. 基准pivot元素的选择与放置。4. 处理大量重复元素时的效率退化为 O(n²)。应对追问方向1. 如何优化避免最坏情况2. 如何实现非递归版本3. 如何实现三路快排用于大量重复元素4. 如何对链表进行快速排序适合场景面试、笔试、机试中的排序算法题需要现场实现高效排序的场合。2. 适用场景与使用边界快速排序并非万能。明确其适用边界能在面试中体现你的工程思维。适合谁用面试者与考生应对算法考察展示对分治思想和原地排序的理解。日常开发在通用排序场景下如对内存中的数组排序其平均效率很高许多语言如 JavaArrays.sort()对基本类型的内置排序就采用了其变体。能解决什么问题高效的内排序对数组或可随机访问的序列进行原地排序无需额外线性空间。分治思想的典范是理解“分而治之”策略的绝佳案例。不适合什么场景数据量极小当 n 10~20 时插入排序等简单算法可能更快。链表结构标准快排依赖随机访问对链表排序效率低但存在专门变体。稳定性要求严格当相等元素的原始顺序必须保留时应选择归并排序等稳定算法。最坏情况不可接受在对响应时间有严格要求的实时系统最坏的 O(n²) 是不可接受的需采用堆排序等保证最坏 O(n log n) 的算法。使用边界与注意事项递归深度对于极端不平衡的划分如已排序数组递归深度可达 n可能导致栈溢出。在实际工程中会采用“混合排序”策略如introsort在递归深度过深时切换到堆排序。原地修改快速排序直接修改输入数组如果原数组需要保留务必先拷贝。3. 环境准备与前置条件手写快速排序不依赖特定 IDE 或复杂环境但需要清晰的思路和正确的语法。这里列出的是“思维环境”和“代码环境”的准备清单。1. 思维准备必读理解分治三步曲分解选取一个基准元素通过partition操作将数组分成两个子数组左边元素都不大于基准右边元素都不小于基准。解决递归地对左右两个子数组调用快速排序。合并因为子数组都是原址排序所以无需合并操作整个数组自然有序。明确循环不变量在实现partition时必须清楚每个指针如i,j所代表的含义并始终保持某个区间内的性质。这是写出正确代码的关键。2. 代码环境以验证为目的编程语言选择你面试常用的语言C/Java/Python/Go 等。简单的测试框架准备几个有代表性的测试用例。常规随机数组。已排序数组测试最坏情况。逆序数组测试最坏情况。所有元素相同的数组测试重复元素处理。空数组和单元素数组测试边界条件。4. 经典实现拆解与“记忆锚点”我们以最经典的 Lomuto 分区方案和 Hoare 分区方案为例给出代码并提炼出必须记住的“锚点”。4.1 方案一Lomuto 分区法较易记忆Lomuto 分区的思路直观遍历数组将小于基准的元素交换到前面。它通常选择最右元素作为基准。Python 实现def partition_lomuto(arr, low, high): 使用arr[high]作为基准(pivot) 返回基准元素的最终位置 pivot arr[high] # 锚点1选择最右元素为基准 i low - 1 # 锚点2i 指向小于基准区域的最后一个位置 for j in range(low, high): if arr[j] pivot: # 当前元素小于等于基准 i 1 arr[i], arr[j] arr[j], arr[i] # 交换到小于区域 # 锚点3循环结束后将基准放到正确位置 (i1) arr[i 1], arr[high] arr[high], arr[i 1] return i 1 def quick_sort_lomuto(arr, low, high): if low high: # 锚点4递归终止条件至少有两个元素 pi partition_lomuto(arr, low, high) quick_sort_lomuto(arr, low, pi - 1) # 排序左半部分 quick_sort_lomuto(arr, pi 1, high) # 排序右半部分 # 测试 if __name__ __main__: test_arr [10, 80, 30, 90, 40, 50, 70] quick_sort_lomuto(test_arr, 0, len(test_arr)-1) print(Sorted array:, test_arr)记忆锚点与易错点基准选择pivot arr[high]。容易错写成arr[low]但后续交换逻辑不匹配。指针初始化i low - 1。i是“小于等于基准区域”的右边界开区间。最终交换循环结束后i1位置是基准该去的地方。必须交换arr[i1]和arr[high]。递归条件if low high:。这是关键保证区间内至少有两个元素才需要排序。写成low high会导致无限递归。4.2 方案二Hoare 分区法原始版本效率稍高Hoare 分区使用两个指针从两端向中间扫描交换不符合条件的元素。它通常选择中间或第一个元素作为基准。C 实现#include iostream #include vector using namespace std; int partition_hoare(vectorint arr, int low, int high) { int pivot arr[low (high - low) / 2]; // 锚点1选择中间元素作为基准避免最坏情况 int i low - 1, j high 1; // 锚点2指针初始化在边界外 while (true) { do { i; } while (arr[i] pivot); // 找到左边第一个 pivot 的元素 do { j--; } while (arr[j] pivot); // 找到右边第一个 pivot 的元素 if (i j) { // 锚点3指针相遇或交叉返回 j return j; // 注意返回的是 j不是 i } swap(arr[i], arr[j]); } } void quick_sort_hoare(vectorint arr, int low, int high) { if (low high) { int p partition_hoare(arr, low, high); // 锚点4递归区间是 [low, p] 和 [p1, high] quick_sort_hoare(arr, low, p); quick_sort_hoare(arr, p 1, high); } } int main() { vectorint test {8, 3, 1, 7, 0, 10, 2}; quick_sort_hoare(test, 0, test.size() - 1); for (int num : test) cout num ; return 0; }记忆锚点与易错点基准选择选择中间元素arr[(lowhigh)/2]是一种常见优化能有效避免已排序数组的最坏情况。指针初始化i low-1,j high1让后续的do...while能先移动再比较。循环终止与返回值条件if (i j)成立时返回j。这是 Hoare 分区最容易错的地方。返回j保证了左区间[low, j]的所有元素 右区间[j1, high]的所有元素但arr[j]不一定等于基准。递归区间根据返回值j递归调用区间为[low, j]和[j1, high]。这与 Lomuto 分区[low, pi-1],[pi1, high]不同。5. 功能测试与效果验证构建你的测试用例库手写代码后必须用一组全面的用例验证。以下测试思路和用例可以直接用于你的练习。测试目的验证排序算法的正确性、边界处理能力和性能表现。操作步骤将上述任一版本的quick_sort函数实现。准备下面的测试数组。调用函数并打印结果与预期排序结果对比。测试用例集test_cases [ (随机数组, [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]), (已排序数组, [1, 2, 3, 4, 5, 6, 7]), # 测试最坏情况 (逆序数组, [7, 6, 5, 4, 3, 2, 1]), # 测试最坏情况 (全相同数组, [5, 5, 5, 5, 5]), # 测试重复元素 (单个元素, [42]), (空数组, []), (两个元素乱序, [2, 1]), (两个元素有序, [1, 2]), (包含负数, [0, -3, 7, -2, 5]), ]预期结果与判断成功对于非空数组排序后应满足arr[i] arr[i1]对所有 i 成立。空数组和单元素数组应保持不变。算法应能正确处理上述所有情况。常见失败原因栈溢出递归终止条件错误如写成low high或对已排序数组使用最左/最右元素作为基准导致递归深度为 n。排序错误partition函数逻辑错误指针移动或交换条件不对。数组越界指针初始值或循环条件设置不当访问了arr[-1]或arr[n]。死循环在 Hoare 分区中do...while循环缺少递增/递减语句或终止条件ij永远不满足。6. 高级技巧与面试追问应对面试官不会满足于一个基础实现。以下是常见的追问方向及回答要点。6.1 如何优化以避免最坏情况 O(n²)随机化在partition开始时随机选择一个元素与末尾或开头元素交换再以该位置元素作为基准。这能将最坏情况的发生概率降到极低。import random def partition_randomized(arr, low, high): rand_index random.randint(low, high) arr[rand_index], arr[high] arr[high], arr[rand_index] # 交换到末尾 return partition_lomuto(arr, low, high) # 再用Lomuto分区三数取中取数组头、中、尾三个元素的中位数作为基准。这能有效避免已排序或逆序数组的最坏情况。int median_of_three(vectorint arr, int low, int high) { int mid low (high - low) / 2; if (arr[low] arr[mid]) swap(arr[low], arr[mid]); if (arr[low] arr[high]) swap(arr[low], arr[high]); if (arr[mid] arr[high]) swap(arr[mid], arr[high]); // 此时 arr[low] arr[mid] arr[high] // 将中位数 arr[mid] 交换到 arr[high-1] 或直接作为基准 swap(arr[mid], arr[high]); return arr[high]; // 基准 }6.2 如何处理大量重复元素—— 三路快速排序当数组中存在大量重复元素时标准快排仍会将它们递归分割效率低下。三路快排将数组分为三部分小于、等于、大于基准。public class QuickSort3Way { public static void sort(int[] arr, int low, int high) { if (low high) return; int lt low, i low 1, gt high; int pivot arr[low]; while (i gt) { if (arr[i] pivot) { swap(arr, lt, i); } else if (arr[i] pivot) { swap(arr, i, gt--); } else { i; } } // 现在 arr[low..lt-1] pivot, arr[lt..gt] pivot, arr[gt1..high] pivot sort(arr, low, lt - 1); sort(arr, gt 1, high); } private static void swap(int[] arr, int i, int j) { /* 交换 */ } }记忆锚点维护三个指针lt小于区域的右边界、i当前扫描位置、gt大于区域的左边界。等于基准的元素直接跳过。6.3 如何实现非递归版本使用栈Stack来模拟递归调用过程避免递归带来的函数调用开销和栈溢出风险。def quick_sort_iterative(arr): if not arr: return stack [(0, len(arr) - 1)] while stack: low, high stack.pop() if low high: continue pi partition_lomuto(arr, low, high) # 使用任一分区函数 # 将两个子区间的边界压入栈注意顺序先压后处理的区间 stack.append((low, pi - 1)) stack.append((pi 1, high))要点栈中存储待排序区间的左右边界。注意压栈顺序不影响正确性但可能影响栈的最大深度。6.4 如何对单链表进行快速排序链表无法随机访问因此不能直接用数组的partition方法。思路是选择头节点作为基准然后遍历链表将小于基准的节点接到一个链表等于和大于的接到另一个链表递归排序后再连接。public ListNode quickSortList(ListNode head) { if (head null || head.next null) return head; ListNode pivot head; ListNode small new ListNode(0), large new ListNode(0); ListNode s small, l large, cur head.next; while (cur ! null) { if (cur.val pivot.val) { s.next cur; s s.next; } else { l.next cur; l l.next; } cur cur.next; } s.next null; l.next null; // 断开连接 ListNode sortedSmall quickSortList(small.next); ListNode sortedLarge quickSortList(large.next); // 连接sortedSmall - pivot - sortedLarge if (sortedSmall ! null) { ListNode tail getTail(sortedSmall); tail.next pivot; } else { sortedSmall pivot; } pivot.next sortedLarge; return sortedSmall; } private ListNode getTail(ListNode node) { /* 找到链表尾部 */ }要点链表排序需要额外的空间递归栈和新建链表节点且不是原地排序。7. 资源占用与性能观察对于手写算法我们主要关注时间与空间复杂度而非显存。时间复杂度观察最佳/平均情况每次划分大致均匀递归树深度为 O(log n)每层 O(n) 操作故为 O(n log n)。可以通过打印递归深度或统计比较次数来感性认识。最坏情况数组已排序且总选最左/最右为基准递归树退化为链表深度为 n时间复杂度 O(n²)。使用随机化或三数取中可有效避免。空间复杂度观察主要来自递归调用栈。平均深度 O(log n)最坏深度 O(n)。如何降低尾递归优化编译器可能对先递归较短区间进行优化但手写时我们可以手动实现——总是先处理较短的子数组将长的子数组通过迭代处理。void quick_sort_tail_opt(int arr[], int low, int high) { while (low high) { int pi partition(arr, low, high); if (pi - low high - pi) { // 左区间短 quick_sort_tail_opt(arr, low, pi - 1); low pi 1; // 尾递归优化右区间 } else { // 右区间短 quick_sort_tail_opt(arr, pi 1, high); high pi - 1; } } }使用非递归迭代版本完全消除递归用显式栈控制栈空间最大为 O(n)但平均情况仍为 O(log n)。8. 常见问题与排查方法问题现象可能原因排查方式解决方案递归栈溢出1. 递归终止条件错误 (low high)。2. 输入数组已排序/逆序且基准选择不当。1. 检查递归函数开头的条件判断。2. 对已排序数组进行测试。1. 修正为if (low high) return;。2. 引入随机化或三数取中选择基准。排序结果不正确1.partition函数逻辑错误返回的基准位置不对。2. 递归调用区间划分错误。1. 单步调试partition函数观察指针移动和交换。2. 用小型数组如 [3,1,2]手动模拟。1. 对照本文的“记忆锚点”检查partition代码。2. 确认递归调用区间Lomuto 用(low, pi-1)和(pi1, high)Hoare 用(low, j)和(j1, high)。数组越界访问指针初始值或循环条件错误导致访问arr[-1]或arr[n]。在代码中添加边界断言或使用调试器观察指针值。仔细检查for/while循环的起止条件以及指针递增/递减的时机。对重复元素排序慢大量重复元素导致划分不平衡。测试全为相同元素的数组。实现三路快速排序将等于基准的元素集中处理。非递归版本死循环栈操作逻辑错误导致同一区间被反复压入。打印每次出栈的low和high值。确保partition后压入栈的区间是有效的low high且不会重叠。9. 最佳实践与手写建议固定你的“默认实现”在面试前选择一种分区方案推荐 Lomuto易记不易错和一种基准选择方法推荐随机化反复练习直到形成肌肉记忆。先写框架再填细节void quickSort(int arr[], int low, int high) { // 1. 终止条件 if (low high) return; // 2. 分区操作 int pivotIndex partition(arr, low, high); // 3. 递归排序左右两部分 quickSort(arr, low, pivotIndex - 1); quickSort(arr, pivotIndex 1, high); }先把这个框架写出来再集中精力实现partition。注释循环不变量在partition函数中用注释明确每个指针的含义和区间性质这能极大减少错误。# i 指向小于等于pivot区域的最后一个位置 # [low...i] 区间内的元素都 pivot # [i1...j-1] 区间内的元素都 pivot (当前循环中) # [j...high-1] 区间是待检查的区域测试驱动在写代码前先列出测试用例如第5节所示。写完立刻运行快速验证。准备追问答案对于时间复杂度分析、优化策略、变体实现等问题提前组织好语言。10. 总结与下一步快速排序的手写核心在于对partition过程的精确掌控和对递归思想的深刻理解。本文提供的“记忆锚点”和多种实现方案旨在帮你构建一个可靠的代码模板。最值得尝试的点掌握 Lomuto 分区思路直观代码简短是应付笔试手写的首选。理解 Hoare 分区与返回值的意义这是深入理解快排的关键一步。熟练实现随机化基准选择这是避免最坏情况最简单有效的优化务必掌握。最先应该验证的功能 用一组包含边界情况的测试数组跑通你的基础版快速排序。确保空数组、单元素数组、已排序数组都能正确处理。最容易踩的坑递归终止条件写成low high。分区区间划分Lomuto 和 Hoare 的递归区间不同记混必然出错。忽略重复元素在可能包含大量重复数据的场景下未考虑使用三路快排。后续扩展方向研究内省排序结合快速排序、堆排序和插入排序的混合排序算法是 Cstd::sort的实现基础。探索其他分区算法如“双指针挖坑填数法”。应用于实际场景尝试在解决 LeetCode “排序数组”、“数组中的第K个最大元素”等问题时使用手写的快速排序及其变种。建议将本文中的代码片段保存下来构建你自己的算法代码库。在面试前花 10 分钟默写一遍能显著提升手写成功率。