ログイン新規登録

Qiitaにログインしてダークテーマを使ってみませんか?🌙

ログインするとOSの設定にあわせたテーマカラーを使用できます!

7

【internal_math編②】AtCoder Library 解読 〜Pythonでの実装まで〜

最終更新日 投稿日 2020年10月25日

0. はじめに

2020年9月7日にAtCoder公式のアルゴリズム集 AtCoder Library (ACL)が公開されました。
私はACLに収録されているアルゴリズムのほとんどが初見だったのでいい機会だと思い、アルゴリズムの勉強からPythonでの実装までを行いました。

この記事では internal_math をみていきます。

internal_mathはACLの内部で使われる数論的アルゴリズムの詰め合わせで内容は以下の通りです。

名称 概要
safe_mod 整数 x の正整数 m による剰余(x)。ただし 0x を満たす。
barrett 高速な剰余演算。
pow_mod xn(modm) の計算。
is_prime 高速な素数判定。
inv_gcd 整数 a と正整数 b の最大公約数 g および xag(modb) となる x の計算。ただし 0x<bg を満たす。
primitive_root 素数 m の原始根。
floor_sum_unsigned 0 以上の整数 n,a,b と自然数 m について i=0n1ai+bm の計算。

本記事ではこれらの内、

  • is_prime
  • primitive_root

を扱います。なお、constexpr(定数式)自体については触れません。

本記事で扱わない

  • safe_mod
  • pow_mod
  • inv_gcd
  • barrett

については以下の記事で扱っています。よろしければそちらもご覧ください。

【internal_math編①】AtCoder Library 解読 〜Pythonでの実装まで〜

なお、floor_sum_unsigned は math 内の floor_sum の補助的な役割なので説明は

【math編】AtCoder Library 解読 〜Pythonでの実装まで〜

で行なっています。

対象としている読者

  • ACLの内部で使われている数学系アルゴリズムを知りたい方。
  • ACLのコードを見てみたけど何をしているのかわからない方。
  • C++はわからないのでPythonで読み進めたい方。

参考にしたもの

is_primeに関連するwikipediaのページです。

強い擬素数についての論文です。

ミラー-ラビン素数判定法について書かれている@srtk86さんの記事です。わかりやすいです。

原始根について説明されています。

1. is_prime

正整数 n が素数であるか判定することを考えます。

1.1. 決定的素数判定法

素数の定義は、「正の約数が1と自分自身のみである、1より大きい自然数」ですから、2から n1 までの自然数で n を割って、割り切れなかったら n は素数だといえます。いわゆる試し割りという方法です。Pythonで実装すると以下のようになります。

def deterministicPT(n):
    if n <= 1: return False
    if n == 2: return True
    for i in range(2, n):
        if n % i == 0: return False
    return True


これを使って素数判定を行うと以下のようになります。

for n in range(1, 10):
    if deterministicPT(n):
        print(f'{n}は素数である')
    else:
        print(f'{n}は素数でない')

# 1は素数でない
# 2は素数である
# 3は素数である
# 4は素数でない
# 5は素数である
# 6は素数でない
# 7は素数である
# 8は素数でない
# 9は素数でない

この方法では定義に忠実に確かめたので、n が素数であるか否かを確実に判定することができます。このような方法を決定的素数判定法といいます。
決定的素数判定法は言い換えれば次のような性質を持つテストを行うことです。

  • 素数ならば必ず合格になる
  • 素数でないならば必ず不合格になる

1.2. 確率的素数判定法

「素数である」「素数でない」のどちらかを判定する決定的素数判定法に対して、「素数かもしれない」「素数でない」のどちらかを判定するアルゴリズムを確率的素数判定法と言います。
確率的素数判定法に用いられるテストは次の性質を持っています。

  • 素数ならば必ず合格する
  • 素数でないならば合格不合格のどちらにもなりうる
  • 自然数 a が組み込まれている

そして、このようなテストに合格する自然数を「a の確率的素数」と言います。

具体例を見てみましょう。判定したい自然数 n に対して次のようなテストを考えます。

  • n=1 ならば不合格
  • 2na ならば合格
  • n>a ならば a で割り切れなければ合格、割り切れたら不合格

