ログイン新規登録

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

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

26

この記事は最終更新日から1年以上が経過しています。

高度合成数を高速に列挙する2つのアルゴリズム

最終更新日 投稿日 2019年12月11日

概要

この記事は データ構造とアルゴリズム Advent Calendar 2019 の11日目の記事です。

この記事では、10100 以下の全ての高度合成数を1秒以下で列挙する2種類のアルゴリズムおよびpythonコードを紹介します。計算時間は google colab (2019/12/02) で計測しました。

参考文献

高度合成数に関するlotzさんの素晴らしい記事があります。列挙アルゴリズムも複数紹介されています。
以下で紹介するアルゴリズムのうち、アルゴリズム1についてはlotzさんの記事およびinamoriさんの記事1 記事2 記事3 を参考にしています(本記事では枝刈り関数の変更などでさらなる高速化を達成しています)。
アルゴリズム2はdario2994さんのコード(gist)を読み解いて解説したものです。

高度合成数の定義

自然数で、それ未満のどの自然数よりも約数の個数が多いものを高度合成数といいます。
たとえば、以下の表を眺めると、1,2,4,6,12は高度合成数であることがわかります。

自然数 1 2 3 4 5 6 7 8 9 10 11 12
約数の個数 1 2 2 3 2 4 2 4 3 4 2 6
競技プログラミングでの使われ方の例

たとえば109以下の最大の高度合成数は735134400であり、その約数の個数は1344個です。
よって、Nの約数の個数の2乗オーダーで解けるような問題は、N109の制約のもとで2秒以内に計算が収まる可能性が高いです。

解きたい問題

N 以下の高度合成数をすべて列挙せよ。ただしN10100.

#ナイーブなアルゴリズム

N 以下の全ての自然数の約数の個数を求めれば、高度合成数を列挙することができます。τ(1),τ(2),τ(3), と順番に約数の個数を計算すると大変ですが、「各iについて、niの倍数ならnumber_of_div[n] += 1」とすると調和級数によりO(NlogN) で計算できます。

このアルゴリズムではすべての iN について約数の個数を計算しているので、少なくとも N に比例する計算量が必要です。計算量を劇的に減らすためには、適切な枝刈りが必要です。

考察

自然数 n の素因数分解を n=2e13e2prer (ただしprr 番目の素数)として、n の約数の個数をτ(n)とすると、
τ(n)=(e1+1)××(er+1)
が成り立ちます。よって、自然数 n の素因数分解から約数の個数を計算することができます。
なので自然数 n だけでなく、その指数表記 [e1,,er] も一緒に考えるとよいです。
たとえば、20=22×5=[2,0,1] といった感じです。
さらに、以下のように指数表記を図で表します。タイル表記としておきましょう。

以下の例は20=22×5=[2,0,1]のタイル表記です。
2022 回割り切れるので、1 列目にはタイルを縦に2枚並べます。
203 で割り切れないので、2 列目にはタイルを並べません。
2051 回割り切れるので、3 列目にはタイルを縦に1枚並べます。
207 以上の素数で割り切れないので、4 列目以降にはタイルを並べません。

tile.png

重要な枝刈り

指数表記と約数の個数に対応がついたので、枝刈りができるようになります。
たとえば、227は高度合成数になりません。なぜなら、223のほうが小さく、かつ約数の個数が等しいからです。一般化すると n=[e1,,er]が高度合成数であるためには、 e1er が必要条件だとわかります。

アルゴリズム1: 枝刈り最良優先探索

高度合成数の列挙に有効な、heapq(優先度つきキュー)を使う最良優先探索を以下に示します。

  • heapq には、(n,τ(n),n)という tuple が入っている。
  • 以下を繰り返す。
    • heapq から最小元 n を取り出す
    • n の約数の個数を見て、高度合成数の条件をみたすか調べる。みたしていたら保存する。
    • n の「近傍」の各元 c について、cが「枝刈り条件」をみたすなら heapqに入れる

