eijirouの競プロ参加記

主に AtCoder Heuristic Contest の解説記事を書く予定です。

AHC015 参加記

seed = 2

トヨタ自動車 プログラミングコンテスト2022(AtCoder Heuristic Contest 015) お疲れ様でした。

161,011,437 点で優勝しました!

順位表

問題概要

 10 \times 10 マスの空の箱があります。そこに 100個のキャンディーを1つずつ追加します。追加する場所は空きマスの中から無作為に選ばれます。事前に  t 番目に受け取るキャンディーの種類が  f_t  (1 \leq f_t \leq 3) であることがわかっています。

1つのキャンディーが追加される度に、箱を前後左右のいずれかの方向に傾けます。箱を傾けると、各キャンディーは箱の壁や他のキャンディーとぶつかるまで移動します。

100個のキャンディーを追加し終えたときに、同じ種類のキャンディーが隣り合わせで連結されているほど得点が高くなります。

傾ける方向をうまく選んで得点を最大化してください。

詳細は公式の問題文を参考にしてください。

atcoder.jp

簡単な考察

探索する場合の探索空間について考えます。傾ける方向が4種類あり、全部で99回傾ける1ため、出力パターンとして  4^{99} 通り考えられます。

ビームサーチなどの手法を用いて探索したくなるかもしれませんが、キャンディーを置く場所が1ターンずつ与えられるという隠しパラメータを含む問題であり、出力パターンに対応する期待値を簡単には求めることはできません。具体的には空きマスの数を  n として  n! 通りの隠しパラメータが考えられます。終盤はともかく、序盤や中盤で隠しパラメータを全探索することは不可能です。

効率よく探索するのが難しそう2なので、まずはルールベース解法で高得点を出すことを考えます。

ルールベース

突然ですが、キャンディーが2種類の問題を考えます3

次のキャンディーがイチゴなら前に傾け、スイカなら後ろに傾けると、イチゴを下に、スイカを上に偏って配置させることができ、高確率で満点をとれます。

キャンディーが2種類のとき

キャンディーが3種類の問題に戻します。

2種類の場合と同じように、種類ごとに特定の場所に偏って配置させることを目指します。

左上にスイカ、右上にパンプキン、下にイチゴを寄せるというように、どの種類をどこに置くかをあらかじめ決めておきます4

イメージ

次のキャンディーと傾ける方向の対応は下の表のようになるでしょう。

次のキャンディー 傾ける方向
イカ
パンプキン
イチゴ

実はこのままだとうまくいきません。

下の画像のようにイチゴが置かれた後、次にパンプキンが来る場合を考えます。

イチゴの後にパンプキンが追加されるとき

左に傾けてもキャンディーは動かず、どこにパンプキンが置かれてもイチゴと混ざってしまいます。

この場合は後ろに傾けると次のようにうまくいくでしょう。

後ろに傾けたとき

この例のように、前のキャンディー、あるいは前に傾けた方向まで考慮してルールを作るとよさそうです。

例えば次のような対応が考えられます。

前のキャンディー \ 次のキャンディー イカ パンプキン イチゴ
イカ 後ろ(右)
パンプキン 後ろ(左)
イチゴ 後ろ 後ろ

括弧を付けたほうを採用すると 133 M 点ほど、括弧が付いていないほうを採用すると 135 M 点ほどになりました5。ルールベース解法だけで順位表の100位程度に入れます6

モンテカルロ法

ルールベース解法は非常に高速に動作して実行時間がもったいないうえ、キャンディーが追加された場所の情報を使用していません。まだまだ最適化の余地がありそうです。

そこで、モンテカルロ法を使用しました。おおよそ次のような流れです。

全体の流れ

  1. 長さ100のルールベース解  a を作成する。
  2.  t = 1, 2, ..., 99 として次の処理を繰り返す。
    1. 入力  p_t を受け取ってキャンディーを置く。
    2. モンテカルロ法で t 回目に傾ける方向を決めて出力する。

モンテカルロ法の流れ

  1. 制限時間を求める。
  2. 制限時間を過ぎるまで次の処理を繰り返す。
    1.  d = 'F', 'B', 'L', 'R' に対して次の処理を繰り返す。
      1. 方向 d に箱を傾ける。
      2. プレイアウトを行う。具体的には  u = t + 1, 2, ..., 100 に対して次の処理を繰り返す。
        1. 空きマスを無作為に選んで種類  f_u のキャンディーを置く。
        2. ルールベースで決めた方向  a_u に傾ける。
      3. スコアを計算する。
  3. スコアの平均値が最も高かった方向を t 回目に傾ける方向として採用する。

t 回目の処理で t 回目に傾ける方向を確定します。各方向に傾けたときの期待値7を知りたいのですが、当然ながら簡単には求まりません。そこで t + 1 回目以降の傾ける方向がルールベースで作成した方向であると仮定し、モンテカルロ法で期待値を推定しました。

t + 1 回目以降に傾ける方向の一部をランダムにするという方法も考えられるのですが、実装してみたらスコアが下がったのでやめました8

上記の方針で 154 M 点ほど獲得できました。順位表の4位相当です。

上記アルゴリズムの改善

上記のアルゴリズムでは t 回目に傾ける方向として前後左右の4方向に対してプレイアウトを同じ回数ずつ行っていますが、期待値が低いとわかっている方向についてはプレイアウトをしなくてよさそうです。

前のキャンディーと次のキャンディーを見て、プレイアウトを行う方向を下の表のように制限しました9

前のキャンディー \ 次のキャンディー イカ パンプキン イチゴ
イカ 後ろ、右 後ろ、左 前、左、右
パンプキン 後ろ、右 後ろ、左 前、左、右
イチゴ 後ろ* 後ろ* 前、左、右

*を付けたところは、候補となっている方向が1つしかないため、プレイアウトを行わずに決定しました。

また、最後の20回については、プレイアウトのコストが低いなどの理由で、キャンディーの種類にかかわらず4方向全てを試すようにしました。

この方針で 161 M 点ほど獲得しました。

以下、細かい実装について説明します。

時間管理

コンテスト中の実装では毎回ほぼ等しい時間を使うようにしました。具体的には、t 回目の処理で残り時間を 100 - t で割った時間を消費するようにしました。

1回のプレイアウトで使用する時間は、およそ 100 - t に比例するため、残り時間の  \frac{100 - t + \alpha}{\sum_{u=t}^{100}{100 - u + \alpha}} を使ったほうがプレイアウトの回数が大体そろっていいかもしれません。 \alpha はスコア計算などの定数部分にあたります。

プレイアウトの回数をそろえた場合、各 t に対し、全方向合わせて1500回程度のプレイアウトを行えました。最大3方向しかプレイアウトを行わないので、各方向に対して少なくとも500回程度はプレイアウトしたことになります。

乱数生成

各方向で使用する乱数を等しくしたほうがいいと思い、乱数を先に生成して使い回しました。

t が変わっても等しい乱数列を使用することで、乱数生成回数を最小限に抑えました。

シミュレーションの実装

初心者向けにシミュレーション部分の実装を簡単に紹介します。言語は C++ です。

grid[x][y] で座標 (x, y) のキャンディーの種類を表すものとします。(x, y) が空きマスのときは grid[x][y] = 0 とします。

キャンディーを置く

