
随机化算法这门课我在读研和工作后回头看最深的感受是它不像分治、动态规划那样有一个“标准答案式”的解题模板反而是逼你跳出“最坏情况”的思维定式从概率分布的角度重新审视问题。国内不少高校的《算法设计与分析》课程比如湘潭大学的期末考题里随机化算法经常作为区分度较高的压轴题出现考察的往往不是你能不能背出某个算法而是你能不能理解“为什么引入随机性之后复杂度分析就变得不一样了”。这篇东西我想从一个既搞过竞赛、又用算法写过工业级代码的从业者角度把随机化算法这块硬骨头拆开揉碎。不管你是正在准备期末考的学生还是工作中想用随机化手段解决性能瓶颈的工程师这篇文章应该都能给你一些课本之外、但非常实在的东西。1. 先从直觉上理解为什么要往算法里“掷骰子”第一次接触随机化算法的人普遍会有一个困惑算法追求的难道不是确定性和正确性吗你引入一个随机数结果不就是“看运气”了这其实是对随机化算法的最大误解。1.1 不是碰运气而是把“最坏情况”的概率压到无限小确定性算法有个很头疼的问题它会被“针对”。比如快速排序如果每次选的基准都是最大值或最小值复杂度就是 O(n²)这就是所谓的“最坏情况”。虽然我们可以用“三数取中”之类的策略来缓解但如果你遇到的数据本身就是精心构造的确定性策略依然可能被击穿。随机化算法的核心思路是我主动放弃对输入分布的假设把“随机性”放在算法内部而不是期待数据“配合”我。这时候分析复杂度我们不再说“最坏情况如何”而是说“期望复杂度如何”。这个期望是对算法内部的随机选择求的而不是对输入分布求的。这意味着不管你给我什么样的数据算法在绝大多数随机选择下性能都稳定在期望值附近被针对的概率是零——因为你做不到针对一个我抛硬币的算法。可以打个比方。你在一个迷宫里找出口确定性算法相当于一套固定的左手法则迷宫设计者如果知道你的法则可以在特定位置设计一个死循环让你绕不出来。而随机化算法是你在每个岔路口掷骰子决定往哪走迷宫设计者知道你掷骰子的规则却不知道你下一秒掷出的点数所以他无法刻意构造一个必然让你失败的迷宫。1.2 两类随机化算法你可能犯错还是可能超时课本上会把随机化算法分成两大类这个分类一定要刻在脑子里因为考试题目和实际工程选型第一件事就是区分你用的是哪一类。第一类叫Las Vegas 算法拉斯维加斯算法。它的特点是结果一定正确但运行时间是个随机变量。也就是说你掷骰子只会影响“什么时候算完”不会影响“算出来的对不对”。随机化快速排序就是典型代表你随机选基准排序结果永远是正确的但递归深度、比较次数会有波动。这类算法的分析核心是“期望时间复杂度”。第二类叫Monte Carlo 算法蒙特卡洛算法。它的特点恰好反过来运行时间是确定的或可控的但结果有概率出错。实际上在很多工程场景里我们追求的不是“绝对正确”而是“错误率小到可以忽略”。判断一个很大的数是不是素数用确定性算法可能要跑很久但 Miller-Rabin 素性检验就是典型的蒙特卡洛算法它可能在极低概率下把合数误判为素数但这个概率比计算机硬件随机的发生故障概率还要低得多工程上完全可用。这两类的区别期末必考面试也常问。记忆方法很简单赌输了的代价不同。拉斯维加斯赌的是“时间”输了重跑就行蒙特卡洛赌的是“答案”输了就是错的。2. 教科书绕不开的经典随机化快速排序与随机化选择咱们从最经典的随机化快速排序开始拆。这部分我会把“期望复杂度”的推导过程完整写出来因为很多同学期末挂就挂在“会背结论但不会推过程”。2.1 随机化快速排序的期望比较次数推导快速排序的基本框架大家应该都熟选基准划分递归排序左右两半。确定性版本的问题在于如果你每次选区间的第一个元素做基准而输入数组本身是逆序的那么每次划分都极不均匀——一边是空另一边是 n-1退化成 O(n²)。随机化版本的做法很简单不是在固定位置选基准而是在整个区间内均匀随机地选一个位置作为基准。这样一来无论输入数据是什么样每一个元素被选为基准的概率都是均等的。要分析它的期望复杂度我们不用去追递归树的具体形态而是用“指示器随机变量 线性期望”这套组合拳。我先定义一个指示器随机变量 X_ij表示第 i 小的元素和第 j 小的元素i j在排序过程中是否曾经被比较过。因为快速排序的性质是两个元素被比较当且仅当其中一个在另一个被划分到不同子问题之前被选为过基准。换句话说如果从第 i 小到第 j 小这 j-i1 个元素中最先被选为基准的是第 i 小或第 j 小本身那么它们会被比较如果最先被选为基准的是这中间的其他元素那么第 i 小和第 j 小会被分到不同子数组之后永远不会再见面也就不会被比较。由于基准是均匀随机选的这 j-i1 个元素中的每一个被最先选中的概率相等都是 1/(j-i1)。所以两个元素被比较的概率就是 2/(j-i1)。总比较次数的期望就是所有元素对比较概率的求和E[C] Σ_{i1}^{n} Σ_{ji1}^{n} 2/(j-i1)这个二重和怎么化简令 k j - i 1当 i 从 1 到 n 变化时k 的取值会覆盖从 2 到 n 的所有值一共 n-1 个。而分母是 k 的项在双重求和里出现的次数是 n-k1 次这一点可以自己拿纸笔验证一下取 n5 列出矩阵就很直观。所以E[C] Σ_{k2}^{n} 2(n-k1)/k 2n * Σ_{k2}^{n} 1/k - 2(n-1)右边是一个调和级数。Σ_{k2}^{n} 1/k ≈ ln n - 1。代入之后主导项是 2n ln n也就是 O(n log n)。这个推导过程看着长但拆解下来就三步定义指示器变量、计算单个事件的概率、利用期望线性求和。期末考如果出“证明随机化快速排序期望复杂度”这三步就是完整的答题骨架。2.2 随机化选择算法期望 O(n) 才是真正的“魔法”如果说随机化快排还算是“把确定性算法加了点随机性”那随机化选择Randomized Select就是真正展示了随机化的独特威力。问题很简单从一个无序数组中找第 k 小的元素。确定性算法最快的目前是 BFPRT中位数的中位数能保证最坏情况 O(n)但常数很大实际表现并不好。随机化选择算法则是快排思想的一个变体随机选基准划分后看基准的位置如果正好是第 k 小直接返回如果 k 在基准左边就递归在左半部分找在右边就去右半部分找。关键差异是它每次只递归一侧而不是两边都递归。期望时间怎么算我们用 T(n) 表示在 n 个元素中寻找目标元素的期望时间。因为基准是均匀随机的基准的排名 r即基准在数组中是第几小在 1 到 n 之间均匀分布。如果 r 正好等于 k那就找到了如果 r 大于 k说明目标在左半部分子问题规模变成 r-1如果 r 小于 k目标在右半部分子问题规模变成 n-r。所以期望递推式写成E[T(n)] ≤ Σ_{r1}^{n} (1/n) * E[T(max(r-1, n-r))] c*n这里的 max(r-1, n-r) 表示递归子问题的最大可能规模c*n 是划分本身的代价。怎么解这个递推式先算一下期望号里的最大规模。从直觉上看当 r 落在数组两端的极端位置时子问题规模接近 n但这种情况概率小当 r 落在中间附近时子问题规模接近 n/2。把这一坨整理一下可以证明 E[T(n)] ≤ O(n)。标准化一点的证明思路是归纳假设 E[T(n)] ≤ cn代入递推式用几何级数或者积分放缩掉那个 Σ最终能得出矛盾项被吸收归纳成立。这个过程工程上不用背但理解它的精髓很有用因为每次只递归一侧且基准均匀分布所以期望规模是线性衰减的整体求和就是一个等比数列收敛到 O(n)。这里我特别想强调一个工程经验随机化选择在实践中的常数非常小比 BFPRT 那套预处理要快得多所以很多标准库里的顺序统计量查找用的都是随机化版本而不是理论最优的确定性版本。这也正是“理论最优不一定实际最优”的典型案例。3. 从概率角度看两个最好用的工具生日悖论与蓄水池抽样除了排序和选择随机化算法还有两个非常实用的“小工具”在工程和考试中都是常客。一个是利用生日悖论来降低冲突概率的哈希设计另一个是蓄水池抽样。3.1 生日悖论为什么 23 个人的教室里就有 50% 概率出现生日重合先来复习一下生日悖论。一个班有 m 个人每个人的生日均匀分布在 365 天里。那么至少有两人生日相同的概率超过 50% 时m 只需要 23 个人。为什么直觉这么反常识因为直觉关注的是“和我同生日的人”概率确实很低但“任意两人”的组合数量是 C(m,2)组合数增长太快了。两两配对的数量是 m(m-1)/2当 m23 时254 对比较关系每对相同的概率约 1/365综合下来整体概率就迅速逼近 50%。这个结论在随机算法里最常见的应用是“指纹冲突”分析。比如我要判断两个巨大的文件是否相同或者两个集合是否相等一个技巧是把它们哈希成固定长度的指纹然后比较指纹。指纹相同则大概率内容相同指纹不同则一定内容不同。那么问题来了为了让指纹碰撞概率低于某个阈值我的哈希值应该有多长假设哈希值有 L 位那么可能的指纹总数是 2^L。根据生日悖论当比较的文件对数接近 2^(L/2) 的时候碰撞概率就开始变得不可忽略了。这被称为“生日界限Birthday Bound”。所以工业界在设计哈希长度时有个经验法则安全使用量级是哈希位宽的一半。这就是为什么很多加密哈希从 128 位升级到 256 位因为 128 位在哈希数十亿10^9个对象时已经越来越逼近 2^64 的碰撞边界了。如果期末考题中出现“设计一个随机化算法判断两个大文件是否相同”这类题目你要能反应过来用随机哈希比较指纹就可以分析里要用到生日悖论来说明指纹长度怎么选。3.2 蓄水池抽样你不知道数据总量却能等概率抽出样本另一个实用价值极高的随机算法是蓄水池抽样。问题的场景是有一个数据流你不能回头读之前的数据也不知道流的总长度 N但要求实时维护一个容量为 k 的样本集合使得流中每一个元素最终被选进样本的概率都是 k/N。这个算法极其优雅。前 k 个元素全部直接进蓄水池。从第 k1 个元素开始依概率 k/t 决定是否用当前元素替换池中随机一个元素其中 t 是当前已经流过的元素总数。为什么这样能保证等概率我们用归纳法。假设处理完前 t-1 个元素后池中每个元素被保留的概率都是 k/(t-1)。当第 t 个元素到达时它被选进池子的概率是 k/t。它进来之后会随机淘汰池中一个元素所以池中任意一个“旧元素”被淘汰的概率是 (k/t) * (1/k) 1/t。于是旧元素在第 t 次操作之后仍留在池中的概率是(k/(t-1)) * (1 - 1/t) (k/(t-1)) * ((t-1)/t) k/t所以每个旧元素和第 t 个元素在第 t 轮之后留在池中的概率都是 k/t数学归纳就成立了。整个过程不需要知道 N只需要存 k 个元素空间复杂度 O(k)时间复杂度 O(N)。我在实际工作中用蓄水池抽样解决过一个问题线上日志系统每天要处理几亿条用户行为日志需要从这些日志中等概率抽取一个“代表性样本”用来做离线分析。日志持续写入总量未知不能全部落盘再随机取——这时候蓄水池抽样就是完美的解决方案维护一个容量为 1 万的池子实时更新线上跑了一年多抽样统计结果和全量计算的历史数据差异完全在理论置信区间内。4. 蒙特卡洛方法可能出错但错得极其优雅拉斯维加斯类型的随机化算法虽然“答案永远正确”但有些问题根本没有高效确定性算法这时候蒙特卡洛方法就登场了。它用“可量化的出错概率”换取了“巨大的性能提升”。4.1 Miller-Rabin 素性检测为什么错误率 1/4 就够用了Miller-Rabin 是最经典的蒙特卡洛例子。判断一个大整数 n 是否为素数确定性试除法在 n 很大时完全不可行而 Miller-Rabin 可以在 O(log^3 n) 时间内给出一个可能性的判断。算法核心基于费马小定理和二次探测性质。费马小定理说如果 n 是素数那么 a^(n-1) ≡ 1 (mod n) 对所有与 n 互素的 a 成立。反过来如果你随机选一个 a发现 a^(n-1) 对模 n 不等于 1那么 n 一定是合数。但反过来不成立——有些合数对某些 a 也能通过同余检验这类数叫伪素数。Miller-Rabin 在此基础上增加了一个“二次探测定理”如果 n 是素数那么方程 x² ≡ 1 (mod n) 只有 ±1 两个解。如果 x² ≡ 1 的根不是 ±1那 n 必为合数。算法把 n-1 写成 2^s * d 的形式然后对随机基 a计算 a^d, a^(2d), a^(4d)... 这一序列检查是否出现“非平凡的平方根”。在每轮独立随机选择一个基的情况下一个合数在单轮测试中被误判为素数的概率不超过 1/4。单轮测试有 1/4 的出错率听上去挺慌的。但蒙特卡洛方法有一个强大的放大技巧独立重复 k 轮。每轮测试用不同的随机基只有当所有轮次都报告“可能是素数”时我们才接受。因为每轮判断相互独立合数连续通过 k 轮的概率就是 (1/4)^k。取 k 10错误率是 (1/4)^10 ≈ 0.00000095也就是约百万分之一取 k 20错误率约为 10^(-12)比硬件随机故障的概率还低几个数量级。事实上对于某些特定范围前人已经验证过使用一组固定的基如前 12 个素数可以确定性地区分出范围 [2, 3.3e24] 内的所有合数。所以实际工程中很多人直接用固定基的 Miller-Rabin安全性和确定性都很好。期末编程题如果考 Miller-Rabin重点就在于把模幂运算快速幂取模写对以及正确生成序列 a^d, a^(2d)... 这个序列的更新方式很容易写错建议写成 a a*a % n 逐步平方推进而不是每一步都重新算幂既能避免溢出也能减少时间开销。4.2 随机化最小割Karger 算法带来的“意外惊喜”说一个期末考试大概率不会考、但特别能体现随机化算法魅力的算法——Karger 的全局最小割算法。这个问题是找一张无向连通图中边数最少的一个割集把图切成两部分。确定性经典算法是 Stoer-Wagner复杂度和实现都很讲究。Karger 在 90 年代给出了一个极其简单的随机化算法每次随机选一条边把它的两个端点“合并”成一个超级节点合并过程中保留其他边去掉自环。重复这个操作直到图中只剩两个超级节点。这时这两个节点之间剩下的平行边的数量就是一个候选最小割的容量。整个过程只需要跑一次“随机收缩”。为什么这个算法能成立从直觉上理解如果图的最小割是容量为 c 的一组割边那么在随机收缩过程中只要这组割边中的任意一条被“选中并收缩”我们的候选答案就毁了。但好消息是在收缩的早期图中被合并的边越多剩下图中边的总数相对越少而最小割容量 c 在这些边中的占比就显得越大所以“避开割边”的难度实际上在逐步降低。每一步避开割边的概率大约为 1 - c/当前边数可以放缩到 1 - 2/(当前点数)。把这些概率连乘起来最终成功概率约为 Ω(1/n²)。也就是说随机跑一次得到正确答案的概率只有大约 1/n²。听上去成功率低得可怜但正因为每一次运行是独立的只要我把 Karger 算法重复跑 O(n² log n) 次并记录所有候选最小割中的最小值那么最终得到正确答案的概率就能逼近 1。单次运行只需要 O(n²) 的并查集操作整个算法仍是一个多项式时间的随机化算法而它的实现复杂度比 Stoer-Wagner 简单一个量级。Karger 的问题关注点不是期末备考而是想说明有些随机化算法看起来“成功率低得离谱”但配合重复独立试验它能用最简单的代码解决理论上看起来很难的问题。这种“暴力 概率 重复”的组合在实际工程里也特别常见。5. 期末复习重点与实验课避坑指南热词里出现了湘潭大学算法设计与分析期末编程题和期末题我猜不少读者正在期末周挣扎。以我多年看题和带新人的经验随机化算法这块的知识点考来考去其实就几个固定套路。5.1 期末常见考点与高频题型拆解我按考查频率给你列一张表可以作为考前自测的清单来用。考点常见出题形式核心解题思路易错点随机化快速排序期望复杂度证明题/计算题指示器变量 期望线性推导 E[C]O(n log n)忘记对“最先被选中的基准”做条件概率分析随机化选择算法算法设计题求第 k 小递归只进入一侧构造期望递推式并放缩递推式里用 max 函数时状态转移方向搞错判断算法属于 Las Vegas 还是 Monte Carlo简答题看结果是否正确还是时间是否有限随机化快排结果一定正确是 Las Vegas蓄水池抽样算法设计题数据流采样第 t 个元素以 k/t 概率替换池中元素归纳证明时概率更新写错Miller-Rabin编程题大数素性检测快速幂取模 二次探测 重复随机基没有把 n-1 分解为 2^s * d或模幂实现有溢出生日悖论与哈希冲突计算题指纹方案设计根据生日界限推导哈希位宽混淆 2^L 安全范围与 2^(L/2) 碰撞边界如果你时间紧优先啃下前四行。快速排序和选择算法的复杂度推导是最常考的大题Las Vegas 与 Monte Carlo 的分类是性价比最高的送分题蓄水池抽样经常作为编程大题出现代码就十来行非常好得分。5.2 实验课踩过的坑随机种子、数据生成与结果复现实验课上随机化算法最烦人的问题是结果不可复现。你的代码上午跑出 0.5 秒下午变成 0.8 秒老师让你截图报告时间你截了结果老师自己复现的时候数据差很多怀疑你造假。这里分享几个非常实在的建议。第一调试的时候固定随机种子。在 C 里设置srand(42)在 Python 里设置random.seed(42)这样每次运行的结果一致方便排查代码 bug。但是提交最终代码之前一定要把固定种子的代码删掉或者改成不固定种子。因为随机化算法的考点就是“随机性”如果你固定了种子相当于退化成确定性算法助教一眼就能看出来。第二你要收集的是“不同随机选择下的性能分布”而不是单次运行时间。实验报告里最专业的写法是跑 50 次记录最小时间、最大时间、平均时间和标准差同时和确定性快排做对照。面试时聊到一个随机算法能说出“平均 0.3 秒方差 0.05 秒”的人和只会说“大概零点几秒”的人专业度完全是两个层级。第三生成测试数据时注意构造退化情形。这是很多人都忽略的一点。比如测试随机化快排时不要只测随机数据还要专门生成有序数组、逆序数组、大量重复元素数组。理由很简单随机化算法不怕输入差但测试数据如果太单一你根本看不出算法在极端场景下的行为差异。我见过不少同学实验报告上写“随机化快排在所有输入上都表现优异”结果一看测试数据全是rand()生成的均匀随机数这种结论毫无说服力。5.3 工程实践中的选型经验与随机数的坑从工程角度选型时我想再强调三个经验。第一个经验是能用拉斯维加斯就用拉斯维加斯蒙特卡洛要慎用。原因很简单Las Vegas 算法错了可以重跑Monte Carlo 错了就是产品事故。在搜索推荐、分布式系统这种对结果正确性要求高的场景我们很少直接接受蒙特卡洛的“大概正确”除非像 Miller-Rabin 那样错误率已经压到硬件故障率之下。第二个经验是注意伪随机数生成器的质量。很多人写代码用rand()或者random()在 Linux 下默认的rand()质量很差低位的随机性尤其差。如果你用rand() % n来生成 [0, n-1] 的随机下标会产生严重的模偏差modulo bias尤其是 n 不是RAND_MAX1的因数时不同下标的出现概率并不相等。更好的做法是用 C11 的random库里的uniform_int_distribution或者 Python 的random.SystemRandom和secrets模块在安全性要求高的场景下使用。这一点在工程面试里也是高频加分点。第三个经验是随机化算法很适合做性能“兜底”。有时候你发现一个确定性算法在最坏情况下崩得很惨比如标准库的快速排序在特定输入下递归过深导致栈溢出与其费劲去分析输入特征不如直接引入随机化。工业级库如 glibc 的qsort里就混用了快速排序、插入排序和堆排序某些版本的实现在选择基准时也用了随机策略来防止恶意输入造成攻击。安全圈里有个概念叫“算法复杂度攻击”——攻击者通过构造最坏输入让服务响应变慢甚至宕机。引入随机化之后这类攻击就很难奏效因为你无法预测算法内部的选择。6. 随机化算法不是终点而是一种思维方式的入口说回最开始的问题为什么《算法设计与分析》课程要把随机化算法放在这么重要的位置我从个人经验出发最大的收获其实不是那几个具体算法的细节而是一种思维方式的转变。以前我分析一个算法下意识会问“最坏情况是多久”现在我会先问“这个最坏情况在真实输入下会出现吗”。如果不会那把最坏情况作为唯一指标就不合理更合理的可能是一个期望指标如果最坏情况本质上是“对手构造”出来的那随机化就是破解对手的最好武器。另一个转变是我开始接受“概率性的正确”这个观念。很多真实世界的系统问题追求绝对正确往往意味着付出不可接受的成本这时候一个错误率可证明、可调节、可监控的概率正确方案反而是一种更工程化的答案。这种思维方式的实用价值远不止于期末考试那几分也不止于面试时能多聊两个算法名字。它会在你面对真实问题时多给出一类思考路径我不需要去设计一个完美适应所有情况的复杂算法我只需要在关键决策点上加上一点随机性然后通过重复试验把风险压低。最后再分享一个实操建议。如果现在有读者在准备期末考建议你再刷一遍“期末编程题里最常见的三个随机化算法模板”随机化快排的 partition 写法、蓄水池抽样的循环实现、Miller-Rabin 的快速幂取模模板。这三个代码量都不大但性价比极高。理解它们能过考试理解它们背后的概率分析你能受益很久。别背代码去推一遍那个期望式子你会发现随机化算法真的像一个聪明又优雅的赌徒知道自己什么时候该下注也知道自己最坏会输多少。