🇯🇵 日本語 | 🇺🇸 English
広告スペース

C言語で学ぶ数値計算・数学アルゴリズム

数学の知識をプログラムに落とし込む。理工系の計算で頻出のアルゴリズムをC言語で実装します。

統計量の計算(平均・分散・標準偏差)

データの集まりから基本的な統計量を計算します。理工系のレポート・研究で最も頻繁に使う処理です。
平均: x̄ = (1/n) Σxi  分散: s² = (1/n) Σxi² - x̄²  標準偏差: s = √s²
#include <stdio.h>
#include <math.h>

int main(void) {
    double data[] = {72.5, 85.3, 91.0, 68.2, 77.8, 83.1, 90.5, 65.9};
    int n = 8;
    double sum = 0, sum2 = 0;

    for (int i = 0; i < n; i++) {
        sum  += data[i];       // 総和
        sum2 += data[i] * data[i]; // 二乗和
    }

    double avg = sum / n;
    double var = sum2 / n - avg * avg; // 分散 = E[X²] - (E[X])²
    double sd  = sqrt(var);

    printf("データ数: %d\n", n);
    printf("平均:     %.2f\n", avg);
    printf("分散:     %.2f\n", var);
    printf("標準偏差: %.2f\n", sd);
    return 0;
}
ポイント: 分散は「二乗の平均 - 平均の二乗」で1パスで計算できます。ループを2回回す必要はありません。

ニュートン法(方程式の数値解)

f(x) = 0 の解を数値的に求めるアルゴリズム。接線の x 切片を次の近似値として繰り返す。
xn+1 = xn - f(xn) / f'(xn)
例: x² - 2 = 0 の正の解(= √2 ≒ 1.41421356...)を求める。
#include <stdio.h>
#include <math.h>

double f(double x)  { return x*x - 2; }       // f(x) = x² - 2
double df(double x) { return 2*x; }            // f'(x) = 2x

int main(void) {
    double x = 2.0;  // 初期値
    double EPS = 1e-10;

    for (int i = 0; i < 100; i++) {
        double x_new = x - f(x) / df(x);
        printf("反復%2d: x = %.15f  誤差 = %e\n", i+1, x_new, fabs(x_new - x));
        if (fabs(x_new - x) < EPS) break;
        x = x_new;
    }
    printf("√2 ≒ %.15f\n", x);
    return 0;
}
反復 1: x = 1.500000000000000 誤差 = 5.00e-01 反復 2: x = 1.416666666666667 誤差 = 8.33e-02 反復 3: x = 1.414215686274510 誤差 = 2.45e-03 反復 4: x = 1.414213562374690 誤差 = 2.12e-06 反復 5: x = 1.414213562373095 誤差 = 1.59e-12 √2 ≒ 1.414213562373095
たった5回の反復で15桁の精度。 ニュートン法は2次収束なので、反復ごとに有効桁数がほぼ倍増します。
注意: 初期値が悪いと収束しない場合があります。f'(x)=0 の点にも注意。

モンテカルロ法(円周率の推定)

乱数を使ったシミュレーションで確率的に答えを求める手法。1×1の正方形にランダムに点を打ち、単位円の内部に入る割合から円周率を推定します。
π ≒ 4 × (円の内部に入った点の数 / 全体の点の数)
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void) {
    int N;
    printf("試行回数: "); scanf("%d", &N);
    srand((unsigned)time(NULL));

    int inside = 0;
    for (int i = 0; i < N; i++) {
        double x = (double)rand() / RAND_MAX; // 0〜1の乱数
        double y = (double)rand() / RAND_MAX;
        if (x*x + y*y <= 1.0) inside++;     // 円の内部?
    }

    double pi = 4.0 * inside / N;
    printf("π ≒ %.6f  (N=%d)\n", pi, N);
    printf("真の値: 3.141593\n");
    return 0;
}
試行回数による精度の変化:
試行回数 N推定値(例)誤差
1,0003.156~0.01
10,0003.1432~0.001
100,0003.14052~0.001
1,000,0003.141284~0.0003
10,000,0003.141552~0.00004
収束は遅い(誤差 ∝ 1/√N)が、多次元の問題にも適用でき、実装が簡単なのが利点。物理シミュレーション、金融工学などで広く使われます。

