LeetCode——链表随机节点/随机数索引:蓄水池算法
蓄水池算法
引用:蓄水池采样算法(Reservoir Sampling)
采样问题经常会被遇到,比如:
- 从 100000 份调查报告中抽取 1000 份进行统计。
- 从一本很厚的电话簿中抽取 1000 人进行姓氏统计。
- 从 Google 搜索 "Ken Thompson",从中抽取 100 个结果查看哪些是今年的。
这些都是很基本的采用问题。既然说到采样问题,最重要的就是做到公平,也就是保证每个元素被采样到的概率是相同的。所以可以想到要想实现这样的算法,就需要掷骰子,也就是随机数算法。
对于第一个问题,还是比较简单,通过算法生成\([0, 100000 - 1)\)间的随机数 1000 个,并且保证不重复即可。再取出对应的元素即可。但是对于第二和第三个问题,就有些不同了,我们不知道数据的整体规模有多大。可能有人会想到,我可以先对数据进行一次遍历,计算出数据的数量 N,然后再按照上述的方法进行采样即可。这当然可以,但是并不好,毕竟这可能需要花上很多时间。也可以尝试估算数据的规模,但是这样得到的采样数据分布可能并不平均。
算法过程
假设数据序列的规模为 \(n\),需要采样的数量的为 \(k\)。
首先构建一个可容纳 \(k\) 个元素的数组,将序列的前 \(k\) 个元素放入数组中。
然后从第 \(k+1\) 个元素开始,以 \(\frac{k}{n}\) 的概率来决定该元素是否被替换到数组中(数组中的元素被替换的概率是相同的)。 当遍历完所有元素之后,数组中剩下的元素即为所需采取的样本。
证明过程:
对于第\(i\)个数(\(i \le k\))。在 \(k\) 步之前,被选中的概率为 \(1\)。当走到第 \(k+1\) 步时,被 \(k+1\) 个元素替换的概率 \(= k+1\) 个元素被选中的概率 * \(i\) 被选中替换的概率,即为\(\frac{k}{k + 1} \times \frac{1}{k} = \frac{1}{k + 1}\)。则被保留的概率为\(1 - \frac{1}{k + 1} = \frac{k}{k + 1}\)。依次类推,不被 \(k+2\) 个元素替换的概率为\(1 - \frac{k}{k + 2} \times \frac{1}{k} = \frac{k + 1}{k + 2}\)。则运行到第 n 步时,被保留的概率 = 被选中的概率 * 不被替换的概率,即:
对于第 \(j\) 个数(\(j>k\))。在第 \(j\) 步被选中的概率为 \(\frac{k}{j}\)。不被 \(j+1\) 个元素替换的概率为\(1 - \frac{k}{j + 1} \times \frac{1}{k} = \frac{j}{j + 1}\)。则运行到第 \(n\) 步时,被保留的概率 = 被选中的概率 * 不被替换的概率,即:
所以对于其中每个元素,被保留的概率都为\(\frac{k}{n}\).
代码示例
public class ReservoirSamplingTest {
private int[] pool; // 所有数据
private final int N = 100000; // 数据规模
private Random random = new Random();
@Before
public void setUp() throws Exception {
// 初始化
pool = new int[N];
for (int i = 0; i < N; i++) {
pool[i] = i;
}
}
private int[] sampling(int K) {
int[] result = new int[K];
for (int i = 0; i < K; i++) { // 前 K 个元素直接放入数组中
result[i] = pool[i];
}
for (int i = K; i < N; i++) { // K + 1 个元素开始进行概率采样
int r = random.nextInt(i + 1);
if (r < K) {
result[r] = pool[i];
}
}
return result;
}
@Test
public void test() throws Exception {
for (int i : sampling(100)) {
System.out.println(i);
}
}
}
两个蓄水池算法题目
Q:给定一个单链表,随机选择链表的一个节点,并返回相应的节点值。保证每个节点被选的概率一样。
进阶:
如果链表十分大且长度未知,如何解决这个问题?你能否使用常数级空间复杂度实现?
示例:
// 初始化一个单链表 [1,2,3].
ListNode head = new ListNode(1);
head.next = new ListNode(2);
head.next.next = new ListNode(3);
Solution solution = new Solution(head);
// getRandom()方法应随机返回1,2,3中的一个,保证每个元素被返回的概率相等。
solution.getRandom();
A:
蓄水池算法:
class Solution {
private ListNode node;
public Solution(ListNode head) {
node = head;
}
public int getRandom() {
ListNode res = node;
ListNode cur = node.next;
int i = 2;
//从第二个节点开始,每次循环替换res的概率都是1/i
while(cur != null){
Random random = new Random();
int ran = random.nextInt(i);
if(ran == 0){
res = cur;
}
cur = cur.next;
i++;
}
return res.val;
}
}
Q:给定一个可能含有重复元素的整数数组,要求随机输出给定的数字的索引。 您可以假设给定的数字一定存在于数组中。
注意:
数组大小可能非常大。 使用太多额外空间的解决方案将不会通过测试。
示例:
int[] nums = new int[] {1,2,3,3,3};
Solution solution = new Solution(nums);
// pick(3) 应该返回索引 2,3 或者 4。每个索引的返回概率应该相等。
solution.pick(3);
// pick(1) 应该返回 0。因为只有nums[0]等于1。
solution.pick(1);
A:
代码:
private int[] nums;
public Solution(int[] nums) {
this.nums = nums;
}
public int pick(int target) {
Random r = new Random();
int n = 0;
int index = 0;
for(int i = 0;i < nums.length;i++)
if(nums[i] == target){
//我们的目标对象中选取。
n++;
//我们以1/n的概率留下该数据
if(r.nextInt() % n == 0) index = i;
}
return index;
}