8-13 随机数生成介绍

生成随机数的能力在某些类型的程序中非常有用,尤其是在游戏、统计建模程序以及需要加密和解密的密码学应用中。以游戏为例——如果没有随机事件,怪物总是以相同的方式攻击你,你总是找到相同的宝藏,地牢布局永远不会改变等等……这将无法构成一款优秀的游戏。

现实生活中,我们常通过抛硬币、掷骰子或洗牌等方式制造随机性。这些事件本质上并非真正随机,但涉及众多物理变量(如重力、摩擦力、空气阻力、动量等),使得结果几乎无法预测或控制,且(除非你是魔术师)其结果在实际意义上堪称随机。

然而计算机的设计初衷并非利用物理变量——它无法抛掷硬币、投掷骰子或洗牌。现代计算机运行于受控的电子世界,其中万物皆为二进制(0或1),不存在中间状态。计算机的本质设计就是产生尽可能可预测的结果。当你要求计算机计算2+2时,你永远希望答案是4,而不是偶尔出现3或5。

因此计算机通常无法生成真正的随机数(至少通过软件实现)。现代程序通常采用算法模拟随机性。

本节课将深入探讨程序生成随机数的理论原理,并介绍后续课程中将使用的相关术语。


算法与状态

首先,让我们绕道探讨算法与状态的概念。

算法algorithm是一组有限的指令序列,遵循这些指令可以解决某个问题或产生某种有用结果。

例如,假设你的老板给你一个包含大量未排序名字的小文本文件(每行一个),要求你对列表进行排序。由于列表较小,且你预计不会经常操作,你决定手动排序。排序列表有多种方法,但你可能会这样做:

  • 创建一个新的空列表来存放排序后的结果
  • 扫描未排序的姓名列表,找出按字母顺序排在最前面的姓名
  • 将该姓名从未排序列表中移出,粘贴到排序列表的末尾
  • 重复前两步,直到未排序列表中没有剩余姓名

上述步骤描述了一个排序算法(使用自然语言)。算法本质上具有可复用性——若明日上司要求你排序另一列表,只需将相同算法应用于新列表即可。

由于计算机执行指令和处理数据的速度远超人类,算法通常通过编程语言实现,从而实现任务自动化。在C++中,算法通常以可复用函数的形式实现。

以下是一个生成递增数列的简单算法:

#include <iostream>

int plusOne()
{
    static int s_state { 3 }; // only initialized the first time this function is called

    // Generate the next number

    ++s_state;      // first we modify the state
    return s_state; // then we use the new state to generate the next number in the sequence
}

int main()
{
    std::cout << plusOne() << '\n';
    std::cout << plusOne() << '\n';
    std::cout << plusOne() << '\n';

    return 0;
}

这将输出:

image

该算法相当简单。首次调用 plusOne() 时,s_state 被初始化为值 3。随后生成并返回输出序列中的下一个数值。

若算法在不同调用之间保留某些信息,则该算法被视为具有状态stateful。反之,无状态stateless算法不存储任何信息(每次调用时必须提供其运行所需的所有信息)。我们的 plusOne() 函数属于有状态算法,因为它使用静态变量 s_state 存储上次生成的数值。在算法领域,“状态state”一词特指有状态变量中保存的当前值(即跨调用周期保留的值)。

生成序列下一个数值的过程分为两步:

  • 首先,将当前状态(由初始值初始化或从前次调用保留)进行修改以产生新状态。
  • 随后,根据新状态生成序列中的下一个数值。

该算法具有确定性deterministic,即对于给定输入(start参数的值),它将始终产生相同的输出序列。


伪随机数生成器(PRNG)

为模拟随机性,程序通常采用伪随机数生成器。伪随机数生成器pseudo-random number generator (PRNG)(PRNG)是一种算法,它生成的数列具有模拟随机数列的特性。

编写基础的PRNG算法并不复杂。以下是一个简短的PRNG示例,可生成100个16位伪随机数:

#include <iostream>

// For illustrative purposes only, don't use this
unsigned int LCG16() // our PRNG
{
    static unsigned int s_state{ 0 }; // only initialized the first time this function is called

    // Generate the next number

    // We modify the state using large constants and intentional overflow to make it hard
    // for someone to casually determine what the next number in the sequence will be.

    s_state = 8253729 * s_state + 2396403; // first we modify the state
    return s_state % 32768; // then we use the new state to generate the next number in the sequence
}