数値積分(台形公式・シンプソン法)

解析的に積分できない関数も、数値的に近似計算できます。

台形公式

ab f(x)dx ≒ h × [ f(a)/2 + f(x₁) + f(x₂) + ... + f(b)/2 ] (h = (b-a)/n)
#include <stdio.h>
#include <math.h>

double f(double x) { return sin(x); } // 被積分関数

// 台形公式
double trapezoid(double a, double b, int n) {
    double h = (b - a) / n;
    double sum = (f(a) + f(b)) / 2.0;
    for (int i = 1; i < n; i++)
        sum += f(a + i * h);
    return h * sum;
}

// シンプソン法(より高精度)
double simpson(double a, double b, int n) {
    double h = (b - a) / n;
    double sum = f(a) + f(b);
    for (int i = 1; i < n; i++)
        sum += (i % 2 == 0) ? 2*f(a+i*h) : 4*f(a+i*h);
    return h / 3.0 * sum;
}

int main(void) {
    printf("∫₀^π sin(x)dx の真の値 = 2.0\n\n");
    int ns[] = {10, 100, 1000};
    for (int i = 0; i < 3; i++) {
        printf("n=%4d  台形=%.10f  Simpson=%.10f\n",
            ns[i], trapezoid(0, M_PI, ns[i]), simpson(0, M_PI, ns[i]));
    }
    return 0;
}
精度の比較: シンプソン法は台形公式より桁違いに高精度(4次の精度)。

連立一次方程式(ガウスの消去法)

Ax = b の形の連立方程式を、前進消去→後退代入で解く。
2x + y - z = 8  -3x - y + 2z = -11  -2x + y + 2z = -3
#include <stdio.h>
#include <math.h>
#define N 3

void gauss(double a[N][N+1], double x[N]) {
    // 前進消去
    for (int i = 0; i < N; i++) {
        for (int j = i+1; j < N; j++) {
            double ratio = a[j][i] / a[i][i];
            for (int k = i; k <= N; k++)
                a[j][k] -= ratio * a[i][k];
        }
    }
    // 後退代入
    for (int i = N-1; i >= 0; i--) {
        x[i] = a[i][N];
        for (int j = i+1; j < N; j++)
            x[i] -= a[i][j] * x[j];
        x[i] /= a[i][i];
    }
}

int main(void) {
    double a[N][N+1] = {
        { 2,  1, -1,   8},
        {-3, -1,  2, -11},
        {-2,  1,  2,  -3}
    };
    double x[N];
    gauss(a, x);
    for (int i = 0; i < N; i++)
        printf("x[%d] = %.4f\n", i, x[i]);
    return 0;  // 答え: x=2, y=3, z=-1
}

二分法(方程式の根を挟み撃ち)

f(a) と f(b) の符号が異なれば、間に根がある。区間を半分ずつ狭めて解を求める。ニュートン法より遅いが、必ず収束するのが利点。
#include <stdio.h>
#include <math.h>

double f(double x) { return x*x*x - x - 1; } // x³ - x - 1 = 0

double bisection(double a, double b, double eps) {
    while (b - a > eps) {
        double mid = (a + b) / 2.0;
        if (f(a) * f(mid) <= 0)
            b = mid;  // 根は左半分にある
        else
            a = mid;  // 根は右半分にある
    }
    return (a + b) / 2.0;
}

int main(void) {
    double root = bisection(1.0, 2.0, 1e-10);
    printf("x³ - x - 1 = 0 の根: %.10f\n", root);
    printf("検算: f(root) = %e\n", f(root));
    return 0;
}
二分法: 遅い(1次収束)が安全。初期値の心配不要。
ニュートン法: 速い(2次収束)が導関数と良い初期値が必要。

