名古屋で数学するプログラマ(仮)

@antimon2 が趣味兼一部本職の数学で何かするときのブログ。

変形魔方陣

一週間、空いてしまいました。
ちょっとリハビリのために、今日はコンピューターパズル問題の話。
もちろん数学のエッセンス多めです。キーワードは、数学的裏付けと式変形

変形魔方陣

かつて存在した、「Cマガジン」という雑誌の、コンピューターパズルのコーナー「Cマガ電脳クラブ」から、個人的に思い入れの深い1問*1
問題は以下の通り(出典:Cマガジン 1998年8月号*2):

 +---+---+---+
 | A | B | C |   A + B + D + E = N  
 +---+---+---+   B + C + E + F = N
 | D | E | F |   D + E + G + H = N
 +---+---+---+   E + F + H + I = N
 | G | H | I |
 +---+---+---+

上の図のA~Iに、1~9の数を1つずつ入れて、2x2の4つの小正方形のマスの中の数の和が一定値(=N)になる(上右の4つの式がすべて成立する)ような配置を見つけてください。
全部で何通りあるでしょう?
(ただし、回転・反転して同じ配置になるものは1つと数えます(区別しません))

普通「魔方陣」というと、縦・横・ななめの各列の和が一定値(これを『定和』と言います)になる、というもの。
その条件を少し変形した魔方陣、ということです。

よくある解答

解答を導き出すだけなら、結構簡単です。
1~9の順列を考えて、それが条件を満たすかどうか調べて、回転・反転解になっていないか調べて、OKならカウントアップする。それだけで良いのですから。

ただしそれだと、あまりにも効率が悪くなってしまいます。そこで少しでも効率よくするために、例えば以下のような工夫を入れます。

  • 回転・反転解を先に排除する(枝刈り1)
  • なるべく早い段階で定和を決定し、そこから基準に合致しない組合せを排除する(枝刈り2)

これで行ってみましょう。コーディングしてみました。今回はPythonです。

def solve():
  flgs = [False for i in range(9)]
  arr = [0 for i in range(9)]
  arr_idxs = [1, 3, 5, 7, 4, 0, 2, 6, 8]
  def solver(i, n, cnt):
    for idx in [m for m in range(9) if not flgs[m]]:
      flgs[idx] = True
      arr[arr_idxs[i]] = idx
      if i == 8:
        if arr[4] + arr[5] + arr[7] + arr[8] == n:
          print_answer(arr)
          cnt += 1
      else:
        if i == 5:
          n = arr[0] + arr[1]  + arr[3] + arr[4]
        if chkOK(arr, i, n):
          cnt = solver(i + 1, n, cnt)
      flgs[idx] = False
    return cnt
  cnt = solver(0, 0, 0)
  print "Count: %d" % cnt
  return cnt

def chkOK(arr, i, n):
  if i == 1:
    return arr[1] < arr[3] # E1
  elif i == 2:
    return arr[3] < arr[5] # E1
  elif i == 3:
    return arr[1] < arr[7] # E1
  elif i == 6:
    return arr[1] + arr[2] + arr[4] + arr[5] == n # E2
  elif i == 7:
    return arr[3] + arr[4] + arr[6] + arr[7] == n # E2
  return True

def print_answer(arr):
  print "%2d%2d%2d\n%2d%2d%2d\n%2d%2d%2d\n" % tuple(map(lambda x: x+1, arr))

if __name__=='__main__':
  solve()

再帰を利用したらわりとコンパクトにまとまりました。もちろんちゃんと正しい答えが出ます。
ソース中のchkOK関数が枝刈りをしているところで、「#E1」が「枝刈り1」、「#E2」が「枝刈り2」です。
実行時間を計ってみました(無駄に benchmarker モジュールを利用)。

##                  user       sys     total      real
solve x 100       8.3400    0.0100    8.3500    8.3415

100回繰り返して 8.34秒。つまり、だいたい 0.083秒。うん。これでも決して遅くはないですね。
でも今回は、この速さには満足せず、「もっと効率よく早く答えが出せないか」を、数学的観点から考えてみましょう。

数学的考察

前節で、何気に9つの数を0~8で処理して、表示するときだけ+1して1~9となるようにしています。
以下もこの前提で進めます。

改めて、今与えられている条件は以下の4つの式です。

A + B + D + E = N …(1)
B + C + E + F = N …(2)
D + E + G + H = N …(3)
E + F + H + I = N …(4)

この4つの式から、以下の式が導かれます。

(A + C + G + I) + 2(B + D + F + H) + 4E = 4N …(5)

はい、全部辺々足し合わせただけ((5)=(1)+(2)+(3)+(4))です。簡単ですね。

あと、A~Iは0~8の数が1つずつ出てくるはずなので、以下も成り立ちます。

A + B + C + D + E + F + G + H + I = 36 …(6)

(5) から (6) を引いてみましょう。

B + D + F + H + 3E = 4N - 36 …(7)

変形するとこうなります。

