hogecoder

tsutaj 競技プログラミングの記録

第三回アルゴリズム実技検定 N 問題: 入れ替えと並び替え

問題概要

原文はこちら

atcoder.jp

長さ  N の数列  A = \left( a_{i} \right) があり、 A ははじめ昇順にソートされている。この数列に対して以下で説明される操作を合計  Q 回行った後、最終的にできた数列を出力せよ。

  • 整数  1 \leq x \lt N が与えられるので、 a_{x} a_{x+1} を交換する。
  • 整数  1 \leq l \leq r \leq N が与えられるので、 a_{l}, a_{l+1}, \ldots, a_{r} を昇順にソートする。

制約

  •  1 \leq N \leq 2 \times 10^{5}
  •  1 \leq Q \leq 2 \times 10^{5}

解説

愚直にやると間に合いそうにないので、何か発想の転換が必要です。まずはどうやったら時間内に処理できそうかを考えてみましょう。

1 番目のクエリは隣り合っている要素を swap するだけなので簡単に  O(1) で実現可能です。以降、2 番目のクエリをどうするかを考えます。まず、準備のため 転倒数 という概念を次のように定めます。

  • 数列  A = \left( a_i \right) のうち、 1 \leq l \lt r \leq N かつ  a_{l} \gt a_{r} を満たす相異なるインデックス  (l, r) の総数を 転倒数 とよぶ。

例えば  A = \left[ 3, 4, 1, 5, 2 \right] という数列に関して転倒数を考えると、インデックスのペア  (1, 3), (1, 5), (2, 3), (2, 5), (4, 5) について上記の条件を満たすので、転倒数は  5 となります。

さて、はじめに与えられる  A は昇順にソートされていることから、転倒数は  0 であることがわかります。また、 1 番目のクエリにおいて、swap される前の要素に関して  a_{x} \lt a_{x+1} ならば転倒数は  1 増え、 a_{x} \gt a_{x+1} ならば転倒数は  1 減ることがわかります。これらのことから、次のようなことが言えます。

  • ある 2 番目のクエリについて、それ以前に発生した 1 番目のクエリの回数を  q とするとき、2 番目のクエリの対象となる数列の転倒数は 高々  q である。

つまり転倒数が  O(Q) であることが言えるというわけです。ある範囲をソートするという操作は、言い換えるとその範囲内の転倒数が  0 となるようにすることですので、転倒数  q に対して  O(f(q)) (ここで  f(q) は計算量に関する関数) で対処できるようなソートアルゴリズムがあるならば、慣らし計算量  O(f(Q)) で元の問題を解くことができます。

さて、ここで問題となるのはどのようなソートアルゴリズムを持ってくるかですが、自分は本番で  f(q) = q \log qアルゴリズムを作って解きました。次でその概要を簡単に説明します。

ソートアルゴリズム

 a_{k} \gt a_{k+1} を満たす  k の集合を管理することを考えます。初期状態ではこのような要素は存在しないため空集合です。

1 番目のクエリで  a_{x}, a_{x+1} について swap の操作が行われるとき、 (a_{x-1}, a_{x}), (a_{x}, a_{x+1}), (a_{x+1}, a_{x+2}) の高々 3 つのペアについて大小関係が入れ替わる可能性があります。これらを更新することで、次の状態に対応する集合が得られます。この操作は、例えば C++ の場合ですと std::set で  O(\log q) で実現できます。

2 番目のクエリでは  l \leq x \lt r を満たす全ての  x について (これは swap をした結果新たに生まれる  x も含む) swap をすればソートができますが、先程の議論からこのような要素の数は高々  q なので、集合を更新しながら転倒数を減らすのは全体で  O(q \log q) で処理できます。

ソースコード

上の解説とちょっと違います ( a_{k} \gt a_{k+1} を満たす  k+1 の集合を管理してます)

int main() {
    int N, Q; cin >> N >> Q;
    set<int> ind;    
    vector<int> A(N);
    iota(A.begin(), A.end(), 1);
    while(Q--) {
        int t, x, y; cin >> t >> x >> y;
        if(t == 1) {
            x--;            
            for(int k=-1; k<=1; k++) {
                // x+k と x+k+1 との比較 -> x+k+1 のほうを削除
                if(x+k < 0 or x+k+1 >= N) continue;
                if(A[x+k] > A[x+k+1]) ind.erase(x+k+1);
            }
            swap(A[x], A[x+1]);
            for(int k=-1; k<=1; k++) {
                if(x+k < 0 or x+k+1 >= N) continue;
                if(A[x+k] > A[x+k+1]) ind.emplace(x+k+1);
            }
        }
        if(t == 2) {
            x--;            
            while(true) {
                auto ptr = ind.upper_bound(x);
                if(ptr == ind.end() or *ptr >= y) break;

                int u = *ptr - 1;
                for(int k=-1; k<=1; k++) {
                    // u+k と u+k+1 との比較 -> u+k+1 のほうを削除
                    if(u+k < 0 or u+k+1 >= N) continue;
                    if(A[u+k] > A[u+k+1]) ind.erase(u+k+1);
                }

                swap(A[u], A[u+1]);

                for(int k=-1; k<=1; k++) {
                    if(u+k < 0 or u+k+1 >= N) continue;
                    if(A[u+k] > A[u+k+1]) ind.emplace(u+k+1);
                }
            }
        }
    }
    for(int i=0; i<N; i++) printf("%d%c", A[i], " \n"[i + 1 == N]);
    return 0;
}

