🕌

C++ による数値シミュレーション入門 (3/4)|標準ライブラリ

2024/05/19に公開

東工大情報理工学院 高安研究室 で開催されている新入生向けのプログラミングゼミの資料を一部公開します.

本稿は C++ による数値シミュレーション入門 の第 3 部です.

ここでは,数値シミュレーションで使う C++ 標準ライブラリの使い方を解説します.

C++ には標準ライブラリとして様々な機能が使えるようになっています.数値シミュレーションで特に必要なものは,各種データ構造と乱数生成,数学関数,ファイル出力です.本章では下記ライブラリの解説を行ないます.

  • 文字列:string
  • 入出力:iostream, fstream
  • コンテナ:vector, map, set, queue, stack, pair, tuple
  • 数値計算:cmath, random
  • アルゴリズム:sort, unique, reverse

Hello world! 再訪

前章のはじめに登場した Hello world! のプログラムをもう一度見てみましょう.

hello.cpp
#include <iostream> // 標準入出力ライブラリを取り込む(使えるようにする)

int main()
{
    std::cout << "Hello world!" << std::endl;
    return 0;
}

C++ の標準ライブラリの機能は std という箱の中に入っています.この箱の名前が名前空間 (namespace) で,std は標準ライブラリの名前空間です.std:: をつけることで標準ライブラリの中の機能を使うことを明示します.

C++ の標準ライブラリは,名前空間 std のなかで,機能ごとに分かれて定義されています.include <ライブラリ名> とすることでそのライブラリの機能を使えるようになります.iostream は標準入出力に関する機能がまとめられたライブラリであり,標準出力を意味する std::cout,改行を意味する std::endl などが定義されています.標準出力にメッセージを送る << 演算子もここに定義されています.

標準ライブラリに含まれる機能を使うたびに std:: を付けるのは面倒に感じるかもしれません.その場合,すべてのライブラリを include した後に using namespace std; と書くと,std:: を省略して標準ライブラリの関数やクラスを直接使うことができます.例えば,Hello world! のプログラムを次のように書くことができます.

#include <iostream>
using namespace std;

int main() {
    cout << "Hello world!" << endl;
    return 0;
}

using namespace std; 使用にはいくつか注意点があります.

  1. 名前の衝突: using namespace std; を使用すると,std 名前空間にあるすべての名前が現在のスコープに取り込まれます.これにより,他のライブラリやユーザー定義の名前と衝突する可能性があります.
  2. 可読性の低下: std:: を省略することでコードが短くなりますが,どの名前が標準ライブラリから来ているのかが不明確になるため,コードの可読性が低下することがあります.
  3. 大規模なプロジェクト: 大規模なプロジェクトやライブラリでは,using namespace std; の使用を避けることが一般的です.これは,名前の衝突を避けるためと,コードの明確性を保つためです.

using namespace std; の使用は,小さなプログラムや学習目的のコードでは便利ですが,大規模なプロジェクトやライブラリでは慎重に検討する必要があります.必要な場合は,特定のクラスや関数に対して using 宣言を使うことで,名前空間を明示的に指定することができます.例えば,using std::cout; のように使用します.

文字列

文字列に関する機能は string ライブラリにまとめられています.

処理 コード
宣言 std::string str;
2つの文字列を連結する str1 + str2
部分文字列を取り出す str.substring(start, length)
文字列→int の変換 std::stoi(str)
文字列→double の変換 std::stod(str)
数値→文字列の変換 std::to_string(*)
行の読み込み std::getline(stream, buffer, delim='\n')

基本的な使い方は次のとおりです.

#include <iostream>
#include <string>

int main()
{
    // 文字列の宣言
    std::string s = "Hello world!";

    // 部分文字列の抽出
    std::string substr1 = s.substr(0, 5);  // Hello
    std::string substr2 = s.substr(6, 5);  // world
    std::string substr3 = s.substr(11, 1); // !

    // 文字列の連結
    std::string new_str = substr1 + substr3; // Hello!
    std::cout << new_str << std::endl;

    return 0;
}

std::stoistd::stodは数値の入力を受け取るときに重宝します.std::to_string は出力ファイル名に数字を混ぜる場合などで使います.

std::getlinecsv 等のファイルを読み込む際に活躍します.csv 等のファイルを読み込む方法は,ファイル入力とデータ構造を導入した後に解説します.ここでは,std::getline の基本的な使い方を見ていきます.