左上から順に空きマスを数え、指定した数になったらその場所にキャンディーの種類を代入するだけなので、簡単に実装できると思います。

void put(int f, int p) {
    // put a f-type candy at p
    for (int x = 0; x < n; ++x) {
        for (int y = 0; y < n; ++y) {
            if (grid[x][y] == 0) {
                if (--p == 0) {
                    grid[x][y] = f;
                    return;
                }
            }
        }
    }
    cerr << "Error: p is invalid in State.put" << endl;
}

箱を傾ける

傾ける方向として前後左右の4つが考えられるので、取り敢えず4つに場合分けします10

void tilt(char d) {
    if (d == 'F') {
        // tilt forward
    } else if (d == 'B') {
        // tilt backward
    } else if (d == 'L') {
        // tilt left
    } else if (d == 'R') {
        // tilt right
    } else {
        cerr << "Error: unknown direction '" << d << "'" << endl;
    }
}

前に傾ける場合を考えます。

様々な実装方法が考えられますが、ここでは空間計算量を抑えた実装を紹介します。

// tilt forward
for (int y = 0; y < n; ++y) {
    int cnt = 0;
    for (int x = 0; x < n; ++x) {
        if (grid[x][y]) {
            swap(grid[cnt++][y], grid[x][y]);
        }
    }
}

swap 関数の部分がわかりにくいですが、やろうとしていることは

if (cnt != x) {
    grid[cnt][y] = grid[x][y];
    grid[x][y] = 0;
}
cnt++;

と同じです。cnt == x のときは上に空きマスがないときなので変更する必要がなく、cnt != x のときは最も上の空きマス grid[cnt][y]grid[x][y] を移動させ、grid[x][y] を空にしています。

何度も呼ばれる関数ですので、ある程度は高速化に拘ったほうがいいと思います。

スコアを求める

BFS や DFS で連結成分を数えればよいです。DSU を用いたほうが実装が少し楽かもしれません。

bool in_grid(int x, int y) {
    return 0 <= x && x < n && 0 <= y && y < n;
}

int dfs(int x, int y, array<array<bool,n>,n> &visited) {
    // return the size of the group
    int candy = grid[x][y];
    assert(1 <= candy && candy < 4);
    visited[x][y] = true;
    stack<pair<int,int>> todo;
    todo.push({x, y});
    int cnt = 0;
    while (!todo.empty()) {
        ++cnt;
        auto [x, y] = todo.top();
        todo.pop();
        for (auto [dx, dy] : dxdy) {
            int xd = x + dx;
            int yd = y + dy;
            if (in_grid(xd, yd) && !visited[xd][yd] && grid[xd][yd] == candy) {
                visited[xd][yd] = true;
                todo.push({xd, yd});
            }
        }
    }
    return cnt;
}

int score() {
    array<array<bool,n>,n> visited{};
    int ret = 0;
    for (int x = 0; x < n; ++x) {
        for (int y = 0; y < n; ++y) {
            if (visited[x][y]) {
                continue;
            }
            int group_size = dfs(x, y, visited);
            ret += group_size * group_size;
        }
    }
    return ret;
}

正確なスコアを求めてもよかったのですが、少し面倒だと思ったので、連結成分の大きさの二乗和をスコアとみなしました。

提出コード

コンテスト中のコードを一部変更したものです。

atcoder.jp

最後に

参加記というより解説らしい記事になってしまいましたが、解法は一例ですし、調子に乗って自分の解法を正当化しすぎている可能性があるので参考程度にしていただけたらと思います。

主催企業のトヨタ自動車様、writer の chokudai さん、wata さん、参加者の皆さん、ありがとうございました。

次の AHC も楽しみにしてます。

最後まで読んでいただきありがとうございました。


  1. 100回目は箱を傾けてもキャンディーが動かないので考えないことにします。
  2. 効率がいい探索手法を私が思いつけなかっただけで、もしかしたらあるかもしれません。
  3. コンテスト中に2種類の場合の問題を考えたわけではありません。説明をわかりやすくするために書きました。
  4. コンテスト中に上、右、左下というような分け方も試しましたがうまくいきませんでした。また、どの種類のキャンディーをどこに配置するかを事前情報から決定するとよさそうですが、あまり本質ではないと思います。出現回数が少ない種類のキャンディーが下に配置されるようにしたつもりでしたが、コンテスト中の私の提出ではバグでパンプキンが下にくるように固定されていたと思います。
  5. [11/4 追記] 前のキャンディーだけでなく、前に傾けた方向まで見ないと高スコアを出せないかもしれないです。私の実装では前に傾けた方向も使用していました。詳しくは提出コードをご覧ください。
  6. 130 M 点台の人が多いので、ちょっとした改善で20位ぐらいまで伸びるかもしれません。
  7. 毎回、最適な方向に傾けたときの期待値のことです。無作為な方向に傾けたときの期待値ではありません。
  8. モンテカルロ木探索の要領で、プレイアウトでうまくいった方向を優先して採用すればうまくいくかもしれません。私は実装していません。
  9. 手動で設定しました。前に傾けた方向や直前にキャンディーが置かれた位置なども考慮すれば、事前の枝刈りを細かく設定できそうです。プレイアウトを繰り返す途中で枝刈りするという手法も考えられます。
  10. 賢い人は場合分けの数を減らせるかもしれません。

AHC014 参加記

estie プログラミングコンテスト2022(AtCoder Heuristic Contest 014) お疲れ様でした。

プレテストの結果は 67,798,337 点で 11 位、システムテストは 2,757,683,578 点で 10 位でした。AHC の最高順位は今まで 25 位だったので、大きく更新できて嬉しいです。

問題概要

頂点を 1 つ増やして長方形を書き足すという操作を繰り返し、頂点の重みの総和を最大化する問題です。頂点の重みは中心からのユークリッド距離の二乗で定義されています。

操作を行うときに次の条件を満たす必要があります。

  • 長方形のちょうど 3 頂点が既に存在している。
  • 長方形の外周上に他の頂点が存在しない。
  • 他の長方形と辺を共有しない。(辺が交わってもよい。)

詳しくは公式の問題文を参照してください。 atcoder.jp

方針

焼きなまし法を使用しました。いくつかの操作を取り消して新しく操作をやり直すということを、焼きなまし法の遷移として採用しました。

部分的破壊と修復を繰り返すというのは AHC001 でも見られた方針だと思います。

操作の依存関係

書き加えた頂点を別の操作で使用することがあるため、操作には依存関係があります。依存関係を無視して操作を取り消すと無効な解となってしまうため、依存関係を適切に把握する必要があります。

次の例を考えてみます。

書き加えた頂点が長方形を構成する他の 3 頂点に依存するため、次のような依存関係があります。

後に追加した頂点は先に書いた長方形の辺上にあるため、この 2 つの操作は入れ替えることができず、次のような依存関係もあります。

locks[x][y] = 頂点 (x, y) が他の頂点から依存される回数

とすると locks[x][y] = 0 となる (x, y) が削除可能な頂点ということになります。

破壊アルゴリズム

次のようなアルゴリズムで解を部分的に破壊します。

  1. 破壊する矩形領域を無作為に決定する。
  2. 削除可能な頂点を列挙する。
  3. 削除可能な頂点をシャッフルしてスタックに積む。
  4. 次の処理を何度か行う。
    1. スタックのトップの頂点を削除する。
    2. 新しく削除可能になった頂点をスタックに積む。

