☰
LeetCode 398 蓄水池抽样:随机索引的等概率与空间取舍
2026/10/5 8:44:34 网站建设 项目流程

LeetCode 上刷到第 398 题 Random Pick Index 时,我第一反应是:这不就是给个数组,随机返回一个目标值的下标嘛?等真正写完提交,才发现里面藏着概率均匀性和空间取舍两个考点。这道题在热门 100 题和各家面试题单里出镜率都不低,很多人背过答案,但被追问一句“为什么每个下标概率一样”就卡住。这篇博客就把这道题从题目到工程完全拆开,重点讲清楚蓄水池抽样(Reservoir Sampling)的来龙去脉,以及哈希表方案和蓄水池方案该怎么选。

1. 一眼看穿题目本质:随机、均匀、索引

1.1 题目还原与表面难度

题目本身一句话就能说清:给定一个整数数组nums和一个目标值target,要求返回target在数组中出现的任意一个下标,但每个下标被返回的概率必须相等。举个例子,如果nums = [1, 2, 3, 3, 3],pick(3)可能有 2、3、4 三种结果,每种概率都应该是 1/3;而pick(1)只能返回 0,pick(2)只能返回 1。很多第一次刷到这题的人,包括我,第一反应都是:这还用想?随机生成一个下标,判断nums[i]是不是 target 不就行了?

等真正去写,坑就来了。如果只在第一次匹配到 target 时返回,每次 pick 得到的是固定下标,完全不随机;如果先遍历一遍收集所有 target 下标,再随机选一个,这确实能过,但并非最优解,尤其是在nums非常大、pick调用频繁的场景下,内存和时间都会成为问题。LeetCode 上这题的标签里挂着 Reservoir Sampling(蓄水池抽样),这才是这道题真正想考察的点。也就是说,它表面是一道“随机返回索引”的模拟题,本质是一道概率题加流式数据处理题。

要真正掌握这题,需要弄明白三件事:一是如何保证“每个下标概率相等”这个约束,二是当数据规模变大时,空间复杂度能不能降下来,三是随机函数的选取会不会引入概率偏差。这三件事也就是这题的三个隐藏考点。

1.2 三个隐藏考点

第一个隐藏考点是概率均匀性。很多人会用rand() % k来选择下标,其中 k 是 target 出现的次数。在 k 比较小的时候,rand() % k的概率分布大体均匀,但严格来说,如果 RAND_MAX + 1 不能被 k 整除,余数较小的几个数出现概率会略微偏高。这种偏差在题目给的测试用例里几乎测不出来,但在工程场景下可能会导致抽样结果有偏。严谨的写法应该用 C++11 的<random>库里的uniform_int_distribution,而不是裸的rand()。

第二个隐藏考点是空间复杂度。哈希表方案先把所有下标存进unordered_map,pick 时直接随机取。预处理 O(n)、pick O(1),单个用例跑得飞快。但数组有十万、百万个元素时,每个元素都要存进哈希表,内存开销一下就上去了。而蓄水池抽样方案只保存nums本身,pick 时从头扫一遍,O(n) 时间和 O(1) 空间搞定。面试官问“如果这数组不是一次性给全,而是像一个数据流一样源源不断过来,你怎么做?”你会发现哈希表需要预知完整数据,而蓄水池抽样天然适合流式环境。

第三个隐藏考点是代码的边界处理。比如 target 不在数组里怎么办,虽然题目默认 target 一定存在,但工程上要处理;再比如数组为空怎么办,pick 一个不存在的值会不会越界;还有随机数为 0 时如何判断,连续多次 pick 会不会用到上一次的状态。这些细节往往是 LeetCode 提交时 WA 和 RE 的根源。理解了这三个点,再往下看两种解法就会清楚很多。

2. 解法一:哈希表缓存全部下标,空间换时间

2.1 实现思路与C++代码

哈希表方案的思路非常直接:在构造函数里遍历一次nums,建立一个从数值到下标数组的映射。之后每次调pick(target),只需要从哈希表里找到 target 对应的下标数组,然后用随机数生成一个下标,返回该下标即可。这个方案的代码量极小,逻辑也最简单,适合作为第一版实现快速通过题目。

class Solution { private: unordered_map<int, vector<int>> positions; public: Solution(vector<int>& nums) { for (int i = 0; i < nums.size(); i++) { positions[nums[i]].push_back(i); } } int pick(int target) { const vector<int>& v = positions[target]; int idx = rand() % v.size(); return v[idx]; } };

