eijirouの競プロ参加記

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

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点以上を目指せると思います。(個人的には貪欲法だけで高得点を出せたことに満足しているので、これ以上の改善は行いません。)

最後に

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

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