随机数漫谈

打印 上一主题 下一主题

主题 529|帖子 529|积分 1587

随机数对步伐计划来说很重要,本日就从几方面探究下一些常见的随机数相干的问题。
本文只讨论整数相干的随机数,另外必要你对概率论有最基本的相识(至少知道古典概型是什么)。
  本文索引

  
如何从rand7生成rand5

首先是和某个知名算法题相反的问题。
这里给定一个可以概率均等地生成0到6的随机数生成器,要求用这个生成器创造出能概率均等地生成0到4的随机数生成器。
有人可能会立刻给出这样的答案:
  1. func rand5() int {
  2.     return rand7() % 5
  3. }
复制代码
然而这个答案只满足了输出的范围在0到4,不满足概率均等,所以不正确。这种时间列表法的作用就显现出来了:
rand7的输出rand7 % 500112233445061发现问题了吗,0和1出现了两次,它们出现的概率是其他数字的两倍,因此概率分布并不均等。
通过列表法我们其实也发现了这个问题真正的解法:除掉5和6的话剩下的输出不仅符合要求概率也是均等的,所以代码会变成这样:
  1. func rand5() int {
  2.     n := rand7()
  3.     for n >= 5 {
  4.         n = rand7()
  5.     }
  6.     return n
  7. }
复制代码
上面的代码其实就是随机采样算法中非常重要的一种:拒绝采样。同样上面的rand7生成rand5也可以归类成一大类问题:给定一组满足规律或者特征是g(x)的样本,现在必要从这些样本中筛选出或者生成另一组满足特征是f(x)的样本。办理这类问题的算法很多,而拒绝采样是比较直观的:判断g(x)的样本是否符合要求,不符合的就清除取下一个样本,符合要求的就归类到满足f(x)的样本集合中。
按照这个角度来看,上面的问题可以划分为几个基本元素:

  • g(x):rand7
  • f(x): rand5
  • 必要拒绝的样本:大于即是5的整数
拒绝采样在大多数时间都能得到理想的结果,但还有采样率必要注意。采样率就是g(x)的样本中有多少可以被接受,采样率太低的时间意味着算法的效率也会非常低。所以我们简单算算rand5的采样率,是七分之五,约莫70%。这个概率不大不小,勉强合适。
当采样率过低的时间要么得改变拒绝采样的标准或范围,要么就不能再使用拒绝采样了。
go标准库的做法

标准库里当然不会有rand5和rand7,但它提供了一个叫Int63n的函数,它办理的问题是如何从均匀分布在[0, 2⁶⁴)范围上的随机整数中生成均匀分布在范围[0, n)上的随机整数。换句话说,虽然范围不一样了,但还是同一个问题。
我们肯定不能像上面那样把大于即是n的样本全丢了,因为2⁶⁴包罗至少1844京(1E16)个整数样本,这会带来低得无法接受的采样率。
但因为我们是用mod来选择范围内的随机数的,因此我们可以选择n的倍数,这个证明太简单了,列表法加归纳就行。或者还可以这么想,有一个整数常数C,x % n和Cx % n能产生的输出的种类和它们的数量都是完全相同的,所以如果样本能均匀分布在[0, n)的范围上,那么范围[0, C·n]只不外是[0, n)重复了C次,所以样本在每一段上都是均匀分布的,整个合起来的区间上也是均匀的。
有了常数C,这样使得我们可以尽可能地让更多的样本被采样,这样能降低重试的次数。
C其实也很好选择,取一个2⁶⁴内的n的最大的倍数就行,如果C本身能被2⁶⁴整除,那么C就是2⁶⁴/n。
所以标准库是这样写的:
  1. func (r *Rand) Int63n(n int64) int64 {
  2.         if n <= 0 {
  3.                 panic("invalid argument to Int63n")
  4.         }
  5.         if n&(n-1) == 0 { // n is power of two, can mask
  6.                 return r.Int63() & (n - 1)
  7.         }
  8.         max := int64((1 << 63) - 1 - (1<<63)%uint64(n))
  9.         v := r.Int63()
  10.         for v > max {
  11.                 v = r.Int63()
  12.         }
  13.         return v % n
  14. }
