programming

エラトステネスの篩とMeissel-Lehmer Algorithm

整数問題の実力をつけるため、Meissel-Lehmer Algorithmを紐解いていく

エラトステネスの篩

競プロのために、いろいろなアルゴリズムを自力で実装してストックすることを始めた。 今のところ、以下をライブラリとして Git に push している。

  • Next Permutation
  • Union Find Tree
  • Binary Search:二分探索
  • Cumulative Sum:累積和
  • Eratosthenes Seive:エラトステネスの篩

今日はエラトステネスの篩を実装していたが、思ったように速度が出ない。Python の遅さも相まって、コンテストで実用に耐えうるのはせいぜいN=107N=10^7といったところである。(手元の環境でも AtCoder のコードテストでも 700ms 程度かかる)

素数の全列挙が必要なケースでは、エラトステネスの篩で戦わざるを得ないが、単純に素数の個数を求めるだけであれば、より優れたアルゴリズムがあるようなので、調べていた。これがかなり難しく、なかなか理解が追いつかないので、思考過程も含めてここにアウトプットしておく。

Meissel-Lehmer Algorithm

なぜ高速化を志したかというと、Library-Checker での入力がN=1011N=10^{11}で、話にならんレベルで高速化が必要だからである。

「こんなん C++専用すぎて Python じゃ通せないだろ」と思って AC 例をあさっていたら、なんとちゃんと AC している人がいる。 強い。これは強い。アルゴリズムの可能性をひしひしと感じた。

幸いにも使用しているアルゴリズムを引用元を記載のうえコードを提出している神のような Submission があったので、勢いいさんで調べてみたもののゴリゴリの整数論でノックアウト寸前である。

それが今回の Meissel-Lehmer Algorithm ということなのだが、ちょっと書ききれるかわからない。というか理解できるかもわからないのが現状である。

現時点でわかっていること

まず、前提となるエラトステネスの篩は、対象となる整数列を小さい方から順番に見ていって、2 の倍数、3 の倍数、5 の倍数……という調子に数を消していくアルゴリズムである。 順番に消していけば、nnの倍数を消すステップに遷移した際に、必ずnnは素数である。なぜなら、ある素数ppを考えた際に、pp以下でかつppと互いに素な数はp1p-1までのすべての整数であるからだ。それまでにふるい落とされていないということは、nnn1n-1までのすべての数と互いに素である。

ここで、ある自然数xxに対してxx以下かつxxと互いに素な自然数の個数をφ(x)\varphi(x)とする。これをオイラーのファイ関数あるいはトーシェント関数などと一般的に呼ぶ。

ppを素数とすると、φ(pk)=pkpk1=pk(11p)\varphi(p^k)=p^k-p^{k-1}=p^k(1-\frac{1}{p})という性質や、互いに素な数m,nm,nに対して、φ(mn)=φ(m)φ(n)\varphi(mn)=\varphi(m)\varphi(n)という性質を持つ。

前者は、pkp^kの素因数がppのみであることを考えれば、pkp^kの素因数でないものはppの倍数に限定される。それは当然、pkp^kppで割れば求まるため、導出できる。

後者は、乗法性と呼ばれるがこれが少し難しい。まず、m×nm\times{n}の表を作って考える。

m/nm/n112233\cdotstt\cdotsmm
11112233\cdotstt\cdotsmm
22m+1m+1m+2m+2m+3m+3\cdotsm+tm+t\cdots2m2m
\cdots\cdots\cdots\cdots\cdots\cdots\cdots\cdots
n1n-1(n2)m+1(n-2)m+1(n2)m+2(n-2)m+2(n2)m+3(n-2)m+3\cdots(n2)m+t(n-2)m+t\cdots(n1)m(n-1)m
nn(n1)m+1(n-1)m+1(n1)m+2(n-1)m+2(n1)m+3(n-1)m+3\cdots(n1)m+t(n-1)m+t\cdotsnmnm

ttmmと互いに素な数だとする。 φ\varphi関数の定義から、1 行あたりttとなりうる数はφ(n)\varphi(n)個ある。このとき、ttで構成される列はnnの完全剰余系であり、そこに含まれるnnと互いに素である数の個数はφ(n)\varphi(n)となる。

