プログラミングにおいて、ある整数が素数であるかどうかを判定する「素数判定」は、アルゴリズム学習の初期段階から競技プログラミング、さらには公開鍵暗号(RSA暗号など)の実装に至るまで、非常に頻繁に登場する重要なトピックです。

特にC++は実行速度が速いため、効率的なアルゴリズムを選択することで、非常に大きな数値に対しても高速に判定を行うことが可能です。

本記事では、初心者がまず習得すべきO(√n)の「試し割り法」から、大量の数値を一括で処理する「エラトステネスの篩」、そして巨大な数値に対応するための「ミラー・ラビン素数判定法」まで、C++での具体的な実装コードを交えて詳しく解説します。

素数判定の基本:試し割り法(Trial Division)

素数とは、1とその数自身以外に正の約数を持たない2以上の自然数のことを指します。

最も直感的な判定方法は、2からその数 $n-1$ まで順番に割り切れるかを確認することですが、この方法では $n$ が大きくなると処理時間が膨大になってしまいます。

O(√n) での判定ロジックとその数学的根拠

素数判定を劇的に高速化する第一歩は、「調べる範囲を $\sqrt{n}$ までに制限する」ことです。

もしある整数 $n$ が合成数であれば、$n = a \times b$ という形で表現できます。

このとき、$a$ と $b$ の両方が $\sqrt{n}$ より大きいということはあり得ません。

なぜなら、もし $a > \sqrt{n}$ かつ $b > \sqrt{n}$ であれば、$a \times b > n$ となって矛盾してしまうからです。

したがって、$\sqrt{n}$ 以下の範囲に約数が存在しなければ、それ以上の範囲にも約数は存在しないことが数学的に証明されます。

この性質を利用することで、計算量を $O(n)$ から $O(\sqrt{n})$ へと大幅に削減できます。

C++による試し割り法の実装

以下に、C++での標準的な素数判定関数の実装を示します。

2と3を例外として先に処理し、それ以降は偶数を飛ばすことで、さらに定数倍の高速化を図っています。

C++
#include <iostream>

/**
 @brief 素数判定を行う関数 (O(√n))
 @param n 判定対象の整数
 @return 素数であればtrue、そうでなければfalse
 */
bool is_prime(long long n) {
    // 1以下は素数ではない
    if (n <= 1) return false;
    // 2と3は素数
    if (n <= 3) return true;
    // 2または3で割り切れる偶数・倍数は素数ではない
    if (n % 2 == 0 || n % 3 == 0) return false;

    // √nまでループを回す
    // i * i <= n とすることで sqrt() 関数の呼び出しを避ける
    for (long long i = 5; i * i <= n; i += 6) {
        // 6k ± 1 の形以外の数値は既にチェック済みの2や3の倍数
        if (n % i == 0 || n % (i + 2) == 0) return false;
    }

    return true;
}

int main() {
    long long numbers[] = {2, 17, 27, 1000000007, 1000000009};

    for (long long n : numbers) {
        if (is_prime(n)) {
            std::cout << n << " is a prime number." << std::endl;
        } else {
            std::cout << n << " is not a prime number." << std::endl;
        }
    }

    return 0;
}
実行結果
2 is a prime number.
17 is a prime number.
27 is not a prime number.
1000000007 is a prime number.
1000000009 is a prime number.

この実装では、i * i <= n という条件式を用いることで、浮動小数点演算である sqrt() を使用せずに判定を行っています。

また、i += 6 のループは「6k ± 1」のルール(すべての素数は2と3を除き $6k \pm 1$ の形で表されるという性質)を利用した高度な試し割り法です。

複数の数値を一括判定する:エラトステネスの篩

単一の数値ではなく、「1からNまでの範囲にある素数をすべて列挙したい」という場合には、個別に試し割り法を適用するのは非効率です。

この場合に最適なアルゴリズムが、紀元前より知られる「エラトステネスの篩(ふるい)」です。

アルゴリズムの仕組みと計算量

エラトステネスの篩は、以下の手順で素数を抽出します。

  1. 2から $N$ までの整数を並べる。
  2. まだ消されていない最小の数(最初は2)を素数として確定させる。
  3. その数(素数)の倍数をすべて消していく。
  4. 次に消されていない最小の数を見つけ、手順2に戻る。
  5. これを $\sqrt{N}$ まで繰り返す。