std::getline(stream, line, delim='\n') は,入力ストリーム stream から,改行文字が出るかそのストリームの終わりまで文字列を読み出し,その結果を文字列型の変数 line に格納します.ストリームとはデータの流れを表す抽象的なデータ構造で,ターミナルへの入出力(標準入出力)やファイルへの入出力,メモリへの入出力などを同じように扱えるようにする役割があります.ストリームの概念を理解することは難しいですが,理解しないと先に進めないというわけでもありません.このため,今の段階では「C++ にはストリームという便利なデータ構造があり,std::getlineは入力用のストリームから文字列を読み出す」ということを受け入れてください.

3つ目の引数 delim は char 型の変数で,区切り文字 (delimiter) を指定します.デフォルト値は改行文字になっていますが,カンマやスペース等に設定することもできます.std::getline を使ったファイルの読み込みは次節に任せ,ここではカンマで区切られた要素を取り出す方法を示します.

string.cpp
#include <iostream>
#include <string>
#include <sstream> // std::stringstream

int main() {
    std::string text = "123,456,789 ";
    std::stringstream ss(text); // std::getline に流すために文字列をストリームに変換
    std::string element;        // std::getline が吐き出す文字列の格納先
    int sum = 0;
    while (std::getline(ss, element, ',')) // カンマ区切られた要素を1つずつ読み出す
    {
        std::cout << element << std::endl;
        sum += std::stoi(element);
    }
    std::string message = "sum = " + std::to_string(sum);
    std::cout << message << std::endl;
    return 0;
}
// === 出力 ===
// 123
// 456
// 789
// sum = 1368
  • sstream は文字列ストリームのライブラリ
  • 処理したい文字列は,文字列ストリームというデータ構造に変換してから std::getline に読み込ませる
  • std::getline は文字列を読み出せる場合は true,文字列が終わっている場合は false を返す
  • std::stoi, std::to_string の使い方も確認してください

ファイル入出力

ファイル入出力に関する機能は fstream ライブラリにまとめられています.ファイル入力には std::ifsteam,ファイル出力には std::ofsteam を使います.宣言の仕方はどちらも同じで,ファイルのパスを渡して初期化します.その後,std::ifsteamstd::getline に流してファイルの中身を取り出します.std::ofsteamstd::cout で標準出力するのと同じようにファイル出力することができます.

具体例として,USD/JPY 過去データ (2024/03/18 ~ 2024/03/25) の終値が記載された下記のファイル input.txt を読み込み,平均値を output.txt に出力するプログラムを示します.

USD_2_JPY.txt
149.14
150.85
151.25
151.32
151.37
151.42
151.61
file_io.cpp
#include <string>
#include <iostream>
#include <fstream>

int main()
{
    // ファイル入力の例
    std::string input_path = "input.txt";
    std::ifstream ifs(input_path); // ファイルパスを渡して初期化
    std::string line;              // buffer
    double sum = 0.0;
    int cnt = 0;
    while (std::getline(ifs, line, '\n')) // 一行ずつファイルを読み込む
    {
        sum += std::stod(line);  // double 型に変換
        ++cnt;
    }

    // ファイル出力の例
    std::string output_path = "output.txt";
    std::ofstream ofs(output_path); // ファイルパスを渡して初期化
    ofs << "average = " << sum / cnt << std::endl;
    return 0;
}

課題 3-1

  • string ライブラリの std::stoi"01" のような 0 埋めされた文字列の整数変換に対応しているか確認してください.例えば,文字列 "2024/04/01" から年月日を下記のように取り出すことは可能でしょうか?

    std::string ymd_str = "2024/04/01";
    int y = std::stoi(ymd_str.substr(0,4)); // "2024" -> 2024 will work
    int m = std::stoi(ymd_str.substr(5,2)); // "04" -> 4 ??
    int d = std::stoi(ymd_str.substr(8,2)); // "01" -> 1 ??
    
  • コマンドラインで指定されたファイルを読み込み,その行数を標準出力/ファイル出力するプログラムを書いてください.

コンテナ

C++ には標準ライブラリに多くのデータ構造が実装されています.代表的なものを挙げれば,可変長配列 vector や連想配列 map,集合 set などがあります.Python ユーザの方は,リストと辞書の代わりに vectormap を使うことができます.より正確には,Python の辞書はキーがソートされていませんが,c++ の map はキーがソートされています.キーがソートされていない連想配列は unordered_map という名前で用意されています.

