KoreanFoodie's Study
C++ 기초 개념 17-3 : 난수 생성(<random>) 과 시간 관련 라이브러리(<chrono>) 본문
C++ 기초 개념 17-3 : 난수 생성(<random>) 과 시간 관련 라이브러리(<chrono>)
GoldGiver 2022. 5. 26. 18:05
모두의 코드를 참고하여 핵심 내용을 간추리고 있습니다. 자세한 내용은 모두의 코드의 씹어먹는 C++ 강좌를 참고해 주세요!
C 스타일의 난수 생성의 문제점
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main() {
srand(time(NULL));
for (int i = 0; i < 5; i++) {
printf("난수 : %d \n", rand() % 100);
}
return 0;
}
위와 같은 C 스타일의 난수 생성은 다음과 같은 문제점들이 있다 :
- 난수처럼 보이지만 무작위로 생성된 것이 아님 (첫 숫자만 무작위, 나머지는 알고리즘으로 생성)
- 시드값이 너무 천천히 변함 (위에서는 초 단위)
- 0 부터 99 까지 균등하게 난수를 생성하지 않음 (rand() 의 최댓값에 따라 rand() % 100 의 분포는 달라짐)
- rand() 자체도 별로 뛰어나지 않음 (선형 합동 생성기 알고리즘을 사용하지만, 품질이 떨어짐)
따라서 C++ 에서는 C 의 srand 와 rand 를 사용하지 않도록 하자!
<random>
0 ~ 99 사이의 난수를 생성하는 코드를 C++ 의 <random> 라이브러리를 사용해서 작성해 보자.
#include <iostream>
#include <random>
int main() {
// 시드값을 얻기 위한 random_device 생성.
std::random_device rd;
// random_device 를 통해 난수 생성 엔진을 초기화 한다.
std::mt19937 gen(rd());
// 0 부터 99 까지 균등하게 나타나는 난수열을 생성하기 위해 균등 분포 정의.
std::uniform_int_distribution<int> dis(0, 99);
for (int i = 0; i < 5; i++) {
std::cout << "난수 : " << dis(gen) << std::endl;
}
}
대부분의 운영체제에는 진짜 난수값들을 얻어낼 수 있는 여러가지 방식들을 제공하고 있다. 예를 들어서 리눅스의 경우 /dev/random 나 /dev/urandom 을 통해서 난수값을 얻을 수 있다. 이 난수값은, 이전에 우리가 이야기 하였던 무슨 수학적 알고리즘을 통해 생성되는 가짜 난수가 아니라 정말로 컴퓨터가 실행하면서 마주치는 무작위적인 요소들 (예를 들어 장치 드라이버들의 noise) 을 기반으로한 진정한 난수를 제공한다.
random_device 를 이용하면 운영체제 단에서 제공하는 진짜 난수를 사용할 수 있다. 다만 진짜 난수의 경우 컴퓨터가 주변의 환경과 무작위적으로 상호작용하면서 만들어지는 것이기 때문에 의사 난수보다 난수를 생성하는 속도가 매우 느리다. 따라서 시드값처럼 난수 엔진을 초기화 하는데 사용하고, 그 이후의 난수열은 난수 엔진으로 생성하는 것이 적합하다.
std::mt19937 는 C++ <random> 라이브러리에서 제공하는 난수 생성 엔진 중 하나로, 메르센 트위스터 라는 알고리즘을 사용한다. 이 알고리즘은 기존에 rand 가 사용하였던 선형 합동 방식 보다 좀 더 양질의 난수열을 생성한다고 알려져있다. 무엇보다도 생성되는 난수들 간의 상관관계가 매우 작기 때문에 여러 시뮬레이션에서 사용할 수 있다.
참고적으로 <random> 라이브러리에는 위 메르센 트위스터 기반 엔진 말고도 기존의 rand 와 같이 선형 합동 알고리즘을 사용한 minstd_rand 외 여러가지 엔진들이 정의되어 있다. 물론 mt19937 이 훌륭한 난수를 생성하기에는 적합하지만 생각보다 객체 크기가 커서 (2KB 이상) 메모리가 부족한 시스템에서는 오히려 minstd_rand 가 적합할 수 있다.
mt19937 를 생성한 이후에 난수를 생성하는 작업은 매우 빠르다.
위와 같이 uniform_int_distribution<int> 의 생성자에 원하는 범위를 써넣으면 된다.
<random> 라이브러리에서는 균등 분포 말고도 여러가지 분포들을 제공하고 있다. 여기서는 다 일일히 소개하기 어렵지만 그 중 가장 많이 쓰이는 정규 분포 (Normal distribution) 만 간단히 살펴보겠다. (전체 목록은 여기서)
#include <iomanip>
#include <iostream>
#include <map>
#include <random>
int main() {
std::random_device rd;
std::mt19937 gen(rd());
// 정규분포 정의
std::normal_distribution<double> dist(/* 평균 = */ 0, /* 표준 편차 = */ 1);
std::map<int, int> hist{};
// 10000 개의 샘플을 무작위로 뽑기
for (int n = 0; n < 10000; ++n) {
++hist[std::round(dist(gen))];
}
for (auto p : hist) {
std::cout << std::setw(2) << p.first << ' '
<< std::string(p.second / 100, '*') << " " << p.second << '\n';
}
}
// 출력 결과
// -3 63
// -2 ****** 620
// -1 ************************ 2400
// 0 ************************************** 3853
// 1 ************************ 2402
// 2 ***** 599
// 3 62
// 4 1
chrono 소개
- 현재 시간을 알려주는 시계 - 예를 들어 system_clock
- 특정 시간을 나타내는 time_stamp
- 시간의 간격을 나타내는 duration
chrono 에서 지원하는 clock 들
일반적 상황에서 현재 컴퓨터 상 시간을 얻어 오기 위해서는 std::system_clock 을 사용하면 되고, 좀더 정밀한 시간 계산이 필요한 경우 (예를 들어 프로그램 성능을 측정하고 싶을 때) std::high_resolution_clock 을 사용하면 된다.
chrono 의 함수들은 지정된 시점으로 부터 몇 번의 틱(tick)이 발생 하였는지 알려주는 time_stamp 객체를 리턴한다. 예를 들어서 std::system_clock 의 경우 1970 년 1월 1일 부터 현재 까지 발생한 틱의 횟수를 리턴한다고 보면 된다.
이를 흔히 유닉스 타임(Unix time) 이라 부른다. 쉽게 말해 time_stamp 객체는 clock 의 시작점과 현재 시간 duration 을 보관하는 객체이다.
각 시계 마다 정밀도가 다르기 때문에 각 clock 에서 얻어지는 tick 값 자체는 조금씩 다르다. 예를 들어 system_clock 이 1 초에 1 tick 이라면, high_resolution_clock 의 경우 0.00000001 초 마다 1 tick 움직일 수 있다.
난수를 생성 속도를 측정하는 아래 코드를 보자.
#include <chrono>
#include <iomanip>
#include <iostream>
#include <random>
#include <vector>
int main() {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dist(0, 1000);
for (int total = 1; total <= 1000000; total *= 10) {
std::vector<int> random_numbers;
random_numbers.reserve(total);
std::chrono::time_point<std::chrono::high_resolution_clock> start =
std::chrono::high_resolution_clock::now();
for (int i = 0; i < total; i++) {
random_numbers.push_back(dist(gen));
}
std::chrono::time_point<std::chrono::high_resolution_clock> end =
std::chrono::high_resolution_clock::now();
// C++ 17 이전
auto diff = end - start;
// C++ 17 이후
// std::chrono::duration diff = end - start;
std::cout << std::setw(7) << total
<< "개 난수 생성 시 틱 횟수 : " << diff.count() << std::endl;
}
}
// 출력 결과
// 1개 난수 생성 시 틱 횟수 : 0
// 10개 난수 생성 시 틱 횟수 : 0
// 100개 난수 생성 시 틱 횟수 : 0
// 1000개 난수 생성 시 틱 횟수 : 0
// 10000개 난수 생성 시 틱 횟수 : 0
// 100000개 난수 생성 시 틱 횟수 : 5983700
// 1000000개 난수 생성 시 틱 횟수 : 47873200
만약에 매번 std::chrono 를 쓰기에 번거롭다면 그냥
namespace ch = std::chrono;
와 같이 ch 라는 별명을 지어주고 ch 로 대체하면 된다.
이들 clock 에는 현재의 time_point 를 리턴하는 static 함수인 now 가 정의되어 있다. 이 now() 를 호출하면 위와 같이 해당 clock 에 맞는 time_point 객체를 리턴한다. 우리의 경우 high_resolution_clock::now() 를 호출하였으므로, std::chrono::time_point<ch::high_resolution_clock> 를 리턴한다.
time_point 가 clock 을 왜 템플릿 인자로 가지는지는 앞서 설명하였듯이 clock 마다 1 초에 발생하는 틱 횟수가 모두 다르기 때문에 나중에 실제 시간으로 변환 시에 어떤 clock 을 사용했는지에 대한 정보가 필요하기 때문이다.
이제 난수 생성이 끝나면 end 에 끝나는 시간을 또 받아서 그 차이를 계산해야 한다. 위와 같이 두 time_stamp 를 빼게 된다면 duration 객체를 리턴한다.
참고로 C++ 17 이전에서는 end - start 가 리턴하는 duration 객체의 템플릿 인자를 전달해야 한다. 따라서 굳이 duration 의 템플릿 인자들을 지정하기 보다는 속시원하게 그냥 auto diff = end - start 로 하는게 낫다.
std::cout << std::setw(7) << total
<< "개 난수 생성 시 틱 횟수 : " << diff.count() << std::endl;
duration 에는 count 라는 멤버 함수가 정의되어 있는데 이는 해당 시간 차이 동안 몇 번의 틱이 발생하였는지를 알려준다. 하지만 우리에게 좀 더 의미 있는 정보는 틱이 아니라 실제 시간으로 얼마나 걸렸는지 알아내는 것이다. 이를 위해선 duration_cast 를 사용해야 한다.
ch::time_point<ch::high_resolution_clock> end =
ch::high_resolution_clock::now();
auto diff = end - start;
std::cout << std::setw(7) << total << "개 난수 생성 시 걸리는 시간: "
<< ch::duration_cast<ch::microseconds>(diff).count() << "us"
<< std::endl;
// ...
// 출력 결과
// 1개 난수 생성 시 걸리는 시간: 0us
// 10개 난수 생성 시 걸리는 시간: 1us
// 100개 난수 생성 시 걸리는 시간: 10us
// 1000개 난수 생성 시 걸리는 시간: 101us
// 10000개 난수 생성 시 걸리는 시간: 1033us
// 100000개 난수 생성 시 걸리는 시간: 10702us
// 1000000개 난수 생성 시 걸리는 시간: 98950us
ch::duration_cast<ch::microseconds>(diff).count()
duration_cast 는 임의의 duration 객체를 받아서 우리가 원하는 duration 으로 캐스팅 할 수 있다. std::chrono::microseconds 는 <chrono> 에 미리 정의되어 있는 duration 객체 중 하나로, 1 초에 10^6count 값은 해당 duration 이 몇 마이크로초 인지를 나타내는 것이다. 번 틱을 하게 된다. 따라서 microseconds 로 캐스팅 한뒤에 리턴하는
우리의 경우 1000000 개의 난수를 생성하는데 불과 98950 마이크로초, 대량 98 밀리초 정도 걸린다고 나왔다. <chrono> 에는 std::chrono::microseconds 외에도 nanoseconds, milliseconds, seconds, minutes, hours 가 정의되어 있기 때문에 상황에 맞게 사용하면 된다.
현재 시간을 날짜로
안타깝게도 C++ 17 까지에서는 chrono 라이브러리 상에서 날짜를 간단하게 다룰 수 있도록 도와주는 클래스가 없다. 예를 들어서 현재 시간을 출력하고 싶다면 C 의 함수들에 의존해야 한다.
#include <chrono>
#include <ctime>
#include <iomanip>
#include <iostream>
int main() {
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::cout << "현재 시간은 : " << std::put_time(std::localtime(&t), "%F %T %z")
<< '\n';
}
put_time 이 궁금하면 이 링크를 참조하자!
'Tutorials > C++ : Beginner' 카테고리의 다른 글
C++ 기초 개념 17-5 : std::optional, variant, tuple (0) | 2022.05.27 |
---|---|
C++ 기초 개념 17-4 : C++ 파일 시스템(<filesystem>) 라이브러리 (0) | 2022.05.27 |
C++ 기초 개념 17-2 : C++ 정규 표현식 <regex> 사용하기 (0) | 2022.05.26 |
C++ 기초 개념 17-1 : type_traits 라이브러리, SFINAE, enable_if (0) | 2022.05.25 |
C++ 기초 개념 16-3 : decltype 과 std::declval (0) | 2022.05.24 |