ここで大事なのは近傍の取り方です。次の性質をみたす近傍がとれるとよいです。

  • 近傍の元のほうがもとの元よりも大きい
  • すべての元がちょうど1回ずつheapqに入るようにする
  • (上の2つの条件でアルゴリズムが正しい解を返すことが保証される)
  • 加えてよい枝刈りができるなら、探索空間をグッと小さくすることができます。

ここでは、横積みと縦積みという、2種類の近傍の取り方を考えます。(下の図を参照)
両方とも、e1er という枝刈り条件のもとで上記の条件をみたします。

1-1 縦積み

tate.png

縦積みでの近傍は上のタイル表記のように、右端の列を縦に伸ばす or 横に新しい列を増やす です。
つまり[e1,,er] の近傍は [e1,,er+1][e1,,er,1] のふたつです。今回はe1en という必要条件があるので、一つめの近傍は er1>er のときだけ考慮すれば大丈夫です。
(逆にこの必要条件がない場合、[e1,,er1,1] の近傍として [e1,,er1,0,1]も追加すれば全自然数が探索できます)

このアルゴリズムでは、N=1025 程度までなら 2 秒以内に計算できます。
なお、縦積みの場合も横積みの場合(次節参照)と同様にさらなる枝刈りができますが、1割程度しか高速化できなかったので詳細は割愛します。

1-2 横積み

yoko.png

横積みでは、e1erをみたす元を全探索できます。
横積みの場合、近傍は上図のように、上に行を増やします。
つまり、[e1,,er] が与えられたとき、e1=ejをみたす任意の j に対して [e1+1,,ej+1,ej+1,,er] はその近傍です。
ただし、nの指数表記が全て 1 のときだけ横に伸ばせるとします。たとえば[1,1,1]の近傍は [1,1,1,1] も含みます。

枝刈りをしない場合でも、横積みは縦積みよりも効率的です。
なぜなら、横積みでの近傍になる自然数のほうが縦積みでの近傍になる自然数よりも小さいからです。
今回の問題では縦積みより2倍弱高速なコードとなり、N=1030くらいまでなら2秒以内に計算できるようです。

さらなる枝刈り

x>y かつ τ(x)τ(y) のとき、xが負ける、 yが勝つ、右が勝つと表現します。
枝刈りをするためには、「元 x から近傍をたどっていける任意の元は高度合成数にならない」という x を発見する必要があります。そのために以下の性質を確認しておきましょう(証明略)。

  • とある数に負けた数は絶対に高度合成数にはならない
  • x vs yy が勝つなら、x とも y とも互いに素な数 z について xz vs yzyz が勝つ
  • x vs yy が勝ち、素数pで割り切れる回数がyよりxのほうが多いなら、 xp vs ypyp が勝つ

ここから次の枝刈り定理が導けます。

枝刈り定理

p<qをともに素数とし、pe vs pexq で右が勝つようなxがあると仮定する。
このときq以上の素因数を使わず、pe回以上割り切れる任意の数は高度合成数でない。
とくに横積みの場合、そのような数はすべて枝刈りできる(つまりそのような数から近傍をたどっていける任意の数は高度合成数でない)。

枝刈りできる条件

ではpe vs pexq で右が勝つ条件を整理しましょう。大小の条件より x>logq/logp、約数の条件より x(e+1)/2となるので、右が勝つ必要十分条件は logpqx(e+1)/2 なる整数 x が存在することです。これの同値条件として
2(logpq+1)e+1
がわかります。これなら左辺を前計算できるので、判定も O(1) でできます。

この枝刈りの効果は劇的で、N=10100としても全ての高度合成数を1秒以下で列挙することができました。サンプルコードを以下に示します。