复制代码
代码很简单,下面看看性能测试:
  1. func (r *Rand) uint64n(n uint64) uint64 {
  2.         if is32bit && uint64(uint32(n)) == n {
  3.                 return uint64(r.uint32n(uint32(n)))
  4.         }
  5.         if n&(n-1) == 0 { // n is power of two, can mask
  6.                 return r.Uint64() & (n - 1)
  7.         }
  8.     hi, lo := bits.Mul64(r.Uint64(), n)
  9.         if lo < n {
  10.                 thresh := -n % n // 2⁶⁴ % n 的简化形式
  11.                 for lo < thresh {
  12.                         hi, lo = bits.Mul64(r.Uint64(), n)
  13.                 }
  14.         }
  15.         return hi
  16. }
复制代码
测试结果:
  1. func rand7() int {
  2.     x := 5*rand5() + rand5()
  3.     max := 25 - 25%7
  4.     for x >= max {
  5.         x = 5*rand5() + rand5()
  6.     }
  7.     return x % 7
  8. }
复制代码
充实利用每一个bit之后我们的性能提升了整整6倍
到现在为止还不错,如果你不在乎生成的随机数的概率分布或者你只想生成[0, 2^n)范围的随机数且这个n可以整除64,那么可以直接跳到下一节继承看了。
接着往下看的人肯定是希望不管在什么范围内都能生成概率均匀的随机数且只管多利用已生成的随机bits的。但事情往往不尽人意,比如,[0, 13)和[0, 7)就是两个例子。前者右边界不是2的幂,后者虽然是2的幂但3不能整除64。
我们先从简单的问题开始办理,[0, 13)。受先表示数字12至少得用4个bit,4能整除64,所以我们还可以每4个连续的bit分割成一组,但这时概率分布均匀的条件是满足不了的。无法保证的原因很简单,和我们第一节里说的“rand7生成rand5”的情况一样,每个均匀分割出来的一组连续的bits的组合里有我们不必要的样本存在。处理这个情况的方法在第一节里已经有表述了,那就是拒绝采样。确定拒绝的范围也使用第一节说到的办法,注意到每一组bits能生成[0, 16)的随机数,再考虑到13本身是素数,这里只必要简单地把≥13的样本全部剔除即可。
所以代码变成了下面这样:
  1. n := rand.Uint64()
  2. mask := uint64(0xf) // 0b1111
  3. for i := range 10 {
  4.         a[i] = int(n & mask) // 只让最右边四位有效(书写顺序的右边,这里不讨论大小端了因为说明起来太麻烦)
  5.         n >>= 4 // 把刚刚使用过的四位去掉
  6. }
复制代码
如果一组不满足采样要求,我们就跳过直接去下一组,因此有可能16组里无法得到充足的随机数,因此我们得重新获取一次64位的随机数,然后再次进入分割计算。这么做会对性能产生一点点负面影响,但仍旧很快:
  1. // 不要这样写代码
  2. // 我这么做是为了避免内存分配和回收会对测试产生不必要的杂音
  3. func getRand(a []int) {
  4.         if len(a) < 10 {
  5.                 panic("length wrong")
  6.         }
  7.         for i := range 10 {
  8.                 a[i] = int(rand.Int32N(16))
  9.         }
  10. }
  11. // 不要这样写代码
  12. // 我这么做是为了避免内存分配和回收会对测试产生不必要的杂音
  13. func getRandSplit(a []int) {
  14.         if len(a) < 10 {
  15.                 panic("length wrong")
  16.         }
  17.         n := rand.Uint64()
  18.         mask := uint64(0xf)
  19.         for i := range 10 {
  20.                 // 这里不需要mod
  21.                 a[i] = int(n & mask)
  22.                 n >>= 4
  23.         }
  24. }
  25. func BenchmarkGetRand(b *testing.B) {
  26.         var a [10]int
  27.         for range b.N {
  28.                 getRand(a[:])
  29.         }
  30. }
  31. func BenchmarkGetRandSplit(b *testing.B) {
  32.         var a [10]int
  33.         for range b.N {
  34.                 getRandSplit(a[:])
  35.         }
  36. }
