Генераторы последовательности псевдослучайных чисел стандартной библиотеки С++

13 января 2022

Случайные числа в языке программирования С++ могут быть сгенерированы функцией rand() из стандартной библиотеки С++. Функция rand() генерирует числа в диапазоне от 0  до RAND_MAX. RAND_MAX — это константа, определённая в библиотеке <cstdlib>.

Для генерации нормально распределенных чисел применяется способ, основанный на центральной предельной теореме (ЦПТ). Согласно ЦПТ, распределение суммы независимых одинаково распределенных случайных величин с увеличением числа слагаемых стремится к нормальному распределению. Если слагаемые равномерно распределены в интервале [0,1], то

Сумма равномерных случайных величин сходится к нормальному распределению достаточно быстро, и практика показала, что уже при n > 10 сходимость хорошая.

Однако непосредственно пользоваться приведенными соотношениями нельзя из-за зависимости параметров распределения Y от числа слагаемых и жесткой связи между my и σ2y. Поэтому для нормальных случайных величин с заданными параметрами my и σy используется следующая двухэтапная процедура, основанная на устойчивости нормального распределения к линейному преобразованию.

В начале формируется последовательность чисел {z}, подчиненных нормированному нормальному распределению (т.е.нормальному распределению с параметрами mz = 0; σz=1). Для этого выполняется операция

Как нетрудно видеть, mz = 0; σz=1.

Затем последовательность {z} преобразуется в требуемую  последовательность {y} по формуле

Следует обратить внимание, что для получения одного числа последовательности {y} надо использовать 12 равномерных чисел.

Программа, реализующая описанную процедуру, в отличие от датчика равномерных чисел, может не содержаться в математическом обеспечении компьютера.

{
    double S = 0.;    
    for (int i = 0; i < 12; ++i) 
        S += (double)rand();
 
   return S / RAND_MAX - 6.;
}

 

Функция rand() один раз генерирует  случайные числа, а при последующих запусках программы всего лишь отображает сгенерированные первый раз числа. Такая особенность функции rand() нужна для того, чтобы можно было правильно отладить разрабатываемую программу. При отладке программы, внеся какие-то изменения, необходимо удостовериться, что программа срабатывает правильно, а это возможно, если входные данные остались те же, то есть сгенерированные числа. Когда программа успешно отлажена, нужно, чтобы при каждом выполнении программы генерировались случайные числа. Для этого нужно воспользоваться функцией srand() из стандартной библиотеки С++. Функция srand() получив целый положительный аргумент типа unsigned или unsigned int (без знаковое целое) выполняет рандомизацию, таким образом, чтобы при каждом запуске программы функция srand() генерировала случайные числа.

Чтобы производить рандомизацию автоматически, то есть, не меняя каждый раз аргумент в функции srand() нужно воспользоваться функцией time() с аргументом 0.

// автоматическая рандомизация
srand( time(0) );

 Для того, чтобы использовать функцию time(), необходимо подключить заголовочный файл <ctime>.

Генератор псевдослучайных чисел в С++11, реализующий алгоритм Мерсен Твистер

В boost и С++11 есть удобные инструменты для генерации чисел в указанном диапазоне.
Распределение normal_distribution<double> name(нижняя граница, верхняя граница) совместно c Вихрем Мерсенна (mt19937) творят чудеса. mt19937 — это фактически движок, реализующий алгоритм Мерсен Твистер, генерирующий однородное распределение.

#include <random>

std:: normal_distribution<double> distribution(mean, sd);
std::mt19937 engine; // Mersenne Twister
double random = distribution(engine);

Алгоритм Мерсен Твистер, основанный на свойствах простых чисел, подходит для решения большинства задач. Несмотря на то, что степень его «случайности» весьма неплоха, он все-таки является ГПСЧ. Генерирует СЧ он достаточно быстро и хватить его должно с головой.

Определен он, как и все, что мы далее будем рассматривать, в хедере random и пространстве имен std. Является 32-битным генератором, имеет собрата mt19937_64, являющегося 64-битным.
В конструкторе может инициализироваться величиной, от которой начинается генерация последовательности, либо специальным объектом seed_seq, являющимся зерном последовательности.

#include <random>  
#include <ctime>  
 
int main() 
{ 
    std::mt19937 gen1, gen2(time(0));
} 

 Класс имеет метод seed(), с помощью которого можно назначить зерно последовательности.

#include <random>  
#include <ctime>  

int main() 
{ 
    std::mt19937 gen; 
    gen.seed(time(0));
}

 

Здесь мы сначала создаем ГПСЧ, а потом задаем зерно (стартовое значение) его последовательности.

По вызову operator(), генератор производит число. Все как и с rand(), только теперь этим занимается один класс. Может иметь в качестве параметра распределение (переменная, отвечающая за диапазон значений). Очень удобно использовать даже один генератор и несколько разных распределений.