操作の優先度 その1

やみくもに探索するよりも、「よさそうな解」を優先して探索したほうが上手くいきました。

「よさそうな解」とは、例えば次のようなパターンをもっているものだと考えました。

軸に平行な正方形は市松模様のように 1 つおきに配置されていて、斜めの正方形は 1 列飛ばしで配置されていることがわかります。

  • 軸に平行な  1 \times 1 の正方形は、左下の頂点 (x, y) について x + y が偶数なら優先する。
  • 斜めの  1 \times 1 の正方形は、偶数列目にあるなら優先する。

などというように、偶奇を決め打ちして優先度をつけました。

上下左右の領域で独立させたかったため、全体を次のように 4 つにわけ、それぞれで偶奇を決め打ちしました。

焼きなまし法の初期解として、偶奇のパターンを 256 通り全探索してスコアが高かったものを採用しました。

操作の優先度 その2

可能な操作の数が多いほどよさそうだと考えたため、可能な操作を増やす操作を優先しました。

オセロにおける、可能な指手が多いほど有利であるという特徴を参考にしました。

操作の優先度 その3

大きい長方形を使うと頂点が疎になりそうだったので、周の長さが短い長方形を少しだけ優先しました。

解の絞り込み

単純な焼きなまし法よりも、多点スタートで少しずつ解を絞り込んだほうがスコアが上がりました。

その他

vector をできるだけ array に変えたり、関数のインライニングをしたり、ちょっとした高速化・最適化でもスコアが上がった気がします。パラメータ調整も重要だったと思います。

提出コード

言語は C++ にしました。

入力生成、テスト、無駄な関数を含めて900行弱なので、長期 AHC の割には実装が短かったと思います。

atcoder.jp

日記

考察したことを雑に書いておきます。

1日目

問題文を読んで、2 週間コンテストにしては設定が単純だと思いました。

とりあえず操作を可能な限り行う貪欲法を実装しようと決めました。

ビームサーチはやろうと思えばできそうですが、グリッドが小さいとは言えないうえに途中の評価が難しそうだと思いました。

操作に依存関係があるので焼きなまし法は難しそうだと思いました。

2 ~ 5 日目

貪欲法の実装をしました。貪欲法で 33 M 点、random playout で 38 M 点ほどのスコアを出しました。

貪欲法では、可能な操作を増やす操作を行うようにすると少しスコアが上がりました。

AHC013 のように、依存関係をうまく解消して焼きなまし法に落とし込めると強そうだなあと考えていたら、依存されていないものから操作を取り消せばいいということに気づいて、操作の取り消しを行う関数を実装しました。この関数の実装には時間がかかりました。

焼きなまし法を完成させて 44 M 点ほどのスコアを出しました。

6 ~ 7 日目

局所探索をよさそうなものに限って行えば、焼きなまし法の精度が上がりそうだと考えました。

モンテカルロ木探索の要素を取り入れたり、得点への寄与率のような概念を取り入れたりしましたが上手くいきませんでした。

8 ~ 11 日目

よいパターンについて真剣に考えました。そこで市松模様のように 1 個おきに正方形を書いたら密な解を得られることに気づきました。

思いついた些細なアイディアが半分以上当たり、どんどんスコアが伸びました。このときが一番気持ちよかったです。

パラメータ調整や高速化も行い、66 M 点ほどのスコアを出しました。

12 ~ 15 日目

スコアが伸び悩みました。

可能な操作を増やす操作を優先するようにしたら少しスコアが伸びました。

bowwowforeach さんの AHC013 の参加記 を読んでいたら「状態を複数持つ」というのがあって、実装したら少しスコアが伸びました。

パラメータ調整を再び行い、最終的に 68 M 点ほどのスコアを出しました。

環境

大学生の夏休みということで 2 週間ほぼ全てを AHC に使えるぐらいの時間がありました。運動をしに出かけたり、映画を観たり、気分転換する時間があったのはよかったです。

マシンリソースはノート PC 1 台だったので、大量のテストを行うには時間がかかりました。コードの変更を行うたびに、序盤は 80 ケース、終盤は 800 ケースを試して評価しました。800 ケースでもスコアがぶれやすかったので、サーバを借りてテストケースを増やすとよかったかもしれません。

反省点

改善できそうな部分について述べます。

枠について考える

公式の解説配信 で述べられていたことです。

頂点から伸ばせる辺が最大 8 つあり、その枠の合計を減らさないように評価値をつけると確かにうまくいきそうだなあと思いました。

適切に実装すれば「操作の優先度 その1 その2」の上位互換になりそうです。

破壊方法

私は外側から操作の取り消しを行いましたが、ある頂点を削除すると決めて、その頂点に依存する全ての頂点を削除すれば、内側の部分もうまく焼きなませてスコアが上がりそうです。

高速化

焼きなましの状態として、点の有無や辺の有無についての情報を保持する必要があります。

私は edge[x][y][d] = (x, y) から方向 d に突き進んだときに最初にぶつかる頂点 という少し変わったものを採用しましたが、各直線を 64 bit 整数で表現したほうがビット演算で高速化ができそうだと思いました。

ちなみに、私の提出における焼きなましの試行回数は 1 万 ~ 15 万回程度で、テストケースによってまちまちでした。

最後に

反省点はあるものの、自己ベストを大きく更新できたのでよかったです。

writer の wata さん、主催企業の estie さん、参加者の皆さん、ありがとうございました。

次のコンテストも楽しみにしてます。

最後まで読んでいただきありがとうございました。

AHC011 参加記

Seed=0 に対する出力

AtCoder Heuristic Contest 011 お疲れ様でした。パズル系のAHCも楽しいなと感じました。

暫定テストは39,923,027点で28位でした。システムテストで順位が上がって欲しいところです。

問題概要

スライディングブロックパズルを解く問題です。

 N\times N の盤面上に  N^{2}-1 個のタイルが敷き詰められていて、各タイルは上下左右の少なくとも1方向に線が伸びています。空きマスへのスライドを最大  K 回繰り返して全域木を構築してください。

Seed=0 の入力

全域木を構築できなかった場合は最大の木の大きさが評価され、全域木を構築できた場合はスライド回数の少なさが評価されます。

詳しくは公式の問題文を参照してください。 atcoder.jp

制約

 6 \leq N \leq 10

 K = 2 \times N^{3}

方針

次の流れで全域木を構築しました。

  1. 辺の追加と閉路の削除を繰り返し、構築可能な全域木を見つける。
  2. 初期状態と全域木の間で、タイルのマッチングを求める。
  3. 1.と2.を0.7秒ほど繰り返す。
  4. chokudai search (ビームサーチの亜種) で適切にスライドする。

全域木の見つけ方

まず、入力生成方法と同様の方法で木を生成します。

具体的には、全ての辺をシャッフルして、閉路ができないように1本ずつ辺を追加して全域木を生成します。

ここで生成した木はタイルの種類ごとの枚数が合いません。

そこで次の操作を繰り返しました。

  1. 辺を1本だけ追加する。新たに閉路が1つできる。
  2. 閉路をDFSで検出して、閉路を構成する辺のうち1本を削除する。連結性が保たれており、全域木に戻る。

例えば次の木を最初に得たとします。

初期状態