复制代码
这时间性能就只提升了一倍。
上面那种情况还是最简单的,但[0, 7)就不好办了。首先表示6必要至少3个bit,而3不能整除64,其次6也不是2的幂。这个怎么处理呢?
有两个办法,核心思想都是拒绝采样,但拒绝的范围有区别。
第一个想法是,既然3不能整除64,那我们选个能被3整除的,这里是63,也就是说超过2⁶³-1的样本全部丢弃,然后把符合要求的样本按每连续的3bits进行分割。这样我们先保证了3bits分割出来的每一组都能均等的生成[0, 8)范围内的随机整数。现在问题转化成了“rand8怎么生成rand7”,这题我们会做而且做了好多回了,最终代码会是这样:
  1. goos: windows
  2. goarch: amd64
  3. pkg: benchrand
  4. cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
  5. BenchmarkGetRand-8                15623799                79.31 ns/op               0 B/op               0 allocs/op
  6. BenchmarkGetRandSplit-8           100000000                11.18 ns/op               0 B/op               0 allocs/op
复制代码
代码变得很长也很复杂,而且必要两步拒绝采样,相对的我们一次也能分割出21组,比4bits的时间多了5组,所以难说性能下降还是不变,因此我们看看测试:
  1. func getRandSplit(a []int) {
  2.         if len(a) < 10 {
  3.                 panic("length wrong")
  4.         }
  5.         mask := uint64(0xf)
  6.         count := 0
  7.         for {
  8.                 n := rand.Uint64()
  9.                 for i := 0; i < 16; i++ {
  10.                         sample := int(n & mask)
  11.                         n >>= 4
  12.                         // 不符合要求后直接跳到下一组去
  13.                         if sample >= 13 {
  14.                                 continue
  15.                         }
  16.                         // 这里也不需要mod
  17.                         a[count] = sample
  18.                         count++
  19.                         if count >= 10 {
  20.                                 return
  21.                         }
  22.                 }
  23.         }
  24. }
复制代码
确实慢了一些,但总体上还是提速了85%。
第二种想法只必要一步拒绝采样,既然3不能整除64,那么就找到一个离3近来的可以整除64且大于3的整数。在这里我们可以直接注意到4符合条件,实际开辟中如果要找到任意符合条件的数,可以依靠一下线性探测。现在我们按连续的4位把64位随机整数分割,这样分割出来的每一组可以生成均匀分布在[0, 16)上的整数。然后问题变成了“从rand16生成rand7”。代码这样写:
  1. goos: linux
  2. goarch: amd64
  3. pkg: benchrand
  4. cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
  5. BenchmarkGetRand-8              16242730                72.22 ns/op            0 B/op          0 allocs/op
  6. BenchmarkGetRandSplit-8         37794038                31.81 ns/op            0 B/op          0 allocs/op
复制代码
代码简单了,也只必要一步拒绝采样就行,但问题在于每一组的生成范围变大导致我们不得不使用取模操作。看看性能怎么样:
  1. func getRandSplit(a []int) {
  2.         if len(a) < 10 {
  3.                 panic("length wrong")
  4.         }
  5.         // 注意,mask现在只要三位也就是0b111了
  6.         mask := uint64(0x7)
  7.         count := 0
  8.         for {
  9.                 n := rand.Uint64()
  10.                 // 先拒绝大于2⁶³-1的样本
  11.                 if n > 1<<63-1 {
  12.                         continue
  13.                 }
  14.                 for i := 0; i < 21; i++ {
  15.                         sample := int(n & mask)
  16.                         n >>= 3
  17.                         // 一组bits如果组合出来的数大于等于7也拒绝采样
  18.                         if sample > 6 {
  19.                                 continue
  20.                         }
  21.                         // 这里是不用mod的,因为产生的sample本身只会在0-6之间
  22.                         a[count] = sample
  23.                         count++
  24.                         if count >= 10 {
  25.                                 return
  26.                         }
  27.                 }
  28.         }
  29. }
复制代码
想法2比想法1快了近20%,看来两步拒绝采样成了硬伤,不仅仅是因为多获取几次64位随机数更慢,多出来的一个if还可能会影响分支预测,即便末了我们多了5组可以采样也无济于事了。
所以,当你必要的随机数范围比较有限的时间,充实利用每一个bit是非常理想的性能提升本领。
带有权重的随机数

