0. はじめに
2020年9月7日にAtCoder公式のアルゴリズム集 AtCoder Library (ACL)が公開されました。
私はACLに収録されているアルゴリズムのほとんどが初見だったのでいい機会だと思い、アルゴリズムの勉強からPythonでの実装までを行いました。
この記事では internal_math をみていきます。
internal_mathはACLの内部で使われる数論的アルゴリズムの詰め合わせで内容は以下の通りです。
名称 | 概要 |
---|---|
safe_mod | 整数 |
barrett | 高速な剰余演算。 |
pow_mod | |
is_prime | 高速な素数判定。 |
inv_gcd | 整数 |
primitive_root | 素数 |
floor_sum_unsigned |
本記事ではこれらの内、
- 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
正整数
1.1. 決定的素数判定法
素数の定義は、「正の約数が1と自分自身のみである、1より大きい自然数」ですから、2から
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は素数でない
この方法では定義に忠実に確かめたので、
決定的素数判定法は言い換えれば次のような性質を持つテストを行うことです。
- 素数ならば必ず合格になる
- 素数でないならば必ず不合格になる
1.2. 確率的素数判定法
「素数である」「素数でない」のどちらかを判定する決定的素数判定法に対して、「素数かもしれない」「素数でない」のどちらかを判定するアルゴリズムを確率的素数判定法と言います。
確率的素数判定法に用いられるテストは次の性質を持っています。
- 素数ならば必ず合格する
- 素数でないならば合格不合格のどちらにもなりうる
- 自然数
が組み込まれている
そして、このようなテストに合格する自然数を「底
具体例を見てみましょう。判定したい自然数
ならば不合格 ならば合格 ならば で割り切れなければ合格、割り切れたら不合格
このテストは上で挙げた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
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. フェルマーテスト
フェルマーの小定理をテストに用いるものをフェルマーテストと言います。
フェルマーの小定理とは次のものです。
が成り立つ。
つまり、フェルマーテストとは
と互いに素な自然数 に対し を満たせば合格- 満たさなければ不合格
というものです。
フェルマーテストは先ほど用いたテストに比べて非常に強力で、
しかし、フェルマーテストにも欠点があります。カーマイケル数と呼ばれる合成数は任意の底のフェルマーテストに合格します。よって、いくらたくさんの底を組み合わせてもフェルマーテストでは誤判定の可能性が無視できない大きさで残ります。
1.5. modの世界における1の平方根
フェルマーテストを改良する準備としてまず、modの世界における1の平方根について考えます。
modの世界における1の平方根とは2以上の自然数
明らかに
非自明な平方根について考えてみましょう。つまり
では
いま素数
を満たします。このとき、
となり、
となります。つまり
1.6. 奇素数に対するフェルマーの小定理
判定したい自然数が2の場合は素数であることが自明なので、事前に処理したとします。このとき、素数は必ず奇数になるので、奇素数
と表せます。よってフェルマーの小定理は
となり、これは
と見ることもできます。前節で示した通り、素数
のどちらかになります。1つ目であった場合は
と見ることができ、これもやはり
のどちらかとなります。これを繰り返していくといつかは
となります。
ここまでをまとめると次のようになります。
と書くことができ、以下の
この合同式は高々
以上より判定したい自然数
-
ならば不合格 -
ならば合格 -
が偶数ならば不合格 -
が奇数ならば とし、 と互いに素な自然数 に対し を法としてのうち一つでも満たせば合格、一つも満たさなければ不合格
このテストを用いた確率的素数判定法はミラー-ラビン素数判定法と呼ばれています。
1.7. テストの実装法
3以上の奇数
確率的素数判定法は複数の底でテストを行い全てに合格した場合のみ素数であると判定するので、テストの過程では不合格の場合のみを検出すれば良いです。
いま
であれば底
そうでない場合、
である限り計算します。また、
もし
しかし、
また、最後まで
以上をまとめると下図のようになります。
1.8. 底の選び方
ミラー-ラビン素数判定法では一般的には底の数を自分で決めて
ACLでは底として
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. 計算速度の比較
時間計算量
比較に使用したコードは以下のものです。
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
- 乱数の範囲 :
の奇数 - ループ回数 :
( ではimportなどのオーバーヘッドがメインになる可能性があるため)
- 乱数の範囲 :
- random_2
- 乱数の範囲 :
の奇数 - ループ回数 :
- 乱数の範囲 :
- random_3
- 乱数の範囲 :
の奇数 - ループ回数 :
- 乱数の範囲 :
測定結果
結果は下図のようになりました。数値は素数判定10000回あたりの時間で単位はmsです。
ミラー-ラビン | 決定的素数判定法 | |
---|---|---|
random_1(10^4) | 63 | 59 |
random_1(10^6) | 34 | 30 |
random_2 | 100 | 4060 |
random_3 | 99 | 4113 |
判定したい数が
しかし、数が大きくなるとミラー-ラビン素数判定法の優位性が顕著になります。
2. primitive_root
素数
2.1. 位数
最初に、原始根を説明する上で重要な用語である「位数」についてみていきます。定義はこうです。
2以上の自然数
となる最小の自然数
具体例をみてみましょう。
なので
また、
なので
位数の性質
(証明)
となり、成立。
となる。もし
(証明終)
特に
2.2. 原始根
例えば、
なので
原始根は1つとは限りません。位数のところで示した例の1つ目から、
となるので
なお、
証明は「原始根定理」で調べてみてください。
原始根の性質
素数
と整数を
は一致する。
これは次のように言い換えられます。
はすべて異なる。
1つ目は
よって2つ目を証明します。
(証明)
いま、
となります。
したがって、
(証明終)
2.3. 原始根であるかの確認方法1
ある2以上の自然数
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)) # 非常に時間がかかります
この方法では
2.4. 原始根であるかの確認方法2
実は原始根の性質を用いることでより簡単に原始根であるかの確認をすることができます。用いる性質は次のものです。
素数
と素因数分解されたとき、
(証明)
この主張の対偶は
なのでこれを示す。
いま
と素因数分解でき、とくに
ここで、
となる。
したがって、
となる
(証明終)
以上より
の素因数を求める。- 各素因数
について となるか調べる。
という手順で
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
最初に判定しているいくつかの
3. おわりに
今回は素数判定法と原始根について見てきました。とくに確率的素数判定法の考え方が面白いと感じました。
internal_mathのなかで今回触れなかったものについてはinternal_math編①で書いていますので、よろしければそちらもご覧ください。
説明の間違いやバグ、アドバイス等ありましたらお知らせください。
コメント
いいね以上の気持ちはコメントで