N = (36 + B + D + F + H + 3E) / 4 …(7)'

これが何を意味しているか、分かりますか?
N(=定和)が、B, D, E, F, H の5つの文字の式で表されています。
B, D, F, H は上下左右の数、E は真ん中の数です。

そう。
この魔方陣の定和は、上下左右と真ん中の数が決まればもう決まってしまうのです。
前節のプログラムでは、上下左右真ん中に加えて、左上(A)まで決めた時に定和を決めていた(N=A+B+D+E だから)のですが、実はその必要はなかった、ということですね。

  • 5つの数を決めたところで定和を求める(効率化1)

さらに式(7)'によく注意すると、4で割っていますがその商が整数になる保証はありません。でも定和って整数のはずですよね。つまりここでもまた枝刈りができるわけです。

  • 求めた定和が整数かどうか確認し、整数にならないものは排除する(枝刈り3)

残るは四隅の数ですが、上下左右の数が決まって、定和も求まっています。だったらそこから引き算するだけで出ちゃいます。

A = N - B - D - E …(1)'
C = N - B - E - F …(2)'
G = N - D - E - H …(3)'
I = N - E - F - H …(4)'

あとはこれらが、0~8の数のうちで、今までに決まった数(B, D, E, F, H)とかぶらないか確認するだけでOK。つまり。

  • 四隅(A, C, G, I)は順列をすべて生成してチェックするのではなく、定和と他の数から算出し、妥当性をチェックする(効率化2)

さらにおまけ。
実は以下の式も成立します(証明は読者への宿題にしますw)。

A + I = C + G
    I = C + G - A …(8)

ここから、四隅のうち3つの数が決まれば残り1つも算出できます。

私の解答

以上を踏まえて、解答プログラムを書きなおしてみました。

def solve():
  flgs = [False for i in range(9)]
  arr = [0 for i in range(9)]
  arr_idxs = [1, 3, 5, 7, 4, 0, 2, 6, 8]
  def solver(i, cnt):
    rstart = 0
    if i == 1 or i == 3:
      rstart = arr[1] + 1
    elif i == 2:
      rstart = arr[3] + 1
    rend = 9
    if i == 0:
      rend = 7
    elif i == 1:
      rend = 8
    for idx in [m for m in range(rstart, rend) if not flgs[m]]: # E1
      flgs[idx] = True
      arr[arr_idxs[i]] = idx
      if i == 4:
        n4 = 36 + arr[1] + arr[3] + arr[5] + arr[7] + 3 * arr[4] # K1
        if n4 % 4 == 0: # E3
          n = n4 / 4
          a0 = n - arr[1] - arr[3] - arr[4]  #K2
          a2 = n - arr[1] - arr[4] - arr[5]  #K2
          a6 = n - arr[3] - arr[4] - arr[7]  #K2
          #a8 = n - arr[4] - arr[5] - arr[7]  #K2
          a8 = a2 + a6 - a0
          if len([m for m in range(9) if not (flgs[m] or m in [a0,a2,a6,a8])])==0: #K2
            arr[0] = a0
            arr[2] = a2
            arr[6] = a6
            arr[8] = a8
            print_answer(arr)
            cnt += 1
      else:
        cnt = solver(i+1, cnt)
      flgs[idx] = False
    return cnt
  cnt = solver(0, 0)
  print "Count: %d" % cnt
  return cnt

def print_answer(arr):
  print "%2d%2d%2d\n%2d%2d%2d\n%2d%2d%2d\n" % tuple(map(lambda x: x+1, arr))

if __name__=='__main__':
  solve()

何気に枝刈り1も少し工夫して効率化してたりします。
実行時間を計ってみました。

##                  user       sys     total      real
solve x 100       0.6300    0.0100    0.6400    0.6346

同じ100回の繰り返しで0.63秒。1回あたり約0.006秒。1/10以下になりました。

私が応募した回答は、ほぼこれと同じ内容をC言語で記述したものでした。
(さらなる効率化のために、再帰を使わずループをベタにネストしていたような気がします)
DOSでBCC32か何かを使用して、実行時間は1000回実行して数秒台(つまり0.00X秒)だったと思います。

まとめ

数学というより、今回やっていることは単純な式変形だけです。
でも、それによって「今まで順番に調べてあてはめてきたもの(例:定和)が、ある程度までは実は途中でもう決まってしまう」ことが分かりました。今回は、その「もう分かっていることをうまく利用して無駄な検索を減らす」という効率化の話でした。
今回は3×3なので検索量も元々たかが知れていましたが、もっと大きな量を検索しなければいけないときは、以下に効率よく無駄を減らせるか、はとても重要になってきます。

*1:Cマガ電脳クラブ」というのは、毎月コンピューターパズルを出題して解答プログラムを応募する、というものだったのですが、私はこの問題に応募して正答例として採用されました。

*2:今手元にないので、記憶とググった結果を元に自分の言葉で記述しなおしてあります。内容は間違っていないはず。