青い辺を追加したとします。

辺の追加

下図の黄緑の辺の中から1本選んで削除します。

閉路検出

このような辺の追加と削除を繰り返しました。

ところが上記の操作をランダムに行っても、枚数が完全に一致することはめったにありません。

そこで、タイルの種類ごとに目標枚数と現在の枚数との差分をとって、その値を2乗したものをコストに設定し、コストが小さくなるように辺の追加や削除を行うようにしました。

このようにコストを適切に設定することで、高速に構築可能な木を見つけることができます。 (遅くても0.1秒以内に1個は見つかると思います。)

マッチングの求め方

全域木を見つけても、同じ種類のタイルが複数存在するため、どのタイルをどこに移動させるかは自明ではありません。

後でビームサーチを行うにあたり、タイルの対応を先に決めておくと実装しやすそうです。 (マッチングが動的で、かつ、高速に動作するビームサーチは思いつきませんでした。)

種類ごとの完全二部マッチングとなるので、最小費用流でマッチングを求めました。コストには、マンハッタン距離の2乗を採用しました。

最小費用の完全マッチングが求まったところで、1つ問題が発生します。それは、そのマッチングが実現可能かどうかです。例えば15パズルの14と15を入れ替えたものは解くことができません。スライディングパズルを解くことができるかどうかは有名問題であり、検索したら多くのサイトが見つかりました。例えば次のサイトです。

manabitimes.jp

判定方法はいくつかありますが、私は置換のパリティの偶奇で判定しました。奇数の場合は実現不可能なので、適当に1組を選んで入れ替えました。

ルールベース解法

ビームサーチを紹介する前に、もっと単純で確実に揃える方法を紹介します。

スライディングパズルを手動で解く場合、多くの人は1枚ずつ端のタイルから揃えていくのではないでしょうか。それをプログラムに落とし込むだけです。

角を1枚ずつ揃えるのは非効率で、2マスを一気に揃えるようにしました。その2マスがswapした状態だと面倒で、2マスを入れ替えるように例外処理を書きました。

このような1枚ずつ愚直に揃えるアルゴリズムでも、少なくとも  3.5 \times 10^{7} 点ぐらいまで到達できたと思います。

chokudai search

単純な揃え方では、移動中のタイル1枚にしか着目しておらず、効率がいいとは言えません。

そこで探索を行うことを考えます。探索空間について考えると、前と逆方向に移動しても無意味だから、ターン毎に3つほどの選択肢があるため、解答の長さを  l として  3^{l} ほどになります。 n が小さければA*サーチでうまく枝刈りができるかもしれませんが、今回は計算量的に難しそうです。

そこで chokudai search を使用しました。素朴なビームサーチではなく chokudai search を使用した理由は、時間管理がしやすいからです。

状態のコストには、各タイルから最終位置までのマンハッタン距離の総和を採用しました。距離の総和が0になったとき、全域木が得られたことになります。

ただし、chokudai search をうまく動作させるために、評価関数に3つほど工夫を加えました。

  • 空きマスが自由に移動できるように、空きマス自体にはコストを定めない。
  • 隣り合う2マスがswapした状態から2マス両方を揃えるのは大変なので、その状態にペナルティを課す。
  • 隣り合う2マスがswapした状態になりにくいように、まだ揃っていないマスで、上下または左右のマスが揃っているようなものにペナルティを課す。

2つ目と3つ目は、距離の総和は小さいが、手動で揃えるときに嬉しくないものを低く評価しようと思って追加しました。

chokudai search のデメリットとして、メモリ使用量が大きくなりやすいことが挙げられます。そこで、1つのターンにつき保持する状態の個数に上限を設けて、メモリ使用量が大きくなりすぎることを防止しました。具体的には、SegmentTreeを用いて最もコストが大きいものを書き換え、最もコストが小さいものを採用しました。

問題点

 N が大きいときにビームサーチも chokudai search もうまく動作しませんでした。

理由の1つとして、およそ  N^{5} に反比例してビーム幅が減ることが挙げられます。状態のコピーがボトルネックになっていそうで、グリッド面積、すなわち  N^{2} に計算量が比例します。また、スライド回数は大体  N^{3} に比例します。よって、1本のビームを走らせるのに  N^{5} に比例した計算量が必要となります。

うまい対処法が思いつかなかったので、 N \geq 8 のときは、1枚ずつ揃えるアルゴリズムで上  N - 7 行、左  N-7 列を揃えた後に chokudai search を行いました。

上1行と左1列を先に揃える

 N が大きい場合をうまく処理できれば、もっと高い順位になれたのではないかと思います。

その他

その他の工夫を簡潔に書いておきます。

  • 全域木の生成では、途中から最善の木を編集するようにして、木に対して山登り法を適用する。
  • ルールベース解を一応作成しておき、その解の長さを chokudai search の最大ターン数に設定する。ルールベース解が保険のようになって、実際に使用される場合がある。

提出コード

C++で実装しました。1089行です。

リファクタリングをしていないため、読みづらいかもしれません。

atcoder.jp

他の人との比較

私と同様に、木生成、マッチング、ビームサーチという流れを実装した人が多かったように思います。ただ、ビームサーチは普通のものを使用した人が多そうで、 chokudai search をした人は少なそうです。

ビームサーチに関して、評価関数や多様性の確保の仕方などは人によって異なるようで、上位者とはそこで差をつけられたように感じました。

最後に

とても楽しかったですし、ビームサーチのいい練習になったと思います。

最後まで読んでいただきありがとうございました。

第8回 Asprova プログラミングコンテスト 参加記

f:id:eijirou_kyopro:20220318104338p:plain

第8回 Asprova プログラミングコンテスト お疲れ様でした。

暫定テストでは得点率99.2536%で2位、システムテストでは得点率99.2106%で4位でした。

問題概要

円環状に繋がったコンベアの生産スケジュールを組む問題です。

何種類かの製品があり、作る製品を変える間は別の製品を作ることができません。また、同じ形状の製品は同時に一定数までしか作ることがきません。

スケジュールの長さと作業員の手間の両方を最小化します。2つの指標の優先度はテストケースごとに異なります。

円環状というのが特徴的で、周期をフックの数に合わせることで作業員の手間が減るようになっています。

方針

1色ずつ順番に処理しました。

周期性が生まれるように周回数を決めました。

条件を変えて貪欲法を何度か試しました。焼きなまし法もビームサーチも使っていません。

形状と色の順番

形状は同じ順番で何周も計画され、色は1周します。どちらもTSP(巡回セールスマン問題)を解くことで(ほぼ)最適な順番を得ることができます。

TSPは、現在いる頂点と訪問済みの頂点集合を考えることで O(N^{2} 2^{N}) で解くことができ、十分高速です。

周期性の揃え方

input0001 の color = 2 のケースを例に説明します。

形状の順番は 2,4,3,5,1,6 で、1周あたりの形状を変えるコストは39です。

1周あたり 250 - 39 = 211 個の製品を作って、形状6で終わっているようなスケジュールが理想的です。

N22 N42 N32 N52 N12 N62
88 49 39 91 19 48
K_2 K_4 K_3 K_5 K_1 K_6
76 53 47 78 23 48

数量をハンガーの数で割ることで、最低何周する必要があるかを求めることができます。

