前几天有个准备秋招的朋友发消息问我:LeetCode 398这道题,代码我背下来了,但面试官让我现场证明"为什么这样选是等概率的",我当场卡住了。这大概是很多刷题人的真实状态——题目本身叫 Random Pick Index,翻译过来就是"随机数索引",代码短到可以闭眼默写,但里面藏的蓄水池抽样(Reservoir Sampling)思想,才是面试官真正想挖的东西。
这篇文章我准备把这道题从原理到代码、从证明到面试话术全部掰开讲透。内容包括:为什么最直观的哈希表解法不是最优解、蓄水池抽样为什么能"扫描一遍就等概率"、Python 和 Java 的双语言实现细节,以及面试里考官最常追问的几个变形和翻车点。无论你是刚开始刷题的新手,还是准备系统过一遍高频题的老手,这篇应该都能帮上忙。
1. 题目到底在考什么:先分清"随机返回下标"的三种解法差异
1.1 先手写最直觉的答案
398 的题意一句话就能说清:给定一个整数数组 nums,可能包含大量重复元素。要实现一个 pick(target) 方法,每次调用时从数组中所有等于 target 的下标里,等概率返回任意一个。
绝大多数人第一反应是预处理:用哈希表把每个值出现过的下标都收集起来,pick 的时候在对应列表里随机选一个。这个方案没有任何错误,代码也很直白:
import random from collections import defaultdict class Solution: def __init__(self, nums): self.pos = defaultdict(list) for i, num in enumerate(nums): self.pos[num].append(i) def pick(self, target): return random.choice(self.pos[target])复杂度也很清楚:初始化要做一遍全量遍历,时间复杂度 O(n),空间上要维护每个值的下标列表,最坏情况是数组里只有一个数、出现了 n 次,那就要存 n 个下标,空间复杂度 O(n)。查询时是 O(1) 时间,直接在列表里随机挑一个。
这个答案能拿满分吗?在力扣上能通过,在面试里可能只能算及格。因为题目出现在"蓄水池抽样"这个标签下,考官想听的解法不是哈希表,而是空间 O(1)、只扫描一遍的蓄水池思路。
1.2 但如果数据是"流"呢:哈希表方案的空间隐患
不妨顺着这个思路想一个场景:假设 nums 不是一个固定下来的数组,而是源源不断产生的数据流——比如后端实时打印的日志、用户不断产生的点击行为、传感器每秒上报的数据。你根本不知道总量是多少,也没法提前把全部下标收集进哈希表。
这时候哈希表方案就失效了:它必须等数据全部到齐、落成数组之后才能建索引。而蓄水池抽样恰好天生就是为"流式数据"设计的:数据来一个处理一个,处理完就可以丢弃,全程只保留一个(或 K 个)候选位置,空间开销是常数级。
所以对 398 这道题,哈希表和蓄水池抽样都能做,但背后的工程语义完全不同。这也是本文第一件要说清楚的事:你写的每一行代码,背后对应的是对"数据是否可预知、是否可存储"的假设。
2. 蓄水池抽样:为什么扫描一遍就能做到等概率
2.1 核心思想:逐个元素"赌博式"替换
先说结论。蓄水池抽样在本题的精简版是这样的逻辑:
从左到右扫描数组。每遇到一个等于 target 的下标,就记为"第 cnt 个目标下标"。对第 cnt 个下标,以 1/cnt 的概率把它选为新候选,否则保留之前的候选。扫描结束后,候选下标就是最终的返回值。
翻译成人话就是:看到第一个目标下标时,反正只有它一个,直接选它;看到第二个时,掷一枚"平均分"的骰子,有 1/2 概率换到第二个、1/2 概率留着第一个;看到第三个时,有 1/3 概率换到第三个、2/3 概率在原来两个里保留一个。这个过程非常像公司在年会上用"击鼓传花"的方式抽奖——每个到场的人都有机会成为最后的获奖者,但概率取决于他在队伍里的位置。
这个机制最反直觉的地方是:你明明只保留了最后一个"赢家",却要求所有出现过的下标都有相同的被选中概率。为什么每个下标最终的概率不是越靠后越大?这是理解这道题的关键,也是面试官最想听到的推导。
2.2 数学归纳法,一次讲透
假设目标值在整个数组中共出现 m 次,下标按扫描顺序记为第 1 个、第 2 个、……、第 m 个。算法结束后,我们要证明:任意第 j 个下标成为最终候选的概率都是 1/m。
用数学归纳法,更准确的描述是:处理完前 i 个目标下标后,当前候选恰好是其中任意一个的概率都是 1/i。
- 当 i = 1 时,只有一个候选,它被选中的概率是 1,即 1/1,成立。
- 假设处理完前 i-1 个目标下标时,前 i-1 个中的每个下标成为候选的概率都是 1/(i-1)。
- 现在来了第 i 个目标下标。算法以 1/i 的概率用它替换旧候选,因此第 i 个下标成为新候选的概率就是 1/i。
- 对任意 j < i,它在第 i 轮"存活"下来的前提是:上一轮它是候选(概率 1/(i-1)),并且这一轮没有被替换(概率 1 - 1/i = (i-1)/i)。两者相乘,恰好是 (1/(i-1)) × ((i-1)/i) = 1/i。
所以处理完第 i 个下标后,前 i 个下标每个仍有完全相同的概率 1/i。当 i 走到 m,每个目标下标成为最终答案的概率就是 1/m。等概率成立。
这个证明里最重要的两个数字是 1/i 和 1 - 1/i。前者保证"新来者"得到它应得的一份概率;后者保证"旧候选"们把概率均匀让渡出来。一个在拿,一个在让,分毫不差。
2.3 换个角度再看:连乘消元的直观理解
如果觉得归纳法太抽象,还可以换一种更"算术"的理解方式。假设一个下标是第 k 个被扫到的目标下标。它要成为最终答案,需要发生以下事件:第 k 轮它被选中,之后每一轮都不被替换。
第 k 轮选中它的概率是 1/k;第 k+1 轮不被替换的概率是 1 - 1/(k+1) = k/(k+1);第 k+2 轮不被替换的概率是 (k+1)/(k+2);……;第 m 轮不被替换的概率是 (m-1)/m。
把这些乘起来:
1/k × k/(k+1) × (k+1)/(k+2) × ... × (m-1)/m
中间项全部约光,剩下 1/m。你看,k 无论取 1 还是 m-1,最终概率都是 1/m。这就是蓄水池抽样"公平"的本质:每一项分子分母前后相消,位置靠前的下标靠"多活几轮"补偿,位置靠后的下标靠"选中的概率高"补偿,一来一去正好扯平。
3. 双语言实现与边界细节
3.1 Python 实现与随机 API 的选择
蓄水池抽样在 398 题上的 Python 代码非常短:
import random class Solution: def __init__(self, nums): self.nums = nums def pick(self, target): cnt = 0 res = -1 for i, num in enumerate(self.nums): if num == target: cnt += 1 if random.randint(0, cnt - 1) == 0: res = i return res注意随机 API 的用法:random.randint(0, cnt - 1)返回的是闭区间 [0, cnt-1] 内的整数,它等于 0 的概率正好是 1/cnt。这里也可以写成random.randint(1, cnt) == cnt,概率同样是 1/cnt,但按习惯我建议统一用判断 0 的写法,语义更直观:一边遍历一边"抽签",抽到 0 号签就换人。
之所以用整数随机而不是浮点数random.random() < 1.0 / cnt,是为了避开浮点精度问题。cnt 很小的时候两者没差别,但 cnt 巨大时浮点数比较的边界情况多少有点隐忧。能用整数就别用浮点,这是写随机算法时一条很实用的经验。
3.2 Java 实现与 Random 的等价写法
Java 版本逻辑完全相同,只是随机 API 的边界要格外小心:
import java.util.Random; class Solution { private int[] nums; private Random rand; public Solution(int[] nums) { this.nums = nums; this.rand = new Random(); } public int pick(int target) { int cnt = 0; int res = -1; for (int i = 0; i < nums.length; i++) { if (nums[i] == target) { cnt++; if (rand.nextInt(cnt) == 0) { res = i; } } } return res; } }rand.nextInt(cnt)返回 [0, cnt) 范围内的整数,也就是 0 到 cnt-1,判断等于 0 正好是 1/cnt 的概率。很多翻车现场都发生在这里:nextInt的参数是上界,不是个数,写成nextInt(cnt + 1)后概率就错成了 1/(cnt+1),整个蓄水池抽样就不再均匀了。
3.3 容易出错的三个实现细节
代码虽然短,但实现里有几个坑是 LeetCode 评论区常年被讨论的,整理成表格方便对照:
| 细节 | 错误写法 | 正确写法 | 原因 |
|---|---|---|---|
| 随机数判断 | rand.nextInt(cnt) == cnt | rand.nextInt(cnt) == 0 | nextInt(cnt) 永远不会返回 cnt |
| 计数位置 | 先判断随机再递增 cnt | 先递增 cnt 再随机 | 第一个目标下标必须用 1/1 概率选中 |
| 提前返回 | 遇到 target 就随机判断并立即返回 | 必须完整遍历整个数组 | 提前返回会破坏后续下标的被选概率 |
第三点值得单独展开说。很多第一次写蓄水池的人会想:反正后面遇到的每个下标都有概率替换前面,那我提前返回岂不是省时间?大错特错。提前返回意味着只在前缀范围内做抽样,如果 target 在后面还有大量下标,它们永远没机会被选中,整体概率立刻失衡。蓄水池抽样的前提就是"必须看完所有数据",一次都不能偷懒。
4. 从 398 到通用蓄水池:K 个样本与流式数据场景
4.1 通用版:从保留 1 个样本到保留 K 个样本
398 只是蓄水池抽样最朴素的 K=1 特例。通用问题是这样的:有一个未知长度的数据流,要在只遍历一遍的情况下,从中等概率抽出 K 个样本。做法也有一脉相承的逻辑:
- 前 K 个数据直接放入"蓄水池"。
- 从第 i 个数据开始(i > K),以 K/i 的概率决定这个数据是否入选。如果入选,就在蓄水池中随机挑一个位置替换掉。
下面是一个完整的 Python 实现,直接看比背概念有用:
import random def reservoir_sampling(stream, k): reservoir = [] for i, item in enumerate(stream): if i < k: reservoir.append(item) else: j = random.randint(0, i) if j < k: reservoir[j] = item return reservoir这里的核心是:第 i 个元素有 K/i 的概率被抽进池子(等价于random.randint(0, i) < k),而池子里原有的元素也有各自的机会被顶掉。把 K=1 代入就会得到j = random.randint(0, i),判断j < 1也就是j == 0,正好和 398 的写法对上。所以说白了,398 的randint(0, cnt - 1) == 0就是这个通用版公式的特殊形态。
4.2 398 的"亲兄弟"们
力扣上和这道题同源的还有几个熟面孔:
- LeetCode 382:链表随机节点。给一个单链表,要求等概率返回任意一个节点的值。链表长度未知,且只能走一遍,天然就是蓄水池抽样的 K=1 场景。
- LeetCode 384:打乱数组。这个用 Fisher-Yates 洗牌算法解决,和蓄水池是一对"表亲",核心都是"每个位置和后续某位置交换"的均匀随机思想。
- LeetCode 470:用 Rand7 实现 Rand10。题目本身是另一类随机算法题,但面试中经常和 398 连着问,考察的是对随机分布的理解。
如果你刷完 398 之后把 382 顺手刷掉,大概率会发现一个是数组形态的蓄水池、一个是链表形态的蓄水池,换汤不换药。这比盲目做十道新题更划算。
4.3 业务场景:数据流采样在工程里的真实用法
面试之外,蓄水池抽样真的在工业界被广泛使用。举几个我实际接触过的例子:
- 日志抽样。每天产生的日志可能有几十亿条,如果想把 1% 的样本喂给离线分析,最简单的方式不是先算出总量再抽样,而是每来一条日志就以 1% 的概率决定留不留。系统不需要知道总量,也不需要存储全部数据,内存占用恒定。
- AB 实验流量分配。在一个大流量系统里做实验,经常需要把用户请求按照比例随机分到对照组和实验组。如果实验组流量配比是动态调整的,蓄水池抽样的思路能帮你在不知道总请求数的情况下保持相对均匀。
- 推荐系统随机探索。给用户生成候选集合时,有时需要从海量候选中均匀捞出几个作为"多样性探索"样本,候选集合是实时算出来的,根本不知道它有多大,这时候蓄水池抽样就是很自然的解法。
如果读者在做后端或者数据相关的工作,下次遇到"从一个大集合里均匀抽几个,但集合大小不可知"的需求,可以下意识想想这道题的解法。它就是教科书和数据工程之间最短的连接点。
5. 面试现场:这道题的三个追问与常见翻车点
5.1 追问一:既然可以"先数再随机",为什么非用蓄水池?
这是面试官最爱抛出的"挑战型"问题,很多候选人一听到就慌了。实际上,对 398 的静态数组而言,先统计一共有多少个 target 下标,再随机选第几个并返回,完全可以做到 O(n) 时间、O(1) 空间:
def pick(self, target): cnt = sum(1 for x in self.nums if x == target) k = random.randint(0, cnt - 1) for i, x in enumerate(self.nums): if x == target: if k == 0: return i k -= 1这方案错了吗?没错。它的问题是必须完整遍历两次:第一次数个数,第二次定位下标。如果数据源是一个只能读取一次的"流",第一次遍历结束后数据就没了,第二次遍历根本无从谈起。蓄水池抽样的核心优势恰恰是单趟扫描,一边读一边维护候选,读完即出结果。
所以正确的面试回答姿势不是喷"先数再随机"的方案烂,而是诚实地承认:在静态数组场景下两种方案复杂度相当;但如果把场景换成未知长度、单次遍历的数据流,蓄水池就是唯一可用的方案。能把这道题辨析到这个深度,比单纯背出一个蓄水池模板要加分得多。
5.2 追问二:哈希表预处理查询是 O(1),蓄水池是 O(n),不是退化了吗?
确实,单次 pick 的时间复杂度蓄水池是 O(n),哈希表是 O(1)。但如果反复调用 pick,两种情况的表现要分开算。
- 哈希表方案:预处理 O(n) 的时间和空间;之后的每次 pick 都是 O(1),多次调用非常快,代价是内存随着数据总量线性增长。
- 蓄水池方案:无需预处理,空间 O(1);但每次 pick 都要重新扫描整个数组,时间 O(n)。
从复杂度角度看,这是一个典型的"空间换时间"和"时间换空间"的权衡。在面试里,考官往往自己想得到的也是这个权衡过程。实际工作中怎么选?取决于数据规模:数组只有几千长度但 pick 调用百万次,哈希表明显更优;数组大到内存装不下、或者本身就是流式数据,蓄水池几乎是唯一解。
5.3 追问三:如果数组不变、pick 被频繁调用,能不能优化蓄水池?
这题在力扣的讨论区里被称为"隐藏的进阶版"。既然蓄水池每次都要 O(n) 扫描,而目标数组根本不变,有没有办法做到"第一次调用建立索引,后续调用直接随机"?
当然有。思路就是折中:对每个不同的值,只存它的出现次数,不存全部下标。pick 时先随机决定要第几个出现的下标,再从数组里定位。这样空间仍然远小于存下所有下标的哈希表,但时间复杂度降不下来——定位还是要扫数组。
如果真的是高频查询场景,老老实实回到哈希表预处理反而更明智。LeetCode 的测试数据通常不会在这一点上卡你,但面试中主动抛出这个权衡讨论,会显得你不是在背模板,而是真的理解每种方案的使用条件。
5.4 现场回答的参考话术
最后给一套可以直接用在面试里的回答逻辑,大约两分钟说完整:
"这道题我会先用蓄水池抽样的思路做。它的核心是:遍历数组,维护一个候选下标,每当遇到第 cnt 个 target 下标,就以 1/cnt 的概率替换候选下标。因为乘以 1/cnt 之后,前面的候选被保留的概率是 (cnt-1)/cnt,和新的 1/cnt 加起来正好让每个前 cnt 个下标概率均等。这里的关键是只能保存一个候选索引,空间 O(1),成本是每次 pick 要完整扫描数组 O(n)。如果高频调用 pick 且数组能全部放入内存,我会改用哈希表预处理所有下标,用空间换时间。如果数据是流式的、长度未知,蓄水池就是唯一的选择。"
这段话把原理、证明、复杂度和工程取舍全部带到了。哪怕部分细节说得不够严谨,面试官也能听出你是系统学过这道题,而不是只背了randint(0, cnt - 1) == 0这一行。
一点个人体会
398 这道题,我是先被面试官问住、之后才彻底搞懂的。当时我能写出代码,却讲不清楚为什么1/cnt这个替换概率能保证全局公平,直到自己动手做了连乘消元的推导,才算真正"拥有"了这个算法。
如果让我给后来人一个建议:刷这道题的时候,不要急着把答案背下来,先给自己十分钟,用笔把"第 k 个下标最终成为答案的概率"算一遍。算通了,这道题和它的兄弟们——382、384、甚至通用蓄水池——就都拿下了。算不通,代码背得再熟,换个马甲还是会露馅。