このテストは上で挙げた3つの性質を満たしているので確率的素数判定法に用いることができます。Pythonで実装すると次のようになるでしょう。

class ProbabilisticPT:
    def __init__(self, a=2):
        self._a = a
    
    def change_base(self, a):
        self._a = a
    
    def test(self, n):
        if n == 1: return False
        if n <= self._a: return True
        if n % self._a != 0:
            return True
        else:
            return False

a=2 の場合に素数判定をしてみましょう。

a = 2
ppt = ProbabilisticPT(a)
for n in range(1, 10):
    if ppt.test(n):
        print(f'{n}は底{a}の確率的素数')
    else:
        print(f'{n}は素数でない')

# 1は素数でない
# 2は底2の確率的素数
# 3は底2の確率的素数
# 4は素数でない
# 5は底2の確率的素数
# 6は素数でない
# 7は底2の確率的素数
# 8は素数でない
# 9は底2の確率的素数

確率的素数判定において「素数でない」という判定は信用できます。なぜならテストの持つ性質の一つ、「素数ならば必ず合格になる」の対偶は「不合格ならば必ず素数でない」だからです。よって4, 6, 8は素数でないことが確定しました。しかし、「確率的素数」と判定された場合、そこからはあまり情報が得られませんので注意が必要です。

確率的素数判定のメリットはやはりその計算速度でしょう。今回の例で言えばたった1回の割り算で「確率的素数」か「素数でない」かが判定できます。しかしあくまでも得られる結果は「素数の可能性あり」であり、本当に素数かはわかりません。実際、合成数である9も確率的素数と判定されています。

1.3. 精度を向上させるために

確率的素数判定法では判定の精度を向上するために複数の底でテストを行います。もしある一つの底で「素数でない」と判定されたならその自然数は素数でないことが確定するので判定精度の向上が期待できます。
実際に底が2, 3, 5の場合に30未満の自然数についてテストを行ってみます。

ppt = ProbabilisticPT()
p = {}
for a in [2, 3, 5]:
    p[a] = set()
    ppt.change_base(a)  # 底をaに設定
    for n in range(1, 30):
        if ppt.test(n):
            p[a].add(n)
for k, v in p.items(): print(f'{k}の確率的素数 : {v}')

# 各底の確率的素数の集合の共通部分を求める
print('全ての底での確率的素数 :', p[2] & p[3] & p[5])



# 底2の確率的素数 : {2, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29}
# 底3の確率的素数 : {2, 3, 4, 5, 7, 8, 10, 11, 13, 14, 16, 17, 19, 20, 22, 23, 25, 26, 28, 29}
# 底5の確率的素数 : {2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 16, 17, 18, 19, 21, 22, 23, 24, 26, 27, 28, 29}
# 全ての底での確率的素数 : {2, 3, 5, 7, 11, 13, 17, 19, 23, 29}

底が2の場合に確率的素数と判定された9は底が3のテストで合成数であることが確定しました。
このように、複数の底を組み合わせることで全ての底での確率的素数が素数である確率を十分な精度まで向上させるのが確率的素数判定法の考え方です。今回用いたテストでは3つの底(2, 3, 5)を組み合わせることで30未満の自然数を100%の精度で素数判定することができました。

1.4. フェルマーテスト

フェルマーの小定理をテストに用いるものをフェルマーテストと言います。
フェルマーの小定理とは次のものです。


p を素数とし、ap の倍数でない整数(ap は互いに素)とするときに、

ap11(modp)

が成り立つ。


つまり、フェルマーテストとは

  • n と互いに素な自然数 a に対し an11(modn) を満たせば合格
  • 満たさなければ不合格

というものです。
フェルマーテストは先ほど用いたテストに比べて非常に強力で、25×109 までの奇数の合成数11,408,012,595個の内、底が2のフェルマーテストに合格できるのは21,853個しかないそうです。(「日本語版Wikipedia-確率的素数」より)

