这一题在我面试中遇到两次(均为2020年7月份),新浪的比较简单,字节的做了变形,具体看后面详细分析。

1. 等概率输出0和1

1.1 题目描述

有一个随机数发生器,以概率 P 产生0,概率 (1-P) 产生 1,请问能否利用这个随机数发生器,构造出新的发生器,以 1/2 的概率产生 0 和 1 。请写明结论及推理过程。( 注意:这里的 p 相当于是未知的,后文会提到已知 p 的类型,解题思路是不同的

变形:
知随机数生成函数f(),返回0的概率是60%,返回1的概率是40%。根据f()求随机数函数g(),使返回0和1的概率是50%,不能用已有的随机生成库函数。

注意:
这里的变形毫无区别,看后续分析即可知道,核心问题都是

P ( 0 , 1 ) = ( p ) ∗ ( 1 − p ) = P ( 1 , 0 ) = ( 1 − p ) ∗ p P(0,1) = (p)*(1-p) = P(1,0) = (1-p)*p P(0,1)=(p)(1p)=P(1,0)=(1p)p

所以让 01 的情况来模拟 0, 让 10 的情况来模拟 1 即可,代码一模一样

1.2 解题思路 & 代码

题目解答:

两次调用该 RANDOM 函数,如果其概率为 P(x),调用2次

P(1) = p
P(0) = 1-p

P’(1) =p
P’(0) = 1-p

概率如下:

P ( 1 , 1 ) = p ∗ p P(1,1) = p * p P(1,1)=pp
P ( 1 , 0 ) = p ∗ ( 1 − p ) P(1, 0) = p * (1-p) P(1,0)=p(1p)
P ( 0 , 1 ) = ( 1 − p ) ∗ p P(0, 1) = (1-p)*p P(0,1)=(1p)p
P ( 0 , 0 ) = ( 1 − p ) ∗ ( 1 − p ) P(0, 0) = (1-p)*(1-p) P(0,0)=(1p)(1p)

很明显,这四种情况中出现 (0,1) 和 (1,0) 的概率相等,那么把 (0,1) 看成是 0 , (1,0) 看成是 1 ,那么他们输出的概率均为 p (1 - p),其他的情况舍弃并重新调用。这样就得到了 0 和 1 均等生成的随机器了。

这种解法可以推广到n个数的情况,我们知道,取n个随机数发生器,存在n个概率相同的独立事件,我们只使用这n个事件就得到1/n的概率了。例如 n=3 ,有 8 中情况000,001,010,011,100,101,110,111,其中001,010,100的概率都是 p 2 ∗ ( 1 − p ) p^2*(1-p) p2(1p)

Python

import random
class Solution:
    def random_index(self):
        # """随机变量的概率函数"""
        # 参数rate为list<int>
        # 返回概率事件的下标索引
        # 这是一个10%概率产生0,90%概率产生1的生成器
        rate = [1, 9]

        start = 0
        index = 0
        randnum = random.randint(1, sum(rate))
        for index, scope in enumerate(rate):
            start += scope
            if randnum <= start:
                break
        return index

    def Rand1(self):
        # 第一题:可以用原发生器周期性地产生2个数,直到生成01或者10。
        i1 = self.random_index()
        i2 = self.random_index()
        if i1 == 0 and i2 == 1:
            return 1
        elif i1 == 1 and i2 ==0:
            return 0
        else:
            return self.Rand1()

if __name__ == '__main__':
    solution = Solution()
    rand1 = []
    for i in range(20):
        rand1.append(solution.Rand1())
    print(rand1)
    
    rand2 = []
    for i in range(20):
        rand2.append(solution.Rand2(100))
    print(rand2)

C++

int random_0_1()
{
	int i = RANDOM();
	int j = RANDOM();
	int result;
 
	while (true)
	{
		if (i == 0 && j == 1)
		{
			result = 0;
			break;
		}
		else if (i == 1 && j == 0)
		{
			result = 1;
			break;
		}
		else
			continue;
	}
 
	return result;
}

这里,等概率输出 0 和 1 的期望调用次数 K
K = 2 2 p ( 1 − p ) = 1 p ( 1 − p ) K= \frac{2}{2p(1-p)}= \frac{1}{p(1-p)} K=2p(1p)2=p(1p)1

可以看出,当 p 很大或者很小的时候,这个期望值会很大,也就是调用次数会很多,只有当 p 趋近于 0.5, 调用次数最少,所以这种方法的期望调用次数的时间复杂度是比较高的!

2. 以 1/N 的概率返回 1~N 之间的数

位运算,因为 i 个二进制位随机的选择 0 或 1,可以随机的构成 0~ 2 i 2^i 2i 的数,而这些数构成了所有的组合数。因此是等概率出现的。比如:2位二进制位,这两位可以随机为0或1而互不影响,随机的构成了00, 01, 10, 11,它们代表了四个数,且这四个数是等概率的。

可以通过已知随机函数 rand() 产生等概率产生 0 和 1 的新随机函数 Rand1(),然后调用 k(k为整数n的二进制表示的位数)次 Rand1() 函数,得到一个长度为 k 的 0 和 1 序列,以此序列所形成的整数即为 1–n 之间的数字。

注意:从产生序列得到的整数有可能大于 n ,如果大于 n 的话,则重新产生直至得到的整数不大于n。

算法:

  1. 第一步:由rand()(在下面的程序中是 random_index())函数产生Rand1()函数,Rand1()函数等概率产生0和1。

  2. 第二步:计算整数 n 的二进制表示所拥有的位数 k, k = 1 + l o g 2 n k = 1 +log_2n k=1+log2n

  3. 第三步:调用k次 Rand1() 产生随机数。

代码中 Rand2()和 Rand3()是两种写法

import random
class Solution:
    def random_index(self):
        # """随机变量的概率函数"""
        # 参数rate为list<int>
        # 返回概率事件的下标索引
        # 这是一个10%概率产生0,90%概率产生1的生成器
        rate = [1, 9]

        start = 0
        index = 0
        randnum = random.randint(1, sum(rate))
        for index, scope in enumerate(rate):
            start += scope
            if randnum <= start:
                break
        return index

    def Rand1(self):
        # 1. 等概率生成 0,1
        i1 = self.random_index()
        i2 = self.random_index()
        if i1 == 0 and i2 == 1:
            return 1
        elif i1 == 1 and i2 == 0:
            return 0
        else:
            return self.Rand1()
        return -1

    def Rand2(self, n: int):
        # 2. 以 1/N 的概率返回 1~N 之间的数
        # int(res, 2) 表示把 str 类型的二进制转成十进制 int('101',2) == 5
        k = len(bin(n)[2:])  # k = 1 + np.log2(n)
        print("k:", k)       # 10
        res = ''
        for i in range(k):
            res += str(self.Rand1())
        if int(res, 2) > n:
            return self.Rand2(n)
        else:
            return int(res, 2)

    def Rand3(self, n: int):
        # 3. 以 1/N 的概率返回 1~N 之间的数
        # (1<<i) 表示 1 * 2^i
        import numpy as np
        result = 0
        k = 1 + np.log2(n)
        print("k:", int(k))   # 10
        for i in range(0, int(k)):
            if self.Rand1() == 1:
                result |= (1<<i)
        if result > n:
            return self.Rand3(n)
        return result

if __name__ == '__main__':
    solution = Solution()
    rand1 = []
    for i in range(20):
        rand1.append(solution.Rand1())
    print(rand1)

    rand2 = []
    for i in range(10):
        rand2.append(solution.Rand2(999))
    print(rand2)

    rand3 = []
    for i in range(10):
        rand3.append(solution.Rand3(999))
    print(rand3)

3. 给定函数rand5() 构造rand7() 或 rand7()构造rand10()

3.1 rand5() 构造rand7()

给定一个函数rand5(),该函数可以随机生成1-5的整数,且生成概率一样。现要求使用该函数构造函数rand7(),使函数rand7()可以随机等概率的生成1-7的整数。

思路:
很多人的第一反应是利用 rand5() + rand()%3 来实现 rand7() 函数,这个方法确实可以产生1-7之间的随机数,但是仔细想想可以发现数字生成的概率是不相等的。rand()%3 产生 0 的概率是 1/5 ,而产生 1 和 2 的概率都是 2/5 ,所以这个方法产生 6 和 7 的概率大于产生 5 的概率。

分析:
要保证rand7()在整数1-7的均匀分布,可以构造一个 1-7*n 的均匀分布的随机整数区间(n为任何正整数)。假设x是这个1-7*n 区间上的一个随机整数,那么x%7+1就是均匀分布在1-7区间上的整数。由于(rand5()-1)*5+rand5()可以构造出均匀分布在1-25的随机数(原因见下面的说明),可以将 22~25 这样的随机数剔除掉,得到的数1-21仍然是均匀分布在1-21的,这是因为每个数都可以看成一个独立事件。

正确的方法是利用rand5()函数生成1-25之间的数字,然后将其中的 1~21 (1~7 * n,其中 n 为任意整数,这里取了 n = 3,即 1 ~ 3*7 ) 映射成1-7,丢弃 22-25 。例如生成 (1,1),(1,2),(1,3),则看成 rand7() 中的 1 ,如果出现剩下的4种,则丢弃重新生成。

(如果是rand7 ( ) 构造rand10 ( ) ,则下面的代码循环条件变成 while ( x > 40),因为 1 ~ 7 *7 之间满足 10 ∗ n 10 * n 10n 且最大的数是 10 * 4 == 40)

解释:

首先 rand5()-1 得到一个离散整数集合{0,1,2,3,4},其中每个整数的出现概率都是1/5。那么 ( r a n d 5 ( ) − 1 ) ∗ 5 (rand5()-1)*5 (rand5()1)5 得到一个离散整数集合 A = {0,5,10,15,20},其中每个整数的出现概率也都是1/5。而 r a n d 5 ( ) rand5() rand5() 得到的集合B={1,2,3,4,5}中每个整数出现的概率也是1/5。显然集合A和B中任何两个元素相加可以与1- 25 之间的一个整数 一 一 对应,也就是说1-25之间的任何一个数,可以唯一确定 A 和 B 中两个元素的一种组合方式,反过来也成立。由于 A 和 B 中元素可以看成是独立事件,根据独立事件的概率公式 P ( A B ) = P ( A ) P ( B ) P(AB)=P(A)P(B) P(AB)=P(A)P(B),得到每个组合的概率是 1 / 5 ∗ 1 / 5 = 1 / 25 1/5*1/5=1/25 1/51/5=1/25。因此 ( r a n d 5 ( ) − 1 ) ∗ 5 + r a n d 5 ( ) (rand5()-1)*5+rand5() (rand5()1)5+rand5()生成的整数均匀分布在 1 − 25 1-25 125之间,每个数的概率都是 1 / 25 1/25 1/25

C++

int rand7()
{
	int x = 0;
	do
	{
		x = 5 * (rand5() - 1) + rand5();
	}while(x > 21);
	return 1 + x % 7;
}

:为什么用 while(x>20)而不用while(x>7)呢?原因是如果用while(x>7)则有20/25的概率需要循环 while,很有可能死循环了。

这种思想是基于,rand()产生[0,N-1],把rand()视为N进制的一位数产生器,那么可以使用 rand()*N+rand() 来产生 2 位的 N 进制数,以此类推,可以产生3位,4位,5位…的N进制数。这种按构造N进制数的方式生成的随机数,必定能保证随机,而相反,借助其他方式来使用rand()产生随机数(如 rand5() + rand()%3 )都是不能保证概率平均的。

此题中N为5,因此可以使用rand5()*5+rand5()来产生2位的5进制数,范围就是1到25。再去掉22-25,剩余的除3,以此作为rand7()的产生器。

3.2 【LeetCode】470. rand7() 构造rand10()

法一、

Java

public int rand10() {
    // 首先得到一个数
    int num = (rand7() - 1) * 7 + rand7();
    // 只要它还大于40,那你就给我不断生成吧
    while (num > 40)
        num = (rand7() - 1) * 7 + rand7();
    // 返回结果,+1是为了解决 40%10为0的情况
    return 1 + num % 10;
}

复杂度分析

  1. 时间复杂度:期望时间复杂度为 O(1),但最坏情况下会达到 O(∞)(一直被拒绝)。
  2. 空间复杂度:O(1)。
  3. 期望调用次数
    在这里插入图片描述

这里先 int num = (rand7() - 1) * 7 + rand7(); 和上面的 do...while... 是一样的

但是这时候我们舍弃了 9 个数,舍弃的还是有点多,效率还是不高,怎么提高效率呢?那就是舍弃的数最好再少一点

法二、(更快,因为拒绝的更少)
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() {
        while (true){
            int num = (rand7() - 1) * 7 + rand7();
            // 如果在40以内,那就直接返回
            if(num <= 40) return 1 + num % 10;
            // 说明刚才生成的在41-49之间,利用随机数再操作一遍
            num = (num - 40 - 1) * 7 + rand7();
            if(num <= 60) return 1 + num % 10;
            // 说明刚才生成的在61-63之间,利用随机数再操作一遍
            num = (num - 60 - 1) * 7 + rand7();
            if(num <= 20) return 1 + num % 10;

        }
    }
}


