PHPerKaigi 2025 PHPER CODE BATTLE

PHPer コードバトル最終コード鑑賞・解説

投稿者:

はじめに

先日は PHPerKaigi 2025 へのご参加ありがとうございました!

3/21 day0 におこなわれた催し物のひとつに、PHPer コードバトルがあります。

PHPer コードバトルとは、指示された動作をする PHP コードをより短く書けた方が勝ちという 1 対 1 の対戦コンテンツです。事前におこなわれた予選を勝ち上がった 6名のプレイヤーが、トーナメント形式でバトルをおこないました。

作問に tadsan さん、解説役に m3m0r7 さん・tadsan さんを迎えて白熱したバトルが繰り広げられ、大盛況のうちに幕を閉じました。

試合結果

トーナメントの結果は以下のとおりです。

  • 準々決勝1: muno92 さん vs. chatii さん
    • → muno92 さん勝利
  • 準々決勝2: meihei さん vs. astandkaya さん
    • → meihei さん勝利
  • 準決勝1: takaram さん vs. muno92 さん
    • → takaram さん勝利
  • 準決勝2: stefafafan さん vs. meihei さん
    • → stefafafan さん勝利
  • 決勝: takaram さん vs. stefafafan さん
    • → takaram さん勝利

予選から決勝までの激戦を制し、見事 takaram さんが優勝されました!

優勝を決めた決勝の問題と回答はこちらです。

問題

標準入力の最初の行に出力の最大行数 $max が、次以降の行に置換リストが 数,名前 の形式で改行区切りで入力されます。置換リストに含まれる数は2以上の素数で、昇順に入力されます。1 から $max までの連続した整数を改行区切りで出力してください。その際、出力しようとしている数が置換リストの数の倍数なら対応する名前に置き換え、その数が置換リストに含まれる複数の数の公倍数ならリスト内の数が小さい順に連結して置き換えてください。

入力:
10
2,Dizz
3,Fizz
5,Buzz

出力:
1
Dizz
Fizz
Dizz
Buzz
DizzFizz
7
Dizz
Fizz
DizzBuzz

回答

$x = fgets(STDIN);
while ([$n, $m] = fgetcsv(STDIN)) {
    $a[$n] = $m;
}

for ($i = 0; $i++ < $x; $s = '') {
    foreach ($a as $n => $m)
        $i % $n || $s .= $m;

    echo $s ?: $i, "
";
}

15分という時間制限の中、コードゴルフらしく for 文を駆使し、|| 演算子を技巧的に用いてシンプルな実装を導かれています。

改めて、takaram さん優勝おめでとうございます!

まだ勝負は続く…

さて、決勝戦の後、バトルに用いられたコードゴルフサイトが一般開放されました。これにより、予選のコードも含めたコードバトルで用いられた全問題が、PHPerKaigi の会期終了まで挑戦できるようになりました。

この記事では、PHPerKaigi の裏で密かに繰り広げられていたコードゴルフにおいて、最終的な1位コードを紹介・解説しようと思います。

注記

このコードゴルフシステムは、以下のルールのもと動いていました。手元で動かす場合とは結果が異なる可能性があります。

スコアはコード中の全 ASCII 空白文字を除去した後のバイト数です。また、先頭や末尾に置かれた PHP タグ (<?php<??>) はカウントされません。

同じスコアを出した場合、より提出が早かったプレイヤーの勝ちとなります。

この環境の PHP バージョンは 8.4.4 です。 mbstring を除くほとんどの拡張は無効化されています。 また、ファイルやネットワークアクセスはできません。

テストの成否は、標準出力へ出力された文字列を比較して判定されます。 末尾の改行はあってもなくても構いません。 標準エラー出力の内容は無視されますが、fatal error 等で実行が中断された場合は失敗扱いとなります。

なお、error_reportingE_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED に設定されています。

なお、PHPerKaigi 会期中はシステムにバグがあり、実際にはテストが通らないにも関わらずランキングに載ってしまっていた回答がいくつかありました。現在はバグを修正しており、そのランキングを基に紹介をおこないます。また、ルールにあるように、同一のスコアの場合には提出が早かったプレイヤーのコードを取り上げます。

練習問題

問題

標準入力から数字が一つ入力されます。二乗して標準出力へ出力してください。