しかし、フェルマーテストにも欠点があります。カーマイケル数と呼ばれる合成数は任意の底のフェルマーテストに合格します。よって、いくらたくさんの底を組み合わせてもフェルマーテストでは誤判定の可能性が無視できない大きさで残ります。

1.5. modの世界における1の平方根

フェルマーテストを改良する準備としてまず、modの世界における1の平方根について考えます。
modの世界における1の平方根とは2以上の自然数 n に対して次の合同式を満たす x のことです。

x21(modn)

明らかに x=1,n1 はこの式を満たすので、これらを自明な平方根と言い、これら以外を非自明な平方根と言います。

非自明な平方根について考えてみましょう。つまり x でも n1 でもない場合を考えます。例えば n=15 のとき、x=4,11 は非自明な平方根となります。

(4)2=161(mod15)(11)2=1211(mod15)

では n が素数の場合はどうでしょうか。実はこのとき非自明な平方根は存在しません。これを背理法で示しましょう。
いま素数 p を法とする非自明な平方根が存在し、これを x とします。つまり、x でも p1 でもなく、

x21(modp)

を満たします。このとき、

x21(modp)x210(modp)(x+1)(x1)0(modp)

となり、p は素数なので (x+1)(x1) の少なくとも一方は p で割り切れます。しかし、x でも p1 でもないので

(x+1)0(modp)(x1)0(modp)

となります。つまり (x+1)(x1) は共に p で割り切れません。よって矛盾が生じたので素数 p を法とする非自明な平方根は存在しないことが示されました。

1.6. 奇素数に対するフェルマーの小定理

判定したい自然数が2の場合は素数であることが自明なので、事前に処理したとします。このとき、素数は必ず奇数になるので、奇素数 p に対するフェルマーの小定理を考えます。いま、p1 は偶数なので自然数 s と奇数 d を用いて

p1=2sd

と表せます。よってフェルマーの小定理は

a2sd1(modp)

となり、これは

(a2s1d)21(modp)

と見ることもできます。前節で示した通り、素数 p を法とする 1 の非自明な平方根は存在しないので、

a2s1d1(modp)a2s1d1(modp)

のどちらかになります。1つ目であった場合は s1>0 であればさらに

(a2s2d)21(modp)

と見ることができ、これもやはり

a2s2d1(modp)a2s2d1(modp)

のどちらかとなります。これを繰り返していくといつかは

ad1(modp)ad1(modp)

となります。
ここまでをまとめると次のようになります。


p が奇素数のとき、自然数 s と奇数 d を用いて

p1=2sd

と書くことができ、以下の p を法とした合同式のうち必ずどれか一つを満たす

a2s1d1a2s2d1a2d1ad1ad1

この合同式は高々 log2p 個程度ですので全てチェックしてしまいましょう

以上より判定したい自然数 n に対するテストはこのようになります。

  • n=1 ならば不合格

  • n=2 ならば合格

  • n3 が偶数ならば不合格

  • n3 が奇数ならば n1=2sd とし、n と互いに素な自然数 a に対し n を法として

    a2s1d1a2s2d1a2d1ad1ad1

    のうち一つでも満たせば合格、一つも満たさなければ不合格

このテストを用いた確率的素数判定法はミラー-ラビン素数判定法と呼ばれています。

1.7. テストの実装法

3以上の奇数 n に対する判定を整理します。
確率的素数判定法は複数の底でテストを行い全てに合格した場合のみ素数であると判定するので、テストの過程では不合格の場合のみを検出すれば良いです。
いま n1=2sd とし、テストの底を a とします。このとき 0r<s となる r について a2rd を計算します。まず、r=0 のとき

ad1or1(modn)

であれば底 a のテストは終了です。
そうでない場合、r=1,2,,s1 について

a2rd1or1(modn)

である限り計算します。また、r についての終了条件 r<s2rd<n1 に対応します。
もし a2rd1(modn) となった場合は底 a のテストは終了です。
しかし、 a2rd1(modn) となった場合はどうでしょうか。このとき、a2r1d1 でも 1 でもないことはすでに確認されているので a2r1d1 の非自明な平方根です。よって n は素数でないので不合格となります。
また、最後まで a2rd1;or;1(modn) であった場合も不合格となります。
以上をまとめると下図のようになります。

