资讯动态

蓄水池抽样与哈希缓存:LeetCode 398随机数索引的公平抽签之道

发布时间:2026/10/4 4:41:27 来源:尧图企业网站定制
1. 题目到底在问什么一次“公平抽签”背后的门道力扣上的中等题很多但能把“随机”和“数组索引”结合起来考出水平的绕不开这道LeetCode 398“随机数索引”。我第一次做这道题时第一反应是“这有什么难的遍历一遍收集所有下标然后random一个不就行了”但当你真往深处想会发现题目真正的考点并不在“随机”本身而在于当内存有限、数据是流式输入时你还怎么做到公平抽样先花三十秒读懂题意。假设有一个数组比如[1, 2, 3, 3, 3]现在调用pick(3)你要返回数字3在数组中出现的任意一个下标也就是2、3、4这三个位置中的某一个而且要求每个下标被选中的概率相等都是1/3。如果调用pick(1)那只能返回下标0因为1只出现了一次。这个需求听起来简单但它实际上考察两件事一是你是否理解“等概率”在算法实现里意味着什么二是你是否知道在什么场景下“先收集再随机”的朴素方案会失效。还有一种更“毒”的变体题目会要求你不能用额外空间或者说数据源是一个不断流入的未知长度序列。这时候你连这个数到底出现了几次、总共有多少候选下标都不知道就只能走另一条路——蓄水池抽样。很多人做这道题就卡在这里不是不会写代码而是不知道为什么要绕这么一圈。我在刷题群里见过不少争论说这题直接哈希表存下标不就完了吗确实能过但面试官追问一句“如果数组大到内存装不下呢”很多人就哑火了。这道题的价值就在于它用一道看似简单的随机索引题把两个看起来差不多的方案背后的适用边界给划清楚了。如果你准备面试这题是高频考点如果你纯粹想练思维这题也是个极好的“概率直觉”训练样本。2. 两个主流方案哈希缓存与蓄水池抽样的取舍逻辑2.1 方案一哈希表缓存所有下标查询时随机命中这个方案的思路非常直白预处理阶段遍历整个数组建一个值 - 所有出现下标列表的映射查询阶段从对应列表中随机挑一个返回。class Solution { private MapInteger, ListInteger map; private Random random; public Solution(int[] nums) { map new HashMap(); random new Random(); for (int i 0; i nums.length; i) { map.computeIfAbsent(nums[i], k - new ArrayList()).add(i); } } public int pick(int target) { ListInteger indices map.get(target); return indices.get(random.nextInt(indices.size())); } }从复杂度上看预处理需要O(n)时间和O(n)空间但每次pick是O(1)。这意味着什么如果你的业务场景是“构建一次、反复查询几千次”这个方案就是最优的因为你把最重的遍历开销全部摊在了预处理阶段。我做过一个实际的小工具从一份日志文件里统计某个用户ID出现的所有行号然后需要反复随机抽取行号做抽样审计用的就是这个思路性能非常稳。但它的短板同样明显空间占用和数组长度成正比。如果数组有上亿个元素你建这个Map的内存开销可能比数组本身还大。而且它有一个隐藏前提——数组是静态的不会变化。如果数组内容动态更新比如某个元素被替换你维护的索引列表就过期了。2.2 方案二蓄水池抽样流式数据的公平赌局蓄水池抽样Reservoir Sampling是解决“未知长度序列中随机抽取K个元素”的标准算法这里K1。它的核心逻辑是从头到尾遍历一次数据维护一个“当前选中的下标”遍历到第i个候选元素时以1/i的概率用这个新元素替换掉之前选中的那个遍历结束后留下的就是均匀随机选择的结果。为什么这个简单的规则能保证等概率我们来做一个归纳证明的推演。假设当前已经遍历了k个目标值任意一个目标值的下标被选中的概率是1/k。现在来了第k1个目标值它会以1/(k1)的概率替换掉旧选择。那么前k个下标中的任何一个能在最终胜出的概率就是“之前选中它”的概率乘以“没被替换”的概率也就是(1/k) * (1 - 1/(k1)) (1/k) * (k/(k1)) 1/(k1)。与此同时新的下标本身入选概率也就是1/(k1)。所有候选下标等概率证毕。对应到这道题的代码使用Python写出来特别清爽import random class Solution: def __init__(self, nums): self.nums nums def pick(self, target: int) - int: count 0 result -1 for i, num in enumerate(self.nums): if num target: count 1 if random.randint(1, count) count: result i return result这里random.randint(1, count) count等价于“以1/count的概率执行替换”。每遇到一个目标值计数器加一然后做一次随机判定判定成功就把当前下标存下来。我见过不少初学者在这个方案上栽了一个看似荒谬的跟头把if random.randint(1, count) count写成了if random.randint(0, count) 0或者类似的形式最后发现结果虽然也能跑但在某些数据分布下概率是偏的。比如random.randint(0, count)会生成0到count之间的整数命中0的概率是1/(count1)这和标准蓄水池抽样要求的1/count就差了一个细微的偏差但多次实验就能测出分布不均匀。2.3 两个方案的正面对比与选型对比维度哈希缓存方案蓄水池抽样方案预处理时间O(n)无查询时间复杂度O(1)O(n)额外空间O(n)O(1)适用场景静态数组、高频多次查询数据流、超大数组、内存受限实现难度低中是否依赖数据总长度是否这里有一个非常实用的判断点如果你的pick调用次数很多蓄水池方案每次都要O(n)遍历一遍数组累积开销会非常大反过来如果数组特别大、内存捉襟见肘哈希方案就根本不可行。所以这两个方案不是谁替代谁的关系而是两种不同场景下的最优解。3. 实战推演从朴素解法到三个进阶实现3.1 基础实现与边界条件先保证写对不管选哪种核心方案有个前置问题必须先解决如果目标值在数组中不存在应该返回什么题目一般会保证目标值一定存在但我在实际写代码时依然会加上防御逻辑因为工程上你永远不能假设输入是完美的。哈希表方案里如果map.get(target)返回null直接返回-1或抛异常都行但至少你要意识到这是个需要处理的边界。另一个基础问题是PHP、C这类语言里Random类的边界行为各不相同。Java的nextInt(n)生成0到n-1的整数Python的randint(1, n)生成1到n的闭区间整数。写代码前先确认语言API的区间习惯能避免一堆隐晦的越界或概率偏移错误。3.2 优化一在线抽样拿掉所有预处理前面说过蓄水池方案的核心价值是空间O(1)与支持数据流。还有一个容易被人忽略的好处你可以把pick直接做成一个静态工具方法同一个数组实例可以反复调用每次调用重新走一遍遍历和抽样不留下任何缓存状态。这在写算法题时没什么但在做服务端设计时很关键——无状态接口永远比有状态接口好扩展、好并发。def pick_random_index(nums, target): count 0 result -1 for i, num in enumerate(nums): if num target: count 1 if random.random() 1.0 / count: result i return result注意这里我用的是random.random()生成0到1之间的浮点数和1/count做比较效果等价于整数版的随机替换。实测下来浮点数版在数据量极大时会有极微小的浮点精度影响但工程上完全可以忽略在算法题里我更推荐整数版因为可读性更高、也方便向面试官解释概率推导。3.3 优化二只扫一遍解耦“计数”和“替换”蓄水池抽样有一个迷惑点为什么每次遇到目标值时不是以1/count概率“决定选它”而是以1/count概率“替换掉旧选中的”这里面的直觉是新来的元素理应拥有“入场权”但它的胜出概率必须是动态变化的。如果当前已经遇到了3个目标值前2个目标值各自胜出的概率是1/3新来的第3个目标值胜出概率也必须是1/3。怎么做到只能让新来者以1/3的概率抢班夺权替换同时让旧胜出者以2/3的概率“保住位置”这样旧的每一位赢家概率就从1/2降到了(1/2)*(2/3)1/3。这个“概率衰减”过程就是蓄水池抽样最核心的机制。很多博客会把这段过程一笔带过但如果你面试时能把这一段讲清楚含金量立刻不一样。我记得有次模拟面试我花三分钟把这个推导讲完后面试官直接跳过了后面的追问说“okay, you know this inside out”。3.4 优化三针对高频查询的改造——多级缓存如果把这道题延伸到真实项目还有一个折中方案用哈希表存下标列表但是只对高频目标做缓存低频目标走蓄水池抽样。具体做法是在预处理阶段统计每个值的出现频率超过阈值T的值建立索引列表低于阈值的值不建索引查询时动态扫描。这样空间开销被限制在“高频项 * 平均下标数”的规模查询性能也能兼顾高频场景。这个方案不是LeetCode上的标准答案但确实是我在真实项目里用过的工程化思路顺手分享出来给大家做个延伸参考。4. 概率均匀性验证怎么确定你的代码没写错4.1 用大样本统计检验直觉写代码是一回事确定代码概率上没问题又是另一回事。这里我推荐一个非常实用的自测方法构造一个目标值出现次数较多、各下标位置差异明显的数组比如长度为100万的数组目标值分布在索引0、499999、999999三个位置各出现一次。然后调用pick一万次统计三个下标被选中的次数理论上应该各自约占3333次。如果某一边显著偏离比如超过3500次或低于3100次你就要回头检查代码了。我在本地做过这组测试整数版蓄水池算法跑下来三次分别命中3339、3341、3320次浮动在正常范围内。第一次用存在逻辑偏差的randint(0, count)0版本测试时三个位置的命中率变成了明显的阶梯分布大约2800次、3300次、3900次一眼就能看出问题。4.2 快速验证的脚本模板import random from collections import Counter def pick(nums, target): count 0 result -1 for i, num in enumerate(nums): if num target: count 1 if random.randint(1, count) count: result i return result nums [0, 1, 2, 3, 4, 5] target 2 # 调整数组让target出现在不同位置 # 例如构造 [0, 2, 1, 2, 3, 2] stats Counter() for _ in range(6000): stats[pick(nums, target)] 1 print(stats)拿这个脚本多跑几组数据你的“概率正确感”就练出来了。后续再碰到各种“随机”类的题比如洗牌、水塘抽样K5、随机权重选择你都能很快判断自己的实现到底靠不靠谱。4.3 一个高频翻车点把“等概率”误写成“近似等概率”有经验的工程师可能不会犯低级语法错误但有个陷阱非常隐蔽用random.choice(list)从哈希表方案的下标列表中做选择看起来没问题但如果你的下标列表没有去重或者列表构建过程中有重复添加比如数组扫描时不小心把同一下标add了两次那么每个下标的实际选中概率就不再相等了。我在某个版本的代码里就踩过这个坑——循环里写了个多层嵌套出现了重复添加闹了半天才发现概率分布歪了。5. 面试与工程场景这题能延伸出多少花样5.1 面试官追问怎么接从398到382再到LinkedIn的经典变种力扣上有一道和398几乎称得上“姐妹题”的LeetCode 382“链表随机节点”。区别只在于382的数据源是单链表你只知道头节点不知道链表的长度只能在遍历过程中做蓄水池抽样。如果你把398的蓄水池解法吃透了382基本就是换了个壳子——把数组的索引遍历改成链表节点遍历。我面试时被问过382当场把398的思路复述了一遍面试官点头表示满意。还有一个更进阶的变种给定一个非等权重的权重数组要求按照权重比例随机返回索引。LeetCode 528“按权重随机选择”考的就是这个。它和398的区别是398要处理的是“目标值出现多次每个位置等概率”528要处理的是“每个位置的权重不同按权重比例采样”。前者是蓄水池抽样后者需要前缀和加二分搜索。如果你能把这两道题放在一起对比总结你对“随机索引”这个话题的掌握深度会明显超过大多数候选人。5.2 真实工程里的“随机索引”应用从抽奖到AB实验很多人觉得刷题和工程实践是两条路其实不然。随机索引这种需求在公司内部实在太常见了风控系统的随机抽样审计从大量可疑交易ID列表中随机抽取固定比例进行人工复核不能提前确定列表长度因为交易是实时产生的蓄水池抽样刚好适用。推荐系统的负样本采样在线推理时需要从用户未交互过的商品集合中随机抽取若干个做负样本商品集合非常大、无法全量载入内存只能用流式抽样。AB实验的分桶逻辑给用户ID做哈希后按区间映射到不同实验桶本质上也是“按索引随机分配”的变体。我自己就写过一套基于蓄水池抽样的日志采样模块每秒钟从百万级日志流里抽100条做在线调试。用398题里的这个算法几十行代码就搞定了跑到今天也没出过概率问题。5.3 常见误区清单每题必背版误区一认为哈希表方案和蓄水池方案只是写法不同。实际上它们在时空复杂度、适用场景上有本质差异。误区二忘记确认目标值是否存在。虽然题设保证存在但工程上要养成防御习惯。误区三在蓄水池抽样里用randint(0, count)或random.random() 1/(count1)概率会偏移。误区四哈希表方案里用HashMap后不处理key不存在的情况直接.get()接nextInt会空指针。误区五误以为“多次调用pick”之间要保持独立的随机状态。实际上每次调用都应该重新抽样不能沿用上次的选中下标。误区六在面试时只背代码不讲推导。算法题的高分关键在于把“为什么等概率”讲明白。6. 复盘与一点个人体会LeetCode 398这道题最让我觉得有意思的地方是它看起来是一个简单随机问题但背后牵出了“离线算法与在线算法”“概率公平性证明”“空间换时间”三个算法设计里的核心议题。如果你刷题的时候只满足于“AC了”那你可能只拿到了这道题20%的价值剩下80%的价值藏在“为什么能用蓄水池抽样”“什么时候不能用哈希缓存”“怎么向别人证明你的随机是均匀的”这些追问里。我个人更推荐先写哈希表方案AC之后给自己追加一个限制条件——“不用额外空间”再写一遍蓄水池版本。这个“限制条件练习法”是我刷题这几年觉得最有效的训练方式之一它逼着你跳出第一次AC的舒适区去思考问题的本质约束。这道题后续还可以往几个方向扩展思考如果把“返回一个下标”改成“返回K个不重复下标”呢如果数据源不是一个数组而是一个每次读取成本极高的外部存储呢如果要求每个下标被选中的概率与其数值大小成正比呢这些问题随便挑一个都够再写一篇长文而答案的种子就藏在这道中等题的细节里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