这样我们可以得到 1−21 之间的随机数,只要舍弃 1 个即可

复杂度分析

  1. 时间复杂度:期望时间复杂度为 O(1),但最坏情况下会达到 O(∞)(一直被拒绝)。
  2. 空间复杂度:O(1)。
  3. 期望调用次数
    使用类似的期望计算方法,我们可以得到调用 Rand7 的期望次数约为 2.2123

参考leetCode 470 题解

变形 3.1 random3() 构造 random5()

已知random3()这个随机数产生器生成[1, 3]范围的随机数,请用random3()构造random5()函数,生成[1, 5]的随机数?

如何从[1-3]范围的数构造更大范围的数呢?同时满足这个更大范围的数出现概率是相同的,可以想到的运算包括两种:加法和乘法

考虑下面的表达式:

3 ∗ ( r a n d o m 3 ( ) – 1 ) + r a n d o m 3 ( ) 3 * (random3() – 1) + random3() 3(random3()1)+random3()

可以计算得到上述表达式的范围是[1, 9] 而且数的出现概率是相同的,即1/9
下面考虑如何从[1, 9]范围的数生成[1, 5]的数呢?
可以想到的方法就是 rejection sampling 方法,即生成[1, 9]的随机数,如果数的范围不在[1, 5]内,则重新取样