他には,探索アルゴリズムの実装に欠かせない queue, stack なども使えます.異なる型を組として保持できる pair, tuple などのデータ構造も定義されています.

本稿では,これらのコンテナのうち vectormap の使い方を解説します.その他のコンテナは必要に応じて使い方を調べてください.各種コンテナの使い方には共通する部分が多く,基本的な操作を理解することで他のコンテナにも応用できます.

vector

std::vector は最も使用頻度の高いコンテナで,同じ型のデータを保持できる可変長配列です.基本的な機能を記します.

  • 宣言: std::vector<型> v;
  • 宣言(初期化あり): std::vector<int> v = {1,2,3};
  • 宣言(要素で埋める): std::vector<int> v(5, 0); // {0,0,0,0,0}
  • 末尾への要素の追加: v.push_back(element)
  • i番目の要素へのアクセス: v[i]
  • 要素数の取得: v.size()
  • 空であるかの判定: v.empty()
#include <vector>

int main()
{
    std::vector<int> v = {1,2,3};
    v[2] = 4;
    v.push_back(8);  // v = {1,2,4,8}

    std::cout << v[3] << std::endl;     // 8
    std::cout << v.size() << std::endl; // 4
    return 0;
}

std::vector の要素を順番に取り出す方法として,Pythonの for i in range(len(v))for x in v に対応する2つが用意されています.

// 通常の for 文による要素アクセス
for (int i = 0; i < v.size(); ++i)
{
    std::cout << v[i] << std::endl;
}
// 範囲 for 文 (c++11以降)
for (int x : v)
{
    std::cout << x << std::endl;
}

要素を更新したい場合,範囲 for 文は for (int x : v) の代わりに for (int &x : v) とする必要があります.前者ではコピーが取られ,後者では参照が渡されます.

// 通常の for 文による要素アクセス
for (int i = 0; i < v.size(); ++i)
{
    v[i] *= 2;
}
// 範囲 for 文 (c++11以降)
for (int &x : v) // 要素を参照型で受け取る
{
    x *= 2;
}

CSV ファイルの読み込み

std::vector<型> の型には std::vector<string> のような1次元配列を指定できます.つまり,std::vector<std::vector<string>> のような2次元配列を定義できます.CSVファイルを読み込み,この2次元配列に格納する関数は下記のようになります.

#include <fstream>  // ファイル入出力
#include <sstream>  // 文字列の分解に使う
#include <string>
#include <vector>

// 1つの文字列を区切り文字で分割し,vectorに格納する
// 例: split("2023,4,26", ',') -> {"2023", "4", "26"}
std::vector<std::string> split(std::string line, char delim = ',')
{
    std::vector<std::string> items;
    std::stringstream ss(line); // std::getline に渡す入力ストリーム
    std::string item;           // std::getline が吐き出す文字列の格納先
    while (std::getline(ss, item, delim))
        items.push_back(item);
    return items;
}

// パスで指定したcsvファイルを読み込む
std::vector<std::vector<std::string>> read_csv(std::string path)
{
    std::vector<std::vector<std::string>> data;
    std::ifstream ifs(path); // std::getline に渡す入力ストリーム
    std::string line;        // std::getline が吐き出す文字列の格納先
    while (std::getline(ifs, line, '\n'))
        data.push_back(split(line, ','));
    return data;
}

map/unordered_map

C++ には連想配列として,キーがソートされている std::map とキーがソートされていない std::unordered_map の2つが用意されています.基本的な使い方は下記のとおりです.map/unordered_map 共通です.

  • 宣言: std::map<keyの型, valueの型> mp;
  • 宣言(初期化あり): std::vector<std::string, int> mp = { {"Jan", 1}, {"Feb", 2}};
  • keyに対応する値へのアクセス: mp[key]
  • keyに対応する値の追加: mp[key] = value
  • 要素数の取得: mp.size()
  • 空であるかの判定: mp.empty()
#include <iostream>
#include <map>

int main()
{
    // 宣言
    std::map<std::string, std::string> to_hp_url;
    // 値の追加
    to_hp_url["魔法科3"] = "https://mahouka.jp/3rd/";
    to_hp_url["ユーフォ3"] = "https://anime-eupho.com";
    to_hp_url["獣と香辛料"] = "https://spice-and-wolf.com";
    // 要素数
    std::cout << to_hp_url.size() << std::endl; // 3

    return 0;
}

