eijirouの競プロ参加記

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

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と焼きなまし法で同じ関数を使い回しました。

最後に

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

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