多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

3步手写实现quicksort,彻底告别排序崩溃焦虑

3步手写实现quicksort,彻底告别排序崩溃焦虑 3步手写实现quicksort,彻底告别排序崩溃焦虑 上周凌晨两点,线上接口突然超时,CPU飙到100%。翻日志一看,全是 java.lang.OutOfMemoryError 和递归栈溢出的 StackOverflowError。当时心里一紧:是不是并发太高?查了半天,发现不是并发问题,而是排序逻辑里的递归深度太深,把线程栈吃光了。 那一刻我才意识到,平时调包库排序时,我们对底层机制了解得太少。当 Arrays.sort() 或者 Collections.sort() 抛出你看不懂的 StackTrace 时,如果你连它背后用的是不是快排、为什么快排在特定数据下会退化成 O(n²),都说不清楚,那你就是在裸奔。 今天不聊虚的,我们就从源码出发,手写实现一个最基础的 Quicksort,看看那些让你头大的 StackTrace 到底是怎么产生的,以及为什么大厂都在用 Introsort 这种混合算法。 入口定位:谁在调用 Quicksort 很多人觉得快排是“古老”的算法,其实不然。在 Java 的 java.util.Arrays 中,对于对象数组(Object[]),默认的排序策略就是基于 TimSort 的归并排序变体,但对于基本类型数组(如 int[], double[]),JDK 源码里明确使用了 Dual-Pivot Quicksort(双轴快排)。 为什么基本类型用快排,而对象类型用归并?这里有个工程取舍:内存开销 vs 时间复杂度。归并排序需要 O(n) 的额外空间来存储临时数组,而快排是原地排序,空间复杂度仅为 O(log n)(递归栈)。对于基本类型,数据在内存中是连续存储的,缓存友好,快排的性能优势更明显;而对象数组存在指针引用,归并排序的稳定性(稳定排序)在某些业务场景下(如多字段排序)更关键。 如果你去翻看 OpenJDK 的源码,在 java.util.Arrays 类中,能找到 dualPivotQuicksort 方法。这就是 Java 8 及以后版本中,处理基本类型数组的核心入口。 核心片段:递归的陷阱与突破 让我们先看看一个最朴素、最容易写出 StackOverflow 的快排实现。这段代码逻辑正确,但在生产环境是绝对禁止使用的。 /*** 朴素版快排:逻辑正确,但存在严重的递归深度风险* 语言: Java*/ public static void naiveQuickSort(int[] arr, int low, int high) {if (low = high) {return;}// 1. 分区操作:选取最后一个元素作为基准int pivot = arr[high];int i = low - 1; // i 指向小于 pivot 区域的最后一个元素for (int j = low; j high; j++) {// 2. 如果当前元素小于等于基准,将其交换到左侧if (arr[j] = pivot) {i++;int temp = arr[i];arr[i] = arr[j];arr[j] = temp;}}// 3. 将基准元素放到正确的位置int temp = arr[i + 1];arr[i + 1] = arr[high];arr[high] = temp;int pivotIndex = i + 1;// 4. 递归排序左半部分和右半部分// 风险点:如果数组已经有序,每次 partition 只能减少 1 个元素// 递归深度将达到 O(n),导致 StackOverflowErrornaiveQuickSort(arr, low, pivotIndex - 1);naiveQuickSort(arr, pivotIndex + 1, high); }逐行拆解与设计缺陷:if (low = high) return;:这是递归终止条件。看似简单,但它是防止无限递归的唯一屏障。如果 low 和 high 交叉,必须退出。 int pivot = arr[high];:这是最大的隐患。固定选取最后一个元素作为基准,在面对近乎有序的数据时,分区极度不平衡。例如数组 [1, 2, 3, 4, 5],第一次分区后,pivot 是 5,左边是 [1, 2, 3, 4],右边是空。递归调用 naiveQuickSort(arr, 0, 3),再选 4 为 pivot,依此类推。递归深度直接达到 n。对于 10 万个元素,递归深度就是 10 万,Java 默认线程栈大小通常只有 512KB 到 1MB,每层递归消耗几百字节,瞬间爆栈。 for (int j = low; j high; j++):遍历区间,将小于 pivot 的元素移到左边。这里用了双指针思想,但效率不高,因为每次交换都是三次赋值。 naiveQuickSort(...):这是“罪魁祸首”。它没有对递归深度进行任何控制。为什么你会看到一堆看不懂的 StackTrace? 因为当递归深度过大时,JVM 抛出的 StackOverflowError 堆栈信息会非常深。你会看到几百甚至几千行 at com.yourcompany.service.NaiveSort.naiveQuickSort(NaiveSort.java:25) 这样的记录,每一层都指向同一个方法的不同调用帧。这种堆栈不仅难以阅读,还会导致日志文件迅速膨胀,甚至撑爆磁盘。 设计思想:从“递归”到“迭代”与“尾递归优化” 要解决这个问题,核心思想是:控制递归深度。 有两种主流方案:尾递归优化(Tail Recursion Elimination):将其中一个递归调用改为循环,另一个保留递归。这样可以保证递归深度最多为 O(log n)。 迭代实现:完全用栈(Stack)数据结构模拟递归过程,手动控制调用栈。更高级的策略是 Introsort(内省排序),这也是 C++ STL std::sort 和 Java 部分场景下的思路。它的核心思想是:监控递归深度,当递归深度超过 2 * log2(n) 时,切换到堆排序(Heapsort)。堆排序是原地排序,时间复杂度稳定在 O(n log n),且没有递归,完美避开了栈溢出风险。 我们来看一个经过优化的、具备生产级安全性的手写实现。它结合了“三数取中”选基准和“尾递归优化”控制深度。 /*** 优化版快排:引入尾递归优化,保证最坏情况下递归深度为 O(log n)* 语言: Java*/ public static void safeQuickSort(int[] arr, int low, int high) {// 使用 while 循环处理“较大”的子数组,模拟尾递归优化while (low high) {// 1. 三数取中法选择基准,避免有序数组退化int mid = low + (high - low) / 2;if (arr[low] arr[mid]) swap(arr, low, mid);if (arr[low] arr[high]) swap(arr, low, high);if (arr[mid] arr[high]) swap(arr, mid, high);// 此时 arr[low] = arr[mid] = arr[high]// 将中位数交换到 high-1 位置,作为 pivotswap(arr, mid, high - 1);int pivot = arr[high - 1];// 2. 分区操作 (Lomuto 分区方案的变种)int i = low;int j = high - 1;while (true) {while (arr[++i] pivot);while (arr[--j] pivot);if (i = j) break;swap(arr, i, j);}// 将 pivot 放到最终位置swap(arr, i, high - 1);int pivotIndex = i;// 3. 递归处理较小的子数组,循环处理较大的子数组// 比较左右子数组的大小,先递归小的,大的留给 while 循环if (pivotIndex - low high - pivotIndex) {safeQuickSort(arr, low, pivotIndex - 1);low = pivotIndex + 1; // 修改 low,继续处理右半部分} else {safeQuickSort(arr, pivotIndex + 1, high);high = pivotIndex - 1; // 修改 high,继续处理左半部分}} }private static void swap(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp; }逐行拆解与设计亮点:while (low high):外层用循环代替一层递归。这是尾递归优化的关键。每次循环处理一个分区,然后决定下一步是递归还是继续循环。三数取中(Median of Three):swap 三次后,保证 arr[low] 是最小值,arr[high] 是最大值,arr[mid] 是中位数。将中位数作为 pivot,可以极大程度避免在有序或逆序数组上的退化。if (pivotIndex - low high - pivotIndex):这是控制递归深度的核心。我们只递归较小的那一边。假设每次分区都极度不平衡(最坏情况),比如每次切出 1 个元素和 n-1 个元素。第一次:递归处理 1 个元素(O(1)),循环处理 n-1 个元素。 第二次:递归处理 1 个元素,循环处理 n-2 个元素。 ... 这种情况下,递归深度依然是 O(n)。等等,这里有个误区。纠正:如果数据是完全有序的,三数取中能让 pivot 尽量居中,使得分区大致平衡。如果数据是“锯齿形”或者特殊构造,三数取中可能失效。但在实际工程中,三数取中 + 尾递归优化已经能应对 99% 的场景。如果要绝对安全,必须引入 Introsort 的堆排序兜底。 但上述代码的 while 循环确实保证了只有一层递归。最坏情况下,递归深度等于二分查找的深度,即 O(log n)。因为每次递归都是处理较小的一半(在理想分区下)。即使在最坏分区下,由于我们总是递归较小的部分,递归树的深度也被限制在了 O(log n)。这是算法设计上的一个经典技巧。swap(arr, i, j):双指针向中间靠拢,比 Lomuto 分区更高效,减少了不必要的交换。手写简化版:Go 语言的参考实现 为了验证逻辑,我们用 Go 语言写一个更简洁的版本。Go 的 sort 包底层也是 Introsort。我们可以参考 GitHub 开源仓库 中 src/sort/zsortfunc.go 的思路,做一个简化版。 package mainimport fmt// Go 语言简化版快排,使用迭代栈模拟递归,避免栈溢出 func quickSort(arr []int) {stack := []int{0, len(arr) - 1}for len(stack) 0 {high := stack[len(stack)-1]stack = stack[:len(stack)-1]low := stack[len(stack)-1]stack = stack[:len(stack)-1]if low high {// 三数取中mid := low + (high-low)/2if arr[low] arr[mid] {swap(arr, low, mid)}if arr[low] arr[high] {swap(arr, low, high)}if arr[mid] arr[high] {swap(arr, mid, high)}swap(arr, mid, high-1)pivot := arr[high-1]i, j := low, high-1for {for arr[i+1] pivot {i++}for arr[j-1] pivot {j--}if i = j {break}swap(arr, i, j)i++j--}swap(arr, i, high-1)p := i// 压栈:先压大的,再压小的,保证小的是后弹出的(模拟递归顺序)// 注意:这里为了简单,直接压入两个区间,利用栈的 LIFO 特性stack = append(stack, low, p-1)stack = append(stack, p+1, high)}} }func swap(arr []int, i, j int) {arr[i], arr[j] = arr[j], arr[i] }func main() {arr := []int{3, 6, 8, 10, 1, 2, 1}fmt.Println(Before:, arr)quickSort(arr)fmt.Println(After:, arr) }关键点:手动栈:用切片 stack 模拟系统调用栈。每次循环取出 low 和 high,处理完后将新的子区间压入栈。 无递归:完全避免了函数调用开销和栈溢出风险。 通用性:这种模式可以应用于任何需要深度优先遍历且可能深度过深的场景,比如二叉树遍历、图搜索等。应用场景与避坑指南 在实际项目中,什么时候你需要手写快排?嵌入式系统或资源受限环境:没有标准库,或者标准库的排序算法不符合你的内存限制。 自定义比较器极其昂贵:如果你需要减少比较次数,快排的 O(n log n) 常数因子比归并排序小,且不需要额外空间。 流式数据处理:在某些特定场景下,原地排序能减少数据拷贝带来的延迟。避坑指南:不要直接递归:永远不要在生产代码中写无限制的递归快排。 基准选择:随机化基准(Randomized Pivot)是应对对抗性数据(Adversarial Input)的最佳手段。比如黑客故意构造有序数据来让你的排序退化为 O(n²),随机化基准可以让他们失效。 小数组优化:当子数组长度小于 10 或 16 时,切换为插入排序。因为插入排序在小规模数据下,常数因子极小,且利用了局部性原理,速度远超快排。 稳定性:快排是不稳定排序。如果你的业务依赖稳定性(比如先按日期排,再按价格排,要求同日期内价格顺序不变),请使用归并排序或 TimSort。你公司项目里是怎么处理的?是直接用库函数,还是自己封装了一套安全的排序工具类?如果遇到过大数组导致的性能瓶颈,你是怎么排查的?欢迎在评论区分享你的踩坑经验,我们一起交流。
返回列表