int random5()
{
	int val = 0;
	do
	{
		val = 3 * (random3() - 1) + random3();
	}while(val > 5);
	return val;
}
变形 3.2

给定一个函数rand()能产生0到n-1之间的等概率随机数,问如何产生0到m-1之间等概率的随机数?

int random(int m , int n)
{
	int k = rand();
	int max = n-1;
	while(k < m)
	{
		k = k*n + rand();
		max = max*n + n-1;
	}
	return k/(max/n);
}
变形 3.3

将这个问题进一步抽象,已知 random_m() 随机数生成器的范围是 [1, m] 求random_n() 生成 [1, n] 范围的函数,m < n && n <= m *m

其中 t 为 n 的最大倍数,且满足 t< m*m
如:

  1. rand3() 生成rand5() , t = 5 ∗ i , t < 3 ∗ 3 , ∴ t = 5 ∗ 1 = 5 t = 5 * i,t < 3 * 3,∴ t = 5 * 1 = 5 t=5it<33t=51=5
  2. rand5() 生成rand7() , t = 7 ∗ i , t < 5 ∗ 5 , ∴ t = 7 ∗ 3 = 21 t = 7 * i,t < 5 * 5,∴ t = 7 * 3 = 21 t=7it<55t=73=21
  3. rand7() 生成rand10() , t = 10 ∗ i , t < 7 ∗ 7 , ∴ t = 10 ∗ 4 = 40 t = 10 * i,t < 7 * 7,∴ t = 10 * 4 = 40 t=10it<77t=104=40