結構面白い問題だなと思いました。慣らし計算量に関する良い題材ですね。

天下一プログラマーコンテスト 2012 予選 B D: 大爆発

調べても解説がなかったので

問題概要

原文 → D - 大爆発

日本語なので元の問題文参照

解説

まず自明なケースとして、以下があります。地味に厄介なので最初に弾きましょう。

  • ブロックがひとつも存在しない (答えは  0 )
  • 全てのマスにブロックが存在する (答えは  -1)

これ以外のケースは、少なくとも 1 つはブロックがないマスであり、そこに爆弾を置くと、置いた列に関して全て爆弾がおける状態になるので、必ず目標を達成可能であることがわかります。それでは次に、最小値がどうなるかを考えましょう。

ここでは  i j 列目のマスを  (i, j) と書くことにします。 (i, j) に爆弾を置くと、以下が成り立ちます。

  •  i 行目にはブロックがひとつも存在しない
  •  j 列目にはブロックがひとつも存在しない

ある場所にいったん爆弾を置いてしまえば行・列方向ともに一掃され、その後は各行に爆弾を置けるため、各行において爆弾は 高々 1 回 置いてしまえばよさそうです。行・列の制約は小さめなので、爆弾を置く行を決め打ち して考えることにしましょう。これは  2^{H} 通りありますが制約が小さいのでひとまず大丈夫です。

行を決め打ちすると、爆弾を置かなければならない列がわかります。具体的には以下の条件を満たす列  j については少なくとも 1 つ置かなければなりません。(理由は明らかだと思います)

  •  i 行目が決め打ちで選ばれておらず、 (i, j) にブロックが存在する場合

これで、「決め打ちした行の集合」と「選ぶべき列の集合」がそれぞれ得られます。図示すると次のようになります。

f:id:tsutaj:20200602012552j:plain

「決め打ちした行」かつ「選ぶべき列」であって、ブロックが存在しない (= 爆弾をおける) マスを列挙してみましょう。先程までの議論から、爆弾を置くべき行・列それぞれについて、1 個でも置ければ十分ですので、それぞれのマスを頂点と見立てて (行, 列) の二部グラフを作ると、最小辺被覆問題 に帰着されることがわかります。(くわしくは けんちょんさんの記事 を読んでみてください。) 今回のケースにおいては、二部グラフの最大マッチングを求めることで容易に解を求められる問題なので、これで解けそうです。

f:id:tsutaj:20200602012557j:plain

ただし一点だけ注意しなければならないケースがあります。それは、「決め打ちした行」かつ「選ぶべき列」であるマスに空きマスが 1 つも存在しない場合です。この場合は選択できるマス (= 二部グラフにおける頂点) が存在しないため、そのままでは対応できません。いま、空きマスが全体で少なくとも 1 つ以上存在する場合のみを考えているので、どこかに空きマスがある (= 爆弾をおける) としてよく、そのようなマスに爆弾を置くと元々置きたかった行または列にも置けるようになります。しかし、最初に設置した爆弾は「元々置きたかった行」かつ「元々置きたかった列」に置いているわけではないため、置くべき爆弾の数は合計で  \text{ (決め打ちした行数) } + \text{ (選ぶべき列数) } + 1 個必要になります。 以上のことに注意すると正答を得られます。

ソースコード

AC コード (tsutaj): Submission #13934922 - 天下一プログラマーコンテスト2012 予選B

// 二部マッチングは略