このアルゴリズムの計算量は $O(N \log \log N)$ です。

これは実用的にはほぼ $O(N)$ に近い非常に高速なアルゴリズムであり、競技プログラミング等では必須のテクニックとなります。

C++によるエラトステネスの篩の実装

C++で実装する場合、std::vector<bool>std::bitset を使用してメモリ効率を高めるのが一般的です。

C++
#include <iostream>
#include <vector>

/**
 @brief エラトステネスの篩を用いてN以下の素数を列挙する
 @param n 最大値
 @return 素数判定結果の配列 (is_prime[i] が true ならば i は素数)
 */
std::vector<bool> sieve_of_eratosthenes(int n) {
    std::vector<bool> is_prime(n + 1, true);
    is_prime[0] = is_prime[1] = false; // 0と1は素数ではない

    for (int p = 2; p * p <= n; p++) {
        // pが素数の場合、その倍数をすべて偽にする
        if (is_prime[p]) {
            for (int i = p * p; i <= n; i += p)
                is_prime[i] = false;
        }
    }
    return is_prime;
}

int main() {
    int limit = 50;
    std::vector<bool> primes = sieve_of_eratosthenes(limit);

    std::cout << "Primes up to " << limit << ":" << std::endl;
    for (int i = 2; i <= limit; i++) {
        if (primes[i]) {
            std::cout << i << " ";
        }
    }
    std::cout << std::endl;

    return 0;
}
実行結果
Primes up to 50:
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47

実装のポイント:std::vector<bool> とメモリ最適化

C++において std::vector<bool> は、1要素を1ビットで保持するように特殊化されており、メモリ消費量を抑えることができます。

しかし、非常に巨大な範囲(例:$10^8$ 以上)を扱う場合は、std::bitset を使用するか、メモリを節約するための「セグメント木を用いた篩(Segmented Sieve)」の検討が必要になります。

また、あらかじめ素数のリストを作成しておくことで、「素因数分解」を高速化するといった応用も可能です。

さらに巨大な数値への対応:ミラー・ラビン素数判定法

試し割り法は $O(\sqrt{n})$ であるため、$n$ が $10^{18}$ を超えるような巨大な数値(long long の限界に近い値)になると、実行時間が数秒に達し、実用的ではなくなります。

このような場合に用いられるのが、確率的アルゴリズムである「ミラー・ラビン(Miller-Rabin)素数判定法」です。

ミラー・ラビン判定法の概要

ミラー・ラビン素数判定法は、「フェルマーの小定理」を拡張した判定法です。

判定にはいくつかの「底(base)」を用います。

「確率的」とは言っても、判定に使用する底を適切に選ぶことで、64ビット整数の範囲内であれば決定論的(100%の精度)に判定できることが知られています。

計算量は $O(k \log^3 n)$ ($k$ は試行回数)であり、試し割り法に比べて圧倒的に高速です。

C++によるミラー・ラビン判定法の実装

巨大な数の計算では、積の計算時にオーバーフローを起こさないよう、__int128_t(GCC拡張)を使用するのが一般的です。

C++
#include <iostream>
#include <vector>

typedef __int128_t int128;

// 冪剰余計算 (base^exp % mod)
long long power(long long base, long long exp, long long mod) {
    long long res = 1;
    base %= mod;
    while (exp > 0) {
        if (exp % 2 == 1) res = (long long)((int128)res * base % mod);
        base = (long long)((int128)base * base % mod);
        exp /= 2;
    }
    return res;
}

/**
 @brief ミラー・ラビン素数判定法
 @param n 判定対象
 @return 素数であればtrue
 */