int random_n()
{
	int val = 0;
	int t;   # t为n的最大倍数,且满足t<m*m
	do
	{
		val = m * (random_m() - 1) + random_m();
	}while(val > t);
	return val;
}
变形 3.4

如何产生如下概率的随机数?0出1次,1出现2次,2出现3次,n-1出现n次?

int random(int size)
{
	while(true)
	{
		int m = rand(size);
		int n = rand(size);
		if(m + n < size)
			return m+n;
	}
}

4. 返回 (0, 1) 之间的均匀分布(字节跳动面试题)

题目描述:

有一个函数 weak_random() 能以 p 概率返回 0 , 以 1-p 概率返回 1 , 且 p 已知 , 使用weak_random 实现float_random()返回(0,1)之间的浮点数 要求均匀分布 精度为 1e-8

先回顾一下均匀分布
均匀分布的概率密度函数为:
f ( x ) = { 1 b − a , a < x < b 0 , else f(x)= \begin{cases} \frac{1}{b - a}, & \text {a < x < b} \\0, & \text{else} \end{cases} f(x)={ba1,0,a < x < belse

可以理解为落在 (a, b) 区间内都是等概率的,概率均为 1 b − a \frac{1}{b - a} ba1

解题思路:

可以理解为把前者映射到后者: (0,1) -> (T - 5e-9, T+5e-9 )

前文提到的第一种最简单的类型,即等概率输出 0 和 1 的期望调用次数
K = 2 2 p ( 1 − p ) = 1 p ( 1 − p ) K= \frac{2}{2p(1-p)}= \frac{1}{p(1-p)} K=2p(1p)2=p(1p)1
可以看出,当 p 很大或者很小的时候,这个期望值会很大,也就是调用次数会很多,只有当 p 趋近于 0.5, 调用次数最少,所以这种方法的期望调用次数的时间复杂度是比较高的!

这一题,返回 (0,1)之间均匀分布的调用次数 K 的下限:
K > − l o g ( e − 8 ) − p l o g p − ( 1 − p ) l o g ( 1 − p ) K>\frac{-log(e^{-8})}{-plogp - (1-p)log(1-p)} K>plogp(1p)log(1p)log(e8)

这是面试官提示的,一看到带有 log,就想到用分治法

可以这样求解:

  1. 因为这里的条件给了 p 是已知的,所以把一条长度为 1 的线段,左边划分长度 p, 右边划分长度 (1-p);
  2. 再把左边的 p 划分成两段,占比分别为 p 和 (1-p)
  3. 同样再把右边的 (1-p) 划分成两段,占比分别为 p 和 (1-p)
  4. 依次递归到满足精度 e − 8 e^{-8} e8 为止

参考:

  1. 01发生器产生均匀分布
  2. 等概率随机函数的实现
  3. 随机数范围扩展方法总结
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