トーナメント対戦表

今回は、個人競技や団体競技で用いられるトーナメント対戦表の生成を取り上げます。仮に対戦者数が 12 である場合、一般的には次のようなトーナメント表になります。なお、トーナメント表は ASCII 文字を使って表示しているので、多少見にくいですがご容赦ください。

          |            
     -----------       
    |           |      
  ------      ------   
 |      |    |      |  
 --     --   --     -- 
|  |   |  | |  |   |  |
|  -   -  | |  -   -  |
| | | | | | | | | | | |
A B C D E F G H I J K L

トーナメント対戦表のプログラムで難しいのは、シード (ここでのシードの意味は単なる1回戦免除) の組み込みです。対戦者数 12 ではシードが A, F, G, L ですが、シード順位は一般的に A, L, F, G の順になります。言い換えると、対戦者数が 15 では A が、14 では A, L が、13 では A, L, F がシードになるということです。

次のプログラムは、コマンドライン引数に 2 から 52 (アルファベット大文字と小文字の合計値) の対戦者数を渡すと、自動的にトーナメント表を作成して表示します。なお、引数を省略すると対戦者数 12 のトーナメント表になります。

 1: use strict;
 2: my $n = $ARGV[0] || 12;          # 対戦者数 (max 52)
 3: my $invol = 2;
 4: $invol *= 2 while $invol < $n;   # 2 の階乗数
 5: my $seed = $invol - $n;          # シード数
 6: my @comb = ('x' x $invol);
 7:
 8: while ($seed--) {                # while ループ内は本文で説明
 9:   if (length($comb[0]) * @comb == $invol) {
10:     $comb[0] =~ s/^xx|xx$/ZZ/;
11:     if ($comb[0] =~ /^ZZx*ZZ$/) {
12:       my $half = length($comb[0]) / 2;
13:       splice @comb, 0, 1, (substr($comb[0], 0, $half), substr($comb[0], $half));
14:     }
15:   } else {
16:     my @target = 0 .. $#comb;
17:     @target = 0 .. ($#target - 1) / 2 while $#target % 2;
18:     my @group_1 = 0 .. $#target / 2;
19:     my @group_2 = ($#target / 2 + 1) .. $#target;
20     splice @comb, $group_2[0], scalar(@group_2), map { scalar(reverse $comb[$_]) } reverse @group_1;
21:   }
22: }
23:
24: foreach my $i (reverse 0 .. $#comb) {   # @comb 配列内の文字列を2文字単位に分解
25:   next if length($comb[$i]) == 2;
26:   my @divide = $comb[$i] =~ /../g;
27:   splice @comb, $i, 1, @divide;
28: }
29:
30: my ($offset, @line) = (0);
31: my @alpha = ('A' .. 'Z', 'a' .. 'z')[0 .. $n - 1];
32: foreach my $i (0 .. $#comb) {           # トーナメント表の下3行の組み立て
33:   if ($comb[$i] eq 'ZZ') {
34:     my $alpha = $alpha[$offset]; $offset++;
35:     $line[2] .= $alpha; $line[1] .= "|"; $line[0] .= "|";
36:   } else {
37:     my $alpha1 = $alpha[$offset]; $offset++;
38:     my $alpha2 = $alpha[$offset]; $offset++;
39:     $line[2] .= "$alpha1 $alpha2"; $line[1] .= "| |"; $line[0] .= " - ";
40:   }
41:   if ($i < $#comb) { $_ .= " "  foreach @line; }
42: }
43:
44: while ($line[0] !~ /^ +\| +$/) {        # トーナメント上部行の作成
45:   my $next = $line[0];
46:   unless ($next =~ s/(-*)-(\1-?)/' ' x length($1) . '|' . ' ' x length($2)/eg) {
47:     $next =~ s/\|( +)\|/' ' . '-' x length($1) . ' '/eg;
48:   }
49:   unshift @line, $next;
50: }
51:
52: s/ +$// foreach @line;         # 末尾の空白を削除
53: print "$_\n" foreach @line;    # トーナメント表の書き出し
   Line 24 〜 27: @comb 配列内の文字列を2文字ずつに分解する
配列内の文字列を分解するのは、トーナメント表の作成を容易にするためです。配列内の各要素は、'ZZ' または 'xx' のどちらかになります。対戦者数 12 を例とすると、分解前と分解後の各要素は次のようになります。
分解前: ZZxx xxZZ ZZxx xxZZ
分解後: ZZ xx xx ZZ ZZ xx xx ZZ
splice @comb, $i, 1, @divide;

配列の各要素を分割するには、splice を使って後ろの要素から順次処理すると比較的うまくいきます。 上記のコードで元の1つの要素を複数の要素で置き換えた場合、前から処理すると配列の添字が狂ってループがうまくいきません。

   Line 30 〜 42: トーナメント表の下3行の組み立て
1番下の行は対戦者名であり、@comb 配列の先頭から 'ZZ' には1対戦者名を、'xx' には2対戦者名を左から昇順でアルファベットを割り当てます。下から2行目は、すべて縦線になります。 下から3行目は、シードが縦線で、そうでない場合は2対戦者の中間に横線を引きます。

|  -   -  | |  -   -  |
| | | | | | | | | | | |
A B C D E F G H I J K L
   Line 44 〜 50: トーナメント表の上部行の作成
4行目から上部は縦線の行と横線の行が交互に続き、ループで直前行から現在行を生成することができます。 3行目から4行目を、4行目から5行目をというふうに進め、縦線1本になったらトーナメント表の作成は終了です。 while ループ内では、2つの正規表現を交互に適用するようになっています。正規表自体は難しいところはないので、 解析してみてください。

トーナメントの対戦者数が 2 の累乗になっている場合は、 シードを割り当てる必要がないことが知られています。また、2 の累乗のトーナメント表は簡単に作成でき、 シード割り当ての出発点とすることができます。例えば、対戦者数が 9 〜 15 では 16 (2 の 4 乗) のトーナメント表を用意し、17 〜 31 では 32 (2 の 5 乗) のトーナメント表を用意し、 そこから対戦者数になるまでシードを割り当てていくことができます。

シード割り当ての順序は、一見しただけでは規則性がないように見えます。 しかし、対称関係にある位置に着目すると、順序付けのヒントを得ることができます。 例として、対戦者数 32 から左端のみがシードされているトーナメント表を示しましょう。

                             |
              -------------------------------
             |                               |
      ---------------                 ---------------
     |               |               |               |
  -------         -------         -------         -------
 |       |       |       |       |       |       |       |
 --     ---     ---     ---     ---     ---     ---     ---
|  |   |   |   |   |   |   |   |   |   |   |   |   |   |   |
|  -   -   -   -   -   -   -   -   -   -   -   -   -   -   -
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z a b c d e
  (8)     (4)             (2)                             (1)

極端に小さなトーナメント表は別ですが、それ以外では内部に小さなトーナメント表が含まれます。 32 のトーナメント表では 16 のサイズが2つ、8 のサイズが4つ、4 のサイズが8つ、というような案配です。 左端にシードを割り当てたら、それぞれのサイズに対応する1つの対称位置があります。 上の表の (1) はトーナメント表全体で、(2) は 1/2 サイズで、(4) は 1/4 サイズで、(8) は 1/8 のサイズでの対称位置を意味します。

シードの割り当ては、左端に無条件に割り当てることから始まります。 それ以降は、基本的には左端の対称位置の (1), (2), (4), (8) の順番にシードを割り当てます。 ただ、それだけではなく、割り当てられた対称位置も独自の対称位置を持っています。 その場合には、対称位置の属するトーナメント表よりも大きなトーナメント表があれば、最上位 (トーナメント表全体) から1つ大きなトーナメント表までの右側にある対称位置にシードを割り当てます。 さらに、新しく割り当てられた対称位置も同様の操作をするので、再帰的な処理をすることになります。

d-e(1) はトーナメント表全体の対称位置なので、そこで終わりになります。N-O(2) は 1/2 のサイズの対称位置なので P-Q(1) が、F-G(4) では X-Y(1) と H-I(2) が、B-C(8) では b-c(1) と L-M(2) と D-E(4) が、A の対称位置から派生する対称位置となります。 言葉だけではわかりにくいところがあるので、シード割り当ての過程と、 順位を加えたトーナメント表を次に示します。なお、最後の T-U にはシードを割り当てることはありません。 なぜなら、T-U に割り当てる必要があるなら、最初から 16 のサイズのトーナメント表を用意すればよいことになるからです。

      対称位置
A     d-e(1), N-O(2), F-G(4), B-C(8)                             |
d-e:  -                                           -------------------------------
N-O:  P-Q(1)                                     |                               |
P-Q:  -                                   ---------------                 ---------------
F-G:  X-Y(1), H-I(2)                     |               |               |               |
X-Y:  -                               -------         -------         -------         -------
H-I:  V-W(1)                         |       |       |       |       |       |       |       |
V-W:  -                              --     ---     ---     ---     ---     ---     ---     ---
B-C:  b-c(1), L-M(2), D-E(4)        |  |   |   |   |   |   |   |   |   |   |   |   |   |   |   |
b-c:  -                             |  -   -   -   -   -   -   -   -   -   -   -   -   -   -   -
L-M:  R-S(1)                        | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | |
R-S:  -                             A B C D E F G H I J K L M N O P Q R S T U V W X Y Z a b c d e
D-E:  Z-a(1), J-K(2)                1  9  13   5   7  15  11   3   4  12   -   8   6  14  10   2
Z-a:  -
J-K:  T-U(1) -- 割り当てず

いままでに解説してきたシード割り当てのアルゴリズムを、 プログラムのコードとして実装しなければなりません。トーナメント表の順位を観察してみると、2 の1乗までが全体の両端、2 の 2 乗までが 16 のサイズの端、2 の 3 乗までが 8 のサイズの端、残りが 4 のサイズの端であることがわかります。そこで、データ構造として、最初に 2 の累乗数の文字列 ('xxxxxxxx', 'xxxxxxxxxxxxxxxx' 等) を用意して配列 @comb に入れて分割することにしました。 文字列の両端がシード割り当ての対象になり、シードを割り当てたら 'xx' を 'ZZ' に変更します。 そして、両端とも 'ZZ' になった文字列は、等分の2つの文字列に分割して、分割面に接した 'xx' が後のシード割り当ての対象になります。次の例は、対戦者数 32 〜 17 までのシード割り当ての様子を示したものです。

32  xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
31  ZZxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
30  ZZxxxxxxxxxxxxxx xxxxxxxxxxxxxxZZ
29  ZZxxxxxx xxxxxxZZ xxxxxxxxxxxxxxZZ
28  ZZxxxxxx xxxxxxZZ ZZxxxxxx xxxxxxZZ
27  ZZxx xxZZ xxxxxxZZ ZZxxxxxx xxxxxxZZ
26  ZZxx xxZZ xxxxxxZZ ZZxxxxxx ZZxx xxZZ
25  ZZxx xxZZ ZZxx xxZZ ZZxxxxxx ZZxx xxZZ
24  ZZxx xxZZ ZZxx xxZZ ZZxx xxZZ ZZxx xxZZ
23  ZZ ZZ xxZZ ZZxx xxZZ ZZxx xxZZ ZZxx xxZZ
22  ZZ ZZ xxZZ ZZxx xxZZ ZZxx xxZZ ZZxx ZZ ZZ
21  ZZ ZZ xxZZ ZZxx ZZ ZZ ZZxx xxZZ ZZxx ZZ ZZ
20  ZZ ZZ xxZZ ZZxx ZZ ZZ ZZ ZZ xxZZ ZZxx ZZ ZZ
19  ZZ ZZ ZZ ZZ ZZxx ZZ ZZ ZZ ZZ xxZZ ZZxx ZZ ZZ
18  ZZ ZZ ZZ ZZ ZZxx ZZ ZZ ZZ ZZ xxZZ ZZ ZZ ZZ ZZ
17  ZZ ZZ ZZ ZZ ZZ ZZ ZZ ZZ ZZ ZZ xxZZ ZZ ZZ ZZ ZZ

ざっと眺めて、どんな感じを受けるでしょうか。後半部分はともかく、31 から 28 あたりの部分は適切にシードが割り当てられている様子を見ることができます。単一だった文字列が分割される過程は、 シード割り当ての対象が小さな単位のトーナメント表に移行していくことに対応しています。 プログラム中では、2つのケースに分けてシードの割り当てをしています。

  1. すべての文字列の長さが同じ (文字列が1つの場合を含む) 場合は、1番目の文字列にシードを割り当てる。32, 31, 30, 28, 24 の状態から、次のシードの割り当てがこれに該当します。左端と左端からの対称位置への割り当ては、 この部分のコードが適用されます。

    while ($seed--) {
      if (length($comb[0]) * @comb == $invol) {   # 1番目の文字列の文字数 x 文字列の数
        $comb[0] =~ s/^xx|xx$/ZZ/;
        if ($comb[0] =~ /^ZZx*ZZ$/) {             # 文字列を2つに分割する
          my $half = length($comb[0]) / 2;
          splice @comb, 0, 1, (substr($comb[0], 0, $half), substr($comb[0], $half));
        }
      } else {
        ...
      }
    }

    if 条件部が適用されるのは、それほど多くありません。32 (Perl 流の書き方で 2 ** 5) トーナメント表では 5 回であり、指数の数値と一致し 5/15 の割合になります。64 (2 ** 6) では 6 回 (6/31) だけで済み、128 (2 ** 7) でも 7 回 (7/63) と、トーナメント表が大きくなるほど全体の増加に対して if 条件部の適用率は少なくなります。

  2. 文字列の長さの違う文字列が混在 (同時に存在するのは2種類) する場合は、if 部のような簡単な規則性がなく少し複雑な処理になります。左端からの対称位置のシード割り当ては if 部が担当しますが、そこから派生する新たなシード割り当ては else 部のコードが担当します。上の文字列の表の 23, 22, 21, 20 の状態を例にすると、次のシードの割り当ては太字で示してある 'xx' です。

    23  ZZ ZZ xxZZ ZZxx xxZZ ZZxx xxZZ ZZxx xxZZ    (9)
    22  ZZ ZZ xxZZ ZZxx xxZZ ZZxx xxZZ ZZxx ZZ ZZ   (10 -> 5)
    21  ZZ ZZ xxZZ ZZxx ZZ ZZ ZZxx xxZZ ZZxx ZZ ZZ  (11)
    20  ZZ ZZ xxZZ ZZxx ZZ ZZ ZZ ZZ xxZZ ZZxx ZZ ZZ (12 -> 6 -> 3)

    シードの割り当ては、左右対称になるような形で進みます。左右対称であるか否かは、 文字列の数を数えてみるとわかります。文字列の数が奇数の場合 (23 と 21) はトーナメント表全体で非対称になっていることを意味し、対称となっていない 'xx' を 'ZZ' に変更します。偶数の場合 (22 と 20) は、文字列の数を半分にして奇数 (22) であれば 1/2 のサイズのトーナメント表での非対称を意味します。それでも偶数 (20) の場合は、さらに半分にすると奇数となり 1/4 のサイズのトーナメント表で非対称になっていることになります。ここでの操作をまとめると、 非対称となっているトーナメント表を探り出して、非対称を解消する位置にシードを割り当てることです。

    while ($seed--) {
      if (length($comb[0]) * @comb == $invol) {
        ...
      } else {
        my @target = 0 .. $#comb;
        @target = 0 .. ($#target - 1) / 2 while $#target % 2;    # 非対称のシード表を探索
        my @group_1 = 0 .. $#target / 2;                         # 非対称のシード表を前半と後半に分ける
        my @group_2 = ($#target / 2 + 1) .. $#target;
        splice @comb, $group_2[0], scalar(@group_2), map { scalar(reverse $comb[$_]) } reverse @group_1;
      }
    }

    配列の要素が奇数か偶数かは、スカラーコンテキストで配列を評価するか、 あるいは配列の末尾の添字 ($#array) を見ればわかります。末尾の添字で判断する場合は 0 から数えるので、奇数であれば要素数は偶数、偶数であれば要素数は奇数になります。上記のコードでは、配列の添字を @target にコピーして、非対称のトーナメント表を抽出しています。

    非対称のトーナメント表を見つけだしたら、次はシード割り当ての位置を探し出す作業があります。 23, 22, 21, 20 の例で示してある太字の 'xx' を見てもわかるとおり、規則性という面で簡単でなく、 トーナメント表が大きく (64, 128 ... 等) なるとさらに複雑になります。ここでは、少し発想を変えて解決策を見つけました。 結局は、非対称のトーナメント表を左右対称になるようにシードを割り当てることです。 そこで、非対称のトーナメント表を前半部と後半部 (合計文字数は同じだが、前半部が1つ要素数が多い) の2つに分けて、後半部を前半部で置き換えることにしました。置き換えるといっても splice の文を解析するとわかりますが、前半部の要素を反転して、さらに個々の要素の文字列も反転しています。

シードの割り当てが終了したら、トーナメント表を書き出してプログラムは終了します。 プログラムの対戦者数が最大で 52 となっているのは、 対戦者名にアルファベットの大文字と小文字を使っているためです。対戦者名を割り当てずに 'X' のように同じ文字を使えば、100 でも 1,000 でもプログラム自体の実行には何の問題もありません。

(2012/04/01)

TopPage