コード

hamaco さん:

echo fgets(STDIN) ** 2 ?>

解説

練習問題と侮るなかれ、今回のルール下で重要なテクニックが用いられています。

注目すべきはこのルールです。

また、先頭や末尾に置かれた PHP タグ (<?php<??>) はカウントされません。

PHP では、閉じタグの直前で ; が自動的に補われます。これを今回のルールと合わせて使うと ; の1バイトを踏み倒すことができます。

参考: https://github.com/php/php-src/blob/011795bcbebc0d7022d70ded6bf114c3b424ec71/Zend/zend_language_scanner.l#L2474

予選ラウンド1

問題

標準入力にスペース区切りの整数col rowが与えられます。「かける数」は1〜col、「かけられる数」は1〜rowまでの連続した整数です。全ての組み合わせのかけ算の結果が右揃えに整列するように出力してください。空白の個数は最も横幅が長いマスに合わせてください。

コード

nsfisis (この記事の筆者):

fscanf(STDIN, "%d %d", $c, $r);
for (; $i++ < $r; print "
")
  for ($j = 0; $j++ < $c; )
    printf("%*d", 4 - ($c * $r < 99), $i * $j)?>

解説

全体の構造は、かける数・かけられる数でそれぞれ2重にループし、各数字を決められた幅で整列させて出力するという単純なものです。

コードゴルフでは、for 文をいかに活用するかが焦点となってきます。内側にある for 文から見てみましょう。

  for ($j = 0; $j++ < $c; )
    printf("%*d", 4 - ($c * $r < 99), $i * $j)?>

インクリメントと条件の判定を同時におこなうことで $j を書く回数を減らしています。また、PHP の for 文や if 文では、内部で1つの文しか実行しないなら波括弧 ({ / }) を省略することができます。

for 文の中では、printf() を使って積を出力しています。このとき、* を使うことで幅を動的に指定しています。

必要な幅の計算は、真面目におこなうなら strlen($c * $r) となるでしょうが、ここでは 4 - ($c * $r < 99) という式を使っています。これを評価すると、最大値が 100 未満なら幅3に、100以上なら幅4になります。これは strlen($c * $r) よりも1バイト短くなっています。お気づきのとおり、最大値が 1000 を越えると対応できなくなりますが、そのようなテストケースは存在しないものとして無視しています。予選のテストケースの準備には十分な時間をかけられておらず、このような抜け道がいくつかあります (あまり入出力のサイズを大きくしすぎるとサーバに負担がかかるという理由もあります)。

それでは外側の for ループについても見てみましょう。

for (; $i++ < $r; print "
")
  // 略

この for 文では、$i の初期値を代入していません。PHP で未定義の変数に対してインクリメント演算子を使うと、その変数が 1 で初期化されます。警告は出ますが、今回のシステムでは E_WARNING レベルのエラーを無視するようになっているため、結果に影響を及ぼしません。

また、ループの更新式で print を呼んでいます。次のようにして「1行分出力し終わったら改行」とするのが自然ですが、

for (; $i++ < $r; ) {
  for ($j = 0; $j++ < $c; )
    printf("%*d", 4 - ($c * $r < 99), $i * $j)?>
  echo "
";
}

これだと for 文の中身が2文となり、波括弧を省略できなくなってしまいます。echo から print に変えることで 1バイト増えてはしまいますが、波括弧が2バイト節約でき、セミコロンも1バイト減るので差し引き2バイト分の短縮です。

なお、改行の出力は普通次のように書くと思いますが、

echo "\n";

PHP では改行コードを直接文字列中に埋め込むことができます。

echo "
";

更に、今回のシステムではソースコード中のスペースや改行をコードサイズにカウントしないため、この変更だけで2バイト分の短縮となります。これは今後もほぼすべての回答で頻出するテクニックです。

予選ラウンド2

問題

「いろはにほへとちりぬるを」の12種類の文字から構成される文字列が改行区切りで入力されます。標準入力の内容を全て読み取り、文字列を「いろは」順に並べて出力してください。

入力:
るりいろ
りぬる
いちにち
ほへとちに
いち
ほとはろ
りにへ
いい
いいい
とりほい
いろ
いろいろ

出力:
いい
いいい
いろ
いろいろ
いち
いちにち
るりいろ
ほへとちに
ほとはろ
とりほい
りにへ
りぬる

コード

nsfisis (筆者):

$l = file('php://stdin');
usort(
  $l,
  fn ($a, $b) => strtr($a, $o = array_combine(str_split("いろはにほへとちりぬるを", 3), range("a", "l"))) <=> strtr($b, $o)
);
echo join($l)?>

解説

まずはソートの戦略から説明します。

最初に「い」は “a”、「ろ」は “b”、「は」は “c” のようにいろはを ABC へ置換します。アルファベットだけにしてしまえば、後はそのままソートすれば終わりです。では、具体的にどのようにおこなっているのかを見ていきましょう。

全体は次のような構造となっています。

$l = file('php://stdin');
usort(
  $l,
  fn ($a, $b) => /* 略 */
);
echo join($l)?>

file() は、指定したファイル名のファイルを1行ずつ読み出し、配列に格納して返す関数です。問題文の例にある入力であれば、次のような配列が $l へと代入されます。

$l = file('php://stdin');
// => [
//   "るりいろ\n",
//   "りぬる\n",
//   "いちにち\n",
//   "ほへとちに\n",
//   "いち\n",
//   "ほとはろ\n",
//   "りにへ\n",
//   "いい\n",
//   "いいい\n",
//   "とりほい\n",
//   "いろ\n",
//   "いろいろ\n",
// ]

これを usort() でいろは順ソートした後、全行を join() で結合して出力します。join()implode() のエイリアスで、3バイトも短いのでコードゴルフでは常にこちらを使うことになります。

それでは、実際にいろは順ソートを実現している、usort() へ渡した比較関数を見てみます。

  fn ($a, $b) => strtr($a, $o = array_combine(str_split("いろはにほへとちりぬるを", 3), range("a", "l"))) <=> strtr($b, $o)

strtr() には複数の呼び出し方がありますが、コードゴルフだと配列を用いた一括置換が便利です。これは、第1引数の文字列から第2引数の配列のキーを探し、対応する値へと置き換えます。この操作を、第2引数の配列の全キーについておこないます。

strtr() に渡している配列について詳しく見てみます。

array_combine(str_split("いろはにほへとちりぬるを", 3), range("a", "l"))

array_combine() は配列を2つ受け取り、1つ目をキー、2つ目を値とした配列を構築します。

キー側の配列は次のように、

str_split("いろはにほへとちりぬるを", 3)
// => ["い", "ろ", "は", "に", "ほ", "へ", "と", "ち", "り", "ぬ", "る", "を"]

値側の配列は次のように評価され、

range("a", "l")
// => ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"]

最終的に array_combine() から次のような結果が得られます。

array_combine(str_split("いろはにほへとちりぬるを", 3), range("a", "l"))
// => [
//   "い" => "a",
//   "ろ" => "b",
//   "は" => "c",
//   "に" => "d",
//   "ほ" => "e",
//   "へ" => "f",
//   "と" => "g",
//   "ち" => "h",
//   "り" => "i",
//   "ぬ" => "j",
//   "る" => "k",
//   "を" => "l",
// ]

このテーブルを元に strtr() で置換すると、"るりいろ\n""kiab\n" に、"りぬる\n""ijk" となり、単なる宇宙船演算子 (<=>) で比較できるようになります。

なお、この置換は比較関数の両引数に対しておこなわれるため、array_combine() の結果は $o へと代入され使い回されています。

ところで、str_split() については別途説明が必要かもしれません。この関数は文字列をバイト単位で区切って配列にする関数です。平仮名には対応していませんが、UTF-8 において平仮名が3バイトで表現されることを使うと、str_split() の第2引数に 3 を指定することで平仮名1文字へと分割できます。これは mb_str_split("いろはにほへとちりぬるを") と書くのと同じですが、str_split() の方が関数名が短いため、,3 の分を含めてもより短くなっています。分割後の配列を直接書く場合と比べ、クォートの数やコンマの数が減らせるため、配列の要素が多くなると str_split() の方が有利になってきます。

予選ラウンド3

問題

標準入力からRFC 4180準拠の行区切りのデータが与えられます。行は name,value のような構造になっています。nameごとにvalueを集約し、name,value1,value2,...のようなCSVとして出力してください。ただし、行はnameのアルファベット昇順、列はnameを先頭にして、valueをアルファベット昇順でそれぞれソートして出力すること。

入力:
Foo,zzz
Foo,xxx
Bar,ccc
Bar,bbb
Foo,yyy
Bar,"a, aa, aaa"
Bar,aaa
Foo,"hello, world"

出力:
Bar,"a, aa, aaa",aaa,bbb,ccc
Foo,"hello, world",xxx,yyy,zzz

コード

LuckyWind さん:

?>Bar,"a, aa, aaa",aaa,bbb,ccc
Foo,"hello, world",xxx,yyy,zzz

解説

これはテストケースの不備を突いた解法 (いわゆる嘘解法) になります。先ほどもあったように、予選問題のテストケースは不完全なものも多く、この問題はその典型例です。察した方もいらっしゃるかもしれませんが、この問題のテストケースは問題文にある例の1件のみです。したがって、この出力例をそのまま出力するようなコードを書けば、真面目にアルゴリズムを実装しなくてもテストを通すことができます。

しかし、この回答は単に echo しているだけではありません。

// 単なる echo による出力
echo 'Bar,"a, aa, aaa",aaa,bbb,ccc
Foo,"hello, world",xxx,yyy,zzz'?>

より短縮するため、この回答では ?> で PHP コードから抜け出して出力内容をそのまま記載しています。PHP を CLI で動かす場合、PHP タグの外にあるテキストはそのまま標準出力へと出力されます。これによって echo やクォートを省略しています。

エキシビション1

問題

標準入力から改行区切りでカタカナ文字列の単語が入力されます。全ての行を読み取り、最初に入力された行から開始して「しりとり」になるように単語を改行区切りで順番に出力してください。一度使った文字を再利用することはできません。出力した単語の最後の文字が「ン」で終わったときは"負けました\n"と出力して、プログラムを終了してください。最初の文字と最後の文字は重複しないように入力されます。

入力:
リンゴ
ライオン
ゴリラ

出力:
リンゴ
ゴリラ
ライオン
負けました

入力:
ルーレット
ネイルサロン
トレイ
アキアカネ
イタリア

出力:
ルーレット
トレイ
イタリア
アキアカネ
ネイルサロン
負けました

コード

nsfisis (筆者):

$l = file('php://stdin');
$c = substr($l[0], 0, 3);
for (; ($e = substr($c, -4, 3)) != 'ン'; )
  foreach ($l as $_)
    if (strpos($_, $e) === 0) {
      echo $c = $_;
      break;
    } 
echo "負けました
"?>

解説

この回答はこれまでに出てきたテクニックだけで構成されているため、詳しい解説は省略します。

  • file() で一括読み込み
  • mb_*() を使わない
  • for の活用
  • 波括弧の省略
  • 不要な処理の削除

エキシビション2

問題

標準入力から行区切りのデータが入力されます。行末の改行コードを取り除いた入力文字列について、2進数で表現した際にビットで表現した時の1の個数を行ごとに合計した数を改行区切りで出力してください。たとえば文字列のXは数値で表すと88ですが、2進数で表示すると01011000なので、1のビットの個数は3になります。

入力:
ABC
Hello, world

出力:
7
47

コード

takaram さん:

while ($l = fgets(STDIN)) {
    $c = -2;
    $p = 'str_split';
    foreach ($p($l) as $h)
        $c += array_sum($p(decbin(ord($h))));
    echo "$c
";
}

解説

この回答のみ、他の回答とは異なり当日の制限時間内 (15分) で提出されたものです。

肝になるのは array_sum() です。

decbin() によって二進数になった文字列から 1 を数えるのに、サンプルコードでは str_split() を呼んで 1文字ごとに分割して array_filter()count()1 の数を数えていました。

しかし、二進数の文字列を str_split() した配列は "0""1" の2通りの要素しか持たないので、各要素を数値とみて array_sum() で合計すれば 1 の数が数えられるという寸法です。

また、ここでは PHP の可変関数を使って、str_split と書く回数を 1回に抑えています。関数名が長い場合や呼び出し回数が多い場合、このテクニックが使えることがあります。ただ、今改めて数え直してみたところ、この問題だとむしろ str_split を2回書いた方が短くなるようです。

str_split(aaa);str_split(bbb);
$p='str_split';$p(aaa);$p(bbb);

なお、サンプルコードでは入力の各行に対して rtrim() を呼んでいましたが、このコードでは呼んでいません。そのためカウント時に改行コード (LF = 0x0A = 10) の分も数えてしまうのですが、これは $c の初期値を -2 にすることで打ち消しています。10 の pop count は 2 なので、ちょうどプラスマイナスゼロになるというわけです。

オンライン予選1

問題

標準入力から改行区切りのデータが入力されます。
最初の行は「プレイヤー1の名前,プレイヤー2の名前」の形式です。
次以降の行は「グー,チョキ」「チョキ,パー」のように「プレイヤー1の手,プレイヤー2の手」が入力されます。プレイヤー1の名前が「X」プレイヤー2の名前が「Y」のとき、手の入力に合わせて「Xさん ✊ vs Yさん ✋ => Yの勝ち」「Xさん ✌️ vs Yさん ✋ => Xの勝ち」のように行ごとに出力してください。
手の種類は「グー」(✊ = "\u{270A}")「チョキ」(✌️ = "\u{270C}")「パー」(✋ = "\u{270B}")の3つです。
また、最初の行を含めて区切り文字以外に , が入力されることはありません。

コード

nsfisis (筆者):

while ($r = [$a, $b] = fgetcsv(STDIN))
  $n ? print
    "$n[0]さん " .
    ($f = fn (&$_) => mb_chr($_ = 9996 - ord($_[2]) % 3))($a) .
    " vs $n[1]さん " .
    $f($b) .
    " => " .
    ($a ^ $b ?
      $n[($a - $b + 2) % 3] . "の勝ち
" : "あいこ
") : $n = $r?>

解説

まずは「グー」「チョキ」「パー」のテキストから絵文字へ変換する処理を見てみましょう。

mb_chr($_ = 9996 - ord($_[2]) % 3)

UTF-8 でエンコードされた「グー」「チョキ」「パー」の 3バイト目 (ord($_[2])) は次のようになっています:

  • グー: 176
  • チョキ: 129
  • パー: 145

これを 3で割った余り (ord($_[2]) % 3) はこうなります:

  • グー: 2
  • チョキ: 0
  • パー: 1

これを 9996 から引くと次のようになり、これは指定された絵文字の Unicode コードポイントと一致します。

  • グー: 9994
  • チョキ: 9996
  • パー: 9995

この数値を後の処理のために $_ へと保存しておき、mb_chr() で文字列に直します。
この処理は 2人分おこなわれるので、関数リテラルを作って $f に入れて使い回しています。
最終的に $a$b にそれぞれの手の絵文字の Unicode コードポイントが代入されます。

次に、これを使って「~の勝ち」や「あいこ」を判定する処理を見てみましょう。

まず、$a$b が一致していればそれはあいこです。$a == $b は長いので、$a ^ $b を使います。
$a$b が一致するときに 0 となるので、$a ^ $b ? "あいこでない" : "あいこ" とします。

勝者がどちらかを判定するのには、($a - $b + 2) % 3 という式を使います。
この式は、1人目が勝ったら 0、2人目が勝ったら 1 になります。
あいこ以外の 6パターンで確認してみてください。

最後のポイントは、fgetcsv() の呼び出し回数の削減です。
1行目にはプレイヤーの名前が入力されるので、素直に書くならループの外でも fgetcsv() を呼ぶ必要があります。

しかし、fgetcsv(STDIN) はそれなりに長いので、2回も書きたくありません。

これを削減するため、このコードでは次のような形にしています。途中の結果出力部を省略したものがこちらです:

while ($r = [$a, $b] = fgetcsv(STDIN))
  $n ? print "結果を出力" : $n = $r?>

1行目では $n が定義されていないので $n = $r が実行されます。
2行目以降では $n が定義されているので print が実行されます。

$r への代入や三項演算子が長いように思えますが、これでも fgetcsv(STDIN) の方が高くつきます。

オンライン予選2

問題

標準入力から数字が改行区切りで入力されます。
出力例のような横2文字縦3行のサイズの罫線文字から構成される文字として、各桁の文字を横に連結して、入力行ごとに出力してください。
アスキーアートに含まれる余白部分は、通常の「半角スペース("\x20")」です。
また、入力される数字は 0 または正の整数です。

入力:
0123456789
88888

出力:
┌┐ ┐╶┐┌┐╷╷┌╴┌┐┌┐┌┐┌┐
││ │┌┘ ┤└┤└┐├┐ │├┤└┤
└┘ ╵└╴└┘ ╵╶┘└┘ ╵└┘└┘
┌┐┌┐┌┐┌┐┌┐
├┤├┤├┤├┤├┤
└┘└┘└┘└┘└┘

コード

nsfisis (筆者):

for (; $l = fgets(STDIN); )
  for ($i = 0; $i < 3; )
    echo strtr(strtr($l, array_chunk(($s='mb_str_split')("12 2821299161212121200 014 53532├2 0├53534 73634 78434 73434", 2), 10)[$i++]), $s('│┌┐└┘┤╴╵╶╷'))?>

解説

この問題は、いかにしてグリフ部分を圧縮するかがポイントになります。

今回のシステムは UTF-8 でエンコードされたソースコードの「バイト数」がスコアになるため、1つ 3バイトもかかる罫線文字はなるべく書きたくありません。
単純にグリフデータを埋め込もうとするとそれだけでコードサイズが肥大化してしまいます。

これを防ぐための工夫が、2重に重ねられた strtr() です。
まず、内側の strtr() で数字と描画位置からグリフのインデックスに変換し、外側の strtr() でグリフのインデックスから実際の罫線文字へと変換しています。

0 から 9 のインデックスであれば 1バイトで表現できるため、バイト数が大幅に削減できるという寸法です。

グリフの種類は全部で 12種類 (空白含む) あるため、2種類のグリフ (1バイトで表現できる空白と、出現頻度が最も低く圧縮効果の薄い罫線文字1つ) は内側の strtr() で直接変換しています。

準々決勝マッチ1

問題

標準入力から行区切りでテキストが入力されます。テキストに含まれる整数(n>0)を序数表現(1→1st, 2→2nd)のように置換して出力してください。

入力:
1 penguin
2 chance
3 time's the charm

出力:
1st penguin
2nd chance
3rd time's the charm

コード

nsfisis (筆者):

echo preg_replace_callback('/\d\d?\b/', fn ($m) => new DateTime("1-1-$m[0]")->format('jS'), fread(STDIN, 1e3))?>

解説

コードのおおまかな構造は、入力全体を文字列として読み込んで preg_replace_callback() で置換するというものです。

入力の読み込みには fread() を用いています。
fread() は第2引数に指定したバイト数のテキストをファイルから読み込むのですが、ここでは 1e3 (1000) を固定で指定しています。
今回のテストケースは最大のものも 1000 バイトを超えないので、入力をすべて一気に読み込めるというわけです。

preg_replace_callback() はマッチした入力に対してコールバックを呼び出して、その返り値でマッチした部分を置換する関数です。
検索パターンには \d\d?\b を用いています。序数表現を得るには末尾2桁があれば十分なので、\b を使って末尾2桁を取り出しています。

序数表現への変換には、日付フォーマットの S を使っています。これは、指定した日付に応じて stndrd を返すフォーマット指定子です。
ここでは、西暦1年1月N日を日付として使っています (N は入力中に含まれる数値の末尾 2桁)。

なお、これだと「1月54日」のような不正な日付が作られる可能性があります。
コードを提出したときはあまり深く考えずに投げたのですが、今確認したところ、こうした不正な日付が与えられた場合は fatal error になるようです。
今回のテストコードにはそのような入力がなかったため、たまたま動作していました。

準々決勝マッチ2

問題

標準入力から整数widthが入力されます。入力した数字から例のような図形を出力してください。ただし、入力は4以上の整数のみです。

入力:
6

出力:
******
 *
  *
   *
  *
 *
******

コード

tkooler-lufar さん:

for($w = $j = fgets(STDIN); $j + 1;)
    echo ($R = 'str_repeat')(' ', min($i++, $j--)) ?: $R('*', $w - 1)
    , '*
'?>

解説

出力を 1つの for ループで巧みに実装しています。ここでは、入力が 6 (上記の入出力例と同じ) だとして説明します。for 文の中身を見てみると、echo の 2つ目の引数で * と改行を出力しているので、($R = 'str_repeat')(' ', min($i++, $j--)) ?: $R('*', $w - 1) では、それぞれの行で次のようになるはずです。

  • 1行目: ***** (* x 5)
  • 2行目: (空白 x 1)
  • 3行目: (空白 x 2)
  • 4行目: (空白 x 3)
  • 5行目: (空白 x 2)
  • 6行目: (空白 x 1)
  • 7行目: ***** (* x 5)

この 1、2、3、2、1 と一度増えてから減るような数列を生成するにはどうすればよいでしょうか?この回答では min() と 2つのカウンタを使って実現しています。

$j は最初の行の数字から 1ずつ減っていき、$i は 0 から 1ずつ増えていきます。これにより、min($i++, $j--) は次のようになります。

N行目$i++$j--min($i++, $j--)
1行目060
2行目151
3行目242
4行目333
5行目422
6行目511
7行目600

2行目から6行目の min() の値はまさに、出力すべき空白の数に対応しています。これを使って str_repeat() を呼び出せば、所望の数の空白が出力できるわけです。

さらに、1行目と7行目に注目すると、min() の値が 0 になっています。このとき str_repeat() は空文字列を返すので、?: の後ろの $R('*', $w - 1) ($R"str_repeat") が評価されます。ここで $wfor 文の初期化式で 1行目の数 (ここでは 6) が入っているので、5つの * からなる文字列となります。

これで、先ほどの文字列がすべてのケースで生成できました。

  • 1行目: ***** (* x 5)
  • 2行目: (空白 x 1)
  • 3行目: (空白 x 2)
  • 4行目: (空白 x 3)
  • 5行目: (空白 x 2)
  • 6行目: (空白 x 1)
  • 7行目: ***** (* x 5)

最後に、for ループの終了条件について確認しておきます。条件式は $j + 1 となっています。このまま 8行目に突入すると $j-1 になり、 $j + 10 となります。これは falsy な値なので、ループが終了します。

準決勝マッチ1

問題

標準入力から行区切りでテキストが入力されます。テキストに含まれる数字を漢数字に置換して出力してください。置換は一桁ずつおこない、数字が連続している場合でも「十」や「百」にはしないでください。

入力:
1234567890
第8回
ペチパー会議2025
気温は摂氏7度
総勢123人
税込98700円

出力:
一二三四五六七八九〇
第八回
ペチパー会議二〇二五
気温は摂氏七度
総勢一二三人
税込九八七〇〇円

コード

m3m0r7 さん:

<?php
echo strtr(
    fread(STDIN, 99),
    str_split(
        '〇一二三四五六七八九',
        3
    )
)?>

解説

この問題はこれまでに解説したテクニックのみで構成されています。

回答もシンプルにまとまっているので、ぜひここまでの知識を使って自力で読解してみてください。キーワードは以下のとおりです。

  • strtr()
  • fread()
  • str_split()
  • ?>

準決勝マッチ2

問題

標準入力から「西暦年-月」の形式で 2025-02 のような行が1行入力されます。入力された月に合わせて「[Y年M月]」と出力してから、入力された月のカレンダーを日曜始まり形式で整列して出力してください。日付はすべて「3桁空白右寄せ」で表示します。

入力:
2025-02

出力
[2025年2月]
                    1
  2  3  4  5  6  7  8
  9 10 11 12 13 14 15
 16 17 18 19 20 21 22
 23 24 25 26 27 28

コード

m3m0r7 さん:

<?php
[$w, $t] = str_getcsv(date('w,t', $q=strtotime(fgets(STDIN))));

echo date('[Y年n月]
', $q);

for (; $i++ < $t+$w;)
    printf(
        "%3s%s",
        $i <= $w ? ' ' : $i - $w,
        $i % 7 ? '' : "
"
    )
?>

解説

以下の説明では、上記の入出力例にある 2025年2月のカレンダーを仮定します。

まずは入力の受け取りについて説明します。今後の処理のために、2025-02 という文字列から $q$w$t という 3種類のデータを取り出しています。

$q には strtotime(fgets(STDIN)) が代入されます。strtotime() は日付時刻の文字列から Unix タイムスタンプを得る関数ですが、日や時刻部分を省略すると 1日の0時0分0秒とみなされます。

$w$t には、$q (2025-02-01 00:00:00 の Unix タイムスタンプ) を使って「曜日を数値にしたもの (0 が日曜で 6 が土曜)」と「その月の最終日 (28、29、30、31 のいずれか)」が代入されます。これには date()str_getcsv() を用いています。

次に、[2025年2月] の出力について説明します。これは $qdate() で書式化するだけです。n はゼロ埋めなしの月です。

最後に、カレンダーの日付部分の出力について説明します。

コアとなるアイデアは、このような出力をおこなうには、1日の前の空白も含めて 28+6 単位の出力をおこなえばよいというものです。

                    1
  2  3  4  5  6  7  8
  9 10 11 12 13 14 15
 16 17 18 19 20 21 22
 23 24 25 26 27 28

28 に足す「6」は、1日が何曜日かを考えれば出すことができ、$w と同じ値です。

for ループの条件式が $i++ < $t+$w; となっているのはこれに対応しています。

日付部分の生成は、1日の前の空白部分かどうかで分岐するこの式 $i <= $w ? ' ' : $i - $w でおこなっています。

最後に1週間ごとの改行 $i % 7 ? '' : "\n" を入れて完成です。

date() のさまざまな書式を活用し切った美しい回答だと思います。

決勝

問題

標準入力の最初の行に出力の最大行数 $max が、次以降の行に置換リストが 数,名前 の形式で改行区切りで入力されます。置換リストに含まれる数は2以上の素数で、昇順に入力されます。1 から $max までの連続した整数を改行区切りで出力してください。その際、出力しようとしている数が置換リストの数の倍数なら対応する名前に置き換え、その数が置換リストに含まれる複数の数の公倍数ならリスト内の数が小さい順に連結して置き換えてください。

入力:
10
2,Dizz
3,Fizz
5,Buzz

出力:
1
Dizz
Fizz
Dizz
Buzz
DizzFizz
7
Dizz
Fizz
DizzBuzz

コード

takaram さん:

for ($x = fgets(STDIN); !$s = ++$i > $x; $a[] = fgetcsv(STDIN)) {
    foreach ($a as $r)
        $r && $s .= $r[$i % $r[0] + 1];

    echo $s ?: $i, "
";
}

解説

こちらについてはご本人による最終コードの解説記事が上がっていますので、まずはそちらをご覧ください。

実際に置換をおこなっているのは $s .= $r[$i % $r[0] + 1]; の部分です。$s が各行の出力になっていて、$r には [置換対象の約数, 置換後の文字列] の形でルールが格納されています。

$i$r[0] の倍数なら $i % $r[0]0 になるので $i % $r[0] + 11 となり $r[1]、つまり置換後の文字列を参照できます。

そうでないなら $i % $r[0]1 以上の数になるので $i % $r[0] + 12 以上となり $r の範囲を超えて null になります。null を文字列に結合しようとすると空文字列になるので、置換が発生しないというわけです。

さて、この回答の天才的なポイントは、ループを 1つにまとめている点です。普通なら、置換ルールのリストを読み込む部分と数字 (また置換後の文字列) を順番に出力する部分で 2つのループを書かなければならないはずです。

上記の記事でも、以下のような説明があります。

入力を読み込むループと出力するループを1つにまとめてしまっています。

1, 2, 3, … と順番に出力するわけですが、nを出力する段階では、n以下の置換リストだけ読み込めていれば十分です。 問題の制約として、置換リストの入力は「2以上の素数で昇順」というのを考慮すると「1行出力して1行読み込む」で問題ないことがわかります。

問題の制約をうまく使って、出力しながら入力の読み込みも同時におこなうことでループを 1つに圧縮しています。

チャンピオンにふさわしい、華麗な回答でした。

おわりに

以上となります!

今改めて集計してみると、全問題のコードの合計提出数が 5,846 件にまで達していました。楽しんでいただけたようでなによりです。

予選・決勝に参加されたプレイヤーのみなさん、作問いただいた tadsan さん、実況解説いただいた m3m0r7 さん・tadsan さん、また、バトルを応援してくださった観戦者のみなさん、ありがとうございました!