8-14 使用梅森扭转器生成随机数
在上一节课8.13——随机数生成介绍中,我们介绍了随机数生成的概念,并探讨了程序中通常如何使用伪随机数生成器(PRNG)算法来模拟随机性。
本节课我们将探讨如何在程序中生成随机数。要使用C++中的随机化功能,需包含标准库的
使用梅森扭转器在C++中生成随机数
梅森扭转器伪随机数生成器(PRNG)不仅名字响亮,或许还是所有编程语言中最受欢迎的PRNG。尽管按当今标准它已略显陈旧,但通常能产生高质量结果且性能尚可。random库支持两种梅森扭转器类型:
- mt19937:生成32位无符号整数的梅森扭转器
- mt19937_64:生成64位无符号整数的梅森扭转器
使用梅森扭转器的操作非常简单:
#include <iostream>
#include <random> // for std::mt19937
int main()
{
std::mt19937 mt{}; // Instantiate a 32-bit Mersenne Twister
// Print a bunch of random numbers
for (int count{ 1 }; count <= 40; ++count)
{
std::cout << mt() << '\t'; // generate a random number
// If we've printed 5 numbers, start a new row
if (count % 5 == 0)
std::cout << '\n';
}
return 0;
}
这产生了以下结果:

首先,我们包含std::mt19937 mt实例化一个32位梅森扭转器引擎。之后,每次需要生成32位无符号整数随机数时,我们调用mt()函数。
顺便提一句……
既然 mt 是变量,你可能好奇 mt() 的含义。
在第 5.7 课——std::string介绍中,我们展示过调用 name.length() 的示例,该操作在 std::string 变量 name 上调用了 length() 函数。
mt() 是调用 mt.operator() 函数的简洁语法。对于这些伪随机数生成器类型,该操作符被定义为返回序列中的下一个随机结果。使用 operator() 而非命名函数的优势在于:无需记忆函数名,且简洁的语法能减少输入量。
使用梅森扭转器掷骰子
32位伪随机数生成器可生成0至4,294,967,295之间的随机数,但我们并不总需要该范围内的数值。若程序模拟棋盘游戏或骰子游戏,我们可能需要生成1至6的随机数来模拟六面骰的掷出结果。若程序是地下城冒险游戏,玩家持有的剑对怪物造成7至11点伤害,那么每次击中怪物时就需要生成7至11的随机数。
遗憾的是,伪随机数生成器无法实现这种限制。它们只能生成覆盖完整范围的数值。我们需要某种方法,将伪随机数生成器输出的数值转换为目标小范围内的数值(且每个数值出现的概率均等)。虽然可以自行编写函数实现,但要确保结果无偏性并非易事。
所幸random库提供了随机数分布功能。随机数分布random number distribution能将伪随机数生成器的输出转换为特定分布的数值序列。
顺带一提……
给数据控们:随机数分布本质上就是一种概率分布,专门设计用于接收伪随机数生成器(PRNG)的输出值作为输入。
随机库包含多种随机数分布,其中大部分除非进行统计分析,否则你永远用不上。但有一种随机数分布极其实用:均匀分布uniform distribution是一种随机数分布,它以相等概率生成介于两个数值X和Y(含边界)之间的输出。
以下是一个与上述程序类似的示例,它使用均匀分布模拟六面骰子的掷出结果:
#include <iostream>
#include <random> // for std::mt19937 and std::uniform_int_distribution
int main()
{
std::mt19937 mt{};
// Create a reusable random number generator that generates uniform numbers between 1 and 6
std::uniform_int_distribution die6{ 1, 6 }; // for C++14, use std::uniform_int_distribution<> die6{ 1, 6 };
// Print a bunch of random numbers
for (int count{ 1 }; count <= 40; ++count)
{
std::cout << die6(mt) << '\t'; // generate a roll of the die here
// If we've printed 10 numbers, start a new row
if (count % 10 == 0)
std::cout << '\n';
}
return 0;
}
这产生了以下结果:

与前例相比,此例仅有两处值得注意的差异。首先,我们创建了一个均匀分布变量(命名为die6)来生成1到6之间的数值。其次,我们不再调用mt()生成32位无符号整数随机数,而是改为调用die6(mt)来生成1到6之间的数值。
上述程序并非表面看起来那么随机
尽管我们掷骰子示例的结果看似相当随机,但程序存在重大缺陷。请运行三次程序,看看能否找出问题所在。开始吧,我们等着。
危险边缘音乐
若多次运行程序,你会发现每次输出的数字完全相同!虽然序列中每个数字相对于前一个都是随机的,但整个序列却毫无随机性!每次运行程序都会产生完全相同的结果。
假设你正在编写一个猜数字游戏:用户有10次机会猜一个随机选定的数字,计算机则告知用户猜测是偏高还是偏低。若计算机每次都选取相同随机数,游戏在首次体验后便毫无趣味。让我们深入探究问题根源及解决之道。
在前一课(8.13——随机数生成介绍)中,我们阐述了伪随机数序列中每个数值都遵循确定性生成机制。PRNG的初始状态由种子值决定。因此,当使用相同种子值时,PRNG将始终生成相同的数列。
由于我们采用值初始化方式设置梅森扭转器,每次运行程序时都使用相同的种子值。种子值相同导致生成的随机数序列完全一致。
要使每次运行时序列呈现不同随机性,我们需要选择非固定值的种子。最直观的解决方案是使用随机数作为种子——这看似合理,但若需随机数生成随机数,便陷入了悖论困境。事实上,种子本身并不需要是随机数——我们只需选择一个每次运行程序时都会改变的值。然后,我们就可以利用伪随机数生成器(PRNG)从该种子生成一串唯一的伪随机数序列。
实现这一目标通常有两种常用方法:
- 使用系统时钟
- 使用系统的随机设备
使用系统时钟作为种子
每次运行程序时,有什么东西是不同的?除非你设法在完全相同的时间点运行程序两次,否则答案就是当前时间不同。因此,若将当前时间作为种子值,程序每次运行都会生成不同的随机数序列。C和C++中长期存在使用当前时间(通过std::time()函数)作为伪随机数生成器(PRNG)种子的做法,因此你会在大量现有代码中见到这种写法。
值得庆幸的是,C++的chrono库提供了多种时钟对象,可用于生成种子值。为最大限度降低程序快速连续运行时两个时间值相同的概率,我们需要采用变化速度最快的时间测量方式。为此,我们将向时钟查询其可测量的最早时间点以来经过的时间。该时间以“时钟脉冲”为单位进行测量,这是极微小的时间单位(通常为纳秒,但也可能是毫秒)。
#include <iostream>
#include <random> // for std::mt19937
#include <chrono> // for std::chrono
int main()
{
// Seed our Mersenne Twister using steady_clock
std::mt19937 mt{ static_cast<std::mt19937::result_type>(
std::chrono::steady_clock::now().time_since_epoch().count()
) };
// Create a reusable random number generator that generates uniform numbers between 1 and 6
std::uniform_int_distribution die6{ 1, 6 }; // for C++14, use std::uniform_int_distribution<> die6{ 1, 6 };
// Print a bunch of random numbers
for (int count{ 1 }; count <= 40; ++count)
{
std::cout << die6(mt) << '\t'; // generate a roll of the die here
// If we've printed 10 numbers, start a new row
if (count % 10 == 0)
std::cout << '\n';
}
return 0;
}


上述程序相较于先前版本仅有两处改动。首先,我们引入了
现在该程序每次运行生成的结果都应不同,您可通过多次运行进行实验验证。
此方法的缺点在于:若程序在短时间内连续运行多次,每次生成的种子值差异较小,从统计学角度看可能影响随机结果的质量。对于普通程序这无关紧要,但对于需要高质量独立结果的程序,这种种子生成方式可能不够理想。
提示
std::chrono::high_resolution_clock 是替代 std::chrono::steady_clock 的常用选择。std::chrono::high_resolution_clock 使用最精细的时间单位,但其获取当前时间时可能依赖系统时钟——该时钟可能被用户修改或回溯。std::chrono::steady_clock 的计时精度虽稍逊,却是唯一能确保用户无法调整的时钟。
使用随机设备生成种子
随机库包含一个名为 std::random_device 的类型,它是一种实现定义的伪随机数生成器(PRNG)。通常我们避免使用实现定义的功能,因为它们无法保证质量或可移植性,但这是例外情况之一。通常 std::random_device 会向操作系统请求伪随机数(具体实现方式取决于操作系统)。
#include <iostream>
#include <random> // for std::mt19937 and std::random_device
int main()
{
std::mt19937 mt{ std::random_device{}() };
// Create a reusable random number generator that generates uniform numbers between 1 and 6
std::uniform_int_distribution die6{ 1, 6 }; // for C++14, use std::uniform_int_distribution<> die6{ 1, 6 };
// Print a bunch of random numbers
for (int count{ 1 }; count <= 40; ++count)
{
std::cout << die6(mt) << '\t'; // generate a roll of the die here
// If we've printed 10 numbers, start a new row
if (count % 10 == 0)
std::cout << '\n';
}
return 0;
}