この例の場合だと、形状2から順に 2,1,1,2,1,1 周する必要があるため、最低でも形状2から5までは2周、形状1と形状6は1周する必要があります。(形状を 2,4,3,5,1,6,2,4,3,5 の順に変えるということです。)

N22 / 2 N42 / 2 N32 / 2 N52 / 2 N12 / 1 N62 / 1 sum
44 24.5 19.5 45.5 19 48 200.5

周期が 200.5 < 211 で、理想的なものよりも少しだけ短くなっています。

周期が長い場合は、周回数を増やすことで周期を短くすることができます。この例の場合、形状1や形状6も2周するようにすれば周期を短くできます。

周期がフックの数に近い場合は、最初と最後の個数を調整することで周期を微調整できます。

この例の場合では、最初に作る形状2の製品の個数を35個程度にすると上手くいきます。

N22 N42 N32 N52 N12 N62 N22 sum
1周目 35 24 20 45 19 48 20 211
2周目 33 25 19 46 none none none 123

1周目と2周目で個数がほぼ揃っていることがわかると思います。

「最初と最後の個数を調整する」と述べましたが、前後の色の影響もあり、具体的にいくら増やす(減らす)と上手くいくのかはよくわからなかったため、いくつか試して得点が高いものを採用しました。

-1か-2か

ハンガーを回収するときは-1、ハンガーを吊るしたままにするときは-2を出力します。

作業員の手間も最小化する必要があるため、可能な限りハンガーを吊るしたままにしておくとよいです。

ところが、ハンガーを回収するべきか否かをその時点で判断するのは難しいです。

そこで、とりあえずハンガーを吊るしたままに設定しておき、ハンガーが足りなくなったら吊るしたままの空ハンガーを後からゲットするというようにしました。すなわち、必要に応じて後から-2を-1に書き換えました。

ひたすら試す

形状や色の順番を変えて何度も試すことで得点が上がりました。

逆順を試したり、最初のものを変えたりしました。

高速化

何度も試すには高速化が必要になります。

私の実装だと、シミュレータのコピーが重くなっており、シミュレータに含まれる解答部分を削除してからコピーすることで高速化できました。解答の長さは数千になるため、コピーに時間がかかっていたようです。

ハイブリッド

解の評価には、作業員の手間と計画の長さという2つの指標が用いられており、テストケースによって優先度が大きく異なります。

f:id:eijirou_kyopro:20220318104459p:plain

今までの方針だと、a(作業員の手間にかかるペナルティ)が非常に大きいレアケースに対応できなさそうだと考え、ハンガーの位置を固定した解法を追加しました。

f:id:eijirou_kyopro:20220318105915p:plain

早く切り上げる処理を適切に入れることで、ある程度は計画を短くできるようにしました。例えば上図の長方形で囲んだ部分では、黄色の正方形を連続で置くことができるにもかかわらず、早く切り上げて黄色の五角形を優先しています。

こちらの解法に約50ms、元の解法に残りの時間を費やして、得点が高いほうを採用しました。

実装について

シミュレータと制御側でクラスを分けました。

同じ製品が連続で並ぶため、まとめて処理することもできそうですが、うまく実装できませんでした。1つずつ製品を並べる形式になっています。

上位陣との比較

f:id:eijirou_kyopro:20220318104600p:plain

wleiteさんが作った統計データによると、私の解法は、aが中途半端で製品数が多いときに強く、aが極端な値で製品数が少ないときに弱かったらしいです。別の言い方をすると、得点を得にくいケースでは上位で、得点を得やすいケースでは比較的下位になっていました。ハイブリッドにしたつもりでしたが、あまり上手く動作しなかったようです。

私が調べた限りだと、上位にはハンガー固定方針と焼きなまし法を組み合わせている人が多かったように感じます。その方針だと、aが大きいときにかなり上手くいきそうな感じがします。

aの値や製品数などで場合分けをして、適切に焼きなまし法を用いると高得点が出そうです。

没にした方針(おまけ)

没にした方針の中で、個人的に面白いと思ったものを紹介します。

  1. 形状の順番について、TSPを解いて決めます。
  2. 周回数 l を決めます。
  3. それぞれの形状に対して製品の数を l 個に分割します。色が異なるものをまとめてもよいです。
  4. 最小費用流で l 個の周回を得ます。
  5. l 個の周回の順番について、TSPを解いて決めます。

最小費用流について

S = 4, C = 4, l = 6 の場合を例にとって説明します。

f:id:eijirou_kyopro:20220318104634p:plain

同じ形状のものを横に並べ、inからoutに向かって辺を追加します。

形状を変えるところは全結合にして、形状変化のコストと色変化のコストのうち大きいほうを辺のコストに設定します。

辺の容量は全て1で、 l = 6 だけ流します。

経路の復元により、 l 個の周回を得ることができます。

計算量はおよそ O(Sl^{3}\log Sl) で十分高速に動作します。

得点が低かったので没にしましたが、aが小さいときには効果的な方針かもしれません。

最後に

コンテストを主催してくださったAsprovaさん、コンテスト環境を提供していただいたAtCoderさん、熱い戦いを繰り広げた競技者の皆さん、統計データをとっていただいたwleiteさん、最後まで読んでいただいた読者の皆さん、ありがとうございました。これからもよろしくお願いします。

AHC008 参加記

f:id:eijirou_kyopro:20220226233422g:plain

MC Digital プログラミングコンテスト2022(AtCoder Heuristic Contest 008) お疲れ様でした。コンテスト時間がいつもより長く、その分実装が重いものになって大変でした。

暫定テストでは5,500,555,555点で 位でした。システムテストで順位が落ちそうなことが心配です。

問題概要

30\times 30 マスからなる部屋の中に N 匹のペットと M 人の人がいます。うまく仕切りを設置して、ペットが入ってこれないスペースを確保してください。

人を上下左右に動かしたり、上下左右に仕切りを設置したりして、うまくペットを隔離する問題です。インタラクティブ形式で、ペットの動きはターンごとに標準入力で与えられます。

得点の計算方法や各ペットの動きなどは公式の問題文を参照してください。

atcoder.jp

制約

10\leq N \leq 20

5\leq M \leq 10

方針

罠をたくさん仕掛けて、ペットが罠にはまったときに出入り口を封鎖しました。

ここでいう罠とは、出入り口が狭くて簡単に封鎖できる領域のことです。ビジュアライザを見ればわかると思います。

罠の張り方は色々考えられます。最終的には斜めに伸ばした通路を罠にしましたが、もっと良い方法があるかもしれません。

罠の張り方について

簡単なものから順に紹介します。

※それぞれの解法に私が獲得した点数をつけていますが、実装方法によって得点が変わると思います。

何もしない作戦

まず、人が何もしないものを提出しました。

f:id:eijirou_kyopro:20220226233552g:plain

2,041,339点で、ほぼ最低点です。

得点の計算方法を見ると、同じ連結成分に属するペットの数に対して指数関数的に得点が減少することがわかります。よって、広い領域を確保することよりも、ペットを隔離することのほうが優先順位が高そうです。

1マス確保作戦

ペットを隔離するのは少し難しそうなので、人間を隔離するものを提出しました。

f:id:eijirou_kyopro:20220226233632g:plain

11,132,901点で、何もしないときの6倍近くの点数になりました。

部屋分け作戦

