刷力扣的人应该都有这种体验:遇到一道题,题目本身读起来很短,但真正动手之后才发现,背后藏的坑和知识点远比想象中深。Linked List Random Node就是典型代表。它出现在力扣热题里,题面只有一句话:给定一个单链表,等概率返回某个节点的值。可就是这个“等概率”,把解法从最朴素的“先数长度再随机”一路推到了“水塘抽样”,也让这道看起来只有中等难度的题成了面试官最爱追问的常客。
这道题适合谁?如果你在准备算法面试,或者刚接触链表、随机类题目不久,那这篇笔记值得从头看到尾。你不仅能拿到两种解法,还能搞明白为什么水塘抽样在这里是正解,以及它背后的概率推导是怎么完成的。我会把实际调试中踩过的坑一并写出来,最后的常见问题速查表可以直接当复习清单用。
1. 题目拆解与核心考点
1.1 题面到底在问什么
原题要求很简洁:你需要设计一个数据结构,构造函数接收单链表的头节点,getRandom()方法以等概率返回链表中某个节点的值。注意,是返回节点的值,不是返回节点本身。链表节点结构是 LeetCode 标准的单链表节点:
public class ListNode { int val; ListNode next; ListNode(int x) { val = x; } }第一眼看过去,这题比反转链表还简单?不就是随机挑一个节点嘛。但仔细一想就会发现一个关键矛盾:单链表只能从头往后走,不能随机访问。数组可以靠下标一步到位,链表做不到。你想随机挑第 k 个节点,就必须从头走到第 k 个位置,这就逼着你考虑几个问题:
- 链表长度是多少?如果不知道长度,怎么保证“等概率”?
- 如果链表特别长,甚至长到内存放不下,只能从头到尾扫描一次,怎么办?
- 如果每次
getRandom()都从头走一遍,时间复杂度能不能接受?
这三个问题,恰好对应了这道题真正的考点:等概率的正确性、遍历次数的限制、以及空间复杂度的边界。很多人在力扣上提交通过之后就走了,完全没有意识到自己只是“碰巧写对了”,并没有真正理解题目要训练的能力。
1.2 一道“中等题”背后藏了三个层次
我刷这道题时的体会是,它其实有三种层次的解法,每往上走一层,对算法的理解就更深一层。
第一层:先计数,再随机。遍历一遍链表数出总长度 n,然后用随机数生成器生成一个[0, n-1]的整数 index,再从链表头走 index 步返回那个节点的值。这个解法完全符合直觉,代码也好写,但它要求链表长度已知且可重复遍历。
第二层:未知长度,一次遍历,水塘抽样。这是面试官真正想看的东西。如果链表长度未知,或者数据是一个流式输入,你只有一次遍历机会,每遇到一个节点都要立刻决定保留还是丢弃,而且最终每个节点被选中的概率必须都一样。这个场景下,水塘抽样的思路几乎是唯一正解。
第三层:理解和证明为什么水塘抽样能保证等概率。很多人能背出水塘抽样的代码,但被问到“为什么第 i 个元素要以 1/i 的概率替换当前结果”时就卡住了。这层不是背代码能解决的,需要真正理解概率推导的连乘约分逻辑。
后面我会按这三个层次逐步展开。老实说,如果你只想 AC 这道题,第一层就够了,两分钟能写完;但如果你想在面试里不被追问倒,第二层和第三层是躲不掉的。
2. 解法一:先求长度再随机取值
2.1 思路与完整代码
思路很直接:第一遍遍历统计链表长度,得到 n;第二遍遍历,走到随机下标对应的位置,返回节点值。随机下标用Random.nextInt(n)生成,它返回[0, n)的整数,刚好覆盖所有节点。
Java 实现如下:
import java.util.Random; class Solution { private ListNode head; private Random random; public Solution(ListNode head) { this.head = head; this.random = new Random(); } public int getRandom() { // 第一遍:统计链表长度 int len = 0; ListNode cur = head; while (cur != null) { len++; cur = cur.next; } // 生成 [0, len-1] 的随机下标 int target = random.nextInt(len); // 第二遍:走到目标位置 cur = head; while (target > 0) { cur = cur.next; target--; } return cur.val; } }这段代码没什么记忆负担,getRandom()的执行流程拆开就是“数一下,跳一下”。时间复杂度是 O(n),因为最坏情况下要遍历两遍链表;空间复杂度 O(1),除了头指针和一个随机数对象,没有额外存储。
2.2 复杂度分析与适用边界
单次getRandom()的复杂度是 O(n),这里 n 是链表长度。注意这个 O(n) 不能优化到 O(1)——你没法做到真正“随机”的同时又避免遍历,因为链表没有索引,跳到第 k 个节点本身就需要 k 步。
第一次看到有人问:那我能不能在构造函数里把链表转成数组?这样getRandom()就能 O(1) 了。可以,但代价是空间复杂度变成 O(n)。这是完全合法的解法,在力扣上也能通过,因为题目没有禁止额外空间。如果链表很长,内存压力会变大;但如果链表本身就不长,这个做法反而比两次遍历快得多。
什么时候选数组缓存?我的判断标准是:如果getRandom()调用非常频繁,而链表只会初始化一次,用数组缓存值得;如果链表初始化一次后很少调用随机,或者链表本身特别大,那就用两次遍历。力扣上的测试用例通常不会把这两者差异放大到超时的程度,所以两种都能过。
2.3 面试官追问时你最容易露怯的三个点
这个解法自己 AC 没问题,但面试官只要一追问,很多人就沉默了。我整理了自己被问过的三个典型问题:
第一个问题:如果链表长度未知,甚至是一个只允许读取一次的流,你这个解法还能用吗?显然不能,因为你必须提前知道 n 才能生成随机下标。流式数据根本不允许你回头再走一遍。
第二个问题:如果链表的长度特别大,比如有十亿个节点,你确定两次遍历不会超时吗?每次都从头走到尾,第一次数长度,第二次走随机下标,均摊下来还是要扫描整个链表。这个开销在大数据场景下是难以接受的。
第三个问题:如果不允许用额外空间,也不允许两次遍历呢?这就把路堵死了。你必须在一遍遍历的过程中,边走边决定“当前这个节点是不是最终结果”,而且还要保证这个决定是等概率的。到了这一步,水塘抽样就该上场了。
这也是我把这道题单独拎出来写一篇笔记的原因。它不像那些刷一遍就会的套路题,而是能自然引出一种在工程上真正有用的随机采样算法。
3. 解法二:水塘抽样
3.1 水塘抽样的直觉:用“替换”代替“提前数个数”
水塘抽样这个名字听起来很深奥,直觉其实特别简单。想象你在参加一个临时召集的活动,主办方说最后会从到场的人里随机抽一个人送奖品,但大家是陆续到场的,你也不知道最后总共会有多少人。为了保证后到的人也有机会,主办方想到了一个规则:每到一个新人,就以“1 ÷ 当前总人数”的概率把之前选中的那个人换成新人。
举个例子:第 1 个人来了,当前就他一个人,所以选中他的概率是 1。第 2 个人来了,要以 1/2 的概率替换,也就是第 1 个人有 1/2 的概率被保留。第 3 个人来了,要以 1/3 的概率替换,前两个人各还有 2/3 的概率被保留。到活动结束时,每个人留在“候选位”上的概率会相互抵消,最后都是 1/n。
对应到这道题:我拿链表的头节点值作为初始候选,然后从第二个节点开始,每遇到一个新节点,就以“1 / 当前节点序号”的概率替换掉候选值。等链表遍历完,候选值就是最终返回的结果。
这跟解法一的本质区别是:你不需要知道链表有多长,也不需要回头遍历第二遍。每一个节点经过时,你只做一次随机判断,然后继续往下走。
3.2 关键概率推导:为什么每个节点被选中的概率都是 1/n
这是整道题最核心的部分,值得把推导过程完整写一遍。
假设链表总共有 n 个节点,第一个节点记为第 1 个,最后一个记为第 n 个。我的做法是:先把第 1 个节点放进候选,然后从第 2 个节点开始做替换判断。那么第 i 个节点最终被选中,需要满足两个条件:
- 第 i 个节点到达时,它以
1/i的概率替换掉原来的候选; - 之后所有节点到达时,它都“不被替换”。
第 j 个节点到达时,替换前一个候选的概率是1/j,所以“不被替换”的概率就是1 - 1/j。于是第 i 个节点最终胜出的概率是:
P(第 i 个节点最终被选中) = (1/i) × (1 - 1/(i+1)) × (1 - 1/(i+2)) × ... × (1 - 1/n)把后面的每一项展开:
1 - 1/(i+1) = i/(i+1) 1 - 1/(i+2) = (i+1)/(i+2) 1 - 1/(i+3) = (i+2)/(i+3) ... 1 - 1/n = (n-1)/n所以整个连乘是:
P = (1/i) × (i/(i+1)) × ((i+1)/(i+2)) × ... × ((n-1)/n)注意看,分子分母疯狂约分:前一项的分母和后一项的分子都一样,一路消下去,最后剩下:
P = (1/i) × (i/n) = 1/n也就是说,不管你是第 1 个节点还是第 n 个节点,最终被选中的概率都精确等于1/n。这个结果跟链表长度无关,跟节点位置无关,只跟“替换概率等于 1/当前序号”这个规则有关。
我用 n = 5 的情况做了个表格,方便直观感受概率变化:
| 节点序号 i | 被选为候选的概率 | 后续不被替换的连乘 | 最终概率 |
|---|---|---|---|
| 1 | 1 | 1/2 × 2/3 × 3/4 × 4/5 | 1/5 |
| 2 | 1/2 | 2/3 × 3/4 × 4/5 | 1/5 |
| 3 | 1/3 | 3/4 × 4/5 | 1/5 |
| 4 | 1/4 | 4/5 | 1/5 |
| 5 | 1/5 | 不需继续 | 1/5 |
看到没有,所有约分最后都殊途同归,全部指向1/n。这个推导就是水塘抽样的“定海神针”,你现场只要能把连乘约分的逻辑讲清楚,面试官基本不会再难为你。
3.3 完整 Java 实现
代码非常短,重点在于理解每一行的语义:
import java.util.Random; class Solution { private ListNode head; private Random random; public Solution(ListNode head) { this.head = head; this.random = new Random(); } public int getRandom() { // 先把头节点作为初始候选 int result = head.val; // 从第二个节点开始遍历 ListNode cur = head.next; int i = 2; while (cur != null) { // 以 1/i 的概率替换当前的候选值 if (random.nextInt(i) == 0) { result = cur.val; } cur = cur.next; i++; } return result; } }这里用到了一个关键 API:Random.nextInt(i)返回[0, i-1]的随机整数。所以== 0的概率正好是1/i,不多不少。
为什么开头不设置result = 0,而是直接用head.val?因为第 1 个节点必须被当成初始候选,概率为 1。如果你把result初始化为 0,然后从第 1 个节点开始也以1/i判断,那第一个节点的选中概率就不是 1 了,最终概率就不满足前面的推导。
3.4 两种等价的循环写法
我见过不少题解写的循环是从头节点就开始判断,代码长这样:
public int getRandom() { ListNode cur = head; int result = head.val; int count = 1; while (cur != null) { if (random.nextInt(count) == 0) { result = cur.val; } cur = cur.next; count++; } return result; }这个写法其实也对。因为循环第一次进入时,count = 1,random.nextInt(1)恒为 0,所以必然把result重新赋值为head.val,等价于“第 1 个节点以 1 的概率进入候选”。只是白白多调用了一次nextInt,而且第一次的result实际上被重复设置了。
我自己的习惯是用 3.3 的写法:头节点先入候选,从第二个节点开始遍历,逻辑更直观,推导也更顺畅。这两种实现只是写法差异,概率上完全等价。
4. 实战细节与避坑指南
4.1 Random 对象的正确用法
很多初学者会在getRandom()里每次new Random(),这是一个不好的习惯。Random类本身需要时间种子来初始化,频繁创建对象既增加开销,又可能因为种子的随机性不足导致多次调用出现相关性。正确的做法是在构造函数里创建一次,复用同一个实例。
如果你在意线程安全,可以用ThreadLocalRandom.current().nextInt(i),它的性能比Random更好,而且线程安全。不过力扣的测试环境是单线程调用,用哪个都行,面试时能说清楚区别就是加分项。
还要注意nextInt(n)的边界:n必须是正数,如果传 0 会抛IllegalArgumentException。如果链表只有一个节点,head.next是 null,循环压根不会进入,所以不会出现i = 1时调用nextInt(1)的情况。万一链表是空的?这种情况题目默认不会出现,但工程上最好加一层判断,比如返回自定义的默认值或抛出明确异常。
4.2 为什么会“看起来随机,实际有偏”
这道题最隐蔽的坑是:代码写对了,测试时感觉分布也对,但你没有意识到随机数生成器的好坏会影响结果。
Java 默认的Random是线性同余生成器,它生成的数字在统计上均匀,但如果你每次调用都用同一个种子初始化,得到的就是一串完全可预测的序列。我把这行代码写在下面,你感受一下问题有多隐蔽:
// 错误示范:固定种子,结果可预测 Random random = new Random(42);固定种子意味着每次程序运行,随机序列完全相同。在本地调试时这很友好,因为结果可复现;但如果你提交到力扣,每次调用的结果是固定的,那就不叫随机了。好在正常写法new Random()默认使用系统纳秒时间做种子,不会出现这个问题。
4.3 空间复杂度到底算不算 O(n)
解法二的空间复杂度是 O(1),这个没有争议,因为只用了两个指针加一个随机数对象。解法一如果选择把链表转成数组,空间复杂度就变成 O(n),这个问题面试官一定会问:你能不能用 O(n) 的空间换 O(1) 的随机时间?如果链表只有几百个节点,当然划算;如果链表有几百万个节点,就要掂量掂量了。
我在实际工程里更倾向于水塘抽样解法,因为它不需要额外存储,也不需要在构造函数里做多余事情,未来如果链表数据源从“内存 list”换成“数据库游标”甚至“实时数据流”,代码几乎不用改。这是我觉得这道题最大的工程价值。
4.4 多次调用 getRandom 的随机性验证方法
AC 之后,我还做了一件事:写了一个简单测试,验证水塘抽样在多次调用下的分布是否真的均匀。思路是初始化一个长度为 5 的链表,然后调用getRandom()十万次,统计每个值出现的频率。
一个比较直观的验证是计算每个值出现的百分比。理论期望是 20%,实测结果在我的机器上大概是 19.8% 到 20.3% 之间浮动,符合预期。这里贴一下我当时测试用的核心逻辑:
int[] count = new int[5]; ListNode head = buildList(5); // 自行构造链表 Solution solution = new Solution(head); for (int i = 0; i < 100000; i++) { int val = solution.getRandom(); count[val - 1]++; } for (int c : count) { System.out.printf("%.2f%% ", c / 100000.0 * 100); }输出类似:
20.05% 19.96% 20.12% 19.88% 19.99%这只是粗略验证,严格来说应该用卡方检验来判断是否显著偏离均匀分布,但对刷题来说,看频率已经足够发现问题了。如果你改动了算法导致概率有偏,比如把nextInt(i)写成了nextInt(n),这种验证会第一时间暴露问题。
5. 变体拓展与实际工程应用
5.1 Follow-up:如果链表大到无法全部放入内存
力扣上的原题默认链表已经存在内存里,但面试官会追一个经典 follow-up:如果链表是一个外部数据源,比如数据库的表记录,你只能一次读取一行,不知道总共多少行,也不允许把所有行都缓存下来,你怎么等概率抽取一行?
这就是水塘抽样的典型应用场景了。因为算法只保留一个候选值,空间复杂度是 O(1),遍历过程中不需要回头,天然适配数据流。你会发现解法二在那个场景下几乎不需要改动,只是把cur = cur.next换成“读下一行数据”,本质完全一样。
如果你需要抽取 k 个样本,而不是一个,那就是标准水塘抽样:维护一个长度为 k 的候选数组,前 k 个元素直接放进去,从第 k+1 个元素开始,以k/i的概率替换数组中的任意一个元素。这样最终每个元素被选中的概率都是k/n。这个变体才是面试中真正高频的追问。
5.2 加权水塘抽样:每条数据概率不同怎么办
实际的业务场景还有一个更难的问题:每条数据被抽中的概率不一定相等。比如做线上日志采样时,高等级错误日志希望被抽到的概率更高,普通日志概率低一些。这就需要加权水塘抽样(Weighted Reservoir Sampling)。
一种直观做法是给每条数据分配一个权重 w,然后生成一个 [0, 1) 的随机数,用Math.pow(random.nextDouble(), 1.0 / w)作为排序键,保留排序键最大的记录。这个技巧叫 exponential order statistics,实现简单,而且面试时讲出来非常加分。知道它能让你在同类题目里脱颖而出,但如果你只准备力扣,这个拓展了解即可,不需要死磕证明。
5.3 现实里哪里真的用到了这种随机取样
我自己在工作中遇到过几个类似的取样场景,模糊处理细节后可以分享给你:
第一个是线上服务的错误日志采样。服务请求量巨大,不可能把所有日志都存下来,于是只随机保留一小部分作为样本,用于后续的异常分析。水塘抽样的好处是:不需要提前知道日志总量,流式处理过程中边来边采样,内存占用恒定不变。
第二个是数据库统计信息估算。数据库在生成执行计划时,需要估算某个字段的唯一值数量或分布,一种低成本方式就是从表里随机抽一批数据,用样本估算整体。实际数据库用的算法比基础水塘抽样复杂很多,但最初的思路一脉相承。
第三个是 A/B 实验的流量分配。如果某个实验需要从所有活跃用户里抽取 1% 作为实验组,你可以在用户请求进入的边界处用一致性哈希或随机数判断,本质上也是一种“等概率入组”的抽样问题。
这三个场景的共同点是:数据量不可预先穷尽,必须边遍历边决定去留,内存又要保持很小。这就是为什么“随机与取样”这道题能进入经典题单,它真的不只是理论。
6. 常见问题与调试实录
6.1 自检清单:三分钟核对你的代码
我把刷这道题时容易踩的坑整理成一个速查表,提交前可以对照自检:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 每次返回的都是头节点值 | 循环没有正确进入,或random.nextInt(i)的 i 没有递增 | 检查是否从head.next开始,循环内是否有i++ |
| 返回结果偏向链表前面的节点 | nextInt(count)里的 count 不是“当前节点序号”,而是固定值 | 确认 count 初始为 2,每遍历一个节点 +1 |
| 返回值分布完全固定,每次执行都一样 | 用了固定种子创建 Random | 改为new Random(),不要手动指定种子 |
| 链表为空时抛空指针 | 没有处理 head 为 null 的边界 | 添加空值判断,返回默认值或抛明确异常 |
| 多次调用 getRandom 性能低 | 解法一每次都要遍历两次链表 | 如果调用频繁,可换数组缓存;否则用水塘抽样保持单次遍历 |
6.2 一次概率失衡的实际排查
我记得自己第一次独立写水塘抽样时,出现过一次分布明显的偏差:头节点被选中的频率远高于其他节点。我把代码反复看了很久才发现问题,原因是我的初始候选不是head.val,而是某个固定的默认值。
当时我写的是:
int result = -1; ListNode cur = head; int i = 1; while (cur != null) { if (random.nextInt(i) == 0) { result = cur.val; } cur = cur.next; i++; }这段代码的问题是:第一个节点必须以概率 1 进入候选。但这里的result初始为 -1,第一个节点只以1/1 = 1的概率被判定进入候选。看起来没问题?确实没问题,nextInt(1) == 0恒成立,所以第一个节点一定会被选中。可是问题在于遍历结束后的最后一步,如果最后一个节点恰好没有替换候选,结果仍然是之前某个节点,这符合预期。
真正的问题出在另一个地方:我为了让代码看起来“从第一个节点开始判断”,初始候选设的是result = -1,然后在循环里第一个节点必然替换。这是等价写法,但一旦你用了result = -1,循环里某个边界写错为random.nextInt(i + 1) == 0,概率就会变成1/(i+1),整个推导就不成立了。
这个排查看似简单,但如果不做频率统计,我根本不会发现概率偏差。所以我还是建议你,写完这类随机算法后,一定要用第 4.4 节的方法跑一下频率验证。它能帮你在提交之前抓住那些肉眼发现不了的概率问题。
6.3 用手算穷举把概率验到骨子里
如果你还想再进一步确认自己对算法的理解,可以手算一个小链表的情况。我当初用 n = 3 的链表穷举了一遍:
遍历过程:
- 节点 1:必然进入候选,当前候选为 A。
- 节点 2:有 1/2 概率替换为 B。
- 节点 3:有 1/3 概率替换为 C。
那么:
- A 最终胜出:A 在节点 2 时不被替换(1/2),在节点 3 时也不被替换(2/3),相乘得 1/3。
- B 最终胜出:B 在节点 2 时替换成功(1/2),在节点 3 时不被替换(2/3),相乘得 1/3。
- C 最终胜出:C 在节点 3 时替换成功(1/3),之前不需要任何条件,概率也是 1/3。
三个节点最终都是 1/3,完美等概率。这个手算过程虽然简单,但比看十遍推导公式都管用,它能让“连乘约分”这个抽象操作变得具体可感。面试时如果被追问,你能现场手算这个例子,比背出公式要有说服力得多。
这道题给我的核心启发是:随机性不是靠“随便选一个”实现的,而是靠精心设计的概率规则,在信息不完备的情况下仍然做到统计意义上的公平。水塘抽样这个思想,从刷题到工程,都能反复派上用场。下一次你遇到“不知道总数,只给一次遍历机会,还要等概率抽样”的问题,直接回想今天这篇笔记里的连乘推导,思路就会有依有据。