常微分方程式(オイラー法・ルンゲクッタ法)

dy/dx = f(x, y) の形の微分方程式を、初期値から出発して少しずつ数値的に解く方法です。放物運動や感染症モデル、電気回路、化学反応など、多くの現象がこの形で書けます。

テスト問題

dy/dx = -y
y(0) = 1
解析解: y(x) = e-x
3つの数値解法で x=5 まで解き、精度を比較します。x=5 での厳密値は e-5 ≈ 0.00673795。

① オイラー法(1次精度)

最も単純な方法。現在の傾き f(x, y) をそのまま使って Δx 先を予測します。
yn+1 = yn + h · f(xn, yn)

② 修正オイラー法(ホイン法、2次精度)

最初に予測 → その点での傾きを使って補正、と2回評価します。
k1 = f(xn, yn)
k2 = f(xn+h, yn+h·k1)
yn+1 = yn + h · (k1 + k2) / 2

③ ルンゲクッタ法(RK4、4次精度)

区間の「始点・2つの中点・終点」で傾きを4回評価して加重平均する、実用で最もよく使われる方法です。
k1 = f(xn, yn)
k2 = f(xn + h/2, yn + h/2 · k1)
k3 = f(xn + h/2, yn + h/2 · k2)
k4 = f(xn + h, yn + h · k3)
yn+1 = yn + h/6 · (k1 + 2·k2 + 2·k3 + k4)

3手法の比較プログラム

#include <stdio.h>
#include <math.h>

// 解きたい微分方程式 dy/dx = f(x, y)
double f(double x, double y) {
    (void)x;          // この例では x を使わない
    return -y;
}

double euler(double x0, double y0, double x_end, double h) {
    double x = x0, y = y0;
    while (x < x_end) {
        y = y + h * f(x, y);
        x = x + h;
    }
    return y;
}

double heun(double x0, double y0, double x_end, double h) {
    double x = x0, y = y0;
    while (x < x_end) {
        double k1 = f(x,     y);
        double k2 = f(x + h, y + h * k1);
        y = y + h * (k1 + k2) / 2;
        x = x + h;
    }
    return y;
}

double rk4(double x0, double y0, double x_end, double h) {
    double x = x0, y = y0;
    while (x < x_end) {
        double k1 = f(x,         y);
        double k2 = f(x + h/2,   y + h/2 * k1);
        double k3 = f(x + h/2,   y + h/2 * k2);
        double k4 = f(x + h,     y + h * k3);
        y = y + h / 6 * (k1 + 2*k2 + 2*k3 + k4);
        x = x + h;
    }
    return y;
}

int main(void) {
    double exact = exp(-5.0);
    printf("厳密値 y(5) = %.8f\n\n", exact);
    printf("   h    | Euler       | Heun        | RK4\n");
    printf("--------+-------------+-------------+-------------\n");
    double hs[] = {0.5, 0.1, 0.01};
    for (int i = 0; i < 3; i++) {
        double h = hs[i];
        printf(" %5.3f  | %.8f  | %.8f  | %.8f\n",
               h, euler(0, 1, 5, h),
                  heun(0, 1, 5, h),
                  rk4(0, 1, 5, h));
    }
    return 0;
}
$ gcc ode_compare.c -o ode_compare -lm $ ./ode_compare 厳密値 y(5) = 0.00673795 h | Euler | Heun | RK4 --------+-------------+-------------+------------- 0.500 | 0.00097656 | 0.01000214 | 0.00679947 0.100 | 0.00515378 | 0.00681497 | 0.00673797 0.010 | 0.00657202 | 0.00673874 | 0.00673795
精度のオーダー: h を 1/10 にすると、誤差は Euler(1次)で約 1/10、Heun(2次)で約 1/100、RK4(4次)で約 1/10000 になります。RK4 は h=0.1 ですでに 6桁一致しているのに対し、Euler は h=0.01 まで細かくしても2桁しか一致しません。

連立常微分方程式への拡張(バネ振動)

