目录

题目描述

382. 链表随机节点

题意分析

需要设计一个类:构造时拿到一个单链表的头指针,之后每次调用 getRandom 都要等概率地返回链表中某个节点的值。等概率的含义是,若链表有 $n$ 个节点,每个节点被返回的概率都恰好是 $1/n$。

关键的约束信号有两条。第一,链表不支持下标随机访问,想拿到第 $k$ 个节点必须从头走 $k$ 步。第二,题目进阶部分明确提出「链表十分大且长度未知」,并要求不额外使用空间——这等于禁止了「先遍历一遍存进数组再随机下标」的直白做法,也禁止了「先数一遍长度再走一遍」的两趟做法。

于是真正的难点变成:在只允许单向走一趟、且事先不知道总数的前提下,如何保证每个元素被选中的概率完全一致。

边界情况:链表至少有一个节点,所以不必处理空链表;getRandom 会被调用很多次,每次都是独立的一次抽样,不能让前一次的结果影响后一次。

解法:水塘抽样

核心思路

把链表转成数组后随机取下标很直观,但需要 $O(n)$ 额外空间;先统计长度再定位随机下标虽然只用 $O(1)$ 空间,却需要两趟遍历。题目的「长度未知、只能单趟读取」正是水塘抽样的使用信号。

对于只选一个元素的水塘,先把第一个节点放入水塘。之后遇到第 $i$ 个节点时,以 $1/i$ 的概率用它替换当前答案。关键不变量是:处理完前 $i$ 个节点后,这 $i$ 个节点各以 $1/i$ 的概率留在水塘中

等概率证明:$i=1$ 时第一个节点必选,不变量成立。假设处理完 $i-1$ 个节点时,每个旧节点的留存概率是 $1/(i-1)$。第 $i$ 个节点以 $1/i$ 的概率被选中;任意旧节点需要先留存、再避免被替换,所以更新后的概率为

\[\frac{1}{i-1}\left(1-\frac{1}{i}\right)=\frac{1}{i-1}\cdot\frac{i-1}{i}=\frac{1}{i}.\]

新旧节点的概率都是 $1/i$,归纳成立。遍历结束时 $i=n$,每个节点就都以 $1/n$ 的概率被返回,整个过程无需提前知道 $n$。

解题步骤

  1. 构造函数只保存头指针,不统计长度,也不复制节点。
  2. 每次 getRandom 都重新开始抽样:先选中头节点,令 answer = head.valseen = 1
  3. 从第二个节点开始遍历。每读到一个新节点,先把 seen 加一,使它表示已处理节点数 $i$。
  4. 在 $[0,i)$ 中均匀生成一个整数;只有结果为 0 时才用当前值替换 answer,因而替换概率恰好是 $1/i$。
  5. 遍历结束后返回 answer。每次调用都应重置 answerseen,但不要重新播种随机数生成器。

例如链表 1 -> 2 -> 3:处理节点 2 后,1 和 2 各以 $1/2$ 的概率留存;处理节点 3 时,3 以 $1/3$ 的概率入选,1 和 2 则各以 $\frac{1}{2}\times\frac{2}{3}=\frac{1}{3}$ 的概率留存。

代码实现

import java.util.concurrent.ThreadLocalRandom;

class Solution {
    private final ListNode head;

    public Solution(ListNode head) {
        this.head = head;
    }

    public int getRandom() {
        int answer = head.val;
        int seen = 1;
        for (ListNode node = head.next; node != null; node = node.next) {
            seen++;
            if (ThreadLocalRandom.current().nextInt(seen) == 0) {
                answer = node.val;
            }
        }
        return answer;
    }
}
import "math/rand"

type Solution struct {
    head *ListNode
}

func Constructor(head *ListNode) Solution {
    return Solution{head: head}
}

func (s *Solution) GetRandom() int {
    answer, seen := s.head.Val, 1
    for node := s.head.Next; node != nil; node = node.Next {
        seen++
        if rand.Intn(seen) == 0 {
            answer = node.Val
        }
    }
    return answer
}

复杂度分析

  • 时间复杂度:构造函数为 $O(1)$;每次 getRandom 为 $O(n)$,需要完整遍历链表。
  • 空间复杂度:$O(1)$。抽样过程只保存指针、计数器和一个候选值。

关键点总结

  • 长度未知、只能遍历一次的数据流要等概率选一个元素,用大小为 1 的水塘抽样。
  • 第 $i$ 个元素的替换概率是 $1/i$,而不是固定概率或 $1/n$;算法在过程中根本不知道 $n$。
  • 正确性证明必须同时覆盖新节点和旧节点:新节点直接以 $1/i$ 入选,旧节点以 $1/(i-1)$ 留存到上一轮后,还要再乘不被替换的概率 $(i-1)/i$。
  • 每次 getRandom 都要重置抽样状态;随机数生成器则应复用,不要在高频调用中反复播种。
  • 如果面试官追问一次随机选 $k$ 个,就把水塘扩大到 $k$:前 $k$ 个元素直接入池,第 $i$ 个元素以 $k/i$ 的概率替换池中的随机一个。

易错点总结

  • 概率分母写错一位:处理第 $i$ 个节点时必须从 $i$ 个整数中选一个。若写成 nextInt(i + 1) == 0,新节点概率会变成 $1/(i+1)$,分布立即失真。
  • 计数从 0 开始却直接调用 nextInt(seen):第一个节点会调用 nextInt(0),Java 抛异常,Go 也会 panic。显式选中头节点可以避免这个偏移错误。
  • 遍历未结束就返回:水塘中的候选值只对「已读取的前缀」均匀,必须读完所有节点才对整条链表均匀。
  • 把抽样候选和计数器存成跨调用状态:第二次 getRandom 不再从第一个节点、概率 $1/1$ 开始,将破坏每次抽样的边缘分布。
  • 每次调用都用低精度时间戳重新播种:短时间连续调用会获得相同随机序列。应使用语言提供的长期随机源,不要在 getRandom 内手动播种。

相似题目

题目 难度 考察点
398. 随机数索引 中等 在等值下标集合内做水塘抽样
384. 打乱数组 中等 洗牌算法,要求整体排列等概率
528. 按权重随机选择 中等 前缀和加二分实现非均匀抽样
497. 非重叠矩形中的随机点 中等 先按面积加权选矩形,再在矩形内均匀取点