ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

分治、排序与随机化:从归并排序到QuickSelect的算法工程指南

分治、排序与随机化:从归并排序到QuickSelect的算法工程指南 分治、排序与随机化是斯坦福算法专项课程中第一阶段的三个核心主题也是 Tim Roughgarden 主讲课程里最强调“设计思想”与“数学证明”并重的部分。很多初学者看视频时觉得归并排序和快速排序都听懂了但一到自己写代码、分析复杂度、处理大规模输入时才发现真正的难点并不在于“把数组分成两半”而在于分治递归如何正确收敛、随机化如何避免最坏情况、以及在真实工程中这些算法应该如何选型、改造成稳定版本、调试边界条件。这篇文章会以课程主线为骨架把分治、排序与随机化拆成一条可实操的技术路径先理解分治复杂度怎么算再用归并排序和逆序对计数跑通第一个分治程序再通过快速排序理解随机化的意义最后扩展到第 k 小元素选择问题并在工程层面给出参数选择、常见坑和排查思路。全文的代码示例以 Python 为主因为表达分治和随机化算法最直观不需要依赖特定框架。课程中的数学证明部分会保留核心结论但不会照搬全套推导更侧重“证明结果如何转化成编码直觉”。1. 分治思想怎么变成递推式从归并排序开始1.1 分治三步法和递推式为什么 T(n) 2T(n/2) O(n) 是主线分治算法的套路看起来只有三步分解原问题、递归求解子问题、合并子问题的解。真正难的不是这三句话而是如何用递推式判断一个分治算法到底快不快。Roughgarden 的课程在第一周就反复强调不要凭感觉说“分半之后肯定快”要用递归树的层数和每层代价来推算总复杂度。归并排序是最标准的例子。假设输入规模为 n归并排序把数组从中间切成两个规模为 n/2 的子数组分别递归排序然后在线性时间内把两个有序数组合并。这个过程的递归式是T(n) 2T(n/2) O(n)递归树有 log2(n) 层每层合并的总代价是 O(n)所以总复杂度是 O(n log n)。这比冒泡排序和选择排序的 O(n^2) 快在数据量变大之后尤其明显。为了不依赖递归展开推导很多算法课程都会引入主定理Master Theorem。对于形如 T(n) aT(n/b) O(n^d) 的递推式主定理给出三种情况条件复杂度典型算法d log_b(a)O(n^d)线性扫描类分治如某些最值拆分d log_b(a)O(n^d log n)归并排序、逆序对计数d log_b(a)O(n^(log_b(a)))矩阵乘法的朴素分治使用主定理时最容易出错的是漏掉常数因子。比如有些实现把“切分”写成每次复制整个数组那么递推式会变成 T(n) 2T(n/2) O(n)看起来没变但常数因子翻倍后实际运行时间可能多出数倍。课程里的观点是渐近复杂度相同不等于工程性能相同分析时要区分“循环次数”和“实际内存操作”。1.2 归并排序的 Python 实现最小可运行版本下面这个实现是最小可运行版本目的不是追求最省内存而是把分治过程表达清楚def merge_sort(arr): if len(arr) 1: return arr mid len(arr) // 2 left merge_sort(arr[:mid]) right merge_sort(arr[mid:]) return merge(left, right) def merge(left, right): i j 0 result [] while i len(left) and j len(right): if left[i] right[j]: result.append(left[i]) i 1 else: result.append(right[j]) j 1 result.extend(left[i:]) result.extend(right[j:]) return result这段代码里有三个关键点。第一个是递归终止条件len(arr) 1时直接返回避免无限递归。第二个是合并时使用而不是这样相等元素的相对顺序不会改变归并排序是稳定排序。第三个是切片操作arr[:mid]会复制数组所以这个版本的空间复杂度不是严格的 O(1)而是 O(n log n) 的临时数组开销在数据量大时要注意。验证方式很简单data [5, 2, 9, 1, 7, 6, 3] sorted_data merge_sort(data) print(sorted_data)预期输出[1, 2, 3, 5, 6, 7, 9]。如果手边有暴力排序结果可以直接对比。1.3 用归并排序顺带计数逆序对课程第一个“加一点额外逻辑”的案例逆序对的定义是数组中的两个下标 i j但 arr[i] arr[j]。逆序对数量可以衡量一个数组接近“有序”的程度。暴力解法是双重循环O(n^2)。课程里更推荐的做法是修改归并排序的合并过程在合并两个有序子数组时如果右边子数组当前元素小于左边子数组当前元素说明左边子数组从当前位置到末尾的所有元素都和这个右边元素构成逆序对。def count_inversions(arr): if len(arr) 1: return arr, 0 mid len(arr) // 2 left, inv_left count_inversions(arr[:mid]) right, inv_right count_inversions(arr[mid:]) merged, inv_cross merge_and_count(left, right) return merged, inv_left inv_right inv_cross def merge_and_count(left, right): i j 0 result [] inv_count 0 while i len(left) and j len(right): if left[i] right[j]: result.append(left[i]) i 1 else: result.append(right[j]) j 1 inv_count len(left) - i result.extend(left[i:]) result.extend(right[j:]) return result, inv_count测试时用一个逆序数组arr [6, 5, 4, 3, 2, 1] sorted_arr, inv count_inversions(arr) print(sorted_arr) print(inv)完全逆序时输出逆序对数量为 15也就是 n(n-1)/2。这里的核心技巧是“一次合并解决跨左右两半的逆序对”而不是再去双重循环。每层合并仍然只需要 O(n) 时间所以总复杂度保持 O(n log n)。2. 快速排序与随机化 Pivot为什么固定选择第一个会退化2.1 快速排序的 Lomuto 分区和原地排序思想快速排序的核心是分区partition选一个 pivot把数组重排成“小于 pivot、等于 pivot、大于 pivot”三块然后递归排序 pivot 左右两侧。课程里常用的一种分区方式是 Lomuto 分区实现简单适合讲解分治与随机化的结合。def quicksort_lomuto(arr, low, high): if low high: return p partition_lomuto(arr, low, high) quicksort_lomuto(arr, low, p - 1) quicksort_lomuto(arr, p 1, high) def partition_lomuto(arr, low, high): pivot arr[high] i low for j in range(low, high): if arr[j] pivot: arr[i], arr[j] arr[j], arr[i] i 1 arr[i], arr[high] arr[high], arr[i] return iLomuto 分区用变量 i 维护“小于 pivot 区域的右边界”用 j 遍历数组。遇到小于 pivot 的元素就把它交换到 i 的位置然后 i 后移。遍历结束后i 位置就是 pivot 的最终位置。注意这里用的是而不是所以等于 pivot 的元素会被分到右侧这样不会因为等于 pivot 的元素太多而导致无限循环。回调执行时不要传入切片后的副本否则无法实现原地排序。上面的实现直接对原数组arr操作递归参数传下标low和high。2.2 固定 Pivot 的退化样例1,2,3,...,n 是怎么变成 O(n^2) 的假设快速排序固定选取数组最后一个元素作为 pivot。输入已经有序比如[1, 2, 3, 4, 5]。第一轮分区pivot 是 5所有元素都小于 pivot所以分区结束后 pivot 在最后一个位置左侧是[1, 2, 3, 4]右侧为空。递归排序左侧时又重复同样过程。这样的话每次只消掉一个元素递归深度是 n每层分区的工作量分别是 n、n-1、n-2、...、1总复杂度是 O(n^2)。不只是“已经有序”会退化任何“ pivot 总是当前区间最小或最大元素”的输入都会退化比如固定选第一个元素作为 pivot 时完全逆序数组也退化。这就是为什么课程里反复强调快速排序的最坏情况不是理论上的边缘问题而是实际输入很容易触发。很多真实业务数据有序程度很高排序前如果不做任何处理固定 pivot 的快速排序可能直接打满 CPU。2.3 随机化 Pivot课程里最重要的概率思想解决退化的最直接办法是随机选 pivot。随机化之后最坏情况仍然存在但最坏情况不再由输入决定而是由随机数决定。输入再有序只要随机源足够好每次选到当前区间中间值的概率都不低。Roughgarden 课程中的结论是随机化快速排序的期望运行时间是 O(n log n)而且这个期望是基于随机选择的不依赖输入分布。实现时只需要在 partition 之前把随机选中的元素交换到数组末尾import random def quicksort_random(arr, low, high): if low high: return rand_idx random.randint(low, high) arr[rand_idx], arr[high] arr[high], arr[rand_idx] p partition_lomuto(arr, low, high) quicksort_random(arr, low, p - 1) quicksort_random(arr, p 1, high)关键点是交换到high位置因为 Lomuto 分区默认 pivot 在最右侧。如果不交换随机选择就失去了意义因为分区的逻辑还是固定取arr[high]。课程里用递归树解释期望复杂度随机化的效果是把不平衡分区出现的概率分散到各层虽然单次运行可能运气差但多次运行的“平均层数”被控制在 O(log n)。这个结论给工程实践带来的启发是不要把随机化当成神秘技巧它其实是一种“用概率换健康”的复杂衡策略。2.4 随机化 vs 预处理洗牌两种思路的工程差别除了随机选 pivot另一种思路是先对整个数组执行一次随机洗牌再运行固定 pivot 的快速排序。两种思路本质上都在打破输入对算法的影响但有差别方案实现成本最坏情况通用性适用场景随机选 pivot每次递归一次 random概率极低但存在任意子数组递归时都随机不修改输入顺序要求适合一般排序预处理洗牌只做一次随机洗牌洗牌后固定 pivot 仍可能退化但概率低需要额外 O(n) 内存或对原数组重排输入来自不可控外部数据且可接受重排随机中位数采样三数取中后随机较稳健工程库常用综合性能和安全性大多数语言的内置排序库更常用“三数取中 插入排序”的混合策略比如 Java 的Arrays.sort对基本类型使用 DualPivotQuickSort对对象使用 TimSort。课程里讲随机化本质上是让读者理解“算法设计时要考虑输入无关性”而不是让生产环境都手写随机快速排序。3. 从快速排序到 QuickSelect用随机化求解第 k 小元素3.1 选择问题的定义为什么第 k 小元素不能只用全排序硬扛选择问题是指给定长度为 n 的数组和一个整数 k找出数组中第 k 小的元素。最直接的做法是把数组排序后取第 k 个时间复杂度 O(n log n)。但很多场景只需要一个元素比如求中位数、求百分位、求 TopK 的基准值不需要完整排序。课程中引入的 QuickSelect也叫 Randomized Select在快速排序的 partition 基础上只递归一边期望时间复杂度是 O(n)比全排序快得多。这个问题的学习价值有两层第一层是掌握基于 partition 的选择算法第二层是理解“排序是过度计算选择是局部计算”这一思想。3.2 QuickSelect 递归实现和迭代实现递归实现很好理解def quickselect(arr, low, high, k): if low high: return arr[low] rand_idx random.randint(low, high) arr[rand_idx], arr[high] arr[high], arr[rand_idx] p partition_lomuto(arr, low, high) if k p: return arr[p] elif k p: return quickselect(arr, low, p - 1, k) else: return quickselect(arr, p 1, high, k)调用方式data [3, 1, 4, 1, 5, 9, 2, 6] k 3 result quickselect(data, 0, len(data) - 1, k - 1) print(result)因为第 k 小元素对应下标 k-1所以调用时传入k - 1。这个版本的数组会在递归过程中被修改如果不想破坏原数组需要先复制一份。迭代版本可以避免递归栈开销def quickselect_iter(arr, k): low, high 0, len(arr) - 1 while low high: rand_idx random.randint(low, high) arr[rand_idx], arr[high] arr[high], arr[rand_idx] p partition_lomuto(arr, low, high) if k p: return arr[p] elif k p: high p - 1 else: low p 1 return -1迭代版本把所有状态都保存在low和high变量里问题规模每次缩小一部分不会出现递归调用栈深度过大的问题。3.3 和快速排序的关系一个递归两边一个只递归一边QuickSelect 与 QuickSort 非常像相似点是都用 partition 把数组按 pivot 切分本质区别是快速排序递归处理两侧QuickSelect 只处理包含第 k 小元素的一侧。对比维度QuickSortQuickSelect递归规模两侧都递归只递归一侧平均复杂度O(n log n)O(n)最坏复杂度O(n^2)O(n^2)是否需要完整排序需要不需要应用场景排序中位数、分位数、TopK 基准这里的“只递归一边”是复杂度从 O(n log n) 降到 O(n) 的关键。每一层的期望工作量大约为 n、n/2、n/4、...等比数列求和得到常数倍 n所以期望复杂度是 O(n)。3.4 确定性线性选择算法为什么不适合工程中位数的中位数课程在讲完随机化 QuickSelect 后通常会提一下 BFPRT 算法也叫“中位数的中位数”算法。它是确定性算法能保证在线性时间内找到第 k 小元素。思路是先把数组按每 5 个一组分组找出每组的中位数再递归找出这些中位数的中位数作为 pivot。这个算法理论上很优雅但工程上很少用它做通用选择。原因是它的常数因子较大分组、排序每组 5 个元素、递归找中位数的中位数这些步骤在随机化 QuickSelect 已经能很好处理绝大多数输入时显得过于复杂。课程里讲它更多是说明“确定性线性选择是存在的”但实践上随机化方案更常用。如果遇到要求最坏情况 O(n) 的严格场景比如实时系统的某个关键路径BFPRT 可以作为备选普通业务中随机化 QuickSelect 加上良好随机种子通常已经足够。4. 分治与随机化算法工程落地环境、调试、常见坑与最佳实践4.1 学习环境与跑通实验的方式学习分治和随机化算法不需要复杂环境一个 Python 解释器足够。需要额外注意的是随机数可复现性。算法实验中经常出现“一次运行通过了换一次输入却失败”的情况所以建议在实验脚本里固定随机种子import random random.seed(42)固定随机种子后相同输入会得到相同的随机序列问题更容易复现。真正生产环境不要固定种子否则随机化就退化成确定性算法输入一旦触发退化场景问题会重新出现。实验脚本建议包含三部分暴力解做基准、被测函数、随机测试生成器。下面是一个面向归并排序和 QuickSelect 的通用测试骨架import random def violent_inversion_count(arr): count 0 for i in range(len(arr)): for j in range(i 1, len(arr)): if arr[i] arr[j]: count 1 return count for n in range(1, 20): for _ in range(100): arr [random.randint(0, 50) for _ in range(n)] _, inv count_inversions(arr) assert inv violent_inversion_count(arr)这种测试方式虽然简单但能覆盖大量边界情况。随机测试配合断言比只看几组手写样例更可靠。4.2 常见坑递归深度、切片复制、Pivot 边界、随机数范围分治和随机化代码看起来简单但实际运行时容易遇到以下问题。递归深度超限。在 Python 中递归深度默认约 1000。如果归并排序处理的数组长度较大或者快速排序退化到接近 O(n^2) 深度程序会抛RecursionError。解决方式有几种对使用 QuickSelect 的场景优先用迭代版对归并排序使用非递归的自底向上版本或者使用sys.setrecursionlimit()调整上限。调整上限只适合可控程序生产环境最好避免不可控的深层递归。切片复制导致空间开销爆炸。归并排序的朴素实现大量使用切片比如arr[:mid]和arr[mid:]。在数据规模较大时切片复制的内存开销可能比算法本身还高。更工程化的做法是使用辅助数组通过下标操作完成合并。Lomuto 分区在重复元素下表现不佳。如果数组里全是相同元素Lomuto 分区会形成极端不平衡的分区因为等于 pivot 的元素都堆到一侧。处理方式可以改用 Hoare 分区或者在分区逻辑里单独处理等于 pivot 的元素。课程里没有过度展开但工程实现必须考虑重复输入。随机数范围写错导致越界。使用random.randrange(low, high 1)或random.randint(low, high)时如果low和high传错会让 pivot 的某个交换步骤越界。比如rand_idx random.randint(low, high - 1)在区间长度为 1 时会产生错误行为。最稳妥的写法是随机索引后立刻和high位置的元素交换。随机种子被全局固定但没有声明。在算法实验里固定种子方便复现但如果在生产代码的公共模块里执行了random.seed()会影响所有使用随机数的库导致真正需要随机性的功能变得可预测。实验脚本和工具脚本要分开处理。4.3 排查链路从错误结果倒推原因当算法输出错误时按下面的顺序排查通常能快速定位检查边界条件low、high、k是否在有效范围内长度为 1 或 0 时是否直接返回。检查分区逻辑用一组小数组手动模拟 partition看分区后的数组是否符合“小于 pivot、pivot、大于等于 pivot”的顺序。检查递归参数快速排序递归区间是否为[low, p-1]和[p1, high]QuickSelect 是否只递归一侧。检查合并逻辑归并排序的merge是否覆盖所有情况是否有元素被遗漏。检查随机数范围固定随机种子后是否能稳定复现错误如果错误概率随机出现优先检查随机索引范围。检查输入副作用如果算法修改了原数组排查是否在调用时不小心传入了切片副本。下面给出一个错误日志的判断思路RecursionError: maximum recursion depth exceeded这个错误在快速排序中通常说明分区不平衡递归深度接近 n。可以先不换算法改用随机 pivot 或三数取中观察是否还会触发。如果归并排序也出现该错误检查数组长度是否过大以及递归终止条件是否正确。4.4 最佳实践清单和扩展方向把课程内容落地到项目时可以按下面的清单自查算法选择清单需要稳定排序优先归并排序或 TimSort避免快速排序。处理大量重复元素时避免 Lomuto 分区考虑三路快排或 Hoare 分区。只需要第 k 小元素、中位数、分位数时优先 QuickSelect而不是全排序后取下标。对不确定来源的外部输入做排序时使用随机化 pivot 或预处理洗牌不要固定取第一个或最后一个元素。生产环境优先使用语言内置排序自定义排序只在特定条件下才值得替代。代码质量清单为分治递归函数写边界测试至少覆盖空数组、单元素、倒序数组、全相等数组。使用暴力解作为基准对小规模随机输入做对比验证。固定随机种子运行测试确保失败可以复现。在大数组上测试前先估算递归深度和内存占用。工程扩展清单外排序当数据量超过内存时把归并排序的思路扩展到多路归并用外部存储保存中间结果。并行分治多核环境下可以把左右子问题分别提交到线程池但要注意任务拆分和数据合并的同步成本。字符串排序分治与排序思想也延伸到字符串后缀数组、多关键字排序等场景。更复杂的概率算法随机化思想还可用于哈希、随机森林、蒙特卡洛模拟等方向。分治、排序与随机化这三个主题虽然基础但它们共同构成了算法设计中“如何拆分问题”“如何选择基准”“如何用概率规避最坏情况”的底层能力。课程的价值不在于让读者背下几个排序实现而在于理解这些实现背后的复杂度分析和工程取舍。学完之后建议自己从零写一遍归并排序、快速排序、逆序对计数和 QuickSelect再对每种算法写一组暴力对照测试这个过程比反复看视频更能巩固判断力。
返回列表