这段代码有个细节:unordered_map的operator[]如果 target 不存在,会自动插入一个空 vector,所以直接用没问题。但注意,v.size()返回的是size_t,rand() % v.size()的结果也是size_t,把它赋给 int 时,如果 vector 很小没问题;极端情况下size_t超过 int 范围会有隐患,但通常不会发生。更安全的写法是先缓存 size,用 int 类型,或者直接让 idx 为size_t然后强转。另外,使用rand()前不需要srand,LeetCode 的评测环境会自动处理,但本地调试时最好srand(time(nullptr)),否则每次运行随机序列固定,看不出概率特性。

这个版本很好理解,唯一要提醒的是,positions[target]这里会复制吗?不会。我用了const vector<int>& v绑定到哈希表里的 vector,不会拷贝。如果写成auto v = positions[target],那就是复制,target 出现次数多时会拖慢 pick。这点细节值得注意。

2.2 时间和空间复杂度

哈希表方案的复杂度很好算。构造函数里遍历数组,时间复杂度 O(n),空间复杂度 O(n),因为每个元素的下标都存了一份。pick 方法里,先查哈希表 O(1),再取随机数 O(1),从 vector 按下标取元素 O(1),所以整体 O(1)。这个复杂度在 LeetCode 上表现很好,通常几十毫秒就能通过。

但要注意,这里的“pick O(1)”是摊还意义上的,严格说还需要随机数生成器的开销。如果使用uniform_int_distribution,构造分布对象和生成一次随机数也是 O(1)。在实际测试中,当 target 重复次数特别多,比如一个长度为 10^5 的数组里全是同一个数,哈希表方案 pick 时是对长度为 10^5 的 vector 求一个随机下标,这没问题;而蓄水池方案 pick 时要从头扫完这 10^5 个元素,耗时明显。所以光看 LeetCode 的提交,哈希表方案可能会更快。

但是空间上的代价是实打实的:每个下标存成一个 int,假设 int 4 字节,再加上unordered_map的桶开销和 vector 的扩容开销,实际内存可能是原始数组的好几倍。如果nums是 100 万元素,哈希表方案可能吃几百 MB 内存,而蓄水池方案只需要保存 nums 本身,约 4 MB。这就是两者最大的分水岭。

2.3 适用边界与内存隐患

所以哈希表方案适合什么场景?适合 nums 不算太大,且 pick 调用非常频繁的场景。比如内存足够,一个服务启动后加载固定配置,里面是一堆 ID 到索引的映射,后续大量查询随机索引,用哈希表能保证每个 pick 都是 O(1),吞吐量高。反过来,如果 nums 是一个超大文件的映射,或者是一个无限数据流,哈希表方案就直接跪了,因为你要么存不下,要么根本等不到完整数据。

另外还有一个隐藏问题:原题中构造函数的输入 nums 后续被视为“不可变”,但工程上如果允许数组更新,哈希表里的下标会失效。比如数组某个位置的值改了,你需要同步更新哈希表,这在高频更新场景下很麻烦。而蓄水池方案每次 pick 都读数组当前值,天然支持数据变化。所以,回答这类问题时,要先跟面试官确认“数组是否只读”“能否用额外空间”,再决定用哪种方案。这也能体现沟通意识。

我自己的建议是:刷题阶段两种方案都写一遍,先哈希表确保会做题,再蓄水池确保懂原理。面试时如果实在想不出蓄水池,先把哈希表方案讲出来,再主动提一句“如果能接受 O(n) 空间,这个最简单;如果要求 O(1) 空间或者流式输入,可以用蓄水池抽样”,瞬间会让面试官觉得你有工程大局观。

3. 解法二:蓄水池抽样的单候选版,省空间又优雅

3.1 蓄水池抽样核心原理

蓄水池抽样是一类经典随机算法。最常见的问题是这样:有一个长度未知的数据流,你想从中等概率地随机抽取 k 个元素,但你不能把所有数据都读进内存,只能遍历一次。算法很简单:先把前 k 个元素放进“蓄水池”;从第 k+1 个元素开始,第 i 个元素以 k/i 的概率决定是否替换蓄水池中的某个元素;遍历结束后,蓄水池里的 k 个元素就是均匀随机样本。

当 k=1 时,算法退化成“单候选”版本:遇到第 i 个元素时,以 1/i 的概率把它设为当前候选,否则保留原来的候选。这个算法保证每个元素最终被选中的概率都是 1/n。它的美妙之处在于,你完全不需要知道 n 是多少,也不知道后面还有多少元素,只用一个变量存候选,一个变量计个数,就能得到等概率随机样本。