is_prime_1.png

1.8. 底の選び方

ミラー-ラビン素数判定法では一般的には底の数を自分で決めて a<n となる自然数 aランダムに選び底とします。計算速度と判定の精度はトレードオフの関係にあるので必要な精度を確保できる中でできるだけ少ない数の底が望ましいです。そのため効率的に精度を上げることができる底の組み合わせが研究されてきました。
ACLでは底として a=2,7,61 を採用しています。Jaeschke(1993)によると、この底のテストを全て通過する最小の合成数は 4759123141;(=4878197561>232) です。よって n<232 の範囲(符号なし4バイト整数の範囲)では 100 の精度で判定できます。

1.9. 実装

それでは実装します。なおACLにおいてpow_modを用いている部分は、同等の機能であるPythonの組み込み関数powで代用しています

def is_prime(n):
    # 自明な部分
    if (n <= 1): return False
    if (n == 2 or n == 7 or n == 61): return True
    if (n % 2 == 0): return False

    d = n - 1  # nは奇数
    while (d % 2 == 0): d //= 2  # 奇数dを求める

    for a in (2, 7, 61):
        t = d  # dは他の底でも使うので保持
        y = pow(a, t, n)

        # a^d = 1, -1ならこのループは入らない
        # a^t = 1, -1となるまで繰り返す
        while (t != n - 1 and y != 1 and y != n - 1):
            y = y * y % n
            t <<= 1
        
        # a^d = 1, -1は通過 (t%2 == 0)
        # a^t = -1は通過 (y != n - 1)
        if (y != n - 1 and t % 2 == 0):
            return False
    return True



print(is_prime(17))  # True
print(is_prime(1000000007))  # True
print(is_prime(121))  # False
print(is_prime(561))  # False (561はカーマイケル数のひとつ)

# 底{2, 7, 61}のテストを通過する最小の合成数
print(is_prime(4759123141))  # True

1.10. 計算速度の比較

時間計算量 O(n) の決定的素数判定法と比較してみます。
比較に使用したコードは以下のものです。

import random

# ミラー-ラビン素数判定法(確率的素数判定法)
def pro_is_prime(n):
    if (n <= 1): return False
    if (n == 2 or n == 7 or n == 61): return True
    if (n % 2 == 0): return False
    d = n - 1
    while (d % 2 == 0): d //= 2
    for a in (2, 7, 61):
        t = d
        y = pow(a, t, n)
        while (t != n - 1 and y != 1 and y != n - 1):
            y = y * y % n
            t <<= 1
        if (y != n - 1 and t % 2 == 0):
            return False
    return True

# 決定的素数判定法
def det_is_prime(n):
    if n < 2: return False
    if n == 2: return True
    if n % 2 == 0: return False
    for i in range(3, int(n ** 0.5) + 1):
        if n % i == 0: return False
    return True


def random_1():
    l1, r1 = [3, 1 << 16]
    return random.randrange(l1, r1, 2)

def random_2():
    l2, r2 = [(1 << 16) + 1, 1 << 32]
    return random.randrange(l2, r2, 2)

def random_3():
    l3, r3 = [3, 1 << 32]
    return random.randrange(l3, r3, 2)


def main():
    """
    seed_list = [111, 222, 333, 444, 555, \
                 666, 777, 888, 999, 101010]
    """
    random.seed(111)  # 乱数固定
    loop = 10000  # ループ回数 10^4 or 10^6

    for _ in range(loop):
        n = random_1()
        #n = random_2()
        #n = random_3()

        pro_is_prime(n)  # 確率的
        #det_is_prime(n)  # 決定的
    

if __name__ == "__main__":
    main()

計測方法

AtCoderのコードテスト(Python 3.8.2)を使用しました。
10種類のシード値で3種類の範囲から乱数を生成し、素数判定10000回あたりの実行時間を調べました。
偶数の場合はどちらも最初に処理されるので奇数のみとしました。

  • random_1
    • 乱数の範囲 : [3,216] の奇数
    • ループ回数 : 104,106 (104 ではimportなどのオーバーヘッドがメインになる可能性があるため)
  • random_2
    • 乱数の範囲 : [216,232] の奇数
    • ループ回数 : 104
  • random_3
    • 乱数の範囲 : [3,232] の奇数
    • ループ回数 : 104