int main() {
    int H, W; scanf("%d%d", &H, &W);
    int block = 0;
    vector<string> board(H);
    for(int i=0; i<H; i++) {
        cin >> board[i];
        block += count(board[i].begin(), board[i].end(), '#');
    }

    // 自明なケース
    if(block == 0) { cout << 0 << endl; return 0; }
    if(block == H*W) { cout << -1 << endl; return 0; }

    int ans = INF;
    // 行の選び方を全部試す
    for(int bit=0; bit<(1<<H); bit++) {
        vector<int> xs, ys;

        // 選んだ行
        for(int i=0; i<H; i++) if(bit >> i & 1) xs.emplace_back(i);

        // 選んでいない行において、ブロックが存在するような列
        for(int j=0; j<W; j++) {
            for(int i=0; i<H; i++) {
                if(bit >> i & 1 or board[i][j] == '.') continue;
                ys.emplace_back(j);
                break;
            }
        }

        Flow<int> fl(H + W + 2);
        int source = H + W, sink = source + 1;
        int N = xs.size(), M = ys.size();
        for(int i=0; i<H; i++) fl.add_edge(source, i, 1);
        for(int i=0; i<W; i++) fl.add_edge(H+i, sink, 1);
        for(auto x : xs) {
            for(auto y : ys) {
                if(board[x][y] == '.') fl.add_edge(x, H+y, 1);
            }
        }

        // 行と列の二部マッチング
        int bm = fl.max_flow(source, sink);

        // bm が 0 であるとき (どの board[x][y] にもブロックがあるとき)
        // -> xs, ys の union? であってブロックがないマスが存在するか探す
        if(bm == 0) {
            bm = -1;
            for(auto x : xs) for(int y=0; y<W; y++) if(board[x][y] == '.') bm = 0;
            for(auto y : ys) for(int x=0; x<H; x++) if(board[x][y] == '.') bm = 0;
        }
        if(bm == -1) chmin(ans, min(N, M) - bm);
        else chmin(ans, N + M - bm);
    }
    cout << ans << endl;
    return 0;
}

iwiwi さんのコードをかなり参考にしました (ありがとうございます)

Educational Codeforces Round 87 G: Find a Gift

こういうの典型なんですかね?結構面白い気がしたのでブログに残しておく

問題概要

原文 → Problem - G - Codeforces

 N 個の箱が一列に並んでいる。箱の中身は石またはギフトである。石が入っている箱はすべて同じ重さであり、ギフトが入っているどの箱よりも真に重い。ギフトが入っている箱どうしは重さが異なる可能性がある (注意: 石が入っている箱より軽いことは保証される)

高々 50 回、以下の形式の質問をすることで、最も左に位置する、ギフトが入っている箱の番号 を当てよ。

  • 積集合が空 (つまりお互いに要素に被りがない) であるような、空でない添字集合  A, B をジャッジに投げて質問する。これは、「 A に属する箱の重さの総和と、 B に属する箱の重さの総和を比較したときに、どちらが重いか (どちらも同じであるばあいは同じであること) 」を質問しており、ジャッジからはその回答が得られる。

解説

※この解説は 公式の解説 をベースに書いています。

最も左に位置するものを当てる必要があるので、左に位置する箱から順に情報が得られるのが望ましいだろう、という直感を働かせましょう。まずはじめに、最も左の箱に入っているものが石であるかギフトであるかを特定できるかを考えてみましょう。

質問時に使用する  A, B の要素数を、それぞれいったん  1 に限定しましょう。 A は必ず最も左の要素のために使うことにします。さて、 B はどのように選択すれば良さそうでしょうか?実は、これについて以下のことが言えます。

ランダムに要素を選び、それを  B の元とする戦略を何度か取ると、高い確率で最も左の要素が石であるかどうかがわかる。

制約を見ると分かるとおり、ギフトの数は 高々  N の半分以下 であるため、適当に要素を選んだ際にそれが石である確率は半分以上はあることになります。最も左の要素がギフトであることを特定するためには、 B の要素として石であるものを選ぶ必要がりますが、半分以上の確率で石を選択できるため、だいたい  20 回程度試行しますと石を選べる確率が  1 - \left( \frac{1}{2} \right)^{20} くらいになるため、十分大きい確率で判定できることになります。よって、最も左の箱がギフトである場合には、 20 回程度の質問で答えが得られること・そうでない場合にも「最も左の箱が石である」という情報が得られることがわかりました。

以下、最も左の箱がギフトではない (石である) 状況を考えます。今は最も左の箱、つまり  1 番目の箱のみについて情報が得られていますが、ここから情報を効率的に増やすにはどうすればよいでしょうか?これについては、以下のことを利用して戦略を取ると良いです。

 2^{k} 番目の箱について全て石が含まれている場合、「 2^{k+1} 番目の箱まで全て石が含まれていること」または「 2^{k} + 1 番目から  2^{k+1} 番目の箱の中にギフトが少なくともひとつ含まれること」のいずれかの情報が得られる