在上面的程序中,我们使用临时实例化的 std::random_device 生成的随机数为梅森扭转器(Mersenne Twister)设置初始值。若多次运行该程序,每次都应产生不同的结果。
std::random_device存在一个潜在问题:它并非强制要求具有非确定性,这意味着在某些系统上,程序每次运行都可能产生相同的序列——而这正是我们试图避免的情况。MinGW曾存在一个漏洞(已在GCC 9.2中修复),该漏洞恰恰会导致这种情况,使std::random_device失去作用。
不过,主流编译器(GCC/MinGW、Clang、Visual Studio)的最新版本均已支持正确实现的 std::random_device。
最佳实践
使用 std::random_device 为您的伪随机数生成器(PRNG)提供种子(除非它在您的目标编译器/架构上未正确实现)。
问:std::random_device{}() 表示什么?
std::random_device{} 会创建一个值初始化的临时 std::random_device 对象。随后 () 会调用该临时对象的 operator() 方法,该方法返回一个随机值(我们将其用作梅森扭转器的初始化值)。
这相当于调用以下函数,其语法应更符合您的认知习惯:
unsigned int getRandomDeviceValue() { std::random_device rd{}; // create a value initialized std::random_device object return rd(); // return the result of operator() to the caller }使用
std::random_device{}()可以在不创建命名函数或命名变量的情况下获得相同结果,因此更加简洁。
问:既然 std::random_device 本身就是随机的,为什么不直接用它代替梅森扭转器呢?
因为 std::random_device 是实现定义的,我们无法对其做出太多假设。访问它可能代价高昂,或者导致程序在等待更多随机数可用时暂停。其取数池也可能迅速耗尽,这将影响通过相同方法请求随机数的其他应用程序的随机结果。因此,std::random_device更适合用于为其他伪随机数生成器(PRNG)提供种子,而非直接作为PRNG使用。
仅对伪随机数生成器进行一次初始化
许多伪随机数生成器在初始化后可重新初始化。这实质上是重新初始化随机数生成器的状态,使其从新的种子状态开始生成结果。除非有特殊原因,否则应避免重新初始化,因为这可能导致结果随机性降低,甚至完全失去随机性。
最佳实践
仅对给定的伪随机数生成器进行一次初始化,且不要重新初始化它。
以下是一个新手程序员常犯的错误示例:
#include <iostream>
#include <random>
int getCard()
{
std::mt19937 mt{ std::random_device{}() }; // this gets created and seeded every time the function is called
std::uniform_int_distribution card{ 1, 52 };
return card(mt);
}
int main()
{
std::cout << getCard() << '\n';
return 0;
}

在 getCard() 函数中,每次调用该函数时都会创建随机数生成器并为其设置种子值。这种做法效率低下,且很可能导致随机结果质量不佳。
梅森扭转器与种子值不足问题
梅森扭转器的内部状态需要19937位(2493字节),相当于624个32位值或312个64位值。因此,std::mt19937分配624个整数,而std::mt19937_64分配312个整数。
顺带一提……
std::mt19937 分配的整数被定义为 std::uint_fast32_t 类型,该类型可能根据架构不同而呈现为 32 位或 64 位整数。若 std::uint_fast32_t 解析为 64 位整型,std::mt19937 将使用 624 个 64 位整型,导致其占用空间达到实际所需的两倍。
在上面的示例中,当我们使用时钟或 std::random_device 为 std::mt19937 提供种子时,种子仅是一个单一整数。这意味着我们本质上是用一个整数初始化 624 个整数,这使得梅森扭转伪随机数生成器严重种子不足。Random库会尽力用“随机”数据填充剩余623个值...但它无法创造奇迹。种子不足的伪随机数生成器可能产生次优结果,这对于需要最高质量输出的应用而言是不可取的。例如,用单个32位值初始化std::mt19937时,其首次输出的数值永远不会是42。
那么如何解决这个问题?截至C++20,尚无简便方案。但我们提供以下建议:
首先介绍std::seed_seq(即“种子序列”)。前课提到,理想情况下种子数据位数应与PRNG状态位数相当,否则将导致种子不足。但在多数情况下(尤其当伪随机数生成器状态较大时),我们往往无法获得如此大量的随机种子数据。
std::seed_seq 类型正是为解决此问题而设计。我们可以向其传递现有所有随机值,它将自动生成足够数量的无偏种子值来初始化伪随机数生成器的状态。若仅用单个值(如来自 std::random_device)初始化 std::seed_seq,再用该对象初始化 Mersenne Twister,则 std::seed_seq 会生成 623 个额外种子值。这虽不会增加随机性,但能优化 0/1 位的混合分布。然而,我们能为std::seed_seq提供的随机数据越多,其工作效果就越好。因此最简便的方法就是直接使用std::random_device为std::seed_seq提供更多数据。若将 std::seed_seq 的初始值从 1 个改为从 std::random_device 获取的 8 个值,则后续由 std::seed_seq 生成的数值质量将显著提升:
#include <iostream>
#include <random>
int main()
{
std::random_device rd{};
std::seed_seq ss{ rd(), rd(), rd(), rd(), rd(), rd(), rd(), rd() }; // get 8 integers of random numbers from std::random_device for our seed
std::mt19937 mt{ ss }; // initialize our Mersenne Twister with the std::seed_seq
// Create a reusable random number generator that generates uniform numbers between 1 and 6
std::uniform_int_distribution die6{ 1, 6 }; // for C++14, use std::uniform_int_distribution<> die6{ 1, 6 };
// Print a bunch of random numbers
for (int count{ 1 }; count <= 40; ++count)
{
std::cout << die6(mt) << '\t'; // generate a roll of the die here
// If we've printed 10 numbers, start a new row
if (count % 10 == 0)
std::cout << '\n';
}
return 0;
}
这相当简单明了,因此至少做到这一点是没有太多理由不做的。
问:为什么不直接从 std::random_device 中获取 624 个值给 std::seed_seq?
可以这样做,但这很可能导致速度变慢,并且有耗尽 std::random_device 所用随机数池的风险。
您还可以使用其他“随机”输入来初始化 std::seed_seq。我们已经演示过如何获取时钟值,因此您可以轻松地将其纳入其中。其他常用的输入包括当前线程 ID、特定函数的地址、用户 ID、进程 ID 等…… 本文虽不深入探讨此类实现,但提供了相关背景信息及实现该功能的randutils.hpp文件链接。
另一种方案是采用状态更小的伪随机数生成器。许多优质PRNG仅需64或128位状态,可通过8次调用std::random_device填充std::seed_seq轻松初始化。
预热伪随机数生成器
当伪随机数生成器(PRNG)被赋予质量较差的种子(或种子不足)时,其初始生成的结果可能质量不高。因此某些伪随机数生成器需要进行“预热”处理——即丢弃生成的前N个结果。此操作能使生成器的内部状态充分混合,从而提升后续结果的质量。通常需丢弃数百至数千个初始结果。伪随机数生成器的周期越长,应丢弃的初始结果数量就越多。
顺带一提……
Visual Studio 对 rand() 的实现存在(或至今仍存在?)一个缺陷:首次生成的结果随机性不足。你可能会发现早期使用 rand() 的程序会舍弃首次结果来规避此问题。
std::mt19937使用的seed_seq初始化会执行预热操作,因此我们无需显式预热std::mt19937对象。
跨多个函数或文件的随机数(Random.h)
此内容已移至 8.15 -- 全局随机数(Random.h)。
调试使用随机数的程序
使用随机数的程序可能难以调试,因为程序每次运行时可能表现出不同的行为。这种情况下,错误行为可能出现,也可能不出现,这会浪费大量时间。调试时,确保程序每次以相同(错误)方式执行会很有帮助。这样你就能反复运行程序,直至定位错误根源。
因此,调试时可采用一种实用技巧:为伪随机数生成器(PRNG)设置特定固定值(如5)作为种子,该值会触发错误行为。若某种子值未能触发程序错误,请持续递增种子值直至找到有效触发点。此举可确保程序每次生成相同结果,从而简化调试流程。定位错误后,即可恢复常规种子设置继续生成随机结果。
随机数生成器常见问题解答
问:救命!我的随机数生成器总是生成相同的随机数序列。
如果每次运行程序时随机数生成器都输出相同的序列,很可能是未正确设置初始化种子(或根本未设置)。请确保每次运行程序时都使用不同的初始化值。
问:救命!我的随机数生成器不断重复生成相同数字。
若每次请求随机数时都得到相同结果,可能是:在生成随机数前重新设置了种子值,或为每次随机数生成创建了新随机数生成器。

浙公网安备 33010602011771号