測定結果

結果は下図のようになりました。数値は素数判定10000回あたりの時間で単位はmsです。

ミラー-ラビン 決定的素数判定法
random_1(10^4) 63 59
random_1(10^6) 34 30
random_2 100 4060
random_3 99 4113

判定したい数が 105 程度の小さな数であれば決定的素数判定法の方がわずかに速いようです。
しかし、数が大きくなるとミラー-ラビン素数判定法の優位性が顕著になります。

2. primitive_root

素数 m を法とする原始根のうち最小のものを求めます。

2.1. 位数

最初に、原始根を説明する上で重要な用語である「位数」についてみていきます。定義はこうです。


2以上の自然数 m および m と互いに素な整数 a に対して

an1(modm)

となる最小の自然数 nm を法とする a の位数という。


具体例をみてみましょう。
m=5,a=3 のとき、

31=31(mod5)32=91(mod5)33=271(mod5)34=811(mod5)

なので 5 を法とする 3 の位数は 4 です。
また、m=12,a=5 のとき、

51=21(mod12)52=251(mod12)

なので 12 を法とする 5 の位数は 2 です。

位数の性質

m を法として m と互いに素な整数 a の位数 e は正整数 n に対して次の性質を持ちます。


an1(modm)en

(証明)
⇐:
en の約数なので自然数 k を用いて n=ke とかける。よって m を法として

an=ake=(ae)k1

となり、成立。
⇒:
n を非負整数 q,r を用いて n=qe+r;(0r<e) と表す。このとき m を法として

ar(ae)qaraqe+r1

となる。もし 0<r<e だとすると、e が位数であることと矛盾する(位数は ae1(modm) を満たす最小の整数)。よって r=0 であり、すなわち en の約数となる。

(証明終)

特に m が素数の場合、フェルマーの小定理より位数は m1 の約数となります。

2.2. 原始根

m が素数の場合を考えます。フェルマーの小定理から m を法として m と互いに素な整数 a の位数は必ず m1 以下になります。そこで、位数がちょうど m1 となる a を特別視し、これを m を法とする原始根と呼びます。

例えば、m=7, a=3 のとき

31=31(mod7)32=91(mod7)33=271(mod7)34=811(mod7)35=2431(mod7)36=7291(mod7)

なので 7 を法とする 3 の位数は 6 です。よって 37 を法とする原始根です。
原始根は1つとは限りません。位数のところで示した例の1つ目から、35 を法とする原始根であることがわかります。一方、a=2 のとき

21=21(mod5)22=41(mod5)23=81(mod5)24=161(mod5)

となるので 2 もまた 5 を法とする原始根です。

なお、m が素数のとき原始根は必ず存在します。
証明は「原始根定理」で調べてみてください。

原始根の性質

素数 m を法とする原始根 g は次の性質を持ちます.


m を法とした g のべき乗の集合

{g(modm),g2(modm),,gm1(modm)}

と整数を m で割ったあまりの集合から 0 を除いた集合

{1,2,,m1}

は一致する。


これは次のように言い換えられます。
1km1 の自然数 k について

  • gk0
  • gk はすべて異なる。

1つ目は gm が互いに素であることから明らかです。
よって2つ目を証明します。

(証明)
いま、k<lm1 なる自然数 k,l が存在し、gkgl(modm) であったとします。このとき

glgk0(modm)gk(glk1)0(modm)glk10(modm)glk1(modm)

となります。lk<m1 なので原始根である g の位数は m1 であることと矛盾します。
したがって、1km1 に対し、gk はすべて異なります。
(証明終)

2.3. 原始根であるかの確認方法1

ある2以上の自然数 gm を法とする原始根であるかどうかを判定するためには、原始根の定義から m を法として g,g2,,gm2 がすべて 1 と合同でないことを確かめれば良いです。したがって、最小の原始根を求めるアルゴリズムの実装は以下のようになります。