LeetCode 398 正是 k=1 蓄水池抽样的应用。注意这里的“数据流”不是整个 nums,而是“值为 target 的那些下标”构成的流。你不能提前知道 target 一共出现几次,但可以通过遍历 nums 时遇到一个 target 就数一个,把每个匹配位置当作流中的一个元素。第 i 个匹配位置出现的概率就是 1/i,最终每个匹配位置被选中的概率是 1/k,正好符合题目要求。

3.2 应用到398题的代码实现

按照蓄水池抽样的思路,pick 方法应该这样写:每次调用时,从下标 0 开始遍历 nums,维护 count 记录已经遇到了几个值等于 target 的元素;每遇到一个 target,count 加一,然后生成一个[0, count-1]的随机整数 r,如果 r == 0,就把当前下标设为 result。遍历完整个数组后返回 result。注意 count 和 result 都必须是 pick 内的局部变量,不能把它们放到类的成员变量里“累加”,否则上一次 pick 的状态会污染下一次。

class Solution { private: vector<int> nums; public: Solution(vector<int>& nums) : nums(nums) {} int pick(int target) { int result = -1; int count = 0; for (int i = 0; i < nums.size(); i++) { if (nums[i] == target) { count++; // 以 1/count 的概率替换 result if (rand() % count == 0) { result = i; } } } return result; } };

这里的rand() % count == 0表示随机数等于 0,概率是 1/count。例如 count 为 3,rand()%3的结果为 0、1、2 各 1/3 概率,只有结果为 0 时替换,所以新元素被选中的概率正好是 1/3。这个写法的优点是简洁,缺点是rand()%count有轻微的 modulo bias,在严格场景可以用uniform_int_distribution:

std::random_device rd; std::mt19937 gen(rd()); ... if (std::uniform_int_distribution<int>(0, count - 1)(gen) == 0) { result = i; }

LeetCode 的判题用例比较宽松,用 rand() 也能通过,只是面试时最好提一嘴更优实现。但要注意,std::random_device和mt19937最好定义为类成员或 static,避免每次 pick 都重新生成引擎,那样会浪费资源,也可能影响随机性。

3.3 等概率性的数学证明

为什么这个简单算法能保证每个 target 下标被选中的概率都是 1/k?我们用数学归纳法来证明。假设 target 一共出现了 k 次,下标依次记为 t1, t2, ..., tk。处理到 ti 时,它被替换进 result 的概率是 1/i。问题是后面 tj(j > i)还有可能替换掉它,所以 ti 最终成为 result 的概率等于“ti 被选中”且“后续所有 tj 都不替换它”的概率。

后续每个 tj 替换 result 的概率是 1/j,不替换的概率是1 - 1/j = (j-1)/j。把这些概率乘起来:

P(ti 最终被选中) = (1/i) × (i/(i+1)) × ((i+1)/(i+2)) × ... × ((k-1)/k) = 1/k。

这个连乘中,从第二项开始分子分母交错相消,最后只剩 1/k。所以不管是第 1 次出现的 target 还是第 k 次出现的 target,最终被选中的概率完全一样,都是 1/k。这就是蓄水池抽样最核心的数学保证。这个证明依赖于每次替换的独立随机性,所以每次调用 random 都必须重新生成,不能用一个固定的随机序列。

3.4 实现时容易翻车的三个细节

细节一:随机数条件的写法。有些同学会写if (rand() / RAND_MAX < 1.0 / count),这样会引入浮点比较,而且rand() / RAND_MAX在整数除法下直接恒为 0,导致永远不替换。正确做法是取模判断,或者用整数随机分布。细节二:count 的语义。count 只统计 target 出现的次数,不是遍历下标 i。如果写成if (rand() % (i+1) == 0),那就把所有数组元素都当作流,非 target 也会参与替换,结果错误。细节三:result 的初始值。可以初始化为 -1,因为题目保证 target 至少出现一次,所以循环内必会更新;如果 target 不存在,返回 -1 也算一种可预期的行为,但 LeetCode 不会出现这种情况。如果遇到“数组为空”的极端输入,需要先判空,避免整数溢出或越界。

另外还有一个很隐蔽的细节:如果 nums 是成员变量,在 pick 里遍历它时,要注意 nums 是否可能在多线程环境下被修改。LeetCode 单线程没问题,但工程中可能要考虑加锁或使用不可变快照。这些属于扩展讨论,放在后面工程部分细说。

4. 两种方案的正面交锋:怎么选才不亏

4.1 时间空间对照表

把两种方案放在一起看,优劣就非常清晰了。整理一张对照表,方便面试和复习时一眼看出差异。

维度哈希表缓存蓄水池抽样
预处理时间O(n)无(构造时只保存引用)
pick 时间复杂度O(1)O(n)
空间复杂度O(n)O(1)(不含原始数组)
支持流式数据否是
支持动态数组不方便自然支持
随机均匀性容易实现数学上严格等概率
代码复杂度低中
适合场景多次查询、数据量可控数据量大、内存受限

这里强调一点:哈希表方案的 pick O(1) 是理论上限,实际还要考虑unordered_map的哈希计算和 vector 的访存;蓄水池方案的 pick O(n) 是最坏情况,如果 target 出现得很早,它还是会傻傻地扫完整个数组,因为它“不知道”后面还有没有 target,必须全部看完才敢返回。这在某些场景下会觉得浪费,但这就是流式算法的代价。

4.2 面试官视角:哪种方案更高级?

在面试时,如果你只给出哈希表方案,面试官一般会追问“能不能不用额外空间”。这就提示你要往蓄水池想。事实上,398 在 LeetCode 的标签里明确有 Reservoir Sampling,所以面试官如果考这题,八成是想听蓄水池抽样的推导而不是哈希表。不过,就算你面试时先讲哈希表,也完全没问题,因为从工程角度哈希表方案在“多次查询”的场景下效率更高,是合理的权衡。关键在于你能不能主动分析两种方案的 trade-off,而不是只会背代码。

一个加分的回答思路是:先确认数据规模。如果数组长度在百万级以内,且 pick 会被频繁调用,哈希表是更好的选择;如果数组长度上亿,或者数据来自 Kafka、日志文件等流式源,就必须用蓄水池。可以这样回答:“我会先看约束条件,如果内存紧张或者数据流式到达,蓄水池抽样 O(1) 空间是唯一可行解;如果内存充足并且查询次数多,哈希表用空间换时间更合理。”这种回答展示了你在做 engineering trade-off。

4.3 变体题目与举一反三

理解了蓄水池抽样后,LeetCode 上很多随机抽样的题都是纸老虎。最经典的变体是 LeetCode 528 按权重随机选择:给定每个下标的权重,要求按权重概率随机返回下标。这题可以用前缀和加二分查找,本质上和蓄水池抽样思路不同,但目的都是控制随机概率。另一个变体是“从数据流中随机选取 k 个元素”,这就是标准的蓄水池抽样 k>1 版本,很多公司的高频题。还可以抽象出“等概率随机整数生成”问题,比如用 rand7() 生成 rand10(),这也和随机均匀性相关。

遇到这类题目,我建议你总结一个套路:第一步,明确随机事件是什么;第二步,确认是否需要知道总数;第三步,选择遍历方式;第四步,用概率公式验证均匀性。把 398 彻底弄懂,再去做 382(链表随机节点)就非常顺,因为 382 就是蓄水池抽样在链表上的直接应用。这些题放在一起刷,效率会高很多,也符合 LeetCode 热门 100 题里“一类题一起刷”的备考思路。

5. 实测与常见错误排查:别被随机数骗了

5.1 用频率检验随机均匀性

写完解法后,怎么验证它真的“等概率”?光看一遍逻辑是不够的。我通常会在本地写一个测试:创建一个大小为 100 的数组,某个 target 出现 10 次,然后调用 pick 十万次,统计每个下标出现的次数。理想情况下每个下标出现约一万次。如果某个下标明显偏多或偏少,说明随机算法有问题。当然,随机数有波动,十万次采样下偏差在几个百分点内都算正常,别因为一次测试没精确等于一万就慌。

更科学的验证方法是计算卡方统计量。把每个下标出现的次数记为 O_i,期望次数 E = 总次数/k,计算sum((O_i - E)^2 / E),得到一个卡方值,再查自由度为 k-1 的卡方分布临界值。如果卡方值落在可接受范围,说明均匀性没有显著问题。这个方法在工程上做 A/B 测试流量切分时也常用。不过 LeetCode 刷题阶段不需要这么严格,用直方图目测就够。

5.2 五个容易踩的坑

我在反复提交 398 的过程中,总结出五个高频坑。第一个坑是把 count 定义成类的成员变量并在多个 pick 之间复用。这样第二次 pick 时 count 已经等于上一次的 target 总数,导致概率计算错误。正确做法是 count 和 result 都在 pick 函数内部定义。第二个坑是用 i+1 代替 count,也就是对数组所有元素计数而不是只对 target 计数。虽然 i+1 在数字上一直在增长,但非 target 的下标会搅乱随机替换逻辑,最终概率完全不对。

第三个坑是使用rand() % count时,如果 count 是 0(target 没出现),会触发除零错误。虽然题目保证 target 存在,但防御性编程还是要先判空。第四个坑是误以为哈希表方案更快就只采用哈希表,结果面试官问“数组是一个流,不能预先加载”,当场卡壳。第五个坑是本地调试时忘了 srand,每次都从同一个种子开始随机,导致看起来有规律,误以为算法有问题。这个坑很冤枉,因为 LeetCode 评测环境会把随机种子设置好,而本地不会。

5.3 我推荐的调试顺序

我这里分享一个实测有效的调试顺序。第一步,先用最简单的用例走一遍代码,比如nums = [1],pick(1),确认返回 0。第二步,用重复值用例,比如nums = [1,1,1],pick(1),手动模拟 count 从 1 到 3,每步替换概率分别是 1/1、1/2、1/3,确认逻辑没有笔误。第三步,写一个循环调用 pick 10000 次,打印频率分布,观察是否大致均匀。第四步,把 nums 换成包含多个不同值的数组,随机挑选 target 测试,确保不会崩溃。第五步,再检查代码中是否有未使用的成员变量、是否用了 C++ 的随机库但忘了初始化。

一开始我建议先用哈希表方案提交,保证题通过,然后再改成蓄水池方案,对比两个提交的耗时和内存。这样既熟悉了两条路,又不会被编译细节卡住。等你把两种写法都 run 熟,面试时无论从哪个角度问都能接住。

6. 跳出题目:随机索引的工程应用

6.1 从代码到服务发现

398 的蓄水池抽样思维,在真实系统里到处都是。拿服务发现举例:一个服务有 N 个可用实例,客户端想把请求均匀地分散到每个实例,最简单的方法是把实例列表装进数组,随机选一个下标。但如果实例列表是一个动态变化的流,每秒钟都有实例注册和下线,你不可能每次请求都重新构造一个完整列表。这时可以在流量进入时遍历当前可用实例,用蓄水池抽样的方式等概率选一个,且只需 O(1) 额外空间。这就是微服务客户端负载均衡里一个很常见的随机策略。

再比如日志抽样。一个高吞吐系统每秒产生百万条日志,不可能全量落盘。如果只抽 1% 的日志做监控,可以维护一个 1/100 的计数器,每 100 条日志采一条。但这属于确定性抽样,不是等概率随机。如果用蓄水池抽样,可以保持从开始到现在每一条日志被采样概率一致,并且在不知道总条数的流式环境下也成立。这类算法在监控和可观测性系统里很重要。

6.2 并发场景下的随机线程安全

在工程中还有一个容易忽略的问题:多线程同时调用 pick 时,rand() 和 uniform_int_distribution 的线程安全性。标准库的 rand() 使用全局状态,多线程调用时会有数据竞争,结果可能不是均匀的,甚至可能崩溃。C++11 的<random>中,如果多个线程共享同一个随机数引擎,也需要加锁保护;更好的做法是每个线程一个 thread_local 局部随机数引擎,避免锁竞争。

对于蓄水池抽样 pick,即使随机数生成是线程安全的,遍历 nums 时如果别处同时修改 nums,也会读到不一致数据。所以要么设计成不可变对象,要么在遍历时加读锁。刷题时不需要考虑这些,但你要知道这题背后的算法一旦落地,需要考虑并发和线程模型,这也是高级工程师和初级工程师的区别。

6.3 一点个人经验

最后分享一点我自己的刷题经验。398 这题我前后写过四遍,第一遍用哈希表,第二遍用蓄水池,第三遍尝试用 Python,第四遍专门为了面试手推概率公式。每次重写都会发现新的细节,比如 rand() 的 modulo bias、count 是 int 还是 size_t、要不要加 random_device。其实 LeetCode 上很多题都是这样,第一次 AC 只是开始,能清楚解释“为什么这样写”才算真正会了。建议你把 398 和 382、528 这三道题放在一起二刷,做一次横向对比,你会形成“随机抽样”这一类题的完整方法论,以后再遇到类似题目基本就是默写。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询