bool is_prime_miller_rabin(long long n) {
    if (n <= 1) return false;
    if (n == 2 || n == 3) return true;
    if (n % 2 == 0) return false;

    long long d = n - 1;
    int s = 0;
    while (d % 2 == 0) {
        d /= 2;
        s++;
    }

    // 64ビット整数の範囲で決定論的に判定するために必要な底
    std::vector<long long> bases = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37};

    for (long long a : bases) {
        if (n <= a) break;
        long long x = power(a, d, n);
        if (x == 1 || x == n - 1) continue;
        
        bool composite = true;
        for (int r = 1; r < s; r++) {
            x = (long long)((int128)x * x % n);
            if (x == n - 1) {
                composite = false;
                break;
            }
        }
        if (composite) return false;
    }
    return true;
}

int main() {
    long long large_prime = 9223372036854775783LL; // 巨大な素数
    if (is_prime_miller_rabin(large_prime)) {
        std::cout << large_prime << " is a prime number." << std::endl;
    } else {
        std::cout << large_prime << " is not a prime number." << std::endl;
    }
    return 0;
}
実行結果
9223372036854775783 is a prime number.

このように、ミラー・ラビン法を用いることで、通常の試し割りでは不可能な $10^{18}$ 規模の数値に対しても瞬時に判定を下すことができます。

パフォーマンス比較と使い分け

状況に応じて最適な手法を選択することが、優れたC++プログラムを書く鍵となります。

以下の表に各手法の特徴をまとめました。

手法計算量主な用途特徴
試し割り法$O(\sqrt{n})$単一の小~中規模の数実装が最も簡単で直感的。
エラトステネスの篩$O(N \log \log N)$範囲内の全素数列挙多数のクエリに答える際に最強。メモリを消費する。
ミラー・ラビン法$O(k \log^3 n)$巨大な数 ($10^{12} \sim 10^{18}$)非常に高速だが実装が複雑。巨大数判定の標準。

実践的な使い分けのガイドライン

  1. 数値が $10^{12}$ 未満かつ判定が1回のみの場合、標準的な試し割り法($O(\sqrt{n})$)が最適です。コードが短く、バグが混入しにくいためです。
  2. 数値が $10^7$ 程度の範囲で何度も判定が必要な場合、プログラムの初期化時にエラトステネスの篩を実行し、配列に結果を格納しておくべきです。
  3. 数値が $10^{12}$ を超える巨大な数、あるいは競技プログラミングの難問などで計算時間が極めてシビアな場合は、ミラー・ラビン法を採用します。

高度な最適化:モダンC++での素数判定

最新のC++(C++20以降)では、constexpr を活用することで、コンパイル時に素数判定を完了させることも可能です。

これにより、実行時のオーバーヘッドをゼロにできます。

C++
#include <iostream>

// コンパイル時に評価可能な素数判定関数
constexpr bool is_prime_constexpr(long long n) {
    if (n <= 1) return false;
    if (n <= 3) return true;
    if (n % 2 == 0 || n % 3 == 0) return false;
    for (long long i = 5; i * i <= n; i += 6) {
        if (n % i == 0 || n % (i + 2) == 0) return false;
    }
    return true;
}

int main() {
    // コンパイル時定数として結果を保持
    constexpr bool result = is_prime_constexpr(1000000007);
    
    if constexpr (result) {
        std::cout << "1000000007 is prime (checked at compile time)." << std::endl;
    }

    return 0;
}

この手法は、あらかじめ決まった定数が素数かどうかを確認したい場合に有効です。

バイナリの中に既に「真」という結果が埋め込まれるため、実行時の計算時間は 完全にゼロ になります。

まとめ

C++での素数判定は、単純な試し割りから高度な確率的アルゴリズム、さらにはコンパイル時計算まで多岐にわたるアプローチが存在します。

  • 基本は $O(\sqrt{n})$:ループの終端を平方根にすることで、実用的な速度が得られます。
  • 大量処理はエラトステネスの篩:範囲内の素数を一括で求める際に最も効率的です。
  • 巨大数はミラー・ラビン法:64ビット整数の限界に挑む場合はこのアルゴリズムが必須です。
  • モダンC++の活用constexpr を使うことで、実行速度を極限まで追求できます。

これらの手法を理解し、対象となる数値の大きさや用途に合わせて適切に使い分けることで、効率的で堅牢なC++プログラムを構築できるようになります。

まずは基本の試し割り法を完璧にマスターし、必要に応じて篩やミラー・ラビン法へとステップアップしていきましょう。