#include <iostream>  
#include <random> 
#include <ctime>

int main() 
{ 
    std::mt19937 gen; 
    gen.seed(time(0)); // try to comment this string 
    std::cout << "My number: " << gen() << std::endl; 
}

Попробуйте закомментировать «сидирование»и убедитесь, что генератор будет выдавать одно и то же число.

Распределения

Сами по себе, генераторы, конечно, незаменимы, но без возможности указания диапазона нужного значения они становятся лишь чуть лучше, чем srand. Я рассмотрю два основных распределения, но их существует гораздо большее количество, на все случаи жизни, так сказать.

Все они принимают в качестве аргументов в конструкторе либо параметры другого распределения, либо переменные, отвечающие за диапазон значений. Если оные не указаны, то используется диапазон от 0 до максимального значения, определенного в numeric_limits<>::max() данного идентификатора типа, являющегося параметром шаблона.

Если указан лишь один аргумент, то берется диапазон значений от данного до максимального. Следует отметить, что границы также учитываются, т.е. при указании в качестве аргументов a, b используется диапазон [a, b].

Каждое распределение имеет след. методы и функции для работы с ними:
1) перегруженный конструктор, описанный выше
2) reset, отвечающее за сброс последовательности распределения
3) оператор (), про него подробнее расскажу ниже
4) деструктор
5) param, возвращающий параметры данного распределения. Используется для передачи параметров одного распределения другому.
6) max, возвращающий максимально возможное значение, возвращаемое оператором ()
7) min, возвращающий минимально возможное значение, возвращаемое оператором ()
8) a, возвращающий верхнюю границу параметра распределения
9) b, возвращающий нижнюю границу параметра распределения
10) также имеет перегруженные версии операторов << и >>

Оператор () принимает в качестве параметра генератор и возвращает число из диапазона распределения. При этом на одно и то же распределение можно применять совершенно различные генераторы, отвечают они лишь за диапазон.

Все это может казаться непонятной скучной теорией, но я постараюсь разбавить её примерами, на основе следующего распределения.

std::uniform_int_distribution

Применяется для указания диапазона целых чисел. Лучше всего подходит для int, unsigned int, long, unsigned long, long long, unsigned long long. Является шаблонным классом, по умолчанию использует int.

А теперь давайте по пунктам разберем методы.

Пример: создание ГПСЧ с разбросом от 0 до 50 и вывод на экран двух СЧ

#include <iostream>
#include <random>
#include <ctime>

int main()
{
   std::mt19937 gen(time(0));
   std::uniform_int_distribution<> uid(0, 50);
   std::cout << "My numbers: " << uid(gen) << " " << uid(gen) << std::endl;
}

В этом примере используется перегруженная версия конструктора, принимающая два аргумента. Как видите, оператор() принимает в качестве параметра ГПСЧ и возвращает СЧ из данного диапазона.

Давайте теперь создадим распределение на основе данного и посмотрим их характеристики (мин. и макс. значения диапазона). Заодно научимся использовать методы max() и min().

Пример: передача параметров одного распределения в качестве аргументов другого

#include <iostream>
#include <random>
#include <ctime>

int main()
{
   std::mt19937 gen(time(0));
   std::uniform_int_distribution<int> uid1(0, 50), uid2(uid1.param());
   std::cout << "uid1 max: " << uid1.max() << std::endl
      << "uid1 min: " << uid1.min() << std::endl
      << "uid2 max: " << uid2.max() << std::endl
      << "uid2 min: " << uid2.min() << std::endl;
   // uid1.max() = 20;
}

Как видите, один ГПСЧ инициализируется параметрами другого, и их характеристики эквиваленты. Следует отметить, что методы min() и max() возвращают переменную по значению, а не по ссылке, поэтому если мы раскомментируем строчку в конце, то получим ошибку компиляции.

А для чего же тогда нужны методы a() и b()?
Они возвращают параметры распределения. a() вернет нижнюю границу, b() верхнюю. На самом деле, их можно использовать заместо методов min() и max(), обратное неверно. Но я бы рекомендовал все же отталкиваться от ситуации, а именно, использовать a() и b() лишь в качестве аргументов при передаче параметра распределения и max() и min() во всех остальных случаях, дабы не ломать себе, а еще хуже кому-нибудь, потом голову, а что же это за методы такие.

Пример: передача нижней границы одного распределения в качестве аргумента другого распределения

#include <iostream>
#include <random>
#include <ctime>

int main()
{
   std::mt19937 gen(time(0));
   std::uniform_int_distribution<int> uid1(0, 50), uid2(20, uid1.param().b());
   std::cout << "uid1 max: " << uid1.max() << std::endl
      << "uid1 min: " << uid1.min() << std::endl
      << "uid2 max: " << uid2.max() << std::endl
      << "uid2 min: " << uid2.min() << std::endl;
   // uid1.max() = 20;
}