ギフトは石よりも軽いため、質問する集合に含まれている要素数が同じで、かつ一方が全て石であるような場合、もう一方が軽いと判定された時点でそこにギフトがあることがわかります。逆にそのような判定をされなかった場合は、もう一方もすべて石であることがわかります。いま、 1 番目が石であるという情報をすでに持っているわけですから、この戦略が利用できるというわけです。

さて、この戦略を利用すると 「添字区間  \left[ l, r \right) の間に少なくともひとつギフトが存在する」ことまでは特定できます。この後にどうすればよいかと言うと、二分探索をすると良いです。というのも、 \left[ 1, l \right) に関しては全て石が含まれているので、片方の集合を  \left[ 1, l \right) から、もう片方の集合を  \left[ l, r \right) から要素数が同じになるように質問すれば、先ほどと同様に「全部石の箱たち」と「一部はギフトかもしれない箱たち」との比較ができ、二分探索が可能です。

ソースコード

#ifdef LOCAL を使ってローカルでもテストできるようにしてあるので、若干冗長です、、、

enum Verdict {
    EQUAL,
    LEFT,
    RIGHT,
};

const int STONE_WEIGHT = 10;
vector<int> ref_vec;

Verdict ask_to_judge(int l1, int r1, int l2, int r2) {
    vector<int> A, B;
    for(int i=l1; i<r1; i++) A.emplace_back(i);
    for(int i=l2; i<r2; i++) B.emplace_back(i);

    printf("? %zu %zu\n", A.size(), B.size());
    for(size_t i=0; i<A.size(); i++) printf("%d%c", A[i]+1, " \n"[i + 1 == A.size()]);
    for(size_t i=0; i<B.size(); i++) printf("%d%c", B[i]+1, " \n"[i + 1 == B.size()]);
    fflush(stdout);

    int verdict;
    
#ifdef LOCAL
    int SA = 0, SB = 0;
    for(auto &a : A) SA += ref_vec[a];
    for(auto &b : B) SB += ref_vec[b];

    if(SA < SB) verdict = RIGHT;
    else if(SA > SB) verdict = LEFT;
    else verdict = EQUAL;

    vector<string> v_to_s = {"EQUAL", "LEFT", "RIGHT"};
    fprintf(stderr, "# verdict: %s\n", v_to_s[verdict].c_str());
    return (Verdict)verdict;
#endif

    string s; cin >> s;
    if(s == "FIRST") verdict = LEFT;
    if(s == "SECOND") verdict = RIGHT;
    if(s == "EQUAL") verdict = EQUAL;
    assert(s != "WASTED");
    return (Verdict)verdict;
}

void answer(int x) {
    printf("! %d\n", x+1);
    fflush(stdout);

#ifdef LOCAL
    assert(ref_vec[x] < STONE_WEIGHT);
    for(int i=0; i<x; i++) assert(ref_vec[i] == STONE_WEIGHT);
    puts("[ OK ]");
#endif
}

void solve() {
    int N, K; scanf("%d%d", &N, &K);
#ifdef LOCAL
    ref_vec.resize(N);
    printf("# Input weights.\n");
    printf("# if you want to choose stone_weight, input '-1'.\n");
    printf("# otherwise, you have to choose the weight less than STONE_WEIGHT (%d).\n", STONE_WEIGHT);
    for(int i=0; i<N; i++) {
        int v; scanf("%d", &v);
        if(v < 0) ref_vec[i] = STONE_WEIGHT;
        else {
            assert(v < STONE_WEIGHT);
            ref_vec[i] = v;
        }
    }
#endif

    Rand rnd(1333);
    for(int i=0; i<25; i++) {
        int x1 = 0, x2 = rnd.NextInt(1, N-1);
        Verdict q = ask_to_judge(x1, x1+1, x2, x2+1);
        if(q == RIGHT) {
            answer(x1);
            return;
        }
    }

    // 1st elem is stone
    int len = 1, lb = -1, ub = -1;
    for(; ; len<<=1) {
        int cnt = min(len, N - len);
        int l1 = 0, r1 = l1 + cnt;
        int l2 = len, r2 = l2 + cnt;
        Verdict q = ask_to_judge(l1, r1, l2, r2);
        if(q != EQUAL) {
            assert(q == LEFT);
            lb = l2, ub = r2;
            break;
        }
    }

    int ll = lb;
    while(ub - lb > 1) {
        int mid = (ub + lb) / 2, w = mid - ll;
        Verdict q = ask_to_judge(0, w, ll, ll+w);
        if(q == EQUAL) lb = mid;
        else ub = mid;
    }
    answer(lb);
}

int main() {
    int T; scanf("%d", &T);
    while(T--) solve();
    return 0;
}

乱択 + うまく選んで  2 ベキに持っていく + 二分探索できる形にする が必要で、けっこう勉強になりました。