讨论了半天概率均匀分布的情况,但业务中还有一种常见场景:一组样本进行采样,既要有随机性,又要样本之间在统计上只管满足某些比例关系。
这个场景我想大家最先想到的应该是抽奖。是的没错,这是带权重随机数的常见应用。但还有一个场景,一个负载平衡器连接着一组权重不同的服务器硬件,现在想要只管按权重来分配链接,这时间带权重随机数就有效武之地了。
假设我们有样本(1, 2, 3, 4)四个整数,然后要按1:2:3:4的比例来随机生成样本,该怎么做呢?
按比例,我们可以得到1,2,3,4生成的概率是0.1,0.2,0.3,0.4,这些概率加起来是一定即是1的,所以我们不妨来想象有一个数轴上的0到1的区间,然后我们把这些比例“塞进”数轴里:
  1. goos: linux
  2. goarch: amd64
  3. pkg: benchrand
  4. cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
  5. BenchmarkGetRand-8              16500700                73.77 ns/op            0 B/op          0 allocs/op
  6. BenchmarkGetRandSplit-8         31098928                39.54 ns/op            0 B/op          0 allocs/op
复制代码
我故意打乱了序次,实际上序次并不影响结果。每个样本可以有一个数轴上的范围,范围的长度之间也是符合比重的,因此当存在一个可以均匀生成[0, 1)之间所有实数的随机数生成器时,这个生成器生成的数落在哪个范围里,我们就选择生成这个范围对应的样本,举个例子,如果生成的实数落在[0.0, 0.4)这个区间里,那么就生成样本“4”,如果落在[0.8, 1.0)这个区间,就生成“2”。这样带权重的随机数就生成了。这个看上面那个图还是挺好明白的,我就不费笔墨去证明了。
但我不是很想用这个方法。为啥呢,因为你看到了,区间的左边是闭合的,这意味着要做浮点数的等值比较,虽然很简单,但我很懒不想多写,而且浮点数没法正确表示所有情况的比例导致我们区间的两端都有精度损失,这就必要考虑偏差率,虽然通常这点精度损失带来的偏差可以忽略不记(尤其是在统计意义上)但只是考虑到这点我就浑身难受了。
所以我要找一种不必要浮点数的办理方案。想出来其实不难,假设我们有0到9的整数,正好10个,现在样本“1”按比例必要占用其中1个样本,样本“4”按比例要占用其中四个样本,现在我们得到了一个能均匀生成0到9的整数的随机数生成器,那只要生成的随机数正好是样本“4”占用的那几个随机数我们就生成“4”,生成的随机数是样本“1”占用的那就生成“1”。可以看到只要占够一定数量的不同的样本,那么我们一样能生成带权重的随机数。
下面有几个问题,一是样本总数怎么确定,这个简单,每个比例当成整数相加即可,比如1:2:3:4就是1+2+3+4=10,2:2:3:5就是2+2+3+5=12,依此类推。如果比例是实数呢?2.3 : 3.4 : 4.7怎么办?这就要用到比例的性质了,等比扩大后比例不变,所以我们每个实数都乘以10,然后去掉小数点后的0全部当成整数,所以23+34+47=104,理论上任意比例都能这么整,不外整数最终有巨细限制的,你总不能生成个随机数还用big.Int吧,所以注意总和别超过整数范围限制。
二是样本的范围怎么算,虽然我们只必要不相同的满足1里总数的离散的点就行,但为了方便计算我们还是选择连续的整数比较好,所以范围限定为[0, sum-1]。这样我们能直接利用rand.Uint64N()来生成必要的随机数生成器。
末了我们只要让样本按比例随机霸占一些连续的整数就行了。而且我们只必要记录右边界就够了,我们从范围是[0, n]的第一个样本开始比较,如果生成器给出的随机数小于即是某个右边界,那它一定落在边界代表的样本上(因为是从最小的边界开始比较的,所以随机数必然不可能落在前一个范围的样本上)。
其实就是把连续的实数换成了离散的整数点罢了,换汤不换药。
搞明白思路子女码写起来就是快:
  1. func getRandSplit2(a []int) {
  2.         if len(a) < 10 {
  3.                 panic("length wrong")
  4.         }
  5.         mask := uint64(0xf)
  6.         count := 0
  7.         for {
  8.                 n := rand.Uint64()
  9.                 for i := 0; i < 16; i++ {
  10.                         sample := int(n & mask)
  11.                         n >>= 4
  12.                         if sample > 13 {
  13.                                 continue
  14.                         }
  15.                         // mod不能漏了,因为我们会产生大于等于7的结果
  16.                         a[count] = sample % 7
  17.                         count++
  18.                         if count >= 10 {
  19.                                 return
  20.                         }
  21.                 }
  22.         }
  23. }