点数を伸ばすには、人間の領域をもっと広くする必要があります。そこで部屋分けをするという考えが思い浮かびました。

f:id:eijirou_kyopro:20220226233715g:plain

9個に部屋を分け、ペットが多い部屋を閉めるようにしたところ、2,027,135,417点を獲得しました。 10^{10} を100%とすると、20%のスコアを獲得したことになります。コンテスト開始2日目で暫定トップになりました。

f:id:eijirou_kyopro:20220226233755p:plain

愚直にこの部屋分けを行おうとすると、犬が中央の部屋に高確率でいてうまくいきません。そこで、犬を左上に誘導することで、先に犬だけを閉じ込めるようにしました。犬は人を追いかけるので厄介ですが、逆に、誘導して閉じ込められる唯一のペットでもあるわけです。このように、犬を狭い場所に誘導して閉じ込める作戦を「犬誘導作戦」と呼ぶことにします。

9個の部屋分けを行うということは、9個(正確には8個)の罠を張るというようにも捉えることができます。罠の数を増やせば、ペットをより狭い領域に閉じ込められそうです。

横縞作戦

正方形の部屋分けは連結性の確保(部屋を閉じても他の部屋同士の連結性が担保されていること)が少し難しいと考え、行を4つにわけるようにして罠を張りました。

f:id:eijirou_kyopro:20220226233821g:plain

3,566,267,413点を獲得しました。

よく考えると、通路3列の左右の列の仕切りはなくてもいいので消しました。

f:id:eijirou_kyopro:20220226233905g:plain

4,668,407,438点まで伸びました。

犬誘導作戦にターン数を割きすぎていると考え、犬誘導作戦をなくしました。

f:id:eijirou_kyopro:20220226233942g:plain

5,003,567,273点を獲得しました。

この方針でどの程度まで得点を伸ばせるかを概算してみます。

ペットを閉じ込める前の時点での、仕切りが占める割合のことを「仕切り占有率」と呼ぶことにします。横縞作戦では、9列は仕切りがほぼなく、残り21列の仕切り占有率が約50%なので、全体の仕切り占有率は約35%になります。

1部屋閉じるごとに4~9マス減少するため、理想的に動いても約53億点(53%)しか得点を得ることができません。(ペットを隔離しきれないケースがあるため、理想よりも少し低い点数になっていました。)

もっと仕切り占有率が低い方針がよさそうです。

部屋を高くする作戦

横縞作戦では横長の部屋だったので、今度は少し高さを出してみました。

f:id:eijirou_kyopro:20220226234039g:plain

4,035,896,807点に下がってしまいました。

仕切り占有率は約28%ですが、入口が狭くてペットが罠に入りにくくなり、ペットを隔離しきれないケースが増えてしまいました。部屋を閉じたときに最大14マス減少するというのもよくなさそうです。

ジグザグ作戦

人は速く通過できるが、ペットは速く通過できない通路を考えた時に思いついたのがジグザグ作戦です。

f:id:eijirou_kyopro:20220226234121g:plain

3,949,690,969点でこれもうまくいきませんでした。

仕切り占有率は30%ですが、思ったよりペットを罠にかけにくかったようです。

斜め縞作戦

罠を斜めに張れば、仕切り占有率を低くしたまま、ペットを閉じ込めやすくできると考えました。

f:id:eijirou_kyopro:20220226234158g:plain

4,652,039,059点を獲得しました。

6分割(人間用通路が5本)は分割数が多すぎる気がしたので、5分割に変更しました。

f:id:eijirou_kyopro:20220226233422g:plain

5,270,763,887点を獲得しました。

仕切り占有率が約29%で、1部屋閉じるごとに7~10マス減少するため、理想的に動けば約56億点(56%)獲得できます。

ローカルでテストケースごとの点数を分析したところ、 M=5 のような人が少ないケースでペットを隔離しきれないものが多いことがわかりました。4列の人間用通路を5人で網羅するのは難しいようです。

ハイブリッド作戦

罠の張り方によって、ペットの閉じ込めやすさに違いが出てくるため、 N,M で場合分けをしました。

およそ M\leq 6 のときは横縞作戦、 M=7 のときは5分割の斜め縞作戦、 M\geq 8 のときは6分割の斜め縞作戦にしたところ、5,500,555,555点を獲得しました。(実際には N も少しだけ考慮しました。)

罠の張り方のまとめ

よい罠の張り方とは

  • 仕切りを置いても連結性が担保される。
  • 仕切り占有率が低い。
  • 仕切りを置いたことによって到達不可能になるマスが少ない。(罠が狭い。)
  • ペットが罠にはまりやすい。
  • ペットが罠から抜け出しにくい。
  • 罠の出入り口が少ない。

などの条件を満たすものだと思います。

牛と豚と兎、犬、猫でそれぞれ性質が異なり、全てのペットに対応した罠を張ることが難しかったです。

また、 N,M の値によって、全ペットの隔離の成功のしやすさが変わってくるのも難しいところです。

罠を張る仕事の割り振りについて

近くにある仕切りをいくつかまとめて、30個程度のグループに分割した後、それらを手の空いた人に割り振るようにしました。

f:id:eijirou_kyopro:20220226234245p:plain

工夫した点をいくつか紹介します。

双方向に仕切りを作れるようにする

左から右に移動するケースと右から左に移動するケースの両方に対応できるようにしました。

仕切りを2マスに一気に置く

「1マス動く→1マス置く」を繰り返すよりも、「平均2マス動く→2マス置く」を繰り返した方が少し効率がよくなります。2マスに仕切りを一気に置こうとすることで、ペットのせいで片方に仕切りを置けなくてももう片方に仕切りを置けるケースが出てくるからです。

他の人から遠い仕事を割り振る

Aさんの仕事が終わり、Aさんに新たな仕事を割り振るときのことを考えます。Aさんから見て最も近い仕事を割り振るのではなく、仕事を基準にして、最も近い人がAさんになるようなものを選びました。

このような処理を入れることで人の移動を減らせると思います。

ペットの閉じ込め方について

下図のようにブタが斜めの通路にいるケースを考えます。

f:id:eijirou_kyopro:20220226234312p:plain

このとき、紫の場所から内側に向かって仕切りを設けたいので、紫の場所に移動要求を出します。

また、上図の猫のように、ペットが人間用通路(縦の通路)にいるときは、優先度を低めにしてその場所に移動要求を出すことにしました。

それぞれの人が最も近い移動要求から順に仕事をこなすようにしました。

犬や猫をひきつけてから仕切りを置いたり、人の横移動を減らしたりするなど、いくつかの工夫を行いました。詳細は省略します。

実装のコツ(初心者向け)

実装が苦手な初心者向けに実装のコツを紹介します。自己流ですので参考程度にしてください。

シミュレータ部分と作戦部分を分離する

ペットや人間の動きをシミュレートして入出力も行うシミュレータと、人間に指示を出す作戦部分でクラスを別にしました。

シミュレータを一度作ってしまえば、どんな方針であれ、ほとんど同じものを使い回すことができます。書き直す部分がわかりやすくなり、方針を変えたときの負担が減少します。

シミュレータには欲しい機能をたくさんつけました。BFSで目的地に近づく方向を返す関数や、全ての人が連結状態であるかを確認する関数など、いくつかの関数を使いまわしました。

assert文を入れる