int main()
{
    // Print 100 random numbers
    for (int count{ 1 }; count <= 100; ++count)
    {
        std::cout << LCG16() << '\t';

        // If we've printed 10 numbers, start a new row
        if (count % 10 == 0)
            std::cout << '\n';
    }

    return 0;
}

该程序的结果是:

image

每个数值相对于前一个数值都显得相当随机。

注意 LCG16() 与我们上面 plusOne() 示例的相似之处!初始状态被设置为 0。随后,为生成输出序列中的下一个数值,当前状态会经过数学运算调整为新状态,序列中的下一个数值便由此新状态生成。

事实上,该算法作为随机数生成器效果欠佳(注意结果呈现奇偶交替的规律——这显然不够随机!)。但多数伪随机数生成器(PRNG)的工作原理与LCG16()类似——它们通常采用更多状态变量和更复杂的数学运算来生成更高质量的结果。


初始化伪随机数生成器

伪随机数生成器生成的“随机数”序列其实完全不随机。就像我们的plusOne()函数一样,LCG16()也是确定性的。给定某个初始状态值(例如0),伪随机数生成器每次都会生成相同的数列。如果你运行上述程序三次,会发现每次生成的数列完全相同。

要生成不同的输出序列,必须改变PRNG的初始状态。用于设定PRNG初始状态的值(或值集)称为随机种子random seed(简称种子seed)。当PRNG通过种子设定初始状态后,我们称其已被种子化seeded

关键洞见
由于伪随机数生成器(PRNG)的初始状态由种子值设定,因此该生成器所产生的所有数值均可从种子值确定性地计算得出。

种子值通常由使用伪随机数生成器(PRNG)的程序提供。以下是一个示例程序,它会向用户请求种子值,然后使用该种子值生成10个随机数(使用我们的LCG16()函数)。

#include <iostream>

unsigned int g_state{ 0 };

void seedPRNG(unsigned int seed)
{
    g_state = seed;
}

// For illustrative purposes only, don't use this
unsigned int LCG16() // our PRNG
{
    // We modify the state using large constants and intentional overflow to make it hard
    // for someone to casually determine what the next number in the sequence will be.

    g_state = 8253729 * g_state + 2396403; // first we modify the state
    return g_state % 32768; // then we use the new state to generate the next number in the sequence
}

void print10()
{
    // Print 10 random numbers
    for (int count{ 1 }; count <= 10; ++count)
    {
        std::cout << LCG16() << '\t';
    }

    std::cout << '\n';
}

int main()
{
    unsigned int x {};
    std::cout << "Enter a seed value: ";
    std::cin >> x;

    seedPRNG(x); // seed our PRNG
    print10();   // generate 10 random values

    return 0;
}

以下是该程序的3个运行示例:

image

image

image

请注意,当我们提供相同的种子值时,会得到相同的输出序列。如果提供不同的种子值,则会得到不同的输出序列。


种子质量与种子不足

若想使程序每次运行时产生不同的随机数,就需要在每次运行时改变种子值。要求用户提供种子值并非理想方案,因为他们可能每次输入相同数值。程序真正需要的是每次运行时自动生成随机种子值的机制。遗憾的是,我们无法使用伪随机数生成器(PRNG)来生成随机种子,因为我们需要一个随机化的种子来生成随机数。相反,我们通常会使用专门设计用于生成种子值的种子生成算法。我们将在下一课中讨论(并实现)此类算法。

伪随机数生成器(PRNG)能够生成的唯一序列的理论最大数量取决于PRNG状态的位数。例如,具有128位状态的PRNG理论上可生成高达2^128(340,282,366,920,938,463,463,374,607,431,768,211,456)个唯一输出序列。数量惊人!

然而实际生成的序列取决于PRNG的初始状态,而初始状态由种子值决定。因此在实际应用中,PRNG能生成的唯一序列数量受限于使用该PRNG的程序所能提供的唯一种子值数量。例如若某种子生成算法仅能产生4个不同种子值,则PRNG最多只能生成4种不同输出序列。

若伪随机数生成器未获得足够位数的优质种子数据,则称为种子不足underseeded。种子不足的伪随机数生成器可能开始产生质量受损的随机结果——种子不足程度越严重,结果质量下降越明显。

例如,种子不足的伪随机数生成器可能出现以下问题:

  • 连续运行生成的随机序列之间可能存在高度相关性。
  • 在生成第N个随机数时,某些数值将永远无法被生成。例如,若梅森扭转算法采用特定方式设置种子值,则其初始输出永远不会出现7或13这两个数值。
  • 攻击者可能通过初始随机值(或前几个随机值)推测种子值,进而推算出生成器未来将输出的所有随机数。这可能导致系统被恶意利用或操纵。