シミュレーション編の放物運動や Lotka-Volterra のように、複数の変数が連動する場合もRK4は使えます。ここではバネ振動 m·x'' = -k·x を1階の2変数方程式に書き換えて解きます。
dx/dt = v, dv/dt = -(k/m)·x
#include <stdio.h>

#define K 1.0
#define M 1.0

// 右辺: dx/dt と dv/dt を返す
void deriv(double x, double v, double *dx, double *dv) {
    *dx = v;
    *dv = -(K / M) * x;
}

void rk4_step(double *x, double *v, double h) {
    double k1x, k1v, k2x, k2v, k3x, k3v, k4x, k4v;
    deriv(*x,              *v,              &k1x, &k1v);
    deriv(*x + h/2*k1x, *v + h/2*k1v, &k2x, &k2v);
    deriv(*x + h/2*k2x, *v + h/2*k2v, &k3x, &k3v);
    deriv(*x + h*k3x,    *v + h*k3v,    &k4x, &k4v);
    *x += h/6 * (k1x + 2*k2x + 2*k3x + k4x);
    *v += h/6 * (k1v + 2*k2v + 2*k3v + k4v);
}

int main(void) {
    double x = 1.0, v = 0.0;   // 初期条件: 振幅1から静止状態で手放す
    double h = 0.05;
    printf("t,x,v\n");
    for (double t = 0; t <= 20; t += h) {
        printf("%.2f,%.4f,%.4f\n", t, x, v);
        rk4_step(&x, &v, h);
    }
    return 0;
}
エネルギー保存の確認: 理論上 E = (1/2)·k·x² + (1/2)·m·v² は時間に依らず一定です。オイラー法で解くと数値誤差でエネルギーが発散しますが、RK4 なら長時間でもほぼ保存されます。これが RK4 が広く使われる理由の1つ。
関連ページ
シミュレーションを作る で扱った放物運動・SIR・Lotka-Volterra をRK4に置き換えると精度が大きく上がります。同じ h でも RK4 の方がステップ数を減らせて速くなることも多い。

チャレンジ問題

ここまでの知識を応用して、以下の問題に挑戦してみましょう。
C1. ニュートン法で立方根を求める
x³ = a となる x を求めよ。f(x) = x³ - a, f'(x) = 3x² でニュートン法を適用する。a=27 のとき x=3 になることを確認せよ。
C2. モンテカルロ法で定積分を求める
∫₀¹ e-x² dx をモンテカルロ法で計算せよ。(ガウス積分の一部。解析解は求められない。)
ヒント: 0〜1の乱数 x に対し f(x) = e-x² を計算し、その平均値が積分の近似値。
C3. 度数分布表とヒストグラム
100個の乱数(0〜1)を生成し、0.1刻みの度数分布を計算してテキストベースのヒストグラムを表示するプログラムを作成せよ。
C4. 信頼区間の計算
モンテカルロ法で円周率を推定する実験をN回行い、95%信頼区間(x̄ ± 2σ/√N)を求めるプログラムを作成せよ。
C5. 素数列挙(エラトステネスの篩)
N以下の素数を全て列挙するプログラムをエラトステネスの篩で実装せよ。配列を使い、O(N log log N) の計算量で動くことを確認せよ。
C6. 行列の積を関数で実装
n×n の行列 A, B の積 C = AB を計算する関数を作成し、3×3の行列で動作確認せよ。
この記事をシェア
X(Twitter)でシェア はてブ

数値計算をさらに深めるなら

数学×プログラミングの理解を深める書籍

📘
苦しんで覚えるC言語
MMGames 著
初心者向けの定番入門書。
Amazonで見る
📗
新・明解C言語 入門編
柴田望洋 著
図解が豊富で演習問題も充実。
Amazonで見る
📙
プログラミング言語C 第2版
B.W.カーニハン, D.M.リッチー 著
通称K&R。C言語の原典。
Amazonで見る

※ 上記リンクはアフィリエイトリンクです。購入によりサイト運営を支援いただけます。