def hcn_heapq(N): #N以下の高度合成数を列挙したリストを返す
    from heapq import heappop,heappush
    from math import log
    prime = [2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97,101,103,107,109,113,127,131,137,139,149,151,157,163,167,173,179,181,191,193,197,199,211,223,227,229,233,239,241,251,257,263]
    lim = [[2*int(log(p,q)) for q in prime] for p in prime] #枝刈り用配列
    # 初期状態
    q = [(2,2,[1])] # (n,nの約数の個数、nの指数表記)を保存する heapq
    res = [(1,1,[])]

    while q and q[0][0] <= N:
        n,val,lst = heappop(q)
        if val > res[-1][1]: #条件をみたすなら答に追加
            res.append((n,val,lst[:]))
        L = len(lst)
        e0 = lst[0]
        #全部1なら新しい素数で横に伸ばせる
        if e0 == 1: 
            heappush(q,(n*prime[L],val*2,[1]*(L+1)))
        #最上段の上を横方向に積む
        for i in range(L):
            if e0 > lst[i]: break #段差があると、もう伸ばせない
            if e0 >= lim[L][i]: break #枝刈り(重要)
            n *= prime[i]
            if n <= N:
                lst[i] += 1
                val = val//(e0+1)*(e0+2)
                heappush(q,(n,val,lst[:]))
    return res

N = 10**100
res = hcn_heapq(N)
print(len(res)) #N以下の高度合成数の個数
print(res[-1]) #N以下の最大の高度合成数

アルゴリズム2: 素因数DP

上述の通り、このアルゴリズムはdario2994さんのコードを読み解いて解説したものです。本アルゴリズムも、10100 以下の全ての高度合成数を1秒以下で列挙することができます。

この種の問題に対しては、「 k番目以下の素数まででの計算値」をもとに「 k+1番目以下の素数まででの計算値」を求めるという動的計画法も有効です。
本問題では、集合 P(k,N), H(k,N)を以下で定めます。

  • P(k,N) を、k 番目以下の素数の積で表されるN以下の自然数全体の集合とする。
  • H(k,N)={kP(k,N)τ(n)>τ(m)m<n,,mP(k,N)} とする。

H(k,N) の定義は一見高度合成数そのものに見えますが、定義中のm,nP(k,N)の元ということに注意です。たとえば P(1,30)=H(1,30)={1,2,4,8,16} のようになります。

重要な性質として、kが十分大きいときにはH(k,N)N 以下の高度合成数の集合が一致します!
つまり、N 以下の高度合成数は集合 H(k,N) に属し、逆に xH(k,N) かつ x<p1pk+1 ならば x は高度合成数です。
よって N<p1pk+1 となる k をとれば、H(k,N) こそが N 以下の高度合成数の集合となります。

あとは H(k,N) から H(k+1,N) への遷移を考察すればOKです。
xH(k,N)xpk+1ek+1H(k+1,N)
が証明できるので、対偶をとって
H(k+1,N){xpk+1ek+1xH(k,N)}
となります。上式右辺の集合は H(k,N) から計算できます。この集合をソートし、条件をみたすものを抽出することで H(k+1,N) が計算できます。

その他のアルゴリズム

「ちょうどk 個」の素因数をもつ整数の集合 HCPk から高度合成数を列挙する手法があるようです。
https://shreevatsa.github.io/site/assets/hcn/hcn-algorithm.pdf
lotzさんの記事で言及されており、Haskellでの実装があります。きちんとした比較はしていませんが、パフォーマンスの面では本記事で紹介したアルゴリズムで十分という感触があります。

まとめ

本記事では高度合成数を高速に列挙する2種類のアルゴリズム、「枝刈り最良優先探索(横積み)」と「素因数DP」を紹介しました。計算量の解析はしていませんが、10100 以下の全ての高度合成数を1秒以下で列挙することができます。
ちなみに10100 以下で最大の高度合成数は9326516884061827509233332641260520263537022401378881409790204489294728443343865526159430547438720000で、約数の個数は128581012920729600個です。

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

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

コメント

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

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

26