No.642 Two Operations No.1

No.642 Two Operations No.1 - yukicoder

考察

とりあえず整数を頂点、操作を辺としてグラフにしてみると以下のような感じになります。

f:id:nanae1914:20180203131428p:plain

頂点数はNで辺の数は2Nなので、N <= 10^6程度ならこのグラフで頂点1からBFSをすることで
最短距離(最小操作回数)が求められますが、今回はN <= 10^9なのでもう少し工夫しないといけないようです。

頂点iへの最短距離をdist(i)と書くことにします。

グラフをよく見てみると、奇数の頂点は1つ大きい数からしか到達できないのでi = 2k + 1とすると、dist(2k + 1) = dist(2k + 2) + 1となっていることが分かります。

問題は偶数の頂点で、この頂点には辺が2つ入ってきます。なので
dist(2k) = min(dist(k), dist(2k+1)) + 1
と書けます。何となく、小さいケースでいくつか試してみると、常にdist(k) <= dist(2k+1)が成り立っているような気がします。

実際に、頂点2kの周りとkの周りの関係を書いてみると以下のようになります。

f:id:nanae1914:20180203131506p:plain

もしここで、dist(k) > dist(2k+1)が成り立っていたとしたら最短路を逆順にたどっていくと、2k -> 2k+1 -> 2k+2までは一直線に進みます。ここでまた分岐するのですが、もしここで2k+2 -> k+1と戻る枝を使ったとすると、結局k+1から3ステップで2kに来ることになるので、dp(2k) = dp(k+1) + 3となります。(下図赤線)
一方で、k+1 -> k -> 2kと高々2stepで行くことができるので、dp(2k) <= dp(k+1) + 2であることが分かります。(下図青線)
これは矛盾しているので、2k+2 -> k+1と戻る枝を使ったことは誤りとなります。

f:id:nanae1914:20180203131524p:plain

なのでさらにさかのぼって2k -> 2k+1 -> 2k+2 -> 2k+3 -> 2k+4と行ってまた枝分かれするんですが、ここでも2k+4 -> k+2と戻る枝を使うと仮定すると矛盾が起こることが同様に分かります。

f:id:nanae1914:20180203131536p:plain

よって帰納的にこのようなことが示せてしまうので、結果的にはそもそもdist(k) > dist(2k+1)と仮定していたこと(つまり2k <- 2k+1が最短路に含まれていたと仮定したこと)が誤りであるということが言えます。よってdist(k) <= dist(2k+1)なので、「2kに行くにはkから来る枝を使う」としていいことが分かります。

上の議論によって何が言えるかというと、

  • nが奇数のときは、n+1に戻ればよい
  • nが偶数のときは、n/2に戻ればよい

ということなので、後はこれをn = 1になるまで繰り返せばよいということになります。2回に1回はnが半分になるので計算量的にもだいたいO(logN)なのでこれで十分間に合うということが分かります。

感想

難しい★2だと思いました。

ABC056/D - No Need

https://beta.atcoder.jp/contests/abc056/tasks/arc070_b

考察

要素の順番は関係無いので、考えやすくするためにとりあえずa_iは昇順ソートされているものだとします。

まず、「カードiが必要であること」とはどういうことかと考えてみると、条件の否定を考えて「カードiを含むある集合が存在して、その集合からiを抜くと総和がK未満になってしまう」ということになります。

このことから「カードiを除いた全ての数で[K - a_i, K)に含まれる数が作れるか」ということと同値だと分かります。もし作れるのなら、そこにカードiを加えた集合は良い集合になるのでカードiは必要だと分かるし、逆に[K - a_i, K)に含まれる数が作れないのならiを加えたところで良い集合になるものは存在しないと分かるからです。

なので、各iについて「カードiを除いた全ての数で[K - a_i, K)に含まれる数が作れるか」を調べていけばいいことになってこれを愚直に総当たり+部分和DPでやるとO(N^2 * K)になってしまいます。これでもN = K = 400までの部分点は取れそうですが、N = K = 5000の満点は無理そうなのでもう少し計算量を落とす必要があります。

まず考えられるのは「愚直に全部見ていく必要はあるのか?」ということです。もし「iが必要である⇒i+1も必要である」というような単調性が成り立っていれば、真面目に全探索する必要はなく二分探索を使えばいいのでそうなっていないかと考えたくなります。a_iが昇順ソートされているものだと思うと、区間[K - a_i, K)はiが増えるごとに広がっていくので何となくそのような単調性がありそうだなと思います。

少し考えると実際に、このような単調性を示すことができます。