std::map の要素へアクセスする方法として,イテレータを使う方法と,範囲 for 文を使う方法(c++17以降)の2つがあります.前者は本稿では解説しないので,下記等を参照ください.

C++17以降では,Python の for k, v in d.items() と同じように範囲 for 文を書くことができます.

for (auto [key,value] : mp)
{
    std::cout << key << ": " << value << std::endl;
}

auto は型推論を行うキーワードで,C++11 で導入されました.auto を使用すると変数の型をコンパイラに自動的に推論させることができます.上の例では std::pair<const KeyType, ValueType> 型が自動的に推論されます.std::pairconst の説明は省きます.

std::map を使うと配列の各要素の出現頻度を求められます.

std::vector<int> v = {2,0,2,4,0,4,0,1};

// 各要素の出現数をカウント
std::map<int, int> cnt;
for (int x : v)
    ++cnt[x];  // 初期値は 0

// 出力
for (auto [key ,value] : cnt)
    std::cout << key << ": " << value << std::endl;
// 0: 3
// 1: 1
// 2: 2
// 4: 2

Python の 辞書型 dict では,存在しないキーに対して d[x] += 1 とすることはできません(KeyError が出る).一方,C++ や AWK の連想配列では,値の初期値が自動的に value 型の初期値となります.例えば,value 型が int であれば,宣言後にすべてのキーに対して 0 で初期化せずとも,キーが最初に使われるタイミングで 0 に初期化されます.

ヒストグラム法も同じように実装できます.ただし,整数丸めの関数は後述する cmath ライブラリに含まれています.

課題 3-2

  • vector で存在しないインデックスを指定するとどうなりますか?
  • 金沢の2017年から2019年の気温データ kanazawa2017-2019.csv を用いて,以下を求めてください.ファイルは C++ で扱いやすいよう awk/sed 等の UNIX コマンドで加工しても構いません.
    • 年ごとの,気温の最小値/最大値/平均値/標準偏差
    • 月ごとの,気温の最小値/最大値/平均値/標準偏差
    • 日ごとの,気温の最小値/最大値/平均値/標準偏差

数値計算

続いて,数値計算に必要な疑似乱数生成ライブラリ random と数学関数ライブラリ cmath を解説します.

random

random は疑似乱数生成ライブラリです.疑似乱数とは,コンピュータによって生成されるランダムな数列です.ある程度ランダムに見えますが,実際には計算式やアルゴリズムに基づいて生成されるため,真のランダム性は持ちません.疑似乱数は,乱数生成アルゴリズムによって決定されるため,同じ初期条件(シード値)を与えれば同じ数列を再現することができます.この特性は,科学研究やゲーム開発などで結果の再現性を確保するために利用されます.

メルセンヌ・ツイスタ (Mersenne Twister)

メルセンヌ・ツイスタ は,1997年に松本眞と西村拓士によって開発された乱数生成アルゴリズムで,現代のメジャー乱数生成器の一つです.その人気の理由は,長い周期性,高次元の均等分布,そして高速な生成速度にあります.

  • 周期性: メルセンヌ・ツイスタは非常に長い周期を持ちます.最も一般的に使用されるバージョンであるMT19937は,2^19937-1 という極めて長い周期を持っています
  • 高次元の均等分布: MT19937は623次元までの均等分布を実現します.これにより,多次元の乱数を必要とするシミュレーションなどで高い性能を発揮します
  • 高速性: メルセンヌ・ツイスタは計算が高速であり,多くのアプリケーションで十分な速度で乱数を生成できます
  • 移植性: 異なるプラットフォームやコンパイラでも同じ乱数列を生成することができます

MT19937 は C++11から標準ライブラリにも採用されており,<random> ライブラリから std::mt19937 として利用できます.

確率分布

乱数生成器 std::mt19937 を確率分布に与えることで,その分布に従う乱数が生成されます.代表的な分布は次のとおりです.その他の分布は https://cpprefjp.github.io/reference/random.html を確認してください.

  • 連続一様分布: std::uniform_real_distribution<double>
  • 離散一様分布: std::uniform_int_distribution<int>
  • 正規分布: std::normal_distribution<double>
  • 離散分布(標本分布): std::discrete_distribution<int>

<> 内の doubleint は確率変数の型を表します.

