連環の数

今回は、連なった円に数字を入れるパズルです。円の重なった部分の数字には、 両隣の数字の和が入ります。円が3つの場合は、1から 5 の数字が入ります。解の1つは、 下図のようになります。

zu

円を1つ増やすと、入れる数は2つ増えます。円が4つの場合は、1から 7 の数字が入ります。今回のプログラムでは、円が 9 くらい (1 から 17) まではストレスなく解が得られます。 それ以上の数でも、解は得られますが少し時間が掛かるようになります。

プログラム

use strict;
my $max = 9;
my ($count, @peices);

for (my $i = 1; $i < $max; $i++) {
  for (my $j = 1; $j < $max; $j++) {
    next if $i == $j;
    push @peices, $i . ':' . ($i + $j) . ':' . $j if $i + $j <= $max;
    last if $i + $j > $max;
  }
}

my @maxs = grep { /:$max:/ and (split /:/)[0] < (split /:/)[2] } @peices;
@peices = grep { ! /:$max:/ } @peices;

foreach (@maxs) {
  /^(\d+):\d+:(\d+)$/;
  my @work = grep { ! /:$1:/ and ! /^$1:/ and ! /:$2:/ and ! /:$2$/ } @pieces;
  tail($_, @work);
}

sub tail {
  my ($chain, @list) = @_;
  head($chain, @list);
  my ($matubi) = $chain =~ /:(\d+)$/;
  foreach my $item (grep /^$matubi:/, @list) {
    my $tmp = $chain;
    $tmp =~ s/\d+$//; $tmp .= $item;
    if (($tmp =~ tr/://) == $max - 1) {
      $tmp =~ s/:/_/g;
      print "$tmp\n";
      $count++;
    } else {
      $item =~ /^(\d+):(\d+):(\d+)$/;
      my @work = grep { ! /\b$1\b/ and ! /\b$2\b/ and  ! /:$3:/ and ! /:$3$/} @list;
      $tmp =~ /^(\d+):.*:(\d+)$/;
      @work = grep { ! /^$2:\d+:$1$/ } @work;
      tail($tmp, @work);
    }
  }
}

sub head {
  my ($chain, @list) = @_;
  my ($atama) = $chain =~ /^(\d+):/;
  foreach my $item (grep /:$atama$/, @list) {
    my $tmp = $chain;
    $tmp =~ s/^\d+//; $tmp = $item . $tmp;
    if (($tmp =~ tr/://) == $max - 1) {
      $tmp =~ s/:/_/g;
      print "$tmp\n";
      $count++;
    } else {
      $item =~ /^(\d+):(\d+):(\d+)$/;
      my @work = grep { ! /\b$2\b/ and ! /\b$3\b/ and ! /:$1:/ and ! /^$1:/ } @list;
      $tmp =~ /^(\d+):.*:(\d+)$/;
      @work = grep { ! /^$2:\d+:$1$/ } @work;
      head($tmp, @work);
    }
  }
}

print "解の数: $count\n";

プログラムの解説

今回のパズルを解くために、少し作戦を立ててみました。 正解を組み立てるために、まず最初に小さな部品を作ります。部品は、3つの数字を繋いだ 1:3:2 のような形になります。中央の値は、両隣の数字の和になっています。 この部品は2つの円に置くことができ、円の重なったところに中央の値が入ります。 このような部品を、2つの数字の順列を生成しながら、最大値を超えないものすべてを作ります。 そして、中央が最大値なっている部品 (最大値は必ず中央になる) を最初に配置して、その両端に繋げることのできる部品を繋ぎ、最終的に解を得ます。 部品は、配列 @pieces に格納しておきます。円が5つ (1 から 9) の場合の @pieces の内容は、次のようになります。

1:3:2, 1:4:3, 1:5:4, 1:6:5, 1:7:6, 1:8:7, 1:9:8,
2:3:1, 3:4:1, 4:5:1, 5:6:1, 6:7:1, 7:8:1, 8:9:1,
2:5:3, 2:6:4, 2:7:5, 2:8:6, 2:9:7,
3:5:2, 4:6:2, 5:7:2, 6:8:2, 7:9:2,
3:7:4, 3:8:5, 3:9:6,
4:7:3, 5:8:3, 6:9:3,
4:9:5,
5:9:4,

最初に配置できるのは、中央が最大値 9 になっている部品 1:9:8, 2:9:7, 3:9:6, 4:9:5 の4つです (逆順値の部品 8:9:1, 7:9:2, 6:9:3, 5:9:4 は、重複解となるので最初から排除しておきます)。この4つの部品を foreach ループを使って、1つずつ再帰型サブルーチンを使って探索していくことになります。 呼び出しおよびサブルーチンのループでは、現時点で使用不可能な部品を除いた部品 (繋げることができる部品ではない) を引数として渡します。例えば 3:9:6 で探索を始めた場合には、部品の分類は以下のようになります。

  1. 使用不可の部品 (中央の数字 (9) を含む部品と両端の数字 (3, 6) が中央値になる部品)
      1:9:8, 8:9:1, 2:9:7, 7:9:2, 6:9:3, 4:9:5, 5:9:4
      1:3:2, 1:6:5, 2:3:1, 5:6:1, 2:6:4, 4:6:2

  2. 繋ぐことができない部品 (3:9:6 と 3:4:1 のようなケース)
      3:4:1, 3:5:2, 3:7:2, 3:8:2,
      1:7:6, 2:8:6

  3. 繋ぐことができる部品
      1:4:3, 2:5:3, 4:7:3, 5:7:3 (頭に)
      6:7:1, 6:8:2 (末尾に)

  4. 未使用の数字のみで構成される部品
      1:5:4, 1:8:7, 4:5:1, 7:8:1, 2:7:5, 5:7:2

tail の呼び出しおよび tail と head のループでは、1の "使用不可の部品" と2の "繋ぐことができない部品" を除外して、3の "繋ぐことのできる部品" と4の "未使用の数字のみで構成される部品" を引数として渡します。 ただこれのみで繋いでいったのでは、充分ではありません。最初の呼び出しでは現れないのですが、 ループの途中で出現してしまいます。例えば、3:9:6 に 6:8:2 を繋いで 3:9:6:8:2 を作った場合に、"繋ぐことができる部品" として 2:5:3 が残ってしまいます (実際には、前後どちらに繋いでも数字が重複してしまうため駄目なのですが)。 これをチェックするために、次のコードを入れています。

$tmp =~ /^(\d+):.*:(\d+)$/;
@work = grep { ! /^$2:\d+:$1$/ } @work;

解を生成する探索は、tail と head の2つの再帰型サブルーチンを使います。tail は末尾に部品を繋ぎ、head は頭に部品を繋ぐ役割をします。tail で末尾に繋ぐことのできるパターンをすべて生成して、tail だけで完成した解を除いて残りのすべてを head に渡します。こうすることで、すべての解を得ることができます。tail と head の2つのサブルーチンは、正規表現さえ理解できれば易しいと思います。

今回のプログラムでは、部品の接続文字として : (コロン) を使って、表示の際に _ (アンダースコア) に変換しています。それでは、最初から _ にした方が良いと思うかも知れません。しかし _ は単語構成文字に含まれるために、 変数名や正規表現により神経を使わなければならなくなります。例えば、変数名の $matubi: は、${matubi}_ のように書かなければなりません。 また次の正規表現は、下の行のように書く必要があります。

my @work = grep { ! /\b$2\b/ and ! /\b$3\b/ and ! /:$1:/ and ! /^$1:/ } @list;

my @work = grep { ! /(\b|_)$2(\b|_)/ and ! /(\b|_)$3(\b|_)/ and ! /_$1_/ and ! /^$1_/ } @list;

部品の各々の数字を捕捉したい場合に、\b だけを使ってマッチさせると 3_9_6 では単語境界が先頭と末尾だけになるので失敗します。: を使った 3:9:6 では、先頭と末尾と共に数字と : の接した位置も単語境界になります。 また、正規表現で選択を使うと、処理時間がより掛かるようになります。下の実行結果の $max = 19 の実行時間は 72 秒ですが、選択を使ったプログラムでは : から _ に変換する処理がないにもかかわらず 10 秒くらい多く掛かります。

プログラムを実行した結果は、以下のようになります。 実行時間は、例によって私の速くないパソコンでの結果です。

$max = 7
  2_5_3_4_1_7_6
  2_7_5_6_1_4_3
  解の数: 2

$max = 9
  1_5_4_7_3_9_6_8_2
  1_4_3_9_6_8_2_7_5
  7_8_1_3_2_6_4_9_5
  解の数: 3

$max = 11
  5_7_2_8_6_9_3_4_1_11_10
  2_8_6_9_3_7_4_5_1_11_10
  3_10_7_8_1_5_4_6_2_11_9
  ・・・
  5_11_6_8_2_9_7_10_3_4_1
  8_10_2_7_5_11_6_9_3_4_1
  8_9_1_3_2_7_5_11_6_10_4
  解の数: 21

$max = 13
  8_10_2_7_5_11_6_9_3_4_1_13_12
  5_11_6_8_2_9_7_10_3_4_1_13_12
  8_11_3_10_7_9_2_6_4_5_1_13_12
  ・・・
  3_4_1_10_9_11_2_8_6_13_7_12_5
  6_13_7_12_5_8_3_4_1_10_9_11_2
  10_11_1_3_2_8_6_13_7_12_5_9_4
  解の数: 63

$max = 15
  7_13_6_11_5_9_4_12_8_10_2_3_1_15_14
  10_12_2_9_7_13_6_11_5_8_3_4_1_15_14
  4_13_9_12_3_11_8_10_2_7_5_6_1_15_14
  ・・・
  1_5_4_11_7_15_8_14_6_9_3_13_10_12_2
  5_12_7_15_8_14_6_10_4_13_9_11_2_3_1
  1_4_3_13_10_12_2_9_7_15_8_14_6_11_5
  解の数: 198
  2 seconds

$max = 17
  11_15_4_10_6_14_8_13_5_12_7_9_2_3_1_17_16
  8_12_4_14_10_15_5_11_6_13_7_9_2_3_1_17_16
  5_14_9_15_6_13_7_11_4_12_8_10_2_3_1_17_16
  ・・・
  3_4_1_14_13_15_2_10_8_17_9_16_7_12_5_11_6
  2_14_12_15_3_11_8_17_9_16_7_13_6_10_4_5_1
  1_4_3_15_12_14_2_10_8_17_9_16_7_13_6_11_5
  解の数: 893
  11 seconds

$max = 19
  11_16_5_12_7_17_10_14_4_13_9_15_6_8_2_3_1_19_18
  11_15_4_13_9_14_5_12_7_17_10_16_6_8_2_3_1_19_18
  8_12_4_14_10_15_5_16_11_17_6_13_7_9_2_3_1_19_18
  ・・・
  16_17_1_5_4_6_2_13_11_14_3_12_9_19_10_18_8_15_7
  16_17_1_12_11_14_3_5_2_6_4_13_9_19_10_18_8_15_7
  13_17_4_6_2_3_1_12_11_16_5_14_9_19_10_18_8_15_7
  解の数: 3874
  72 seconds

$max = 21
  10_19_9_14_5_17_12_16_4_15_11_18_7_13_6_8_2_3_1_21_20
  13_17_4_15_11_18_7_12_5_14_9_19_10_16_6_8_2_3_1_21_20
  13_18_5_12_7_16_9_19_10_14_4_15_11_17_6_8_2_3_1_21_20
  ・・・
  10_21_11_20_9_17_8_14_6_18_12_13_1_16_15_19_4_7_3_5_2
  5_6_1_4_3_13_10_21_11_20_9_17_8_15_7_19_12_14_2_18_16
  10_21_11_20_9_17_8_15_7_19_12_16_4_6_2_3_1_14_13_18_5
  解の数: 18981
  505 seconds

$max = 23
  14_20_6_18_12_16_4_15_11_21_10_19_9_17_8_13_5_7_2_3_1_23_22
  14_20_6_17_11_15_4_16_12_21_9_19_10_18_8_13_5_7_2_3_1_23_22
  14_18_4_15_11_17_6_16_10_19_9_21_12_20_8_13_5_7_2_3_1_23_22
  ・・・
  2_6_4_7_3_14_11_23_12_22_10_19_9_17_8_21_13_18_5_20_15_16_1
  2_6_4_5_1_16_15_18_3_14_11_23_12_22_10_19_9_17_8_21_13_20_7
  5_6_1_3_2_16_14_18_4_15_11_23_12_22_10_19_9_17_8_21_13_20_7
  解の数: 106955
  3687 seconds

$max = 25 :  以下は解が1つ
  11_18_7_20_13_21_8_22_14_23_9_19_10_15_5_17_12_16_4_6_2_3_1_25_24
  2 seconds

$max = 27
  16_21_5_20_15_22_7_19_12_23_11_25_14_24_10_18_8_17_9_13_4_6_2_3_1_27_26
  4 seconds

$max = 29
  17_24_7_23_16_21_5_20_15_26_11_25_14_27_13_22_9_19_10_18_8_12_4_6_2_3_1_29_28
  15 seconds

$max = 31
  17_22_5_23_18_28_10_26_16_25_9_24_15_29_14_27_13_21_8_20_12_19_7_11_4_6_2_3_1_31_30
  54 seconds

$max = 33
  19_31_12_30_18_26_8_25_17_28_11_27_16_29_13_23_10_24_14_21_7_22_15_20_5_9_4_6_2_3_1_33_32
  141 seconds

$max = 35
  20_32_12_33_21_28_7_24_17_30_13_31_18_29_11_27_16_26_10_25_15_23_8_22_14_19_5_9_4_6_2_3_1_35_34
  788 seconds

今回のプログラムは、内容がほぼ同じのサブルーチンが並んでいて、いかにも素人っぽく (まあ紛れもない素人だが) なってしまいました。最初に作ったプログラムの骨組みは、次のようになっていました。

sub search {
  ...
  foreach my item (grep { /^$matubi:/ or /:$atama$/ } @list) {
    ...
    if (($tmp =~ tr/://) == $max - 1) {
      ...
    } else {
      ...
      search($tmp, @work);
    }
  }
}

この最初のプログラムを実行すると、頭に繋げる部品と末尾に繋げる部品を foreach の平坦なリストで扱っているために、同一解が発生してしまいます。例えば 3:9:6 の頭に 1:4:3 を繋ぎ、末尾に 6:8:2 を繋いで 1:4:3:9:6:8:2 を生成する順序は、どちらを先にするかによって2つあります。 1つの再帰型サブルーチンの中では、この繋ぐ順序の制御が非常に難しく今回は諦めました。 そのうちいいアイデアが浮かんだら、直すことにしましょう。

(2005/09/15)

TopPage