复制代码
首先是数据结构,WeightRandom是我们的带权重随机数生成器,upperBoundary是样本数量的总和,entries则是各个样本和样本霸占的连续整数的右边界。
接着来看构造WeightRandom对象的方法:
  1. goos: linux
  2. goarch: amd64
  3. pkg: benchrand
  4. cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
  5. BenchmarkGetRand-8              16451838                75.86 ns/op            0 B/op          0 allocs/op
  6. BenchmarkGetRandSplit-8         30802065                39.15 ns/op            0 B/op          0 allocs/op
  7. BenchmarkGetRandSplit2-8        38995390                30.75 ns/op            0 B/op          0 allocs/op
复制代码
lowerBoundary用来统计有多少样本,我们最终选择了左闭右开的区间,这样方便算。rndMap的key是样本,value则是比例。当样本的范围计算并保存结束之后,我们必要按照右边界从小到大排序这些样本,因为后面的查找范围到样本的对应关系必要右边界满足从小到大的序次。
末了是查找函数:
  1. 0.0   0.1   0.2   0.3   0.4   0.5   0.6   0.7   0.8   0.9   1.0
  2. |-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|
  3. |_______________________|
  4.             4           
  5.                         |_____|
  6.                            1  
  7.                               |_________________|
  8.                                        3        
  9.                                                 |___________|
  10.                                                       2
复制代码
查找时老师成一个范围在[0, upperBoundary)之间的随机数,然后我们从最小的边界开始逐一比较,一旦发现比自己大的边界,那么就阐明必要生成边界对应的样本。底部那句panic如字面意思,理论上是执行不到的,但go不知道,我们要么返回个空值要么panic,考虑到能走到这里那阐明我们的步伐或者标准库的代码有重大bug,panic了去调试才是比较好的办法。
根据upperBoundary的巨细,实际上我们还能复用上一节充实利用每一个bit的办法,不必要每次都生成新的随机数,等分割出来的分组都斲丧完了再生成,这样可以大大加快这个函数。不外为了通用性和只管简化代码,我就不这样写了。
末了附加一个用例:
  1. type WeightRandom[T comparable] struct {
  2.         entries       []entry[T]
  3.         upperBoundary uint64
  4. }
  5. type entry[T comparable] struct {
  6.         value T
  7.         end   uint64
  8. }
复制代码
我们按权重生成“abcd”四个字母,比例是15:30:45:60,简化一下就是1:2:3:4,所以理论上概率应该接近10%,20%,30%和40%。不外统计上的概率总是有点偏差的,只要大抵趋势接近于这个比例就行了。我们运行100亿次来看看结果:
  1. func NewWeightRandom[T comparable](rndMap map[T]uint64) *WeightRandom[T] {
  2.         var lowerBoundary uint64
  3.         entries := make([]entry[T], 0, len(rndMap))
  4.         for k, v := range rndMap {
  5.                 if v == 0 {
  6.                         panic("weight cannot be zero")
  7.                 }
  8.                 if lowerBoundary+v < lowerBoundary {
  9.                         panic("overflow")
  10.                 }
  11.                 lowerBoundary += v
  12.                 entries = append(entries, entry[T]{
  13.                         value: k,
  14.                         end:   lowerBoundary,
  15.                 })
  16.         }
  17.         slices.SortFunc(entries, func(a, b entry[T]) int {
  18.                 return cmp.Compare(a.end, b.end)
  19.         })
  20.         if len(entries) == 0 {
  21.                 panic("no valid sample")
  22.         }
  23.         return &WeightRandom[T]{
  24.                 entries:       entries,
  25.                 upperBoundary: lowerBoundary,
  26.         }
  27. }