assert文は、条件式がfalseのときにエラーを出してくれます。今回のコンテストでは、予期しないケースが起きやすく、エラーだらけでまともにコードが動かない状態に陥りやすいです。assert文を適切に配置することでデバッグを行いやすくなります。(私の場合、コンテスト中だけで何十回もassert文にひっかかったと思います。)

特にシミュレータ部分にassert文を配置すると、WAやREを防ぎやすくなると思います。

提出コード

ハイブリッド作戦を行なったため、コードが長くなりました。約1900行です。

atcoder.jp

上位との差(反省点)

自分よりも上位の解法ツイートなどを見て気づいた点を挙げておきます。

  • 罠をグリッド全体に張らない方針でも高スコアを出せる。
  • 仕事の割り当て方をもっと計画的にできる。
  • ペットを捕まえることを最小カット問題に帰着できる。
  • 終盤でもペットが捕まらなかった場合に無理やり捕まえるといい。
  • 部屋(罠)を 1\times 4 の大きさにして、うまく実装すると高得点を出せる。

コンテスト中は罠の張り方ばかり考えてしまいましたが、ペットを捕まえるアルゴリズムのほうで差をつけられたような気がします。

最後に

2週間のコンテストでしたが、上位層の間でもある程度の差がついていて、とてもやりがいがあるコンテストでした。

最後まで読んでいただき、ありがとうございました。

AHC002 解説

f:id:eijirou_kyopro:20220211183916p:plain
Seed=0 の出力

AtCoder Heuristic Contest 002 の解説です。

コンテスト中は2,786,732点で380位、コンテスト後は6,019,141点で6位相当のスコアを出しました。ここではコンテスト後の解法を紹介します。

紹介する解法は一例です。もっといい解法があると思います。

問題概要

50\times 50 マスのグリッド上にタイルが敷き詰められています。各タイルは 1\times 1,1\times 2,2\times 1 のいずれかの大きさです。各マスには得点が設定されており、そのマスを通ったときに得点を得ることができます。 (s_i,s_j) からスタートするような経路で、各タイルをたかだか一度しか通らないもののうち、得点ができるだけ高いものを求めてください。

方針

探索順に優先度をつけたDFS(深さ優先探索)を0.25秒間行い、その後、2点選んでその間を変更する形で焼きなまし法を行いました。

なお、この解法は1位のats5515さんのツイートを参考にしています。 https://twitter.com/ats5515/status/1386324082581405705

DFSについて

貪欲法の欠点

貪欲解法の1つとして、移動可能なマスのうち、最も得点が高いものを選ぶというものが考えられます。

実装例(クリックで展開)

#pragma GCC target("avx2")
#pragma GCC optimize("O3")
#pragma GCC optimize("unroll-loops")

#include <bits/stdc++.h>
//#include <atcoder/all>
using namespace std;
//using namespace atcoder;

struct Solver {
    vector<int> dx = {-1,1,0,0};
    vector<int> dy = {0,0,-1,1};
    int n = 50;
    int si;
    int sj;
    vector<vector<int>> t = vector<vector<int>>(50, vector<int>(50));
    vector<vector<int>> p = vector<vector<int>>(50, vector<int>(50));
    int t_max = 0;
    vector<bool> used;
    int score = 0;
    vector<int> x;
    vector<int> y;

    void input() {
        cin >> si >> sj;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                cin >> t[i][j];
            }
        }
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                cin >> p[i][j];
            }
        }
    }

    void init() {
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                t_max = max(t_max, t[i][j]+1);
            }
        }
        used = vector<bool>(t_max, false);
    }

    void push(int i, int j) {
        assert(!used[t[i][j]]);
        used[t[i][j]] = true;
        x.push_back(i);
        y.push_back(j);
        score += p[i][j];
    }

    void greedy() {
        push(si, sj);
        while (true) {
            int best_d = -1;
            int best_p = -1;
            for (int d = 0; d < 4; d++) {
                int xd = x.back() + dx[d];
                int yd = y.back() + dy[d];
                if (0 <= xd && xd < n && 0 <= yd && yd < n) {
                    if (!used[t[xd][yd]] && best_p < p[xd][yd]) {
                        best_d = d;
                        best_p = p[xd][yd];
                    }
                }
            }
            if (best_d == -1) {
                break;
            }
            push(x.back()+dx[best_d], y.back()+dy[best_d]);
        }
    }

    void print() {
        for (int i = 0; i < x.size()-1; i++) {
            for (int d = 0; d < 4; d++) {
                if (x[i]+dx[d] == x[i+1] && y[i]+dy[d] == y[i+1]) {
                    cout << string(abs(x[i+1]-x[i])+abs(y[i+1]-y[i]), "UDLR"[d]);
                    break;
                }
            }
        }
        cout << endl;
    }
};

int main() {
    Solver solver;
    solver.input();
    solver.init();
    solver.greedy();
    solver.print();
}

この提出だと約180,078点しか得ることができません。なぜでしょうか。ビジュアライザを見てみましょう。

f:id:eijirou_kyopro:20220211183955p:plain
貪欲法の出力

赤がスタート地点で緑が最終地点を表しています。"6"の字を描くように、最終地点から先へは動けなくなっていることがわかります。

少し戻って別の経路を試すとよさそうです。長い経路を求めたいので、DFSを行うことにします。

単純なDFS

上下左右の順に移動できるなら移動する、という形式のDFSを1.9秒間行うと約3,081,655点を獲得できます。

f:id:eijirou_kyopro:20220211184056p:plain
単純なDFSの出力

ビジュアライザを見ると、右下のタイルが使われていないことがわかります。上や左への移動を優先させたことが原因であると推測できます。そこで、上下左右ではない、何らかの優先度をつけて探索するとよさそうです。

得点を優先度にしたDFS

貪欲法を延長して、得点が高いものから順に探索するようにしても約2,804,578点しか得られません。

f:id:eijirou_kyopro:20220211184148p:plain
得点を優先度にしたDFSの出力

貪欲法のときと同じように"6"の字を描いて先へ進めなくなっていて、左上を探索できていません。もっと全体を探索できるように優先度をつけるのがよさそうです。

中心からのユークリッド距離を優先度にしたDFS

優先度 priority を次のように定義します。

priority = (x-25)^{2} + (y-25)^{2}

すると、中心から遠い点を優先して探索するため、理想的には円を描くようにして中心に近づきます。

f:id:eijirou_kyopro:20220211184227p:plain
ユークリッド距離を優先度にしたDFSの出力

ビジュアライザを見るとわかるように、先ほどのDFSより改善されて、約4,462,468点を獲得できます。

焼きなまし法について

上記のビジュアライザの紫色の部分に注目してみます。

f:id:eijirou_kyopro:20220211184327p:plain
拡大画像

38->33 ではなく 38->91->57->33 のように通れば、スコアが148点増加します。このように、DFSだけしか行っていないものには最適化の余地が残っています。

そこで、長さ10程度の部分を壊して片方からDFSを行い、スコア変化に応じて繋ぎ直すという処理を行うことで約6,019,141点を獲得できます。

焼きなまし法の手順

  1. 破壊する範囲 (l,r) を決める。
  2. (l,r) に含まれるタイルを未探索の状態にする。
  3. (x_l,y_l) から探索順がランダムなDFSを行い、 (x_r,y_r) にたどり着いたら処理を中断する。
  4. スコアの差分計算を行い、焼きなまし法のスケジューラーにかける。
  5. 採用する場合は (l,r) を新しいものと取り替える。不採用の場合はタイルの探索状態を元に戻す。

