Project Euler Problem 30

問題30: 2以上の自然数について、各桁を5乗した数の和が元の数と一致する数をすべて求め、その和を答えよ。[=>問題文]
 上限が与えられない問題です。左辺の値いくつまで調べればよいのか。注目するのは「桁数」です。
 k 桁の数を考えます。各桁の最大値は 9 なので右辺 a のとりうる最大値 a-max は次のようになります。

[右辺 a 最大値]
a-max(1): 9^5
a-max(2): 9^5 + 9^5
a-max(3): 9^5 + 9^5 + 9^5
:
a-max(k-1): 9^5 + 9^5 + 9^5 ... 9^5
a-max(k)  : 9^5 + 9^5 + 9^5 ... 9^5 + 9^5

 つまり、a の最大値は桁が増える毎に 9^5 ずつ増えていきます。次は左辺 b の最小値 b-min について考えます。

[左辺 b 最小値]
b-min(1): 1 (0)
b-min(2): 10
b-min(3): 100
:
b-min(k-1): 1000...00  (k-1桁)
b-min(k)  : 1000...000 (k桁)

 k=2 の時は2桁の最小値なので10、k=3の bの最小値は 100 です。b の最小値は桁が増えるごとに 10 倍になります。1桁の最小値は 0 ですが便宜的にここでは 1 とします。
 もう一度 b-min と a-max を並べて書いてみます。

k b-min  a-max
1    1    59049 (9^5)
2   10   118098 (9^5 + 9^5)
3  100   177147 (9^5 + 9^5 + 9^5)
4 1000   236196 (9^5 + 9^5 + 9^5 + 9^5)
:

 このように、はじめのうちは b-min(左辺最小)< a-max (右辺最大)ですが、桁が増える毎の値の増加量は b-min が指数的増加量(というか指数関数そのものですが)であるのに対して、a-max は単調増加です。だから、k を増やしていけばいずれどこかで b-min > a-max となる瞬間があるはずです。そして大小が逆転した後はその差は開いていく一方です。この問題は b = a となる数を探すわけですから、b-min > a-max となった後は調べる必要がありません。
 b-min > a-max となった瞬間の a-max が、調べる値の上限となります。

問題30の解答その1