もし、iが必要であるとすると、iを除いた集合で[K - a_i, K)に含まれる整数が作れます。もしそれがi+1を含まないもので作れているとすると、i+1を除いた集合でも明らかに作れて、それは[K - a_{i+1}, K)にも明らかに含まれるので良いです。
問題はi+1を使って作っている場合ですが、このとき、i+1をiに置き換えて作った整数はいくらか小さくはなりますが[K - a_{i+1}, K)に含まれていることは分かります。小さくなった分だけ区間が広がっているからです。

こうやって二分探索が使えることが分かると、計算量はO(N * K * log(N))に落とすことができます。それでも、最大ケースでは3 * 10^8程度になるっぽいのでこれは想定解じゃないのかな……と思いましたが、1つの想定解であったそうです。
実際これをやってみるとそんなに無理なく通すことができました。

そしてO(N*K)の想定解もあったようで、こちらは前の方から部分和DPをやって後ろの方から部分和DPをやると、{0, ..., i-1}で作れる整数と{i + 1, ..., N - 1}で作れる整数が分かるので、これらの結果をtwo-pointersでいい感じにやるとO(N*K)で出来るみたいな感じでした。(本当は解説にはtwo-pointersじゃなくて「累積和を使う」と書いてあるのですが、よく分かりませんでした)

ソースコード

ソースコードはテンプレートとか適当に省略しています。

二分探索

void main() {
    int n, k;
    int[] a;

    scan(n, k);
    a = readln.split.to!(int[]);

    a.sort();

    bool check(int mid) {
        if (a[mid] >= k) {
            return true;
        }
        
        auto dp = new bool[](k + 1);
        dp[0] = 1;

        foreach (i ; 0 .. n) {
            if (i == mid) continue;
            foreach_reverse (j ; a[i] .. k + 1) {
                dp[j] |= dp[j - a[i]];
            }
        }

        foreach (i ; max(0, k - a[mid]) .. k) {
            if (dp[i]) {
                return true;
            }
        }

        return false;
    }


    int btm = -1, top = n;

    while (top - btm > 1) {
        int mid = (top + btm) / 2;

        if (check(mid)) {
            top = mid;
        }
        else {
            btm = mid;
        }
    }

    writeln(btm + 1);
}

前/後ろDP + two-pointers

void main() {
    int n, k;
    int[] a;

    scan(n, k);
    a = readln.split.to!(int[]);

    auto dp1 = new bool[][](n + 1, k);
    dp1[0][0] = 1;

    foreach (i ; 1 .. n + 1) {
        foreach (j ; 0 .. k) {
            dp1[i][j] = dp1[i-1][j];
            if (j - a[i-1] >= 0) dp1[i][j] |= dp1[i-1][j-a[i-1]];
        }
    }

    auto dp2 = new bool[][](n + 1, k);
    dp2[n][0] = 1;

    foreach_reverse (i ; 0 .. n) {
        foreach (j ; 0 .. k) {
            dp2[i][j] = dp2[i+1][j];
            if (j - a[i] >= 0) dp2[i][j] |= dp2[i+1][j-a[i]];
        }
    }

    int ans;

    foreach (i ; 0 .. n) {
        int mv;

        for (int l = 0, r = k-1; l < k; l++) {
            if (!dp1[i][l]) continue;
            for (; r >= 0; r--) {
                if (dp2[i+1][r] && l + r < k) {
                    mv = max(mv, l + r);
                    break;
                }
            }
        }

        if (mv < k - a[i]) ans++;
    }

    writeln(ans);
}

競技プログラミング初めて1年経ちました

競技プログラミング(競プロ)を初めて1年くらい経ったので、1つの区切りとして競プロについて得られた知見や感想を書いておきます。
強くなる方法とか競プロお役立ちテクニックとかそういう話は一切書いてません。

パソコンのプロじゃなくても出来る

何かいきなり馬鹿みたいな話ですが、競プロはパソコンのプロじゃなくても簡単に参加できます。
競技プログラミングやってる人っていうと、日常的にLinuxとか使っててコマンドプロンプトとかめっちゃ使いこなしているスーパーハッカーみたいなイメージでしたが、私みたいにWindowsしか使えない、マウスが無いとまともに操作できない、プログラミングは大学で習った程度のことしかできずアプリとかも作れない……みたいな人でも何とかできます。サンプルコピペするときにカチッペタッカチッペタッてやるのは何かアホっぽいですがそれでもコンテストに参加していくつかの問題を解くことができます。

pythonで競プロ続けていくのはなかなか厳しい