したがって、各t1,t2,,tφ(m)t_1,t_2,\cdots,t_{\varphi(m)}に対して、φ(n)\varphi(n)個ずつm,nm,nと互いに素となる数があるので乗法性が成り立っている。

完全剰余系における互いに素である数の個数については要補足。今後の予定。

Prime counting function

xx以下に含まれる素数の個数を返す関数を素数計数関数、またはπ\pi関数という。 π(10)=4\pi(10)=4のように表す。1010以下の素数は、2,3,5,72,3,5,744つである。

Partial seive function

ここでπ(x)\pi(x)は、既往のエラトステネスの篩を用いれば求めることが可能であるが、π(x)\pi(x)をもっと計算負荷の低い何か別の関数への操作で得ることができれば、計算量を落とすことが可能である。

そこで、部分ふるい関数(partial seive function)という概念を導入する。 これは、ϕ(x,a):=([1,x]N)\j=1apjZ\displaystyle\phi(x,a):=\left|([1,x]\cap\mathbb{N})\backslash\bigcup_{j=1}^{a}p_j\mathbb{Z}\right|で定義される集合である。

式だけ書くと意味不明なので、言葉で解釈すると閉区間[1,x][1,x]にある自然数を、aa番目までの素数でふるった残りの数である。

ここで、aa番目の素数とは、{p1,p2,p3}={2,3,5}\{p_1,p_2,p_3\cdots\}=\{2,3,5\cdots\}というように22から小さい順に素数にインデックスを割り付けたときの番号を指す。

kkth partial seive function

さらに、これを計算するためにkk次の部分ふるい関数を定義する。

これは、Pk(x,a)P_k(x,a)で表し、ϕ(x,a)\phi(x,a)のうち、素因数をちょうどkk個だけ含む数の集合を表す。

kk次部分ふるい関数の具体例

例として、ϕ(10,1)\phi(10,1)を考えてみる。

1010以下の素数は2,3,5,72,3,5,7であり、a=1a=1であるので、11番目の素数22でふるった残りとなることが分かる。

したがって、以下となる。

ϕ(10,1)={1,2,3,4,5,6,7,8,9,10}={1,3,5,7,9}=5\begin{aligned} \phi(10,1)=\{1,\cancel2,3,\cancel4,5,\cancel6,7,\cancel8,9,\cancel{10}\}\\ =\{1,3,5,7,9\}\\ =5 \end{aligned}

さらに、このうち素因数を11つも含まないものを部分ふるい関数で表すとP0(10,1)={1,3,5,7,9}=1P_0(10,1)=\{1,\cancel3,\cancel5,\cancel7,\cancel9\}=1となる。

また、素因数をただ11つ含むもの、すなわち素数それ自身は、P1(10,1)={1,3,5,7,9}=3P_1(10,1)=\{\cancel1,3,5,7,\cancel9\}=3である。

部分ふるい関数とkk次部分ふるい関数の関係性

ここでkk次のふるい関数で得られる集合は、次数が異なればそれぞれの集合は排他的であるので、以下が言える。

ϕ(x,a)=k=0Pk(x,a)(1)\tag{1}\phi(x,a)=\sum_{k=0}^{\infty}P_k(x,a)

また、以下の式も成り立つ。これは、定義を考えれば自明であろう。

π(x)=P1(x,a)+a(2)\tag{2}\pi(x)=P_1(x,a)+a

さらに、上の(1)(1)式をP1P_1について解けば以下も成り立つことがわかる。

P1(x,a)=ϕ(x,a)1k=2Pk(x,a)(3)\tag{3}P_1(x,a)=\phi(x,a)-1-\sum_{k=2}^{\infty}P_k(x,a)

したがって、(2)(2)および(3)(3)を連立して以下が言える。

π(x)=a+ϕ(x,a)1k=2Pk(x,a)(4)\tag{4}\pi(x)=a+\phi(x,a)-1-\sum_{k=2}^{\infty}P_k(x,a)

力尽きた…

ちょっと重くなってきたので、いったん記事を切ることにする。 そう簡単には「かんぜんにりかいした」段階にすら至らない重さだ。 終わりが見えない…