拒绝采样
拒绝采样(Rejection Sampling) ,顾名思义,核心思想就是:先生成一个范围更大的随机样本,然后把不符合要求的部分扔掉(拒绝),只保留符合要求的部分(接受)。
它是一种在概率论和统计学中非常基础且经典的随机抽样方法。
1. 用一个通俗的例子理解
想象一下,你手里有一个飞镖盘,盘子上画着一个圆形 的靶区。你想在圆形靶区内实现完全随机的均匀打点。
但是,你手里的自动发球机只能在一个大正方形区域内均匀地发射飞镖(而且正方形刚好包住了这个圆)。
你应该怎么做?
- 让机器在正方形里随便发射一枚飞镖。
- 看看飞镖落在哪:
- 如果飞镖落在圆形里面 -> 接受(这就是你想要的点)。
- 如果飞镖落在圆形外面 (正方形的四个角) -> 拒绝(拔下来扔掉,当作没发生过)。
- 重复这个过程,直到你收集到足够多的点。
因为机器在正方形里是均匀打点的,所以只要我们只看落在圆里的那些点,它们在圆内也绝对是均匀分布的。
2. 回到 rand7() 到 rand10() 的例子
在刚才的算法题中,我们也是用了完全一样的逻辑:
- 目标(圆形靶区): 生成 1 到 10 的等概率随机数(或者 1 到 40,因为 40 是 10 的倍数,方便映射)。
- 工具(正方形发球机): 我们通过
(rand7() - 1) * 7 + rand7()制造出了 1 到 49 的等概率随机数。 - 拒绝与接受:
- 如果生成的数字在 1 到 40 之间 -> 接受(按比例映射给 1 到 10)。
- 如果生成的数字在 41 到 49 之间 -> 拒绝(这个数字作废,重新生成一次)。
因为 1 到 49 里的每一个数字出现的概率都是一样的(都是 149\frac{1}{49}491),所以当我们把 41 到 49 砍掉,只看 1 到 40 时,这 40 个数字出现的概率依然是相等的(变成 140\frac{1}{40}401)。
3. 更广义的数学定义
在更高阶的统计学中,如果我们想从一个非常复杂的概率分布 p(x)p(x)p(x) 中抽样,但这个分布太复杂了,算不出来。我们可以找一个简单的、容易抽样的"提议分布" q(x)q(x)q(x),并乘以一个常数 MMM,用 M⋅q(x)M \cdot q(x)M⋅q(x) 把目标分布 p(x)p(x)p(x) 完全"罩住"(就像用正方形罩住圆)。
每次我们从 q(x)q(x)q(x) 中抽一个样本出来,然后以一定的概率决定是接受它还是拒绝它。这个概率取决于目标分布和提议分布在那个点的高度差。
4. 拒绝采样的致命弱点:效率
拒绝采样的最大问题在于**"浪费"**。
效率取决于接受率 。如果我们要的目标区域很小,而我们用来生成的范围很大,那么绝大多数生成的样本都会被拒绝掉。在代码里,这就意味着 while (true) 会循环很多次,浪费大量的计算资源。
这也是为什么在 rand10() 的进阶解法中,我们要想尽办法把原本要被拒绝的 41 到 49 重新利用起来,目的就是为了提高接受率,减少拒绝的次数。
rand10() 生成 rand7()
从大范围生成小范围的随机数(比如 rand10() 生成 rand7()),最直白的方法就是遇到不要的数字直接丢弃、重新抽。
为了打消你对"这到底是不是等概率"的疑虑,我们帮你把里面的数学逻辑重新梳理了一下。其实,这就是一个简单的无限等比数列求和问题。
假设我们想生成 1 到 7 里的特定数字 k(比如 k = 1):
- 第一次就命中
k: 概率直接是 1/101/101/10。而抽到废票(8、9、10,需要重抽)的概率是 3/103/103/10。 - 第二次才命中
k: 意味着第一次抽到了废票,第二次成功。概率是 (3/10)×(1/10)(3/10) \times (1/10)(3/10)×(1/10)。 - 第 n 次才命中
k: 意味着前 n-1 次都是废票,第 n 次终于成功。概率是 (3/10)n−1×(1/10)(3/10)^{n-1} \times (1/10)(3/10)n−1×(1/10)。
因为我们只要一直抽下去,总有一次会命中,所以把所有可能命中的概率全加起来,就是一个等比数列求和:
P=110+310⋅110+(310)2⋅110+... P = \frac{1}{10} + \frac{3}{10} \cdot \frac{1}{10} + \left(\frac{3}{10}\right)^2 \cdot \frac{1}{10} + \dots P=101+103⋅101+(103)2⋅101+...
根据无限等比数列求和公式(首项除以 1 减公比,即 S=a11−qS = \frac{a_1}{1 - q}S=1−qa1),我们代入计算:
P=1/101−3/10=1/107/10=17 P = \frac{1/10}{1 - 3/10} = \frac{1/10}{7/10} = \frac{1}{7} P=1−3/101/10=7/101/10=71
结果非常漂亮,刚好是 1/71/71/7!这就严谨地证明了:只要把超出范围的数字扔掉重来,最后剩下的数字,每一个被选中的概率都是绝对均等的。
rand7() 生成 rand10()
将小范围均匀映射到大范围
要从 rand7() 等概率生成 rand10(),核心在于将小范围 均匀 映射到大范围。这段逻辑可以提炼为以下三个关键点:
1. 为什么简单的加法行不通? 如果直接用 rand7() + rand7()(或再减去 1),不同数字出现的概率会变得不一致。这就像掷两枚骰子,中间数字(如 5)的组合方式多(2+3 或 3+2),而边缘数字(如 14)的组合方式只有一种(7+7)。这种概率分布是中间高、两边低,不满足"等概率"的前提。
2. 正确的做法:利用乘法"拉开间距" 为了保证等概率,标准的构造公式是 (rand7() - 1) * 7 + rand7()。
- 第一步:
rand7() - 1等概率生成 {0, 1, 2, 3, 4, 5, 6}。 - 第二步:乘以 7,将数字间距拉开,得到基准集合 A:{0, 7, 14, 21, 28, 35, 42}。
- 第三步:加上第二次生成的
rand7(),也就是集合 B:{1, 2, 3, 4, 5, 6, 7}。
我们可以通过下面这三个点直观地看懂正确的做法在第二部和第三步做了什么:
1. 最小值和最大值是怎么定的? 非常直观的加法极限:
- 最小的可能: 选集合 A 最小的"基准" 0,加上集合 B 最小的"零头" 1,结果就是 1。
- 最大的可能: 选集合 A 最大的"基准" 42,加上集合 B 最大的"零头" 7,结果刚好是 49。
2. 中间的数字是怎么被严丝合缝填满的? 集合 A 里的数(0, 7, 14...)就像是每一层楼的"地板高度",而集合 B 里的数(1到7)就像是从地板往上走的"台阶"。 当你把它们两两组合时,奇妙的事情就发生了:
- 地板 0 + 台阶 1~7 = 生成 1 到 7
- 地板 7 + 台阶 1~7 = 生成 8 到 14
- 地板 14 + 台阶 1~7 = 生成 15 到 21
- ...以此类推...
- 地板 42 + 台阶 1~7 = 生成 43 到 49
3. 为什么不重不漏? 因为两次 rand7() 各自独立,总共会产生 7 × 7 = 49 种组合。集合 A 每两层地板之间的距离刚好是 7,而集合 B 的台阶数也刚好是 7 级。这就保证了上一层的最高点(比如 7),加上下一层楼的最低点(比如 8),刚好首尾相连,没有任何断层,也没有任何重复。
这样一来,49 种组合就公平地对应了 1 到 49 里的每一个数字。
对应代码如下:
cpp
// The rand7() API is already defined for you.
// int rand7();
// @return a random integer in the range 1 to 7
class Solution {
public:
// @return a random integer in the range 1 to 10
int rand10() {
int num = (rand7() - 1) * 7 + rand7();
while(num > 10) {
num = (rand7() - 1) * 7 + rand7();
}
return num;
}
};
优化 1
但其实上面那份代码跑的非常慢,如果你放到 LeetCode 上面去测试,会发现在时间复杂度上,它之击败了 10%10\%10% 的用户(尽管这个值并不是很准确)。
我们来想想为什么?
我们的函数会得到 1~49 之间的数,而我们只想得到 1~10 之间的数,这一部分占的比例太少了,简而言之,这样效率太低,太慢,可能要 while 循环很多次,那么解决思路就是舍弃一部分数,舍弃 41~49,因为是独立事件,我们生成的 1~40 之间的数它是等概率的,我们最后完全可以利用 1~40 之间的数来得到 1~10 之间的数。所以,我们的代码可以改成下面这样:
cpp
// The rand7() API is already defined for you.
// int rand7();
// @return a random integer in the range 1 to 7
class Solution {
public:
// @return a random integer in the range 1 to 10
int rand10() {
int num = (rand7() - 1) * 7 + rand7();
while(num > 40) {
num = (rand7() - 1) * 7 + rand7();
}
return 1 + num % 10;
}
};
- 当
num属于[1,40]时,num % 10会得到等概率的[0,9],此时我们再+1就会得到等概率的[1,10]
再次提交可以发现,这次打败了 40%40\%40% 的用户。
优化 2
更进一步,优化 1 中我们舍弃了 9 个数(41~49),舍弃的还是有点多(17\frac{1}{7}71),效率还是不高,怎么提高效率呢?那就是舍弃的数最好再少一点!因为这样能让 while 循环少转几次,那么对于大于 40 的随机数,别舍弃呀,利用这 9 个数,再利用那个公式操作一下:
(大于40的随机数−40−1)∗7+rand7() (大于 40 的随机数−40−1)∗7+rand7() (大于40的随机数−40−1)∗7+rand7()
- 大于 40(41~49) 的随机数
- 40 - 1得到 0~8 - 0~8
* 7得到 0~56 - 0~56 +
rand7()得到 1~63
这样我们可以得到 1−63 之间的随机数,只要舍弃 3 个即可,那对于这 3 个舍弃的,还可以再来一轮:
(大于60的随机数−60−1)∗7+rand7() (大于60的随机数−60−1)∗7+rand7() (大于60的随机数−60−1)∗7+rand7()
- 大于 60(61~63) 的随机数
- 60 - 1得到 0~2 - 0~2
* 7得到 0~14 - 0~14 +
rand7()得到 1~21
这样我们可以得到 1−21 之间的随机数,只要舍弃 1 个即可。
最终代码如下:
cpp
// The rand7() API is already defined for you.
// int rand7();
// @return a random integer in the range 1 to 7
class Solution {
public:
// @return a random integer in the range 1 to 10
int rand10() {
while(true) {
int num = (rand7() - 1) * 7 + rand7();
// 如果在 40 以内,直接返回
if(num <= 40) return 1 + num % 10;
// 否则,num 在 [41,49],利用随机数再来一轮
num = (num - 40 - 1) * 7 + rand7();
if(num <= 60) return 1 + num % 10;
// 否则,num 此时在 [61,63] 之间,利用随机数再来一轮
num = (num - 60 - 1) * 7 + rand7();
if(num <= 20) return 1 + num % 10;
}
}
};
此时我们可以发现,我们已经打败了 100%100\%100% 的用户。
rand7() 调用的期望值
计算的逻辑非常直接:总期望 = 单轮平均调用次数 ÷ 单轮成功概率。
总调用次数 = 单轮平均调用成本 × 平均需要跑的轮数。这里的
÷ 单轮成功概率,在数学上就等同于× 平均轮数。假设抛硬币求正面,每次抛算作调用 1 次函数(单轮调用 = 1),拿到正面的概率是 1/2(单轮成功率 = 1/2)。你觉得平均要抛几次才能如愿?显然是 2 次。 如果用除法:1 ÷ (1/2) = 2 次,完全吻合。
1. 单轮平均调用几次?
每进一次 while 循环,前两次 rand7() 是必调的。
会不会调第三次?取决于第一步是否落入那 9 个废号,概率是 949\frac{9}{49}499。
会不会调第四次?取决于第二步是否落入那 3 个废号,概率是 949×363=3343\frac{9}{49} \times \frac{3}{63} = \frac{3}{343}499×633=3433。
所以,单轮调用的期望次数 CCC 为:
C=2+949+3343=752343C = 2 + \frac{9}{49} + \frac{3}{343} = \frac{752}{343}C=2+499+3433=343752
2. 单轮就成功的概率多大?
逆向思维最简单:看什么情况下会彻底失败、被迫重开?
只有在最后一步,刚好抽到了数字 21 这一种情况才会重开。经过了 4 次独立的 rand7(),总的样本空间是 74=24017^4 = 240174=2401 种组合。
单轮失败的概率仅仅是 12401\frac{1}{2401}24011,那么成功的概率 PPP 就是:
P=1−12401=24002401P = 1 - \frac{1}{2401} = \frac{2400}{2401}P=1−24011=24012400
3. 最终的总期望值
因为每次循环是独立重复事件,整体期望 EEE 就是单轮代价除以单轮成功率:
E=CP=752/3432400/2401=329150≈2.1933E = \frac{C}{P} = \frac{752 / 343}{2400 / 2401} = \frac{329}{150} \approx 2.1933E=PC=2400/2401752/343=150329≈2.1933
算下来,平均只需要约 2.1933 次就能生成一个结果。这个严谨的推导,足以向任何人证明你已经摸到了这道题的性能天花板。
参考资料
- 作者:Jerry
- 链接:https://leetcode.cn/problems/implement-rand10-using-rand7/solutions/139870/xiang-xi-si-lu-ji-you-hua-si-lu-fen-xi-zhu-xing-ji/
- 来源:力扣(LeetCode)
- 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
更好的写法,参考 GPT
核心思路:拒绝采样。
先用 rand7() 构造一个 1~49 的均匀随机数,因为 7 × 7 = 49,再只取前 40 个数,映射到 1~10。这样每个数字出现概率完全一样。
cpp
// The rand7() API is already defined for you.
// int rand7();
// @return a random integer in the range 1 to 7
class Solution {
public:
int rand10() {
while (true) {
int num = (rand7() - 1) * 7 + rand7(); // 1~49,且均匀
if (num <= 40) {
return 1 + (num - 1) % 10; // 映射到 1~10
}
}
}
};
解释一下:
rand7() - 1先变成0~6(rand7() - 1) * 7 + rand7()就能均匀生成1~49- 只接受
1~40 - 因为
40 = 10 × 4,所以每个数1~10都正好对应 4 个结果,完全均匀
期望调用次数:
- 每轮要调 2 次
rand7() - 接受概率是
40/49 - 所以期望调用次数是
2 × 49 / 40 = 2.45
这是这题最经典、最好写、面试也最稳的做法。
前面那份代码,在真实的业务开发里,为了省那 0.26 次的调用把代码写这么长,其实容易手滑出 Bug。
总结
若 A 能等概率生成 [1, X] 的随机数,randY() 能等概率生成 [1, Y] 的随机数,那么 A * Y + randY() 能等概率生成 1 ~ A*Y 之间的所有整数。
进阶:randX() 生成 randY(),X<Y
核心还是一样:先把小范围随机数拼成一个更大的均匀范围,再做拒绝采样。
当 X < Y 时,单次 randX() 不够,就调用多次,把它看成 X 进制 来扩展。
补充:
讲一个万能的方法,可以无脑等概率生成任何数!
【步骤】
- 下面将需要生成的随机数中最大值记为n
- 先去生成一个等概率产生0和1的方法:
rand2()。- 不要去纠结为什么返回值不是1和2,因为方便二进制位运算。
- 实现:调用
rand7(),1,2,3返回1,4,5,6返回2,7重来。
- 将n转为二进制,看看是几位的二进制。
- 例如本题的
10,二进制为1010。也就是说仅仅需要4位二进制数就能表示10进制的10。
用这个rand2()方法生成n的每个二进制位。
- 例如本题的
- 创建一个数为
rand2(),每次左移一位再加rand2()。 - 如果出现不符合题意的情况,再次调用
rand10()。- 例如如上步骤可能生成
0000,1011、...... 、1111(分别为0,11、 ...... 、15),显然这些都是不在[1,10]中的正整数,所以只能重新生成。
- 例如如上步骤可能生成
【重难点】
主要理解二进制的每一位出现0和1的可能性都是相同的,所以能生成的每一个数概率都是相同的。
【JAVA代码】
java
/**
* The rand7() API is already defined in the parent class SolBase.
* public int rand7();
* @return a random integer in the range 1 to 7
*/
class Solution extends SolBase {
public int rand10() {
int ans = rand2();
for (int i = 0; i < 3; i++) {
ans <<= 1;
ans ^= rand2();
}
return (ans <= 10 && ans > 0) ? ans : rand10();
}
public int rand2() {
int ans = rand7();
return ans == 7 ? rand2() : ans % 2;
}
}
【拓展思考】
怎么用一个不等概率生成0和1的方法 rand() 等概率的生成任意数?
- 注:只是生成0和1的概率不相等,但是每次生成0或1的概率永远是恒定的。(例如生成0概率永远为10%,生成1概率永远为90%)
【思路】
- 同样是用二进制生成一个能等概率生成0和1的方法
rand2()。 - 用
rand()方法生成的二进制为01或10分别表示0和1,生成的二进制为00或11重复上述过程。
其余同本题步骤。
【重难点】
主要理解出现的4种情况中只有 01 或 10 两种情况是等概率的,其余两种情况应该要舍弃。
【代码】
无代码,自己可以动手尝试一下
其中 rand() 函数可以把本题的 rand7() 做改动,1-3返回1,4-7返回2。
【说明】
本人仅为算法爱好者,以上思路均来自左程云老师(侵删)。
如有描述不清楚的地方或者更多算法问题,请留言,看见后第一时间回复。
希望能和大家共同进步!!!