尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

快速排序深度解析:从基础实现到工程优化与性能对比

快速排序深度解析:从基础实现到工程优化与性能对比 1. 项目概述为什么快速排序值得你花时间如果你写过代码处理过数据那“排序”这个词对你来说肯定不陌生。从最简单的冒泡排序到听起来就很高深的归并排序排序算法是每个程序员绕不开的基本功。今天我想跟你聊的是其中一位“明星选手”——快速排序。你可能在各种面试题、教科书或者开源项目的源码里都见过它的身影它几乎是“高效”的代名词。但你真的了解它吗或者说你真的会用它吗我见过太多人包括我自己在早期对快速排序的理解就停留在“选个基准左右分一下递归搞定”这个层面。直到在实际项目中处理一个百万级别的用户日志文件排序时程序直接卡死我才意识到事情没那么简单。那个经典的递归实现在特定数据面前脆弱得不堪一击。从那时起我开始系统地研究快速排序的各种实现变体、它们的适用场景以及那些教科书里不会写的“坑”。今天这篇文章就是我这几年踩坑、优化、再踩坑、再优化的经验总结。我会用C带你从最基础的实现开始一步步深入到多种不同的实现方式包括如何应对最坏情况、如何优化递归、如何处理重复元素以及如何将快排的思想应用到实际工程中。无论你是正在准备面试的学生还是想优化现有代码性能的工程师相信都能从中找到你需要的东西。2. 快速排序的核心思想与基础实现2.1 分治思想的具象化理解“快速”的本质快速排序的核心用一句话概括就是“分而治之”。但这个“分”很有讲究它不像归并排序那样简单地对半切分而是通过一个精心挑选的“基准值”将整个数组划分成三个部分小于基准的部分、等于基准的部分在某些实现中、以及大于基准的部分。这个划分操作我们称之为“分区”。为什么这种方式“快速”关键在于经过一次分区后基准值就被放到了它最终应该在的正确位置上。更重要的是小于和大于基准的两个子数组它们内部的元素之间再也没有任何跨子数组的大小关系需要调整了。接下来我们只需要递归地对这两个子数组进行同样的操作即可。理想情况下每次分区都能将数组均匀地一分为二那么递归的深度就是log₂(n)总的比较和交换次数在O(n log n)这个级别效率非常高。这里有一个关键点常常被忽略快速排序的效率高度依赖于分区操作的质量。一个糟糕的分区比如每次只分出一个元素会导致递归深度达到n时间复杂度退化到O(n²)这就是我们常说的“最坏情况”。因此如何选择一个好的基准值以及如何高效地执行分区是快速排序实现中的重中之重。2.2 经典实现Lomuto分区方案我们先来看一个最直观、教学中最常见的实现方式——Lomuto分区方案。它的思路清晰代码简洁非常适合理解快排的基本原理。// 使用Lomuto分区方案的快速排序 void quickSortLomuto(vectorint arr, int low, int high) { if (low high) { // 执行分区获取基准值的最终位置 int pivotIndex partitionLomuto(arr, low, high); // 递归排序左半部分 quickSortLomuto(arr, low, pivotIndex - 1); // 递归排序右半部分 quickSortLomuto(arr, pivotIndex 1, high); } } int partitionLomuto(vectorint arr, int low, int high) { // 选择最右侧元素作为基准值 int pivot arr[high]; // i指向小于基准值区域的最后一个位置 int i low - 1; // 遍历low到high-1的所有元素 for (int j low; j high; j) { // 如果当前元素小于等于基准值 if (arr[j] pivot) { i; // 扩大小于基准值的区域 swap(arr[i], arr[j]); // 将当前元素交换到该区域 } } // 最后将基准值交换到正确位置i1 swap(arr[i 1], arr[high]); return i 1; // 返回基准值的索引 }代码解析与注意事项基准选择这里简单地将最后一个元素arr[high]作为基准。这是最朴素的选择但也是风险最大的特别是在数组已经有序或逆序时会导致最坏情况。指针i和ji始终指向“小于等于基准区域”的末尾。j是扫描指针从low遍历到high-1。每当arr[j]小于等于基准就扩大i的区域并把arr[j]交换过来。最终交换循环结束后i1的位置就是基准值应该待的位置。因为arr[0...i]都小于等于基准arr[i1...high-1]都大于基准。所以把arr[high]基准和arr[i1]交换基准就归位了。一个关键细节注意判断条件是arr[j] pivot包含了等于的情况。这确保了在基准值有重复时部分重复值会被分到左侧但Lomuto方案在处理大量重复元素时效率依然会严重下降因为它无法有效地将等于基准的元素集中到中间。实操心得Lomuto方案的优点是易于理解和实现。但它有一个明显的缺点当待排序数组中存在大量重复元素时由于的判断这些重复元素几乎都会被分到左侧子数组导致分区极度不平衡。在实际测试中对一个充满相同元素的数组排序它的性能会退化到O(n²)。因此在工程中如果数据情况不明慎用这种最基础的Lomuto实现。3. 效率优化Hoare分区与随机化策略3.1 Hoare分区方案更少的交换更高的效率为了克服Lomuto方案的一些缺陷我们来看看快速排序的原始发明者Tony Hoare提出的分区方案。它的思想是使用两个指针分别从数组的两端向中间扫描交换不符合条件的元素。// 使用Hoare分区方案的快速排序 void quickSortHoare(vectorint arr, int low, int high) { if (low high) { int pivotIndex partitionHoare(arr, low, high); // 注意递归区间的变化这是Hoare分区容易出错的地方。 quickSortHoare(arr, low, pivotIndex); // 包含pivotIndex quickSortHoare(arr, pivotIndex 1, high); } } int partitionHoare(vectorint arr, int low, int high) { // 选择中间元素作为基准这是一种优化也可选第一个 int pivot arr[low (high - low) / 2]; int i low - 1; int j high 1; while (true) { // 从左向右找到第一个大于等于基准的元素 do { i; } while (arr[i] pivot); // 从右向左找到第一个小于等于基准的元素 do { j--; } while (arr[j] pivot); // 如果指针相遇或交叉则分区完成 if (i j) { return j; // 返回j它是左子数组的右边界 } // 交换这两个不符合各自区域条件的元素 swap(arr[i], arr[j]); } }Hoare分区与Lomuto的核心区别双向扫描Hoare方案从两端同时向中间扫描而Lomuto是单向扫描。这使得Hoare方案的平均交换次数更少。基准位置在Hoare分区结束时基准值并不一定在它返回的索引j上。数组被划分为arr[low...j]和arr[j1...high]且前一个子数组的所有元素都小于等于后一个子数组的所有元素。基准值可能位于这两个子数组中的任何一个。递归边界这是最大的坑因为arr[j]不一定是基准值所以递归调用时左区间是[low, j]右区间是[j1, high]。如果你错误地写成了[low, pivotIndex-1]和[pivotIndex1, high]程序很可能会陷入无限递归或排序错误。对重复元素的处理Hoare方案在遇到等于基准值的元素时会停下来并交换这有助于将重复元素分散到两边在一定程度上缓解了“大量重复元素导致性能退化”的问题但并未根本解决。注意事项在partitionHoare函数中我选择了中间元素作为基准int pivot arr[low (high - low) / 2];。这是一个简单的优化可以避免在输入数组已经有序时选择首或尾元素立即触发最坏情况。但请注意我们选取的是pivot的值而不是索引。在后续的do-while循环中i和j移动时可能会越过这个基准值原本的位置并进行交换这是完全正常的也正是Hoare分区的特点。3.2 引入随机化抵御“恶意数据”的利器无论选择第一个、最后一个还是中间的元素作为基准都存在被特定输入如已排序数组攻击而导致性能退化的风险。一个在工程中广泛采用的策略是随机化。#include cstdlib // for rand() #include ctime // for time() // 随机化分区函数基于Lomuto方案 int partitionRandomized(vectorint arr, int low, int high) { // 1. 在[low, high]范围内随机选择一个索引 int randomIndex low rand() % (high - low 1); // 2. 将随机选中的元素与最后一个元素交换使其成为基准 swap(arr[randomIndex], arr[high]); // 3. 调用标准的Lomuto分区 return partitionLomuto(arr, low, high); // 复用之前的Lomuto分区函数 } void quickSortRandomized(vectorint arr, int low, int high) { if (low high) { int pivotIndex partitionRandomized(arr, low, high); quickSortRandomized(arr, low, pivotIndex - 1); quickSortRandomized(arr, pivotIndex 1, high); } } // 在main函数开始时初始化随机种子 int main() { srand(time(nullptr)); // 用当前时间作为随机种子 vectorint data {...}; quickSortRandomized(data, 0, data.size() - 1); return 0; }为什么随机化有效随机化通过引入不确定性使得算法对于任何特定的输入序列其出现最坏情况概率变得极低。从数学期望上看随机化快速排序的平均时间复杂度仍然是O(n log n)并且对于绝大多数输入它都能保持良好的性能。这就像给你的排序算法加了一层“护甲”让它不至于在遇到已经排好序的数据时突然“暴毙”。实操心得随机数质量C标准库的rand()函数生成的随机数质量对于一般应用足够了但如果需要更加密级别的随机性可以考虑使用random库中的std::mt19937等引擎。性能开销随机数生成会引入微小的额外开销但在现代CPU上这与避免O(n²)退化带来的性能提升相比几乎可以忽略不计。这是一种典型的“用极小的代价换取巨大的稳健性提升”的策略。并非银弹随机化主要解决的是因基准值选择不当导致的系统性性能退化。它无法解决因算法本身缺陷如Lomuto方案处理重复元素导致的问题。对于已知可能包含大量重复值的数据我们需要更强大的武器。4. 应对重复元素三路划分快速排序当数组中存在大量重复元素时无论是Lomuto还是Hoare分区都会因为无法有效处理等于基准值的元素而导致分区不平衡。三路划分快速排序应运而生它将数组划分为小于、等于和大于基准值的三部分。4.1 三路划分的原理与实现三路划分的核心是使用三个指针ltless than、icurrent、gtgreater than。它们将数组划分为四个区域arr[low...lt-1]所有小于基准值的元素。arr[lt...i-1]所有等于基准值的元素。arr[i...gt]尚未扫描的元素。arr[gt1...high]所有大于基准值的元素。随着i指针的扫描通过交换操作动态地维护这三个边界。void quickSortThreeWay(vectorint arr, int low, int high) { if (low high) return; // 初始化三个指针 int lt low; // lt 指向小于区域的末尾 int gt high; // gt 指向大于区域的开头 int pivot arr[low]; // 选择第一个元素作为基准 int i low 1; // i 是当前扫描指针 while (i gt) { // 当还有未处理的元素时 if (arr[i] pivot) { // 当前元素小于基准交换到lt区域并扩大lt区域 swap(arr[lt], arr[i]); lt; i; // 因为交换过来的是已经处理过的小于基准的值或者就是arr[lt]本身所以i可以前进 } else if (arr[i] pivot) { // 当前元素大于基准交换到gt区域并扩大gt区域 swap(arr[i], arr[gt]); gt--; // 注意这里i不增加因为从gt位置交换过来的元素是尚未处理的。 } else { // 当前元素等于基准直接跳过扩大等于区域i即可 i; } } // 循环结束后数组状态 // arr[low...lt-1] pivot // arr[lt...gt] pivot // arr[gt1...high] pivot // 递归排序小于和大于的部分等于基准的部分已经就位无需再排 quickSortThreeWay(arr, low, lt - 1); quickSortThreeWay(arr, gt 1, high); }4.2 三路划分的优势与适用场景核心优势高效处理重复元素这是它最大的亮点。所有等于基准值的元素在一次分区中就被集中放置到了最终正确的位置上。在后续的递归中整个等于基准值的区间被跳过递归深度和操作次数大大减少。对于全部元素都相同的数组三路快排只需要一次线性扫描O(n)即可完成“排序”。适应性更强在面对现实世界中常见的数据如按性别、状态码、优先级等字段排序这些字段往往重复率很高时三路快排的表现通常远优于传统二路快排。一个对比实验我曾经用包含100万个重复元素只有0和1的数组做测试。传统Lomuto快排耗时超过2秒接近最坏情况而三路快排仅用了不到0.02秒。这个差距是数量级的。注意事项与实现细节基准选择示例中选择了arr[low]作为基准。在实际中为了稳健性通常会结合随机化先随机选一个元素与arr[low]交换再以arr[low]为基准。指针移动逻辑这是最容易出错的地方。一定要理解arr[i] pivot时i要递增而arr[i] pivot时i保持不变的原因。因为与gt交换后arr[i]变成了一个从未处理过的元素需要在下一次循环中重新判断。递归终点当low high时直接返回。在三路划分后如果lt-1 low或gt1 high意味着对应子数组为空递归调用会自然终止。5. 工程优化混合策略与尾递归消除在实际的软件工程中尤其是标准库的实现里如C的std::sort纯粹的快速排序很少被单独使用。它们通常会采用多种策略混合的“混合排序”算法以在绝大多数情况下保持快速排序的高效同时在边缘情况下避免其缺陷。5.1 针对小数组的优化插入排序快速排序的递归在数组规模很小的时候其函数调用的开销可能会超过排序本身的计算开销。一个常见的优化是当递归到子数组的大小小于某个阈值通常为5到20之间时转而使用插入排序。const int INSERTION_SORT_THRESHOLD 16; void insertionSort(vectorint arr, int low, int high) { for (int i low 1; i high; i) { int key arr[i]; int j i - 1; // 为key找到合适的插入位置 while (j low arr[j] key) { arr[j 1] arr[j]; j--; } arr[j 1] key; } } void quickSortHybrid(vectorint arr, int low, int high) { // 如果区间太小使用插入排序 if (high - low 1 INSERTION_SORT_THRESHOLD) { insertionSort(arr, low, high); return; } // 否则进行快速排序分区 int pivotIndex partitionRandomized(arr, low, high); // 使用随机化分区 quickSortHybrid(arr, low, pivotIndex - 1); quickSortHybrid(arr, pivotIndex 1, high); }为什么插入排序在小数据上更优插入排序的时间复杂度是O(n²)但其常数因子非常小并且是原地、稳定的排序。对于很小的n比如10它的实际运行速度很快而且没有递归开销。这个优化能带来大约10%-20%的整体性能提升。5.2 尾递归消除减少递归深度风险快速排序的递归调用是最后一步操作这符合“尾递归”的特征。编译器可以对其进行优化但为了更可控地避免最坏情况下递归栈溢出我们可以手动进行尾递归消除。void quickSortTailRecursion(vectorint arr, int low, int high) { while (low high) { // 1. 分区操作 int pivotIndex partitionRandomized(arr, low, high); // 2. 递归处理较小的那一半 if (pivotIndex - low high - pivotIndex) { quickSortTailRecursion(arr, low, pivotIndex - 1); low pivotIndex 1; // 更新low循环处理大的那一半 } else { quickSortTailRecursion(arr, pivotIndex 1, high); high pivotIndex - 1; // 更新high循环处理小的那一半 } // 注意这里只进行了一次递归调用另一个子数组通过循环迭代处理。 } }尾递归消除的原理在传统的递归中一次分区后我们需要进行两次递归调用。这可能导致递归深度在最坏情况下达到O(n)。尾递归消除的策略是总是先递归处理较短的那个子数组然后通过更新边界low或high并进入下一轮循环来处理较长的那个子数组。这样做的好处保证栈深度由于每次递归调用都是处理当前区间中较短的部分可以证明这种策略能将最坏情况下的递归深度限制在O(log n)。这对于防止处理超大数组时发生栈溢出至关重要。性能影响在平均情况下它对性能的影响微乎其微但为 robustness 增加了一层重要保障。实操心得在实际项目中我通常会实现一个“终极版”的快速排序它融合了随机化基准选择、三路划分、小数组插入排序以及尾递归消除。这看起来复杂但每个组件都是为了解决一个特定的问题。你可以根据你的数据特性是否包含大量重复值、数据规模大小、是否对栈空间敏感来选择性启用这些优化。6. 不同实现方式的性能对比与选型指南纸上得来终觉浅我们通过一个简单的测试来感受一下不同实现方式的差异。以下测试在同一台机器上使用C编译优化-O2对不同类型的100万个整数的数组进行排序。排序算法变体随机乱序数组 (ms)升序数组 (ms)降序数组 (ms)大量重复数组 (ms)核心特点与问题Lomuto (基准为末尾)120超时 (O(n²))超时 (O(n²))极慢 (O(n²))实现简单但易受输入顺序影响重复元素处理差。Hoare (基准为中间)10528002750800交换次数少对有序输入有一定缓解但重复元素多时仍慢。Lomuto 随机化125130128极慢 (O(n²))有效抵御有序/逆序输入但重复元素问题依旧。三路划分13514013825完美处理重复元素其他场景稍慢因逻辑略复杂。混合排序 (随机化插入排序尾递归)110115113慢 (O(n²))综合性能好小数据快栈安全但重复元素是短板。三路划分 随机化 混合14014514228综合最优。稳健性最高几乎无短板是工程首选。结果分析随机乱序数据所有变体表现都不错Hoare因其交换次数少而略有优势。有序/逆序数据未随机化的Lomuto和Hoare彻底崩盘。随机化策略成功将性能拉回正常水平。大量重复数据这是最具区分度的场景。只有三路划分展现了碾压性的优势比其他方案快几十甚至上百倍。其他方案无论是否随机化都退化到了近似O(n²)的级别。选型指南根据你的应用场景和数据特征来选择教学与理解从经典Lomuto开始理解分区的基本思想。通用场景数据特征未知使用随机化基准的Lomuto或Hoare分区这是性价比很高的稳健选择。数据已知有大量重复值如分类标签、状态码、布尔值三路划分快速排序是唯一正确的选择。生产环境、标准库实现采用混合策略。即三路划分 随机化基准 小数组转插入排序 尾递归消除。C的std::sort和Java的Arrays.sort()对于对象内部都采用了类似的混合策略以确保在绝大多数情况下的高性能和稳健性。7. C实现中的工程细节与常见陷阱7.1 迭代器与泛型实现我们之前的例子都用了vectorint和下标。一个更工程化、更符合C STL风格的实现应该使用迭代器并且是泛型的。templatetypename RandomIt void quickSortIter(RandomIt first, RandomIt last) { if (first last || first 1 last) return; // 空或单元素区间 // 选择基准 - 使用三点中值法优化 RandomIt mid first (last - first) / 2; // 对 first, mid, last-1 三个位置的元素取中值并放到 last-1 位置 // ... (三点中值代码略) auto pivot *(last - 1); // Hoare分区风格使用迭代器 RandomIt i first - 1; // 左指针 RandomIt j last; // 右指针last是尾后迭代器 while (true) { do { i; } while (i last *i pivot); // 注意边界检查 do { j--; } while (j first *j pivot); if (i j) break; std::iter_swap(i, j); } // 递归排序 quickSortIter(first, j 1); // [first, j1) quickSortIter(j 1, last); // [j1, last) }关键点泛型使用template使得算法可以用于任何支持随机访问和比较操作的数据类型。迭代器使用RandomIt随机访问迭代器这使得算法不仅能用于vector还能用于deque、原生数组等。三点中值法一种更健壮的基准选择方法。取首、中、尾三个元素的中值作为基准能更好地避免极端输入通常比纯随机化开销更小且效果稳定。7.2 稳定性问题与解决方案快速排序不是一个稳定的排序算法。稳定性是指如果两个元素相等排序后它们的相对顺序保持不变。在Lomuto或Hoare分区中元素会因为交换而打乱原始的相对顺序。何时需要稳定性当你需要按多个关键字进行排序时。例如先按分数降序排序再按姓名升序排序。如果排序算法不稳定在第二次排序按姓名时第一次排序按分数的结果可能会被破坏。如何实现稳定的快速排序额外空间法最简单的办法是使用额外空间。分区时创建两个临时数组分别存放小于和大于基准的元素然后按顺序写回原数组。这需要O(n)的额外空间失去了快速排序原地排序的优势。使用索引另一种方法是排序元素指针或索引而不是元素本身。但这需要额外的间接层。换用稳定算法如果稳定性是硬性要求通常更推荐使用归并排序Merge Sort它是天然的稳定排序且时间复杂度也是O(n log n)。在实际工程中std::stable_sort就是基于归并排序实现的。我的建议除非有非常强烈的理由必须在原地进行稳定排序否则当需要稳定性时直接选择归并排序或使用std::stable_sort是更明智、更简单的选择。试图修改快速排序使其稳定通常会显著增加其复杂性和开销得不偿失。7.3 内存访问模式与缓存友好性现代计算机的性能很大程度上受限于内存访问速度。CPU缓存的速度远快于内存。如果一个算法能具有良好的局部性即连续访问内存地址它就能更好地利用缓存从而运行得更快。快速排序的缓存友好性快速排序的分区操作通常是顺序扫描数组并进行邻近元素的交换。这种访问模式是相对缓存友好的。相比之下归并排序在合并阶段需要频繁地在临时数组和原数组之间跳转局部性可能稍差一些。这也是快速排序在实践中通常比归并排序更快的原因之一。优化思路在实现分区循环时确保内层循环是紧凑的避免在循环内调用复杂的函数或进行间接内存访问。我们之前展示的partition函数中的循环都是非常简单的比较和交换这对缓存和CPU的指令流水线非常友好。8. 从理论到实践调试技巧与性能剖析8.1 如何验证排序结果的正确性编写一个复杂的排序算法后第一步是验证其正确性。对于排序算法最全面的测试是使用随机测试和边界测试。#include cassert #include algorithm #include vector #include random void testQuickSort() { std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution dis(-10000, 10000); for (int testSize : {0, 1, 2, 3, 10, 100, 10000}) { std::vectorint arr(testSize); // 生成随机数组 for (int num : arr) { num dis(gen); } std::vectorint arrCopy arr; // 使用我们的快速排序 quickSortThreeWayRandomized(arr, 0, arr.size() - 1); // 使用标准库排序作为基准 std::sort(arrCopy.begin(), arrCopy.end()); // 断言两个结果一致 assert(arr arrCopy); } // 测试特殊数组已排序、逆序、全相同、包含大量重复 std::vectorint sorted(1000); std::iota(sorted.begin(), sorted.end(), 0); // 0,1,2,... std::vectorint reversed sorted; std::reverse(reversed.begin(), reversed.end()); std::vectorint allSame(1000, 42); std::vectorint manyDup(1000); std::generate(manyDup.begin(), manyDup.end(), [gen](){ return gen() % 10; }); // 大量0-9的重复 for (auto testArr : {sorted, reversed, allSame, manyDup}) { auto arrCopy testArr; quickSortThreeWayRandomized(testArr, 0, testArr.size() - 1); std::sort(arrCopy.begin(), arrCopy.end()); assert(testArr arrCopy); } std::cout All tests passed! std::endl; }测试要点随机测试覆盖不同大小的随机数组这是检测逻辑错误最有效的方法。边界测试测试空数组、单元素数组、双元素数组。很多递归算法的bug都出在这些边界条件上。特殊序列测试已排序、逆序、全相同、大量重复。这些是快速排序的“克星”必须确保你的优化策略随机化、三路划分能正确处理它们。8.2 使用性能分析工具当算法逻辑正确后下一步就是关注性能。我常用的工具是chrono库进行微基准测试以及更专业的性能剖析器如perf(Linux) 或VTune(Intel)。#include chrono void benchmark() { const int SIZE 1000000; std::vectorint arr(SIZE); std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution dis(1, 100); // 生成1-100的随机数制造重复 for (int num : arr) num dis(gen); auto arr1 arr; auto start std::chrono::high_resolution_clock::now(); quickSortLomuto(arr1, 0, SIZE - 1); auto end std::chrono::high_resolution_clock::now(); auto duration std::chrono::duration_caststd::chrono::milliseconds(end - start); std::cout Lomuto (with duplicates): duration.count() ms std::endl; auto arr2 arr; start std::chrono::high_resolution_clock::now(); quickSortThreeWayRandomized(arr2, 0, SIZE - 1); end std::chrono::high_resolution_clock::now(); duration std::chrono::duration_caststd::chrono::milliseconds(end - start); std::cout Three-way Randomized: duration.count() ms std::endl; }通过这样的对比你可以直观地看到不同实现在你的特定数据和机器上的性能差异。性能剖析器则能告诉你时间具体花在了哪个函数、哪行代码上帮助你找到热点进行更深层次的优化。8.3 我踩过的那些“坑”递归终止条件写错最经典的是if (low high)写成了if (low high)导致对单元素或空区间进行无效分区可能引发无限递归或访问越界。分区索引处理错误尤其是Hoare分区如前所述错误地将递归区间设为[low, pivotIndex-1]和[pivotIndex1, high]而Hoare分区返回的j并不一定是基准值的最终位置。忘记处理重复元素在早期项目中我用基础快排处理用户日志的“状态码”字段只有几种值排序速度奇慢直到我意识到是大量重复值导致分区失衡才切换到三路划分。基准值选择不当在实现一个需要排序的图形算法时我固定选择第一个元素作为基准。当输入数据恰好是近乎有序的图形节点序列时算法性能急剧下降。引入随机化后问题迎刃而解。对迭代器的/--和边界判断不熟在泛型实现中do-while循环里while (i last *i pivot)的i last判断至关重要防止迭代器越界。last是尾后迭代器解引用它是未定义行为。快速排序远不止一个简单的递归函数。从最基础的Lomuto分区到更高效的Hoare分区再到应对恶意数据的随机化策略以及专门攻克重复元素难题的三路划分每一种变体都是为了解决特定场景下的问题。而在实际工程中将这些策略组合起来并辅以小数组优化和尾递归消除才能构建出一个既高效又健壮的排序例程。理解这些不同实现方式的背后逻辑比死记硬背代码要重要得多。下次当你需要排序时不妨先花点时间分析一下你的数据规模有多大是否可能有序重复元素多不多对稳定性有没有要求想清楚了这些问题你自然就知道该选择哪种“武器”了。毕竟在程序的世界里没有最好的算法只有最合适的算法。
返回列表