;;Emacs Lisp
(require 'cl)

(defun problem030a (n)
  (let
      ;; 0^n 〜 9^n を格納した配列。
      ;; n乗計算を省略するためにメモ化しておく。
      ((expt-n-vect
        (do ((v (make-vector 10 0))
             (i 1 (1+ i)))
            ((= i 10) v)
          (aset v i (expt i n))))
       ;; 計算対象の上限。
       ;; k 桁での左辺最小値を bk。 右辺最大値を ak とし、
       ;; bk が ak を超えたとき、その時点の ak の値を
       ;; 計算すべき上限値とする。 
       (limit (do ((k 1 (1+ k))        ; 実は不要だけど、一応
                   (a-max (expt 9 n))  ; a(k)の最大値
                   (b-min 1)           ; b(k)の最小値
                   (a-inc (expt 9 n))) ; a増加量
                  ((<= a-max b-min) a-max)
                (incf a-max a-inc)
                (incf b-min (* b-min 10)))))
    (let*
        ;; 桁分割リスト作成関数。 123 => (1 2 3)
        ((split-digit
          (lambda (x &optional lis)
            (if (zerop x)
                lis
              (funcall split-digit (/ x 10)
                       (cons (mod x 10) lis)))))
         ;; 左辺 b から 右辺 a を計算する関数
         (calc-a
          (lambda (b)
            (reduce
             #'(lambda (acc y)
                 (+ acc (aref expt-n-vect y)))
             (funcall split-digit b)
             :initial-value 0))))
      ;; メインルーチン
      ;; 左辺 b (limit 〜 2) に対する 右辺 a を計算し
      ;; b = a となる b をリストに保存し、合計する。
      (do ((lis) (b limit (1- b)))
          ((= b 1) (apply #'+ lis))
        (when (= b (funcall calc-a b))
          (push b lis))))))

(problem030a 5)

 やってることは、最初の説明のとおりです。最後のメインルーチンでは、左辺 b の値を上限 limit から 2 までデクリメントし、すべての b に対する a を計算して b = a となるものを得ています。
 このやり方だとn=5の場合、約47万回の右辺計算をしています。しかもその中には b = 1234,1342,2341... のように右辺 a の値が同じになるものが多数含まれています。この無駄をなんとかしてみましょう。

問題30の解答その2

(require 'cl)

(defun problem030b (n)
  ;; --- 前半は、problem030a と同じ ----------
  (let ((expt-n-vect                    ; 0^n 〜 9^n を格納した配列
         (do ((v (make-vector 10 0))
              (i 1 (1+ i)))
             ((= i 10) v)
           (aset v i (expt i n))))
       ;; 計算対象の上限。
       (limit (do ((k 1 (1+ k))        ; 実は不要だけど、一応
                   (a-max (expt 9 n))  ; a(k)の最大値
                   (b-min 1)           ; b(k)の最小値
                   (a-inc (expt 9 n))) ; a増加量
                  ((<= a-max b-min) a-max)
                (incf a-max a-inc)
                (incf b-min (* b-min 10)))))
    (let
        ;; 桁分割リスト作成関数。 123 => (1 2 3)
        ((split-digit
          (lambda (x &optional lis)
            (if (zerop x)
                lis
              (funcall split-digit (/ x 10)
                       (cons (mod x 10) lis)))))
         ;; --- ここまで、problem030a と同じ ----------
         ;; リスト要素のn乗和計算関数。 (1 2 3) => 1^n+2^n+3^n
         (sum-of-expt-n
          (lambda (lis) (reduce #'(lambda (acc y)
                                    (+ acc (aref expt-n-vect y)))
                                lis
                                :initial-value 0))))
      (let*
          ;; limitの桁数
          ((limit-digits (1+ (truncate (log limit 10))))
           ;; 2〜limit までの数を桁分割し sortしたリストを key として
           ;; 保存した hash tableを作成。hash table の key なので重複は
           ;; 除去される。
           (ht (do ((ht (make-hash-table :test #'equal))
                    (x limit (1- x))
                    (border (expt 10 (1- limit-digits)))
                    (zero-list))
                   ((= x 1) ht)
                 ;; すべてのリストが同じ長さになるよう zero-list を
                 ;; append して調整する。
                 (let ((lis (append zero-list
                                    (funcall split-digit x))))
                   (puthash (sort lis #'<) t ht))
                 ;; x の桁が減ったら、zero-list に 0 を追加
                 (when (= x border)
                   (push 0 zero-list)
                   (setq border (/ border 10)))))
           ;; 題意に該当する数を保存する為のリスト
           (correspond-numbers))
        ;; 作った hash table (ht) のすべてのキーに対して題意のテスト
        ;; を行い合致するものを correspond-numbers に保存する
        (maphash
         #'(lambda (a-list v)
             ;; a-list は右辺の基数となる数値のリスト。
             ;; a-list の要素の n乗和を 左辺 bとし、
             ;; bを桁にばらして sortしたリストを b-list とする.
             (let* ((b (funcall sum-of-expt-n a-list))
                    (b-list (sort (funcall split-digit b) #'<))
                    (b-list-len (length b-list)))
               ;; a-list の桁数はすべて limit-digits なので
               ;; b-list の桁数も 0 埋めしてあわせる
               (if (< b-list-len limit-digits)
                   (setq b-list
                         (append
                          (make-list (- limit-digits b-list-len) 0)
                          b-list)))
               ;; a-list と b-list が一致したら、それが求める数。
               (if (equal a-list b-list)
                   (push b correspond-numbers))))
         ht)
        ;; correspond-numbers には 1 も含まれるので、
        ;; sort して car を除いてから合算。
        (apply #'+ (cdr (sort correspond-numbers #'<))))))))

(problem030b 5)

 1234,1342,2341...のような重複要素を除去してからメインの計算ループに入ろうという発想です。上限 limit までの数をすべて (1 2 3 4) (1 3 4 2) (2 3 4 1) のようなリストに変換し、sort した上で hash table に key として保存しています。ソートすることにより、上記 3 つのような数はすべて同じパターンとなり hash table によって重複要素は除去されます。
 n=5の場合、すべてのリストを ht に登録し重複除去された時点でパターン数は 4542個まで絞り込まれました。右辺 a の計算回数は、problem030a の 100分の1にまで減っています。その代わり前準備がかなり複雑になっています。

プロファイル

Function     Avg (sec)
===========  =========
problem030a  4.156500
problem030b  2.421500

 hash table 登録や リストのソートなど、オーバーヘッドになりそうな処理をしているにもかかわらず、後者の方式のほうが速いようです。ループ回数 100倍の差が効いているのでしょうか?