def naive_primitive_root(m):
    g = 2
    while True:
        for i in range(1, m - 1):
            if pow(g, i, m) == 1:
                g += 1
                break
        else:
            return g


print(naive_primitive_root(7))  # 3
print(naive_primitive_root(23))  # 5
#print(naive_primitive_root(1000000007))  # 非常に時間がかかります

この方法では gm2 までのべき乗をすべて調べるので m が大きくなると非常に時間がかかります。

2.4. 原始根であるかの確認方法2

実は原始根の性質を用いることでより簡単に原始根であるかの確認をすることができます。用いる性質は次のものです。


素数 pi および自然数 ei を用いて 3 以上の素数 m に対して m1

m1=i=1npiei

と素因数分解されたとき、2 以上の自然数 g について次のことが成り立つ。

gm1ingm1pi1(modm)

(証明)
⇒:
pim1 の素因数なので m1pi は整数であり、1m1pi<p1 を満たす。g が原始根のとき gp1 が初めて 1 と合同になるべき乗なので 1in;;gm1pi1(modm) となる。

⇐:
この主張の対偶は

ggm1pi1(modm)i

なのでこれを示す。
いま g は原始根でないので位数 em1 未満である。位数の性質で示した通り、em1 の約数なので m1 の素因数と同じ pi を用いて

e=j=1npjej(ejej)

と素因数分解でき、とくに ej<ej となる j が少なくとも1つ存在する。このような j のうち1つを i とする。このとき

m1pi=1pijpjej=1pi(jpjejej)(jpjej)=pieiei1(jipjejej)(jpjej)

ここで、eiei10 かつ ejej0 なので

m1pi=()×e

となる。
したがって、g が原始根でないならば

gm1pi(ge) 1(modm)

となる i が存在する。

(証明終)

以上より

  1. m1 の素因数を求める。
  2. 各素因数 pi について gm1pi1(modm) となるか調べる。

という手順で g が原始根であるかを判定できます。

2.5. 実装

それでは実装します。is_primeと同様にpow_modは組み込み関数powで代用します。

# @param m must be prime
def primitive_root(m):
    if m == 2: return 1
    if m == 167772161: return 3
    if m == 469762049: return 3
    if m == 754974721: return 11
    if m == 998244353: return 3

    # m-1の素因数抽出
    divs = [2]
    x = (m - 1) // 2
    while x % 2 == 0: x //= 2
    i = 3
    while i * i <= x:
        if x % i == 0:
            divs.append(i)
            while x % i == 0: x //= i
        i += 2
    if x > 1: divs.append(x)

    # 全ての素因数で1と合同でない最小のgを探す
    g = 2
    while True:
        if all(pow(g, (m - 1) // div, m) != 1 for div in divs): return g
        g += 1


print(primitive_root(7))  # 3
print(primitive_root(23))  # 5
print(primitive_root(1000000007))  # 5

最初に判定しているいくつかの m はconvolutionで使われるmodのようです。

3. おわりに

今回は素数判定法と原始根について見てきました。とくに確率的素数判定法の考え方が面白いと感じました。

internal_mathのなかで今回触れなかったものについてはinternal_math編①で書いていますので、よろしければそちらもご覧ください。

説明の間違いやバグ、アドバイス等ありましたらお知らせください。

新規登録して、もっと便利にQiitaを使ってみよう

  1. あなたにマッチした記事をお届けします
  2. 便利な情報をあとで効率的に読み返せます
  3. ダークテーマを利用できます
ログインすると使える機能について

コメント

この記事にコメントはありません。

いいね以上の気持ちはコメントで

7

新規登録して、Qiitaをもっと便利に使ってみませんか

この機能を利用するにはログインする必要があります。ログインするとさらに下記の機能が使えます。

  1. ユーザーやタグのフォロー機能であなたにマッチした記事をお届け
  2. ストック機能で便利な情報を後から効率的に読み返せる

ソーシャルアカウントでログイン・新規登録

メールアドレスでログイン・新規登録