复制代码
非常符合预期。作为一项优化步伐,我们可以利用类似二分查找的办法来定位样本,因为右边界本身是有序的,这可以明显改善在有大量样本时的性能表现。不外如果你的样本不超过10个的话我觉得现在这样的线性查找就充足了。
怎么说呢,简单粗暴但有效办理了问题。只是和浮点数的方案比还是有缺点的:

  • 因为整数有范围限制,这导致了样本总量会被限制在一个比浮点数方案更小的范围内
  • 同样因为整数巨细限制,比例的选择范围会比浮点数更小
因为限制更少,所以在通用的工具库里用浮点数方案的人更多,但业务场景和通用工具库是不一样的,很多时间选整数方案也没啥问题,最终你应该选择一个符合业务需求并且自己和同事都看得懂的方案。
至于性能怎么样,浮点数方案的查找过程和整数方案是一样的,性能必要做一次完整的测试才能看出孰高孰低,我不好在这凭空幻想。当然测试我就不做了,我偷个懒。
随机数种子

“种子”其实是指一些伪随机数生成算法必要的一些初始状态,这些算法会根据初始状态来生成一系列有随机性的数值序列。
所以相同的种子通常会带来相同的序列,这时间虽然每个序列都有随机性,但两个序列之间是没有随机性的——有了其中一个序列就可以精准预测另一个序列的排列。具体表现很可能会是你编写了一个游戏,其中敌人会随机采取一些举措,然而因为种子没设置好,导致每次见到这个敌人的时间它都会采取一模一样的举措步调,这样的游戏是极其无聊的。
不仅如此,产生相同的数值序列后还会带来无数安全问题。
种子通常只用设置一次,并且在步伐第一次必要随机数的地方设置——理想情况是这样的,然而总是有倒霉蛋忘记了这一点,于是随机算法经常只能使用默认的低质量且相同的种子。所以比较现代的语言,比如go1.22和python3都选择了在步伐刚开始运行的时间帮你自动设置种子。
此外我们还得担心一下种子的质量。
“种子”的值必要每次获取都不一样,符合要求的常见种子来源有以下几种:

  • 使用体系时间。这个确实每次获取都不一样,获取速度也很快,是很多开辟者和库的默认选项。但是体系时间是可以修改的,而且世界上还有闰秒这个麻烦东西,你意外得到相同的体系时间的概率其实不低。
  • 使用随机装备,这些是软件模拟出来的随机数生成器,以Linux为例,有random和urandom两种,其中random以操作体系及外部环境的噪音为数据源产生随机值,要求不高时我们可以认为这是“真随机”,当然它的生成速率是比较低的;另一个是urandom,它会用外部噪音生成一个初始状态,然后基于这个状态用伪随机数算法快速产生随机值,因此它的生成速率高但质量低。一般使用urandom生成种子是够用的。
  • 使用产生真实随机数的硬件,原理和软件模拟的类似,和大多数软件实现转硬件实现后会性能提升不同,TRNG反而可能会有更低的性能,但相比软件实现它可以更精致地收集环境里的杂音从而生成实行证明的不可预测的高质量的随机数。常见的TRNG生成速率从数百k/s到数兆/s的都有,但像/dev/random通常速率可以有数兆到数十兆/s。除了性能上的不稳定,不是所有装备都包罗TRNG,这导致了适用面受限,所以直接用它的人不多。不外很多依靠高质量随机数的场景就不得不考虑TRNG了。
  • 利用地址空间布局随机化。现代操作体系在加载步伐后都会给步伐的内存地址加点随机的偏移量,所以步伐每次运行的时间获取的变量地址基本都是不同的。这个是成本极低的获取随机值的方法,几乎不花费运行时代价,谷歌的abseil库里就有很多用这个方法获取随机数种子的代码。然而,使用这个方法的条件是你的体系要支持地址空间布局随机化,其次体系加的随机偏移量质量要尚可,这两个我们都控制不了,我们只能相信常用操作体系都做到这几点了。另外,高权限的步伐和用户始终能把一些代码写进有固定地址的地方,虽然这种操作正变得越来越难,但还不是完全不可能,所以必要高质量种子的时间这个方案通常不会被考虑(另一个原因是有的体系可以设置关闭随机化布局甚至根本不支持随机化)。
  • auxiliary vector。Linux特有的,可以通过/proc//auxv或者glibc的函数getauxval来获取。这个数组包罗一系列操作体系和当进步程的信息,全部是操作体系在步伐加载时写入的,Windows有类似的东西。这些数据中有些是不变的比如硬件信息宁静台信息,有些则是完全随机的,比如其中有步伐的入口地址和vDSO的地址,这些因为ASLR的缘故都是完全随机的,另外auxv里还有专门的随机值字段,这些信息加一起比单纯依靠ASLR能带来更强的不可预测。