最初半年ぐらいはpythonでやってたんですが、それからはだいたいD言語でやるようになってしまいました。ひねくれ者だったので「pythonで競プロは厳しい」みたいな風潮を見てもいけるとこまでいってやろうとか思ってましたが……散々言われてたことですが、「やっぱりpythonは遅い」です。経験的に、pythonって反復処理が10^6超えるとTLEするかも?って感じなんですよね。10^7だったら絶対TLEします(pypyで出せるならもうちょっとマシになりますが10^7になると相当不安です)。でも競プロの問題って想定解が10^7とか普通にあって、何なら10^8ってこともありました。Atcoderの簡単な問題やyukicoderでやる分にはpypyで提出したりすることで割といけるのですが、こどふぉとかJOIの過去問とかはどうしてもpythonじゃきついなあっていう場面が結構ありました。あと再帰深さの制限を上げても再帰深さのせいで(?)REしたりしたこともありました。いろいろあって辛くなるのでpythonで競プロをやるのは止めました。

こどふぉに参加すると生活リズムが狂う

日本で流行ってる競プロサイトといえば、一番はAtcoder、二番はCodeforces、三番Topcoderって感じっぽいです。私が初めて「競技プログラミング」という存在を知ったころはTopcoderというサイトしか知らなかったので今もそんな感じでTopcoderが一番栄えてるんだろうな~と思っていたんですが、どうやらそうではないらしいです。

それは置いといてCodeforces、通称こどふぉですがこれはロシアの競プロサイトです。「なんでロシア?」と思いますが、ロシアは競プロが一番強い国らしいです。ここのコンテストにも結構日本人が参加しています。問題文はロシア語ではなく、英語なので何とか読めます。コンテストの質(?)は正直あまりよろしくないというのが競プロ界隈での定評です。サイトが重い・つながらない、ジャッジキューが詰まりすぎ、問題の質も悪い(こどふぉはストーリー付きの問題文であるのでそれが邪魔みたいなのとか、考察が軽く実装が重いみたいな問題は嫌われる傾向にあるからかもしれません。あと想定解が誤解法だったとか)などなど。デメリットは多いですが、それでも世界規模で見ると一番栄えている競プロサイトです。こどふぉはSNS的な側面もあって、コンテストが終わるとフォーラムが賑わうので見ていると面白いです。そういう意味ではAtcoderよりお祭り感があって楽しいです。あとはAtcoderにない特徴的なシステムとして、他人のコードを撃墜できるHackというシステムがあってそれも面白いかもしれません。

そんなわけでこどふぉのコンテストに参加するのはなかなか刺激的で楽しいんですが、大きな欠点としてだいたい日本時間で深夜に行われるというのがあります。通常24:00~26:00くらいでそっからシステムテストが終わるのを待ったり、寝ようと思ってもコンテスト後は目が冴えてしまっているのでうだうだやっていると27時とか28時くらいになってしまいます。なので結構な頻度でこどふぉに参加してしまうと完全に昼夜逆転生活になってしまいます。

全探索すればいい

私が競プロ始めた頃によくあったこととして、問題を解こうとしたときに「賢く解こう」としてしまうということがあったなあと思いました。賢く解こうというか数学的に解こうとか、貪欲で解こうとか、そういう感じです。

例えば、yukicoderの「No.90 品物の並び替え」という問題があります。
https://yukicoder.me/problems/no/90

これはある程度慣れた人なら「N <= 9と制約が小さいから、全ての並び順を全探索するO(N! * N^2)の解法でも十分間に合うな」とぱっと分かる問題です。しかし1年前の私のコードを見ると、「各アイテムをどの位置に挿入したら最も点数が高くなるか?」という怪しい貪欲で解こうとしてWAを食らってます。

このエピソードから得られる教訓として「証明できない貪欲は出来るだけ避けよう」という話もありますが、それ以前の問題として「全ての並び順を調べればいい」という全探索の思考が全く無かったからこのようなコードになってしまったのだと思います。

全探索っていうと何となく「頭の悪い解法、非効率的で競プロには向かない」みたいな先入観があったのですが、全然そんなことは無くて基本中の基本でとっても大事なので全探索の心は常に持っておきたいなと思いました。

まとめ

結構飽き症なので競プロ始めた頃はすぐ飽きるかな~と思ってましたが、意外と1年くらい続きました。(2ヶ月くらい飽きてやってない時期ありますが)
競プロって見た目は地味ですが、多種多様な問題が出されるので「またこの問題か……」みたいなのがあんまり無くて毎回凄く考えさせられます。面白いです。パズルとか考えるゲームが好きな人にはおすすめです。