对于进阶读者
理想的种子应具备以下特征:

  • 种子位数至少应等于伪随机数生成器(PRNG)的状态位数,确保PRNG状态中的每个位都能由种子中的独立位初始化。
  • 种子中的每个位都应独立随机化。
  • 种子应包含0和1的良好混合分布,且覆盖所有位。
  • 种子中不应存在始终为0或始终为1的位。此类“卡位”毫无价值。
  • 种子与先前生成的种子应具有低相关性。

实际应用中,我们可能对部分特性作出妥协。某些伪随机数生成器具有庞大状态(例如梅森扭转器的状态长达19937位),生成如此庞大的优质种子往往困难重重。因此,高状态PRNG常被设计为能承受较少位数的种子输入。卡位现象也普遍存在——例如若将系统时钟作为种子来源,由于表示较长时间单位(如年份)的位数实质上处于固定状态,最终将不可避免地产生若干卡位。

不熟悉正确种子初始化方法的开发者,常会尝试使用单个32位或64位值来初始化伪随机数生成器(遗憾的是,C++标准库Random的设计无意中助长了这种做法)。这通常会导致伪随机数生成器种子严重不足。

使用64字节高质量种子数据(若PRNG状态较小则可减少)进行初始化,通常足以满足非敏感场景(如非统计模拟或密码学应用)生成8字节随机值的需求。


什么是优秀的伪随机数生成器?(可选阅读)

优秀的伪随机数生成器需具备以下特性:

  • 每个数字应以近似相等的概率生成。

这称为分布均匀性。若某些数字生成频率过高,使用该伪随机数生成器的程序结果将产生偏差!检验分布均匀性可采用直方图。直方图是记录每个数字生成次数的图表。由于我们的直方图基于文本,将用 * 符号表示每次生成特定数字的情况。

假设某个PRNG生成1至6之间的数字。若生成36个数字,具有分布均匀性的PRNG应生成类似下图的直方图histogram

1|******
2|******
3|******
4|******
5|******
6|******

存在某种偏倚的伪随机数生成器将生成不均匀的直方图,如下所示:

1|***
2|******
3|******
4|******
5|******
6|*********

或者这样:

1|****
2|********
3|******
4|********
5|******
6|****

或者甚至可能是这样:

1|*******
2|********
3|*******
4|********
5|
6|******

假设你正在为游戏编写随机物品生成器。当怪物被击杀时,你的代码会生成1到6之间的随机数,若结果为6,怪物就会掉落稀有物品而非普通物品。按理说这种情况发生的概率应为1/6。但若底层伪随机数生成器(PRNG)分布不均匀,且生成的6远超预期(如上图第二个直方图所示),玩家获得稀有物品的频率就会超出设计预期,可能导致游戏难度被削弱,或破坏游戏内经济体系。

要找到能产生均匀结果的PRNG算法实属不易。

  • 生成序列中下一个数字的方法不应具有可预测性。

例如,考虑以下伪随机数生成器算法:return ++num。该算法生成的序列完全均匀,但同时也完全可预测——作为随机数序列毫无用处!

即便是看似随机的数列(如上文LCG16()的输出),对有心人而言也可能轻易被破解。只需分析该函数生成的少量数字,就能推断出其使用的状态修改常数(8253729和2396403)。一旦掌握这些参数,便能轻松推算出该伪随机数生成器未来将生成的所有数值。

现在设想你运营一个博彩网站,用户可下注100美元。网站生成0至32767之间的随机数:若数值大于20000,客户获胜则返还双倍赌注; 否则客户输掉赌注,网站没收赌金。由于客户获胜概率仅为12767/32767(39%),网站理应赚得盆满钵满,对吧?然而,若客户能预知下一个随机数,便可通过策略性下注实现持续(或通常)获胜。恭喜,现在你可以申请破产了!

  • 伪随机数生成器应具有良好的数字维度分布。

这意味着伪随机数生成器应在所有可能结果的范围内随机返回数字。例如,伪随机数生成器应看似随机地生成低数值、中等数值、高数值、偶数和奇数。

若伪随机数生成器先返回全低数值再返回全高数值,虽然看似均匀且不可预测,但仍会导致结果偏倚——尤其当实际使用的随机数数量较少时。

  • 伪随机数生成器应具备高周期性

所有伪随机数生成器均具有周期性,这意味着生成的数列在某个时刻会开始重复自身。伪随机数生成器开始重复自身前的数列长度称为周期period

例如,以下是由周期性较差的伪随机数生成器生成的前100个数:

112	9	130	97	64	31	152	119	86	53	
20	141	108	75	42	9	130	97	64	31	
152	119	86	53	20	141	108	75	42	9	
130	97	64	31	152	119	86	53	20	141	
108	75	42	9	130	97	64	31	152	119	
86	53	20	141	108	75	42	9	130	97	
64	31	152	119	86	53	20	141	108	75	
42	9	130	97	64	31	152	119	86	53	
20	141	108	75	42	9	130	97	64	31	
152	119	86	53	20	141	108	75	42	9

你会注意到它生成了9作为第二个数,再次作为第十六个数,之后每隔14个数就会重复出现。这个伪随机数生成器(PRNG)陷入了循环,不断重复生成以下序列:9-130-97-64-31-152-119-86-53-20-141-108-75-42-(重复)。

这种现象源于伪随机数生成器的确定性特性。当生成器的状态与先前状态完全一致时,它将开始输出相同序列——从而形成循环。

优秀的伪随机数生成器应具备所有种子数值的长期周期性。设计满足此特性的算法极其困难——许多伪随机数生成器仅对部分种子数值具有长期周期性,而对其他数值则不然。若用户恰巧选取了导致短期周期的种子值,当需要大量随机数时,该伪随机数生成器将无法有效工作。

  • 伪随机数生成器应具备高效性

大多数伪随机数生成器的状态大小小于4096字节,因此总内存占用通常无需担忧。然而,内部状态越大,伪随机数生成器越容易出现种子不足的情况,初始化过程也会越慢(因为需要初始化的状态更多)。

其次,为生成序列中的下一个数值,伪随机数生成器需通过多种数学运算混淆其内部状态。此过程耗时因生成器及架构差异显著(某些生成器在特定架构上表现更优)。若仅需周期性生成随机数则影响不大,但若需大量随机数则可能造成重大影响。


存在多种不同的伪随机数生成器算法

多年来,人们开发出了许多不同类型的伪随机数生成器算法(维基百科在此处提供了详尽列表)。每种伪随机数生成器算法都存在优缺点,这可能使其更适合或不太适合特定应用场景,因此为具体应用选择合适的算法至关重要。

许多伪随机数生成器按现代标准衡量已显不足——既然性能优异的算法同样易于使用,实在没有理由继续采用表现欠佳的方案。


C++中的随机化

C++的随机化功能可通过标准库的头文件访问。在随机库中,共有6个伪随机数生成器(PRNG)家族可供使用(截至C++20):

Type name Family Period State size* Performance Quality Should I use this?
minstd_rand
minstd_rand0
Linear congruential generator 2^{31} 4 bytes Bad Awful No
mt19937
mt19937_64
Mersenne twister 2^{19937} 2500 bytes Decent Decent Probably (see next section)
ranlux24
ranlux48
Subtract and carry 10^{171} 96 bytes Awful Good No
knuth_b Shuffled linear congruential generator 2^{31} 1028 bytes Awful Bad No
default_random_engine Any of above (implementation defined) Varies Varies ?? ?? No
rand() Linear congruential generator 2^{31} 4 bytes Bad Awful No

完全没有理由使用knuth_b、default_random_engine或rand()(后者是为兼容C语言而提供的随机数生成器)。

自C++20起,梅森扭转算法是C++内置的唯一兼具良好性能与质量的伪随机数生成器。

对于进阶读者

常使用名为PracRand的测试来评估伪随机数生成器(PRNG)的性能和质量(以确定其是否存在各类偏倚)。您可能还会看到SmallCrush、Crush或BigCrush的引用——这些是其他有时用于相同目的的测试。

若需查看PracRand的输出效果,该网站提供了C++20标准下所有C++支持的PRNG输出示例。


那么我们应该使用梅森扭转器对吧?

大概吧。对于大多数应用场景而言,梅森扭转器在性能和质量方面都足够用。

但值得注意的是,按现代伪随机数生成器标准衡量,梅森扭转器已略显过时。其最大缺陷在于:观察到624个生成的数值后就能预测后续结果,因此不适用于任何需要不可预测性的应用场景。

若您正在开发需要最高质量随机结果(如统计模拟)、最快生成速度,或强调不可预测性的应用(如密码学),则必须使用第三方库。

截至本文撰写时的主流选择:

好了,理论部分到此为止,想必各位已经眼花缭乱。接下来让我们探讨如何在C++中使用梅森扭转算法实际生成随机数。

posted @ 2026-02-28 08:03  游翔  阅读(0)  评论(0)    收藏  举报