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

资讯详情

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

从荷兰国旗问题到三路快排:掌握快速排序的核心思想与工程实践

从荷兰国旗问题到三路快排:掌握快速排序的核心思想与工程实践 1. 从“分类”到“排序”的思维跃迁如果你写过排序算法尤其是快速排序大概率听说过“荷兰国旗问题”这个名字。我第一次接触它时觉得这名字挺有意思后来才明白它本质上是一个关于“分类”的经典问题而快速排序的核心恰恰就是一次次的“分类”操作。很多人学排序上来就背代码模板记住了partition函数里i和j指针怎么移动但一到实际应用或者遇到变种问题就懵了。其实从荷兰国旗问题这个更直观、约束更少的“分类”场景入手再去理解快速排序会顺畅得多。这就像先学会怎么把一堆混杂的红、白、蓝三色球分开再学怎么给一堆数字排队前者帮你建立了“分区”和“指针移动”的肌肉记忆后者则是这种思想在排序领域的一次精妙应用。荷兰国旗问题描述很简单给定一个只包含0、1、2的数组原地将它们排序使得所有0在前1在中2在后。这里的0、1、2你可以想象成红、白、蓝三种颜色。快速排序呢是选定一个“基准值”pivot然后把数组分成小于基准、等于基准、大于基准的三部分。看出来联系了吗荷兰国旗问题是快速排序中“三路划分”3-way partition的一个特例——它的“基准值”是固定的1并且元素只有三种可能的值。理解了这个你就掌握了快速排序最核心、也最易变的部分如何高效、正确地把数组分成几个区域。所以这篇内容我们不打算罗列所有排序算法而是聚焦于这条从“分类思想”到“高效排序”的路径。我会带你从最基础的荷兰国旗问题解法开始一步步推演到经典快速排序再到更健壮、更实用的三路快速排序。过程中我会穿插大量我实际编码和调试时遇到的“坑”以及为什么某些写法看似简洁却暗藏风险。无论你是用Python、Java还是C这里面的指针或索引操作逻辑都是相通的。我们的目标不是记住一个代码片段而是真正理解让数组元素“各归其位”的底层逻辑从而能够灵活应对各种变体问题。2. 荷兰国旗问题三指针分区的直观演练我们先抛开排序专注解决这个分类问题。假设有一个数组nums [2, 0, 2, 1, 1, 0]我们需要原地操作最终变成[0, 0, 1, 1, 2, 2]。要求是只遍历一次数组且使用常数级的额外空间。这直接排除了计数排序虽然对于只有3种值的情况计数排序很简单这种非原地的方法逼我们必须用指针索引来交换元素。最经典且高效的解法是使用三个指针left、curr、right。它们将数组划分为四个区域理解这四个区域是解法的关键[0, left)这个区间内的所有元素都是0红色。left指向这个区间的下一个位置即第一个不是0的位置。[left, curr)这个区间内的所有元素都是1白色。curr是我们当前正在检查的元素指针。(right, n-1]这个区间内的所有元素都是2蓝色。right指向这个区间的前一个位置即第一个不是2的位置。[curr, right]这个区间是待处理的、尚未分类的“灰色”区域。初始时left 0curr 0right n-1。整个数组都处于“待处理”的灰色区域。我们的算法核心就是通过移动curr指针并配合left和right不断地缩小灰色区域直到curr超过right。操作规则如下如果nums[curr] 0它应该被归到最前面的红色区域。我们将nums[curr]与nums[left]交换然后left和curr都向右移动一位。为什么curr也要移动因为从left位置交换过来的元素只可能是1因为[left, curr)区间里全是1所以交换后curr位置的新元素已经是1无需再次处理可以直接检查下一个。如果nums[curr] 1它正好就在白色区域。我们不需要交换只需将curr向右移动一位扩大白色区域。如果nums[curr] 2它应该被归到最后面的蓝色区域。我们将nums[curr]与nums[right]交换然后right向左移动一位。这里有一个关键点curr不能移动因为从right位置交换过来的元素其值是未知的可能是0、1、2它仍然属于待处理的灰色区域需要在下一次循环中被curr检查。这个过程一直持续到curr right。此时灰色区域消失所有元素都已归位。我用一个具体的例子来走一遍你可以拿纸笔画一下nums [2, 0, 1, 1, 0, 2]初始: left0, curr0, right5。数组: [2,0,1,1,0,2]nums[0]2: 与nums[5]2交换数组不变right4, curr0。数组: [2,0,1,1,0,2]nums[0]2: 与nums[4]0交换数组变为[0,0,1,1,2,2]right3, curr0。nums[0]0: 与nums[0]自身交换left0left1, curr1。数组: [0,0,1,1,2,2]nums[1]0: 与nums[1]自身交换left1left2, curr2。数组: [0,0,1,1,2,2]nums[2]1: currcurr3。nums[3]1: currcurr4。此时curr4 right3循环结束。最终数组: [0,0,1,1,2,2]实操心得与避坑点curr指针在遇到2时的行为这是最容易出错的地方。很多人会在这里也curr这会导致一个潜在的问题如果从right换过来的是一个0而这个0因为curr已经前移就被永远留在了中间或后面无法再被换到前面去。记住口诀“见0左移left和curr都动见1不动仅curr动见2右移仅right动”。循环条件必须是while(curr right)。当curr right时nums[curr]这个元素仍然在灰色区域内需要被处理。只有curr跑到right右边才说明所有元素处理完毕。边界思考你可以想想如果数组里只有0和1这个算法还工作吗显然工作right指针一开始就在末尾遇到1时curr后移遇到0时交换并移动left和curr最终right指针不会动算法依然正确。这说明了该解法的普适性。这个三指针分区法时间复杂度是O(n)因为每个元素最多被curr指针访问一次被交换如果需要常数次。空间复杂度是O(1)。它完美解决了分类问题也为我们理解快速排序的分区操作打下了坚实的基础。3. 快速排序核心二路划分Partition的得与失现在我们把问题升级数组元素不再是有限的0、1、2而是任意整数或其他可比较的数据。我们需要将它们按升序排列。快速排序的基本思想是“分治”选择一个元素作为基准pivot通过一趟扫描将数组分成独立的两部分其中一部分的所有数据都比另一部分的所有数据要小然后再按此方法对这两部分数据分别进行快速排序。这里最核心的一趟扫描操作就是“划分”Partition。最经典的划分算法是Lomuto partition scheme和Hoare partition scheme。我们先看更直观的Lomuto划分法它和我们刚才的荷兰国旗解法在思路上有延续性。3.1 Lomuto划分法清晰的边界与一个致命弱点Lomuto划分法的逻辑通常如下选择最右边的元素作为基准值pivot。初始化一个指针i它指向“小于pivot区域”的末尾初始为low - 1low是当前子数组的起始索引。遍历从low到high-1的元素索引为j。如果nums[j] pivot说明这个元素应该属于“小于区域”。我们将i向右移动一位然后交换nums[i]和nums[j]。遍历结束后i1位置就是pivot最终应该在的位置。我们将pivot即nums[high]与nums[i1]交换。返回i1作为新的pivot索引。这个过程完成后数组被划分为[low...i]都小于pivoti1位置是pivot[i2...high]都大于等于pivot。注意这里是“大于等于”等于pivot的元素被划到了右半部分。def partition_lomuto(nums, low, high): pivot nums[high] # 选择最右元素作为基准 i low - 1 # 小于pivot区域的边界 for j in range(low, high): if nums[j] pivot: i 1 nums[i], nums[j] nums[j], nums[i] # 将pivot放到正确位置 nums[i 1], nums[high] nums[high], nums[i 1] return i 1为什么说它有致命弱点考虑一个极端情况数组已经是升序排列例如[1, 2, 3, 4, 5]。每次我们都选最右边的元素5, 4, 3...作为pivot。在第一次划分时所有元素1,2,3,4都小于5i指针会一步步走到high-1的位置最后交换nums[4]和nums[4]。划分结果是左半部分[1,2,3,4]右半部分为空。这会导致递归树严重倾斜深度达到O(n)而每层划分需要O(n)时间总时间复杂度退化为O(n²)。更糟糕的是如果数组所有元素都相等Lomuto划分法也会进行大量无意义的交换并且同样导致递归树倾斜。3.2 Hoare划分法更早的相遇与更少的交换Hoare划分法是快速排序发明者Tony Hoare最初提出的方法。它使用两个指针分别从数组两端向中间扫描思路更接近“寻找一个分割点”。选择最左边的元素作为基准值pivot。初始化两个指针i low - 1,j high 1。无限循环 a. 让i指针向右移动直到找到一个大于等于pivot的元素。 b. 让j指针向左移动直到找到一个小于等于pivot的元素。 c. 如果i j说明指针已经相遇或交叉返回j作为分割点。 d. 否则交换nums[i]和nums[j]。def partition_hoare(nums, low, high): pivot nums[low] i, j low - 1, high 1 while True: i 1 while nums[i] pivot: # 找到左边第一个 pivot 的 i 1 j - 1 while nums[j] pivot: # 找到右边第一个 pivot 的 j - 1 if i j: return j nums[i], nums[j] nums[j], nums[i]Hoare划分法有一些微妙但重要的特性它返回的索引j其左边的元素[low...j]都小于等于pivot右边的元素[j1...high]都大于等于pivot。注意pivot本身不一定在j的位置它可能在左半部分也可能在右半部分。这与Lomuto划分法pivot在最终位置不同。正因为pivot不一定在边界递归调用时需要小心。通常的写法是quicksort(nums, low, j)和quicksort(nums, j1, high)。Hoare法在平均情况下交换次数比Lomuto法少但对于包含大量重复元素的数组它依然不能很好地处理因为等于pivot的元素仍然会被来回交换。实操中的选择对于学习理解我建议先掌握Lomuto法因为它边界清晰易于调试。但在实际生产代码中如果非要二选一Hoare法通常性能稍好。然而两者都解决不了重复元素导致的性能退化问题。这就引出了我们接下来要看的融合了荷兰国旗问题思想的终极方案。4. 三路快速排序应对重复元素的工业级方案回忆一下荷兰国旗问题它将数组分成了“小于”、“等于”、“大于”三个区域。当数组中存在大量重复元素时这正是我们梦寐以求的分区方式。因为所有等于pivot的元素在一次划分后就已经在最终位置了后续的递归只需要处理小于和大于pivot的两个子数组这能极大地提升效率避免递归深度失控。三路快速排序3-way Quicksort正是将这种思想推广到了任意可比较元素。它的核心是一个三路划分函数将数组nums[low...high]划分为三段nums[low...lt-1]所有小于pivot的元素。nums[lt...gt]所有等于pivot的元素。nums[gt1...high]所有大于pivot的元素。其中lt(less than) 指向等于pivot区域的第一个元素gt(greater than) 指向等于pivot区域的最后一个元素。我们使用三个指针lt、curr、gt。是不是和荷兰国旗问题的left、curr、right如出一辙算法步骤选择nums[low]作为pivot初始化lt low,gt high,curr low 1。pivot nums[low]。主循环当curr gt时如果nums[curr] pivot交换nums[curr]和nums[lt]然后lt,curr。如果nums[curr] pivot交换nums[curr]和nums[gt]然后gt--。注意curr不变原因同荷兰国旗问题如果nums[curr] pivotcurr。循环结束后区间[lt, gt]就是所有等于pivot的元素它们已经排好序了。递归地对左半部分[low, lt-1]和右半部分[gt1, high]进行三路快速排序。def quicksort_3way(nums, low, high): if low high: return # 三路划分 pivot nums[low] lt, gt low, high i low 1 while i gt: if nums[i] pivot: nums[lt], nums[i] nums[i], nums[lt] lt 1 i 1 elif nums[i] pivot: nums[gt], nums[i] nums[i], nums[gt] gt - 1 # i 不增加因为交换过来的 nums[i] 是未处理的 else: # nums[i] pivot i 1 # 递归排序小于和大于pivot的部分 quicksort_3way(nums, low, lt - 1) quicksort_3way(nums, gt 1, high)为什么这是工业级方案对重复元素的适应性这是最大的优势。如果数组中所有元素都相同那么一次划分后lt仍为lowgt等于high递归调用会立即因为low gt1和lt-1 high的条件而返回整个排序在一次线性扫描后完成时间复杂度是O(n)。而传统的二路快排会退化成O(n²)。缓存友好性等于pivot的元素被集中放在中间减少了后续递归中这些元素的移动次数。算法稳定性虽然快速排序本身不是稳定排序即相等元素的相对位置可能改变但三路划分在某种程度上减少了一些不必要的交换。实测中的技巧与陷阱Pivot的选择依然重要虽然三路划分减轻了糟糕pivot选择的影响但选择一个好的pivot如随机选择、三数取中仍然能改善平均性能避免极端情况下的递归栈过深。在上面的代码中我们简单选择了nums[low]作为pivot。在生产环境中通常会先随机选择一个索引将其值与nums[low]交换再进行上述过程。递归终止条件注意递归调用的是[low, lt-1]和[gt1, high]。务必确保这两个区间是有效的即low lt-1和gt1 high。我们的代码中if low high: return已经处理了基础情况递归调用时即使区间无效也会被这个条件捕获。与二路划分的对比实验你可以自己写一个测试用一个包含100万个重复数字1的数组分别用二路Lomuto快排和三路快排排序直观感受一下时间差异。前者可能会超时甚至栈溢出后者则是瞬间完成。从荷兰国旗问题到三路快排我们完成了一次思维的闭环。荷兰国旗问题教会我们如何用多指针进行三向分类而三路快排则将这种分类能力应用于排序解决了实际工程中非常常见的重复数据问题。理解了这个脉络你再去看各种编程语言标准库里的排序实现如Java的Arrays.sort()对于基本类型使用双轴快排的变体就能明白它们为什么如此设计了。5. 从理论到实践不同语言下的实现细节与性能调优理解了原理最终要落到代码上。不同语言有其特性实现快速排序时需要注意的细节也不同。这里我会分别用Python、Java和C来展示三路快速排序的实现并指出其中的关键点。5.1 Python实现利用切片与递归的简洁性Python的列表切片和递归写法可以让代码非常简洁但需要注意递归深度和原地修改的问题。import random def quicksort_3way_python(nums): 对列表进行原地三路快速排序 def _sort(lo, hi): if lo hi: return # 随机化pivot选择避免有序数组下的最坏情况 rand_index random.randint(lo, hi) nums[lo], nums[rand_index] nums[rand_index], nums[lo] pivot nums[lo] lt, gt lo, hi i lo 1 while i gt: if nums[i] pivot: nums[lt], nums[i] nums[i], nums[lt] lt 1 i 1 elif nums[i] pivot: nums[gt], nums[i] nums[i], nums[gt] gt - 1 else: i 1 # 递归排序左右两部分 _sort(lo, lt - 1) _sort(gt 1, hi) _sort(0, len(nums) - 1) return nums # 原地修改返回原列表便于链式调用 # 测试 arr [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5] print(quicksort_3way_python(arr)) # 输出: [1, 1, 2, 3, 3, 4, 5, 5, 5, 6, 9] print(arr is quicksort_3way_python(arr)) # 输出: True证明是原地排序Python实现的注意事项递归深度Python默认的递归深度限制通常1000对于排序大型数组可能不够。对于超过1000个元素的数组建议使用显式栈来模拟递归迭代版快排或者使用Python内置的sort()方法它是Timsort混合了归并和插入排序非常高效且稳定。随机化random.randint(lo, hi)包含了hi。这是为了确保pivot在当前区间内均匀随机选择。原地性我们直接修改传入的列表nums。这是快速排序的常规做法。如果你需要保持原列表不变可以在开始时复制一份arr_copy nums[:]然后对副本排序。5.2 Java实现处理泛型与边界Java实现需要考虑泛型以支持不同类型的数据并且要处理数组的边界。import java.util.Random; public class QuickSort3Way { private static final Random RND new Random(); public static T extends ComparableT void sort(T[] arr) { if (arr null || arr.length 1) return; sort(arr, 0, arr.length - 1); } private static T extends ComparableT void sort(T[] arr, int lo, int hi) { if (lo hi) return; // 随机化pivot选择 int pivotIndex lo RND.nextInt(hi - lo 1); swap(arr, lo, pivotIndex); T pivot arr[lo]; int lt lo, gt hi; int i lo 1; while (i gt) { int cmp arr[i].compareTo(pivot); if (cmp 0) { swap(arr, lt, i); } else if (cmp 0) { swap(arr, i, gt--); // i 不增加检查交换过来的新元素 } else { i; } } // 现在 arr[lo..lt-1] pivot, arr[lt..gt] pivot, arr[gt1..hi] pivot sort(arr, lo, lt - 1); sort(arr, gt 1, hi); } private static void swap(Object[] arr, int i, int j) { Object temp arr[i]; arr[i] arr[j]; arr[j] temp; } // 测试 public static void main(String[] args) { Integer[] arr {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5}; QuickSort3Way.sort(arr); for (int num : arr) { System.out.print(num ); // 输出: 1 1 2 3 3 4 5 5 5 6 9 } } }Java实现的注意事项泛型与Comparable使用T extends ComparableT确保数组元素可以相互比较。compareTo方法返回负数、零、正数分别对应小于、等于、大于。随机数生成RND.nextInt(hi - lo 1)生成[0, hi-lo]的随机数加上lo后得到[lo, hi]的随机索引。swap方法由于使用泛型swap方法的参数类型是Object[]。这是安全的因为泛型在运行时会被擦除为Object。5.3 C实现指针操作与迭代器风格C可以实现得非常高效接近底层也可以利用模板和迭代器写出通用代码。#include iostream #include vector #include cstdlib #include ctime #include algorithm // for std::swap (C11后可用) templatetypename T void quicksort_3way_cpp(std::vectorT arr, int low, int high) { if (low high) return; // 随机化pivot选择 int pivot_idx low rand() % (high - low 1); std::swap(arr[low], arr[pivot_idx]); T pivot arr[low]; int lt low, gt high; int i low 1; while (i gt) { if (arr[i] pivot) { std::swap(arr[lt], arr[i]); } else if (arr[i] pivot) { std::swap(arr[i], arr[gt--]); } else { i; } } // 递归排序 quicksort_3way_cpp(arr, low, lt - 1); quicksort_3way_cpp(arr, gt 1, high); } templatetypename T void quicksort_3way_cpp(std::vectorT arr) { if (arr.size() 1) return; srand(time(nullptr)); // 初始化随机种子 quicksort_3way_cpp(arr, 0, arr.size() - 1); } // 测试 int main() { std::vectorint arr {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5}; quicksort_3way_cpp(arr); for (int num : arr) { std::cout num ; // 输出: 1 1 2 3 3 4 5 5 5 6 9 } std::cout std::endl; return 0; }C实现的注意事项随机数使用C风格的rand()和srand(time(nullptr))进行初始化。在更严格的C代码中可以考虑使用random库。模板使用模板函数使其适用于任何支持、比较操作的类型。递归深度与Python类似对于极大数组递归可能导致栈溢出。工业级实现通常会结合插入排序对小数组和迭代法来优化。std::swapC11后std::swap在utility或algorithm中可以高效交换大多数类型。性能调优的通用建议小数组切换插入排序当递归到子数组规模很小比如长度小于15时快速排序的递归开销变得不划算。此时切换成插入排序Insertion Sort能带来明显的性能提升。因为插入排序对小规模、部分有序的数组效率很高。尾递归优化在递归调用quicksort时可以先处理较短的那部分子数组然后通过更新参数改变low或high和循环来处理长的部分。这可以节省递归栈空间防止栈溢出。一些编译器也能自动进行尾递归优化。双轴快速排序Dual-Pivot Quicksort这是更进一步的优化Java标准库对基本类型数组的排序就使用了这种算法。它选择两个pivot将数组分成三份理论上比单pivot有更少的比较次数。但实现也更复杂。在彻底理解单pivot三路快排之前不建议过早追求双轴实现。掌握了这些你不仅能在白板编程中写出正确的快速排序更能理解其各种变体背后的权衡并能在实际项目中根据数据特点选择合适的实现或优化策略。排序算法是基本功而快速排序及其思想无疑是这门基本功里最闪亮的部分之一。
返回列表