vectorのeraseやinsertなどの重い計算を減らすことで、この1.~5.の処理を約350,000回行うことができます。(破壊する範囲の大きさなどによって回数が変わります。)

稀にDFSが終わらないことがあったので、時間がかかりそうなときは処理を中断するといいと思います。

提出コード

atcoder.jp

序盤のDFSと焼きなまし法で同じ関数を使い回しました。

最後に

今回の解説では「ビジュアライザを見て課題を発見し、その課題を解決する」という一連の流れを少し強調して書いてみました。ビジュアライザをうまく使うことで、上位を目指しやすくなるかと思います。

最後まで読んでいただき、ありがとうございました。

RECRUIT 日本橋ハーフマラソン 2021 A問題 解説

f:id:eijirou_kyopro:20220209215827p:plain
Seed=0 の出力 (turn=850)

Recruit 日本橋ハーフマラソン 2021 A問題 の解説です。

A問題について、コンテスト中は311,660点で17位、コンテスト後は346,841点で1位相当のスコアを出しました。ここではコンテスト後の解法を紹介します。

問題概要

長さ N=300 の配列 A が与えられます。

K=10^{8} とします。

p,q を自由に選んで A_p

A_p\leftarrow (A_p+A_q)\% K

のように更新する処理を最大 M=1000 回まで行えます。

A の値をできるだけ小さくしてください。

方針

最初の M-N=700 回で \min(A_i,K-A_i) をできるだけ小さくし、最後の N 回で全ての要素が小さくなるように調整しました。

優先度付きキューを用いた、ある種の貪欲法で処理しました。

なお、この解法は下記の解説動画を参考にしています。 www.youtube.com

端に寄せる

この問題のポイントは 0 付近の小さい値と K 付近の大きい値をたくさん生成することです。小さい値だけでなく、大きい値も作ることで、微調整がききやすくなり、スコアが大幅に改善します。

具体的には、優先度付きキューを2つ用意し、片方には K/2 未満の値を入れ、もう片方には K/2 以上の値を入れます。前者からは最大値 B_0 と2番目に大きい値 B_1 、後者からは最小値 C_0 と2番目に小さい値 C_1 を取り出します。

B_0+B_1\leq (K-C_0)+(K-C_1) のときは C_0\leftarrow (C_0+B_0)\%K を行い、

B_0+B_1\gt (K-C_0)+(K-C_1) のときは B_0\leftarrow (B_0+C_0)\%K を行います。

B_0C_0 だけの比較ではダメなのか

結論から言うと、単純に B_0C_0 を比較するよりも、次の値まで考慮したほうがスコアが少し高くなります。

例えば、 B_0=100,B_1=10,C_0=K-99,C_1=K-98,C_2=K-10 の場合に行われる最初の2回の処理を考えてみます。

B_0C_0 を比較した場合、

B_0=(B_0+C_0)\%K=1

C_0=(C_0+B_1)\%K=89

の2つの処理が行われます。2個目の処理の効率が悪いことがわかります。

一方で B_1C_1 まで考慮して比較した場合、

C_0=(C_0+B_0)\%K=1

B_0=(B_0+C_1)\%K=2

の2つの処理が行われます。この場合は2個目の処理も効率がよいです。

一例の、しかも一部分だけで良し悪しを判断するのは危険ですが、実験してみた結果、2番目まで考慮したほうがスコアが上がりました。

終盤の調整

K 付近の値は必ず 0 付近になるように、0 付近の値もできるだけ小さくなるようにします。

先ほどと同様の優先度付きキューを使用します。

B_1\lt K-C_0 のときは C_0\leftarrow (C_0+B_0)\%K を行い、

B_1\geq K-C_0 のときは B_0\leftarrow (B_0+C_0)\%K を行います。

処理の回数を M 回に抑えるために、必要に応じて後者の処理をスキップする必要があります。

提出コード

#pragma GCC target("avx2")
#pragma GCC optimize("O3")
#pragma GCC optimize("unroll-loops")

#include <bits/stdc++.h>
//#include <atcoder/all>
using namespace std;
//using namespace atcoder;

struct Solver {
    int n;
    int m;
    int k;
    vector<int> a;
    priority_queue<pair<int,int>> small;
    priority_queue<pair<int,int>> big;
    vector<int> p;
    vector<int> q;
    double score = 0;

    void input() {
        cin >> n >> m >> k;
        a = vector<int>(n);
        for (int i = 0; i < n; i++) {
            cin >> a[i];
        }
    }

    void magic(int pi, int qi) {
        assert(0 <= pi < n && 0 <= qi < n);
        p.push_back(pi);
        q.push_back(qi);
        a[pi] += a[qi];
        a[pi] %= k;
    }

    void heap_push(int i) {
        if (a[i] < k / 2) {
            small.push(make_pair(a[i], i));
        } else {
            big.push(make_pair(-a[i], i));
        }
    }

    void init() {
        for (int i = 0; i < n; i++) {
            heap_push(i);
        }
    }

    void solve() {
        pair<int,int> sp = small.top();
        small.pop();
        pair<int,int> bp = big.top();
        big.pop();
        for (int i = 0; i < m-n; i++) {
            int pi, qi;
            if (sp.first + small.top().first <= 2*k + bp.first + big.top().first) {
                pi = bp.second;
                qi = sp.second;
                bp = big.top();
                big.pop();
            } else {
                pi = sp.second;
                qi = bp.second;
                sp = small.top();
                small.pop();
            }
            magic(pi, qi);
            heap_push(pi);
        }
        small.push(sp);
        big.push(bp);

        int tmp = small.top().second;
        small.pop();
        while (!big.empty()) {
            int pi, qi;
            if (small.empty() || small.top().first < k + big.top().first) {
                pi = big.top().second;
                qi = tmp;
                big.pop();
            } else if (big.size() < m - p.size()) {
                pi = tmp;
                qi = big.top().second;
                tmp = small.top().second;
                small.pop();
            } else {
                tmp = small.top().second;
                small.pop();
                continue;
            }
            magic(pi, qi);
            heap_push(pi);
        }
    }

    void print() {
        for (int i = 0; i < n; i++) {
            score += log2(k) - log2(a[i]+1);
        }
        //cout << "Score: " << ceil(score) << endl;

        for (int i = 0; i < p.size(); i++) {
            cout << p[i] << ' ' << q[i] << endl;
        }
    }
};

int main() {
    Solver solver;
    solver.input();
    solver.init();
    solver.solve();
    solver.print();
}

更なる高みへ

上記の提出コードの実行時間は10ms以下です。実行時間制限が2000msであることを踏まえると、似たような計算を何度も行えることがわかります。よって、山登り法や焼きなまし法と組み合わせることでスコアを改善できる可能性が高いです。解説動画のように、ノイズを焼きなますなどの方法で350,000点以上を目指せると思います。(個人的には貪欲法だけで高得点を出せたことに満足しているので、これ以上の改善は行いません。)

最後に

非自明な戦略を元に、貪欲法だけで高スコアが出る問題でした。発想力や考察力が問われたと思います。

最後まで読んでいただき、ありがとうございました。