原则就是只管让预测结果的难度增加,最好是能做到完全不可预测。
那作为开辟者我用啥呢?一般来说体系时间是够用了,自己写着完或者做些简单工具可以用这个,不外要记住体系时间是可修改的不可靠的。如果是库或者对随机性依靠比较重的比如游戏,/dev/urandom是个比较理想的选择。追求极致性能并且对种子质量要求没那么高时,像谷歌那样利用ASLR带来的随机值也是可以的。
实在有选择困难症的话,我们来看看别人是怎么做的。
golang和python3选择了那些源作为种子

golang实际上是先利用auxv,如果体系不支持,就回退到从urandom之类的随机装备读取随机值,这个也出问题了就使用体系时间:
  1. func (w *WeightRandom[T]) RandomGet() T {
  2.         x := rand.Uint64N(w.upperBoundary)
  3.         for i := range w.entries {
  4.                 if x < w.entries[i].end {
  5.                         return w.entries[i].value
  6.                 }
  7.         }
  8.         panic("not possible")
  9. }
复制代码
这个是全局函数的设置,go还能自己创建rand.Source,这个的种子只能显式传进去,这时间传什么go就没法管了,灵活的同时牺牲了一定的安全性。
Python3则是先读取urandom,失败后会结合体系时间加当进步程pid来生成种子,这样比单使用体系时间要强:
  1. func main() {
  2.         w := NewWeightRandom(map[string]uint64{
  3.                 "a": 15,
  4.                 "b": 30,
  5.                 "c": 45,
  6.                 "d": 60,
  7.         })
  8.         m := make(map[string]int, 4)
  9.         const limit = 100_0000_0000
  10.         for range limit {
  11.                 m[w.RandomGet()]++
  12.         }
  13.         for k, v := range m {
  14.                 fmt.Printf("key: %s, count: %d, p: %g\n", k, v, float64(v)/limit)
  15.         }
  16. }
复制代码
然后这个函数会被Random对象的__init__方法调用,如果初始化一个Random对象但不传seed参数,那么就会进行默认设置。而random模块里所有的方法其实都是由一个全局的Random()对象提供的,因为没传seed进去,所以代码里会自动设置seed:
  1. $ go run main.go
  2. key: b, count: 2000011606, p: 0.2000011606
  3. key: a, count: 1000058297, p: 0.1000058297
  4. key: d, count: 3999943022, p: 0.3999943022
  5. key: c, count: 2999987075, p: 0.2999987075
复制代码
这样python就防止了用户忘记设置seed的问题。
总结

关于随机数要讲的临时就这么多了,除了一丁点数值算法之外都是些比较浅显易懂的东西。
概率和统计对步伐计划的影响是很大的,所以我觉得与其花时间看近来比较火的微分方程,稍微抽出点时间看看概率统计对自己的帮助可能更大。
末了,其实标准库还有各种第三方库已经贴心准备了几乎全套功能了,看懂文档就能放心用,而且我也更推荐用这些库,开源的且久经检验的代码始终是比自己闭门造车来的强。
参考

https://stackoverflow.com/questions/18394733/generating-a-random-number-between-1-7-by-rand5
https://en.wikipedia.org/wiki/Rejection_sampling
https://www.linuxquestions.org/questions/linux-kernel-70/what-does-proc-pid-auxv-mean-exactly-4175421876/

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

半亩花草

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表