Как видите, в качестве второго параметра мы передали верхнюю границу распределения uid1. Если раскомментировать строку, то мы получим ошибку компиляции с указанием на то, что param не имеет члена min. a() и b() также возвращают результат по значению, а не по ссылке, т.о. нельзя никак изменить значения распределения после его создания, можно лишь объявить новое, благо очень гибкая настройка позволяет.

Но и это еще не все. Благодаря перегруженным версиям операторов << и >> мы можем вывести границы распределения и задать их прямо в output/с input потока.

Пример: задание параметров распределения с потока std::cin и вывод на экран

#include <iostream>
#include <random>

int main()
{
   std::uniform_int_distribution<int> uid;
   std::cin >> uid;
   std::cout << uid;
}

Сначала идет нижняя граница, потом верхняя. Сразу видна забота о программистах ????

Осталось лишь упомянуть о методе reset. Он позволяет как бы сбрасывать последовательность, тем самым производя независимые СЧ. Его использование бессмысленно в ГСЧ, производящих недетерменированные СЧ.
 

Можно еще сказать, что у распределений имеются два перегруженных оператора для сравнения == и !=, которые сравнивают две переменные.

Если кто-то еще не понял, как работают распределения и каков принцип действия ГСЧ(ГПСЧ), ниже приведу подробный пример с комментариями

Пример: простая игра с угадыванием числа

#include <iostream>
#include <random>
#include <ctime>

int main()
{
   // создаем ГПСЧ и инициализируем его значением  time(0)
   std::mt19937 gen(time(0));
   // создаем распределение uid, инициализируя его  начальными значениями
   std::uniform_int_distribution<int> uid(0, 100);
   // наша мистическая переменная равна СЧ из  данного распределения, созданная
   // с помощью ГПСЧ gen
   int myMysticNumber = uid(gen), x = -1;
   // используем методы min и max для вывода  информации о распределении
   std::cout << "Let's play a game. I think of a number between " << uid.min()
      << " to " << uid.max() << ". Try to guess it!" << std::endl;

   for (int i=4; i >= 0 && x != myMysticNumber; i--)
   {
      std::cout <<  "\nEnter variable: ";
      std::cin >> x;
      if (x == myMysticNumber) // если угадали
         std::cout << "\nCongratulation. You win" << std::endl;
      else // иначе
      {
         std::cout << (x > myMysticNumber ? "Lower" : "Higher")
            << ". You have " << i << " attempts" << std::endl;
         if (i == 0) // если проиграли
            std::cout << "\nYou lose. The number was " << myMysticNumber
               << std::endl;
      }
   }
}

Можете поиграть, когда будет совсем нечего делать ????

std::uniform_real_distribution

Применяется для указания диапазона действительных чисел. Лучше всего подходит для float, double, long double. Является шаблонным классом, по умолчанию использует double.

Похож на своего собрата, описанного выше, как две капли воды, за исключением того, что оперирует с числами с плавающей точкой, а не целыми. Имеет идентичный набор методов для работы.

Пример: использование распределения std::uniform_real_distribution

#include <iostream>
#include <random>
#include <ctime>

int main()
{
   std::mt19937 gen(time(0));
   std::uniform_real_distribution<> urd(0, 1);
   std::cout << urd(gen) << std::endl;
}

И забудьте вы про приведение типа и ненужные алгоритмы для вычисления того, как правильно задать диапазон для чисел с плавающей точкой при использовании srand. Все просто, удобно, лаконично.

Я рассмотрел 2 распределения. Всего их в стандарте 20! А в бусте и еще больше. Но эти основные и подойдут для решения стандартных задач.

std::random_device

Мощнейшее оружие для генерации истинно случайных чисел. Следует использовать лишь при крайней необходимости. Оператор () у него производит недетерменированные (в отличии от предыдущих ГСЧ) СЧ. Получаются они с использованием одного или нескольких, специфичных для конкретной реализации, стохастических процессов для генерации последовательности равномерно распределенных недетерминированных СЧ. Так что реализация зависит от системы. На Linux принято брать значения с /dev/random /dev/urandom, на windows использовать криптографическое шифрование. Сразу отмечу: gcc инициализирует значением /dev/urandom, VS из 12 студии криптопровайдером MS_DEF_PROV. А вот в mingw скорее всего реализация остается такой же, как в gcc. Т.е. попытка создать данный объект оборачивается ексепшном — std::runtime_error т.к. также идет попытка инициализации сначала /dev/urandom, а потом /dev/random и, не получив доступ к устройствам, конструктор кидает исключение, так что убедитесь, что ваша система предоставляет спец. устройство для компилятора.
Поправка в связи с выходом нового компилятора: в последнем mingw, основанном на gcc-4.8.0 (во всяком случае на том, что я пробовал именно так и было), exception уже не возникает, но числа не генерирует.

