AtCoder Heuristic Contest 002 の解説です。
コンテスト中は2,786,732点で380位、コンテスト後は6,019,141点で6位相当のスコアを出しました。ここではコンテスト後の解法を紹介します。
紹介する解法は一例です。もっといい解法があると思います。
問題概要
マスのグリッド上にタイルが敷き詰められています。各タイルは のいずれかの大きさです。各マスには得点が設定されており、そのマスを通ったときに得点を得ることができます。 からスタートするような経路で、各タイルをたかだか一度しか通らないもののうち、得点ができるだけ高いものを求めてください。
方針
探索順に優先度をつけた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点しか得ることができません。なぜでしょうか。ビジュアライザを見てみましょう。
赤がスタート地点で緑が最終地点を表しています。"6"の字を描くように、最終地点から先へは動けなくなっていることがわかります。
少し戻って別の経路を試すとよさそうです。長い経路を求めたいので、DFSを行うことにします。
単純なDFS
上下左右の順に移動できるなら移動する、という形式のDFSを1.9秒間行うと約3,081,655点を獲得できます。
ビジュアライザを見ると、右下のタイルが使われていないことがわかります。上や左への移動を優先させたことが原因であると推測できます。そこで、上下左右ではない、何らかの優先度をつけて探索するとよさそうです。
得点を優先度にしたDFS
貪欲法を延長して、得点が高いものから順に探索するようにしても約2,804,578点しか得られません。
貪欲法のときと同じように"6"の字を描いて先へ進めなくなっていて、左上を探索できていません。もっと全体を探索できるように優先度をつけるのがよさそうです。
中心からのユークリッド距離を優先度にしたDFS
優先度 を次のように定義します。
すると、中心から遠い点を優先して探索するため、理想的には円を描くようにして中心に近づきます。
ビジュアライザを見るとわかるように、先ほどのDFSより改善されて、約4,462,468点を獲得できます。
焼きなまし法について
上記のビジュアライザの紫色の部分に注目してみます。
38->33 ではなく 38->91->57->33 のように通れば、スコアが148点増加します。このように、DFSだけしか行っていないものには最適化の余地が残っています。
そこで、長さ10程度の部分を壊して片方からDFSを行い、スコア変化に応じて繋ぎ直すという処理を行うことで約6,019,141点を獲得できます。
焼きなまし法の手順
- 破壊する範囲 を決める。
- に含まれるタイルを未探索の状態にする。
- から探索順がランダムなDFSを行い、 にたどり着いたら処理を中断する。
- スコアの差分計算を行い、焼きなまし法のスケジューラーにかける。
- 採用する場合は を新しいものと取り替える。不採用の場合はタイルの探索状態を元に戻す。
vectorのeraseやinsertなどの重い計算を減らすことで、この1.~5.の処理を約350,000回行うことができます。(破壊する範囲の大きさなどによって回数が変わります。)
稀にDFSが終わらないことがあったので、時間がかかりそうなときは処理を中断するといいと思います。
提出コード
序盤のDFSと焼きなまし法で同じ関数を使い回しました。
最後に
今回の解説では「ビジュアライザを見て課題を発見し、その課題を解決する」という一連の流れを少し強調して書いてみました。ビジュアライザをうまく使うことで、上位を目指しやすくなるかと思います。
最後まで読んでいただき、ありがとうございました。