[蓄水池抽样] 382. 链表随机节点 398. 随机数索引
382. 链表随机节点(整个数据流上做抽样)
题目链接:https://leetcode-cn.com/problems/linked-list-random-node/
分类:
- 算法积累:蓄水池抽样(每个数被选择的概率相同)
思路:蓄水池抽样
这题要我们设计一种“随机从链表中选出一个节点”的算法,要保证链表中每个节点被选中的概率是一样的。
本题属于特殊情况下的蓄水池抽样问题,求解前先了解一下蓄水池抽样算法。
什么是蓄水池抽样?
蓄水池抽样是一种数据抽样算法,推广到更一般的情况是:
给定一个数据流,数据流长度N很大,且N直到处理完所有数据之前都不可知,
请问如何在只遍历一遍数据(O(N))的情况下,能随机选取出m个不重复的数据,
且数据流中的每个数被选中的概率都是m/N?
简单概括:就是从N个元素中随机的等概率的抽取m个元素。
通常能直接想到的思路(可行,但最差需要做2次遍历)是:
用random对象生成一个随机数x,然后在数据流中遍历x次找到数字num,
如果num已经被选择过,就回到上一步,重新生成一个随机数,寻找对应的数字;
如果num未被选择过,那么num就是本次所选的数字。
执行上述步骤m次,就能找到m个不重复的数。
这个思路是可行的,但也存在一些问题:参考 蓄水池抽样及实现 - by handspeaker
如果num被选择过,就要重新计算随机数+重新遍历:
- 当m较小时,遇到选择过的数字的几率较低,发生重复寻找的概率较低,例如:m=100, n=10000,当选取第100个时,已选择的数字个数只有99个,第100个数字和这99个重复的概率只有:99/10000;
- 当m较大时,例如:m=5000,n=10000,当选择第5000个时,已选择的数字个数有4999个,则第5000个数字和这4999发生重复的概率为:4999/10000 ≈ 50%,有一半的几率需要重新计算随机数,重新遍历数据流,因此上述方法在m较大时效率会下降,最差时间复杂度为O(2N)。
可见上述方法虽然简单易想到,但并不适用于任何场合的随机抽样。
下面介绍的蓄水池算法能解决上述思路存在的问题,只需遍历一次就能选取出目标数字,而不会发生重新遍历的问题,可以将时间复杂度降为O(N):
蓄水池抽样:从N个元素中随机的等概率的抽取m个元素,其中N无法确定。
则执行一次“选取m个数字”的流程:
-
先将前m个数字都选中(即前m个数字的选择概率为1),在选择第x个数字时,该数字被保留的概率设为m/x(x是抽取的次数),然后按 1/m 的概率替换掉被选中的元素。
-
当数据流的所有元素都处理完后,这m个数字就是这一次选取的结果。
举例说明:
数据流共有100个数(N),按1~100排列,要抽取10个元素(m)。
- 注意区分:抽取的数字有抽取次序和本身的序号两个标识,我们这里说的都是抽取次序。
首先,将1~10都选中,作为已选择的10个数字;
对于第11个数n11,以m/x即10/11的概率保留它,如果保留n11,则说明已选择的10个数字里要选出一个数字和它交换,每个数字被选出的概率为1/m即1/10;
对于第12个数n12,以m/x即10/12的概率保留它,如果保留n12,则说明已选择的10个数字里要选出一个数字和它交换,每个数字被选出的概率为1/m即1/10;
...
以此类推,直到所有元素都处理完,这10个数字就是这一轮我们随机抽取出来的数字。
- 蓄水池抽样算法可以确保遍历一次数据流就能得到随机等概率选取出来的目标数字。
为什么蓄水池采样法能够保证每个数字被抽到的概率是相等的?
假设数据流有N个数,我们要抽取m个数,如果每个数字被抽到的概率是相等的,那么每个数被被取的概率就应该是 m/N。
我们对一轮“随机选取m个数”的过程进行分析:
首先,将数据流的前m个数字都保留,它们被选中的概率是1;
对于要选择的第m+1个数n[m+1],以m/m+1的概率保留,那么已选择的前m个数字中的n[r](r∈[1,m])被保留的概率是:
p(n[r]被保留) = p(n[r]上一轮被保留) * (p(n[m+1]被丢弃) + p(n[m+1]被保留)*p(n[r]未被替换))
所以,
- 要选择的第m+1个数被保留的概率和已选择的每个数字被保留的概率是相等的。
对于要选择的第m+2个数n[m+2],以m/m+2的概率保留,同理,已选择的前m个数字中的n[r](r∈[1,m])被保留的概率是:
以此类推,可以发现对于要选择的第k个数n[k],它以m/k的概率保留,而已选择的每个数字被保留的概率也是m/k,当计算到第N个数字时,这个概率也就变成了m/N,满足“每个元素的选取概率都是相等”的这一要求。
严谨的证明可以用数学归纳法:蓄水池抽样及实现 - by handspeaker
解决蓄水池抽样的简化版本(m=1,即第382题)
本题每一次只需要选择1个数据,相当于在一般情况的基础上令m=1,所以一轮“选取1个数字”的流程为:
-
先将第一个节点作为被选择的节点,要选择第x个节点时,该节点被保留的概率设为1/x(x是抽取的次数),然后按 1/1 的概率替换掉被选中的元素。
也就是说,对于被选中的元素就一定会替换之前选择的元素。
-
当处理完链表的所有节点时,此时记录的被选择节点就是随机选取出来的节点。
举例说明:
链表为:1->2->3->4->5,要随机等概率地选出一个节点,每个节点的选取概率应该为1/5
先将1作为选择节点,节点1被保留的概率为1;
对于2,有1/2的概率保留,1的保留概率=1在上一轮被保留的概率*2不被保留的概率=1*1/2=1/2;
对于3,有1/3的概率保留,1的保留概率=1/2*2/3=1/3,2的保留概率=1/2*2/3=1/3;
...以此类推
当处理到第5个节点时,1、2、3、4、5的保留概率都=1/5,到达了等概率选取的目的。
实现遇到的问题:用随机数实现“按一定概率做处理”
如何实现按一定的概率保留节点? → 使用随机数
例如:对于第k个节点,要以1/k的概率保留它,则可以使用random.nextInt(k)来生成[0,k)之间的随机数,随机数 == 0的概率就是1/k。
//以1/k的概率保留一个节点
Random random = new Random();
if(random.nextInt(k) == 0)
当前节点保留
- 注意:nextInt(k):获取的是[0,k)上的随机数。
实现代码:
class Solution {
ListNode root;
public Solution(ListNode head) {
this.root=head;
}
/** Returns a random node's value. */
public int getRandom() {
ListNode p = root;
Random random = new Random();//用于获取随机数
int ret = p.val;
int i = 1;
while(p != null){
//从[0,i)之间随机选取一个数,如果随机数==0,相当于以1/i的概率保留当前p.val
if(random.nextInt(i) == 0)
ret = p.val;
p = p.next;
i++;
}
return ret;
}
}
- 时间复杂度:和初始思路相比,蓄水池抽样的时间复杂度得到了一定的提升,将初始思路最差可能两次遍历O(2N)降低为只需要遍历一次O(N)。(每调用一次getRandom都需要遍历一遍整个链表)
- 空间复杂度:O(1)。
398. 随机数索引(缩小抽样范围)
题目链接:https://leetcode-cn.com/problems/random-pick-index/
分类:
- 蓄水池抽样:不是对整个数据流做抽样,而是对和target值相等的所有元素做随机等概率抽样 → 设置一个计数器表示找到第几个目标元素
思路:蓄水池抽样法
本题和382题类似,都属于蓄水池抽样问题,但要随机等概率抽取的是数组中和target值相等的元素的索引,所以我们只需要以数组中和target值相等的元素为对象做蓄水池抽样即可。
-
方案1:设置一个辅助数组存放所有和target相等的元素,再在这个辅助数组上做一次蓄水池抽样。
存在的问题:需要额外空间,如果相等元素太多可能溢出。
-
方案2(采用):设置一个计数器cnt,用于表示遇到的目标元素是已找到的第几个元素,找到的第cnt个目标元素以1/cnt的概率保留,就能实现蓄水池抽样,其他操作和举例见382题的分析。
注意:random.nextInt(n)生成的是[0,n)中的随机数,区间是左闭右开。
实现代码:
class Solution {
int[] nums;
public Solution(int[] nums) {
this.nums = nums;
}
public int pick(int target) {
Random random = new Random();
int ret = 0;
int cnt = 0;
for(int i = 0; i < nums.length; i++){
if(nums[i] == target){
cnt++;
//以1/cnt的概率保留当前所找的元素的下标
if(random.nextInt(cnt) == 0) ret = i;
}
}
return ret;
}
}