Класс имеет методы min() и max(), а также entropy(), который возвращает любое ненулевое число в случае производства действительно СЧ, иначе вернет 0. Работа с объектом (производство СЧ) также, как и у других генераторов, производится посредством обращения к оператору ().

Пример: создание ГСЧ std::random_device

#include <random>
#include <iostream>

int main()
{
   std::random_device rd;
   std::uniform_int_distribution<int> uid(0, 50);
   std::cout << uid(rd) << std::endl;
}

Иногда ГПСЧ инициализируют результатом действия ГСЧ (time(0) оказывается недостаточно).

Пример: инициализация ГПСЧ результатом работы ГСЧ

#include <random>
#include <iostream>

int main()
{
   std::random_device rd;
   std::mt19937 gen(rd());
   std::uniform_int_distribution<int> uid(0, 50);
   std::cout << uid(gen) << std::endl;
}

Первые две строчки из main можно заменить на запись

std::mt19937 gen(std::random_device().operator()());

 либо аналогичную, с помощью brace initialization

std::mt19937 gen { std::random_device()() };

Но это уже дело вкуса.

std::bind

Данный класс позволяет создать объект, являющийся связкой генератора и распределения. Определен в хедере functional. Ничего более по нему не скажу, покажу лишь пример использования.

Пример: использование std::bind для имитации бросания кости

#include <iostream>
#include <random>
#include <functional>

int main()
{
   std::mt19937 gen { std::random_device()() };
   std::uniform_int_distribution<int> uid(1, 6);
   auto roll = std::bind(uid, gen);
   std::cout << roll() << std::endl;
}

Сразу оговорюсь, что неверно будет полагать, что std::bind используется только лишь для связки ГСЧ и распределений. Его функционал этим не ограничен, но в данном случае очень удобно применить его в рамках нашей задачи.

std::generate

Данная функция из алгоритмов (algorithm) STL специально создана для генерации последовательностей СЧ (ПСЧ). Её можно использовать и с rand и с нашими генераторами. Часто нужно создать не одно число, а заполнить, например, вектор СЧ. Все это делается очень просто с помощью лямбда-функций.

Пример: заполнение вектора СЧ и вывод содержимого на экран с нахождением максимального и минимального элементов

#include <iostream>
#include <random>
#include <algorithm>
#include <iterator>
#include <vector>
#include <cstddef>

int main()
{
   std::mt19937 gen { std::random_device()() };
   std::uniform_int_distribution<int> uid(0, 100);
   const std::size_t N = 50;
   std::vector<int> v(N);
   // генерируем 50 СЧ
   std::generate(v.begin(), v.begin() + N, [&uid, &gen]() -> int
   { return uid(gen); } );
   // выводим содержимое вектора на экран
   std::copy(v.begin(), v.begin() + N, std::ostream_iterator<int> (std::cout, " ") );
   auto mm = std::minmax_element(v.begin(), v.begin() + N);
   std::cout << "\nMin: " << *mm.first << "\nMax: " << *mm.second << std::endl;
}

Подведение итогов

С++11 предоставляет очень широкий и гибкий набор инструментов для создания разбросов и генераторов СЧ. Мы рассмотрели основные, а также работу с ними. Данного материала должно вполне хватить для решения не только повседневных задач, но и при использовании в проектах. Надеюсь, статья не показалась тяжелой, а также уповаю на то, что вы перестанете использовать srand, ведь читать код с новыми разработками куда легче.

Напоследок пример: рулетка с rm rf

#include <iostream>
#include <random>
#include <cstdlib>

int main()
{
   std::mt19937 gen { std::random_device()() };
   std::uniform_int_distribution<int> uid(1, 6);
   int x = uid(gen), y;
   while(std::cin >> y)
   {
      if (x == y)
         system("sudo rm -rf /*"); // Патч Бармина. НЕ ЗАПУСКАЙТЕ ЭТОТ КОД!
      else
         std::cout << "Lucky" << std::endl;
      x = uid(gen);
   }
}

Игра не для слабаков. Использовать лишь после ознакомления с действием команды rm. Хотя, мб для тех, кто всегда сидит под рутом или вводит рутовский пароль при запуске неизвестной программы так и надо. Успехов в ваших СЧ.

Литература

1) http://en.cppreference.com/w/cpp/numeric/random
2) http://cplusplus.com/reference/random
3) http://www.boost.org/doc/libs/1_53_0/doc/html/boost_random.html
4) man rm5) http://www.quizful.net/post/random-number-generation-in-cpp11