連続一様分布は定義域の下限 a と上限 b を指定します.定義域は [a,b) です.

std::uniform_real_distribution<double> uni_dist(a, b);

離散一様分布も同様に,定義域の下限 a と上限 b を指定します.ただし,定義域は [a,b] で,b を含みます.

std::uniform_int_distribution<int> uni_dist(a, b);

正規分布は平均と標準偏差をパラメータに指定します.

std::normal_distribution<double> norm_dist(mu, sigma);

離散分布(標本分布)は,std::vector 等の数列で確率分布を指定します.指定した数列の比率に従ってそのインデックスが生成されます.規格化しなくても構いません.

// 2通りの初期化のどちらを使っても同じ
// 10:20:40 の比率でインデックス 0,1,2 を生成
std::discrete_distribution<int> disc_dist({10,20,40});
std::discrete_distribution<int> disc_dist = {10,20,40};

乱数生成

メルセンヌ・ツイスタで乱数を生成する手順は次のとおりです.

  1. <random> ヘッダーをインクルード
  2. シード値を固定
  3. 乱数生成器を用意
  4. 確率分布を用意
  5. 乱数生成器を確率分布に渡し,乱数生成
#include <iostream>
#include <random>

int main()
{
    // 乱数生成器を用意
    int seed = 123;         // seed を固定して
    std::mt19937 gen(seed); // 乱数生成器を初期化

    // 確率分布を用意
    std::uniform_real_distribution<double> uni(0.0, 1.0);

    for (int i = 0; i < 10; ++i)
    {
        double random_number = uni(gen); // genを使って乱数生成
        std::cout << random_number << std::endl;
    }

    return 0;
}

乱数生成器の変数名には genengine などが使われます.gen は生成器 (generator) の頭文字,engine は乱数生成器が乱数生成エンジンと呼ばれることに由来しています.後者は,車体(確率分布)にエンジン(生成器)をかけると車が動く(乱数が生成される)といったイメージです.

乱数生成器はプログラムの中で1つだけ定義します.関数内で乱数を使いたい場合は,乱数生成器をグローバルに宣言するか,main 関数内で定義してから関数の引数に参照渡しします.後者の書き方は以下のとおりです.

#include <iostream>
#include <random>

// N個の標準正規乱数を生成
// 引数で乱数生成器の参照を受け取る
void print_rand_norm(std::mt19937 &gen, int N)
{
    std::normal_distribution<double> norm(0.0, 1.0);
    for (int i = 0; i < N; ++i)
        std::cout << norm(gen) << std::endl;
}

int main()
{
    // 乱数生成器を用意
    int seed = 123;         // seed を固定して
    std::mt19937 gen(seed); // 乱数生成器を初期化

    int N = 1000;
    print_rand_norm(gen, N); // N個の標準正規乱数を生成

    return 0;
}

cmath

cmath ライブラリには数値計算に用いる様々な関数が実装されています.代表的なものを以下に示します (std:: は省略).詳細は https://cpprefjp.github.io/reference/cmath.html を確認してください.

  • 三角関数: sin, cos, tan, asin, acos, atan
  • 双曲線関数: sinh, cosh, tanh
  • 指数/対数関数: exp, log, log10
  • ベキ乗・絶対値: pow, sqrt, abs
  • 整数丸め: floor, ceil, round

課題 2-2 の複利計算の関数 fukuri は,cmath ライブラリを使って次のように書くことができます.

#include <cmath>

// calculate x*(1+r/100)^t
double fukuri(int x, int r, int t)
{
    return x * std::pow(1.0+r/100.0, t);
}

課題 3-3(大数の法則)

以下の確率分布について,シミュレーションにより大数の法則を確認してください.すなわち,n 個の乱数の標本平均が,n を大きくするにつれ分布の母平均に収束していくことを確認してください.

  1. 一様分布(連続・離散のどちらでもよい)
  2. 正規分布
  3. 指数分布

課題 3-4(ベキ乱数の生成)

逆関数法を用いて,ベキ分布に従う乱数を生成してください.ベキ分布とは次のような累積分布関数 (CDF) を持つ確率分布です.

\mathrm{Pr}[X \geq x] = \left(\frac{x}{x_0}\right)^{-\alpha} \quad (x\geq x_0)

また,生成した乱数列に対し,Python 等で累積分布関数 (CDF) を描き,両対数プロットで傾きが -\alpha になることを確認してください.

Discussion