はまやんはまやんはまやん

hamayanhamayan's blog

余り (Remainder) [JOI 2021/2022 一次予選 (第1回) 過去問 A]

https://atcoder.jp/contests/joi2022yo1a/tasks/joi2022_yo1a_a

解説

https://atcoder.jp/contests/joi2022yo1a/submissions/26484859

C++では何かで割った余りを計算する場合に%という演算子を使う。
21で割った余りを求めたいなら% 21だし、10で割った余りなら% 10である。
C++以外でもこのような演算子はあるはずだ。「剰余」と言語名でgoogle検索してみよう。

もしこの問題が難しい場合は入門問題を一通りこなすのがいいと思う。
https://qiita.com/drken/items/fd4e5e3630d0f5859067#3-hello-world-----practice-contest-a-%E5%95%8F%E9%A1%8C%E3%81%AE%E3%81%BF
もし使っている言語が違う場合はこっちを参照してみよう。
https://qiita.com/drken/items/fd4e5e3630d0f5859067#c-%E4%BB%A5%E5%A4%96%E3%81%AE%E8%A8%80%E8%AA%9E%E3%81%A7

int X;
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> X;
    int ans = X % 21;
    cout << ans << endl;
}

Strange Lunchbox [サイシードプログラミングコンテスト2021(AtCoder Beginner Contest 219) D]

https://atcoder.jp/contests/abc219/tasks/abc219_d

前提知識

解説

https://atcoder.jp/contests/abc219/submissions/26483767

あまりピンとこないかもしれないがDPで解ける。
初手でDPが思いついて試すとできたので、DPに至る過程は説明できない…

DP

dp[i][tako][tai] := i番目の弁当まで買うかが確定していて、これまでのたい焼きの個数がtaiで、たこ焼きの個数がtakoである最小弁当個数

これで解く。重要な部分が、X,Y≦300であるという部分である。
状態として、たい焼き・たこ焼きの個数はX,Yより大きくなりうるが、X以上である個数は条件としてはすべて同一視しても問題ない。
よって、Xより大きい個数はすべてXであると丸めてしまうことにすると、たい焼き、たこ焼きの個数は0~X/Yということになる。
この前提を考えると、DPの状態数は300×300×300通りくらいになるので、状態数的にはメモリ/計算量に収まる。
これは、上限を適切に指定して丸めることで間に合うというテクであり、DPを行う上で状態を丸める良い手法である。

あとは?

後は頑張って実装するだけ。
上限を指定する場合は更新時にminを使って超えたら最大値に置き換えるという感じにすると分岐が減って見栄えがいい。

int N, X, Y;
int A[301], B[301];
int dp[301][301][301];
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N >> X >> Y;
    rep(i, 0, N) cin >> A[i] >> B[i];

    rep(i, 0, N + 1) rep(tako, 0, 301) rep(tai, 0, 301) dp[i][tako][tai] = inf;
    dp[0][0][0] = 0;
    rep(i, 0, N) rep(tako, 0, X + 1) rep(tai, 0, Y + 1) if (dp[i][tako][tai] < inf) {
        chmin(dp[i + 1][tako][tai], dp[i][tako][tai]);
        chmin(dp[i + 1][min(tako + A[i], X)][min(tai + B[i], Y)], dp[i][tako][tai] + 1);
    }

    int ans = dp[N][X][Y] < inf ? dp[N][X][Y] : -1;
    cout << ans << endl;
}

Neo-lexicographic Ordering [サイシードプログラミングコンテスト2021(AtCoder Beginner Contest 219) C]

https://atcoder.jp/contests/abc219/tasks/abc219_c

解説

https://atcoder.jp/contests/abc219/submissions/26483284

実装問題。
C++であれば、特殊なソートは、比較関数を独自に作ることで実装が可能である。

比較関数

2つの文字列が与えられたときに大小関係を判定するような比較関数を用意しよう。
通常であれば、charのまま大小比較すればそれがそのまま使えるのだが、今回は文字(文字コード)の順番と実際の順番が一致しないので、
文字を実際の順番に変換する必要がある。
自分はこれにはmapをつかって、mapping配列というのを用意した。
charを入れるとそれに対応した実際の順番が出てくるようになっている。
これを使って文字の先頭から大小比較をしていこう。

文字列のprefixがどちらも同じだった場合は文字数が少ない方が辞書順では早くなるので、
決まらなかったら文字数の昇順を条件として返してやればいい。

string X;
int N;
string S[50101];
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> X >> N;
    rep(i, 0, N) cin >> S[i];
    map<char, int> mapping;
    rep(i, 0, 26) mapping[X[i]] = i;

    sort(S, S + N, [&](string a, string b) {
        int len = min(a.length(), b.length());

        rep(i, 0, len) {
            int x = mapping[a[i]];
            int y = mapping[b[i]];

            if (x != y) return x < y;
        }

        return a.length() < b.length();
    });

    rep(i, 0, N) printf("%s\n", S[i].c_str());
}

Red and Blue Tree [エクサウィザーズプログラミングコンテスト2021(AtCoder Beginner Contest 222) E]

https://atcoder.jp/contests/abc222/tasks/abc222_e

解説

https://atcoder.jp/contests/abc222/submissions/26482282

恐らく初見だとかなり難しい問題に見えると思う。

問題を簡単化する

辺の塗り方を求める問題であるが、操作によってとある辺について何回通過するかという回数については
どうなっても変化しないことが分かる。
これはとても便利な性質で、通る回数が分かれば、もともとの操作・木構造はそれほど重要でない。
よって、各辺によって、操作によって通る回数を先に求めておけば、それを赤に塗ればRがその分増えるし、
青に塗ればBが増えるという単純な事象に落とし込める。

なので、木上で通った回数分RBが増減するのではなく、単に辺について着色をした場合にRBが増減すると
もう少し問題を簡単化することにしよう。
このためには、辺を通る回数を数える必要がある。

より簡単な問題にするために辺を通る回数を数える

色々工面するかと思うが、実はDFSで比較的簡単に書ける。
DFSとだけ言われてもあまりピンとこないかもしれないが、AからBへの最短経路を求める場合にDFSを使った場合、
DFSの探索時は関係ない所への再帰で移動していくが、最終的には目的のBへ到達することができる。
到達した後に遷移元を順にたどっていくと、それがちょうど最短経路になっている。
これを利用して辺を数え上げることにする。
多分これだけ聞いてもちょっとわかりにくい。実装を見てもらうといいと思う。

エッセンスだけ再掲しておくと、AからBへの最短経路を求めたいときにDFSを使う場合、
行きに注目すると最短経路は求められないが、Bに到達した後に再帰から戻ってくる帰りの部分は丁度最短経路のみ通ってくることになる。
このようなある種のバックトラックを利用することで比較的少ない実装量で最短経路上に対して計算を行うことができる。
今回は計算量に余裕があるので、この方法でカウントしてしまおう。

残った問題は?

これで辺について何回通るかのcnt配列が計算できたことになる。
各辺について赤か青に適切に塗ることでR-B=Kを満たすような組合せを求める問題に帰着した。
mod 998244353ということもあるが、DPで解いてみよう。

dp[i][tot] := i番目の辺まで着色が終わっていてR-B=totであるような組合せ

これを解ければ、dp[N-1][K]が答えになる。
これも…実は難しい…

DPを解く

難しい点は2点ある。

  1. dp[1000][100000]で配列を取るとMLEするかも
  2. tot部分が負の数になる可能性がある

1. dp[1000][100000]で配列を取るとMLEするかも

メモリの圧縮テクを使う。今回はdp[i+1][?]の更新はdp[i][?]だけが必要でそれ以前は必要ないので、
1つ前だけを保持してDPしていくようなメモリ削減を行ったこれで、第一添え字部分が1000ではなく2で十分になるので
大幅にメモリ削減ができてMLEを回避できる。
なお、同じような実装であればメモリ使用量が少ない方が高速になる傾向がある(要出典)。
自分の実装を見てもらえば何となく何をしているかは分かると思うがもう少し細かい説明が必要な場合は
https://qiita.com/drken/items/68b8503ad4ffb469624c#%E6%B3%A8%E6%84%8F%E7%82%B92-%E3%82%88%E3%82%8A%E6%B1%8E%E7%94%A8%E7%9A%84%E3%81%AB%E4%BD%BF%E3%81%88%E3%82%8B%E3%83%A1%E3%83%A2%E3%83%AA%E7%AF%80%E7%B4%84%E3%83%86%E3%82%AF%E3%83%8B%E3%83%83%E3%82%AF
とやってることは同じなので、見てみてるといいと思う(ここに書かれている他のテクは汎用テクで必見です)

2. tot部分が負の数になる可能性がある

C言語の配列は添え字に負の数が来ると(動くけど)めちゃくちゃになっちゃうので中心をずらして全部非負にする必要がある。
自分はいつもBASE変数として中央の数を指定して入れている。
0はdp[BASE]だし、-5はdp[-5 + BASE]だし、100はdp[100 + BASE]だしといった感じ。
なので、初期値もdp[0][BASE] = 1で、答えもdp[N-1][K + BASE]である。

int N, M, K, A[101];
vector<pair<int, int>> E[1010];
int cnt[1010];
mint dp[2][201010];
const int BASE = 100005;
//---------------------------------------------------------------------------------------------------
bool dfs(int cu, int pa, int goal) {
    if (cu == goal) return true;

    fore(p, E[cu]) if (p.first != pa) {
        bool res = dfs(p.first, cu, goal);
        if (res) {
            cnt[p.second]++;
            return true;
        }
    }

    return false;
}
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N >> M >> K;
    rep(i, 0, M) cin >> A[i];
    rep(i, 0, N - 1) {
        int u, v; cin >> u >> v;
        E[u].push_back({ v, i });
        E[v].push_back({ u, i });
    }

    rep(i, 0, M - 1) dfs(A[i], -1, A[i + 1]);

    dp[0][BASE] = 1;
    rep(i, 0, N - 1) {
        int cu = i % 2;
        int nxt = 1 - cu;
        rep(tot, 0, 201010) dp[nxt][tot] = 0;
        rep(tot, 0, 201010) if (0 < dp[cu][tot].get()) {
            dp[nxt][tot - cnt[i]] += dp[cu][tot];
            dp[nxt][tot + cnt[i]] += dp[cu][tot];
        }
    }
    cout << dp[(N - 1) % 2][K + BASE] << endl;
}

Between Two Arrays [エクサウィザーズプログラミングコンテスト2021(AtCoder Beginner Contest 222) D]

https://atcoder.jp/contests/abc222/tasks/abc222_d

前提知識

解説

https://atcoder.jp/contests/abc222/submissions/26480980

問題を眺めるとかなりDPな感じがする。
具体的には

  • mod 998244353
  • 先頭から数を決めていくと広義単調増加は1つ前しか見なくていい
  • 制約的に何となく

みたいな所からDP感を感じてDPで考えてみると解けると分かってくる感じ。

naive DP

※ lstはlast, nxtはnextのつもり。関数名とかと被ってしまうのでこんな表記にしてる

単純なDPから考えてみよう。
先ほども書いたが、先頭から数を決めていくと広義単調増加は1つ前しか見なくていいので、
DPの状態としては最後に何の数を選んだかという状態を含めておけばいいことになる。
なので、

dp[i][lst] := i番目まで数列が確定していて、最後の数がlstであるような条件を満たす組合せ

というのを計算すればいい。
dp[i][lst]からdp[i+1][nxt]に遷移を考えたときに、満たす条件は

  • lst ≦ nxt
  • a[i]≦nxt≦b[i]

の2つである。
これに気を付けながらDPを書いていくとO(N3)の解法が出来上がるはず。
https://atcoder.jp/contests/abc222/submissions/26480825
これが理解できるまでは最初の段階。

高速化

これを高速化していくことを考える。
前のnaive DPは遷移元を起点として遷移先に分配していくような「配るDP」から、
遷移先を起点として、遷移元を見ていくような「貰うDP」にまずは変化させよう。
一概には言えないが、この変換が高速化に役立つことが多々ある。
言葉では何とも説明しにくいのだが、以下のような実装に変化する。

https://atcoder.jp/contests/abc222/submissions/26480899

これを眺めると、最終的な更新で、[0,nxt]の区間和を取るような計算が要求されている。
これは累積和を作っておけば高速に処理することができる。
よって、別途累積和を作っておいて、毎回それを使うことにすれば大幅に計算量が改善でき、
O(N2)となって通る。

int N, a[3010], b[3010];
mint dp[3010][3010];
mint rui[3010];
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N;
    rep(i, 0, N) cin >> a[i];
    rep(i, 0, N) cin >> b[i];

    dp[0][0] = 1;
    rep(i, 0, N) {
        rui[0] = dp[i][0];
        rep(lst, 1, 3001) rui[lst] = rui[lst - 1] + dp[i][lst];
        rep(nxt, a[i], b[i] + 1) dp[i + 1][nxt] += rui[nxt];
    }
    
    mint ans = 0;
    rep(lst, 0, 3001) ans += dp[N][lst];
    cout << ans << endl;
}

Swiss-System Tournament [エクサウィザーズプログラミングコンテスト2021(AtCoder Beginner Contest 222) C]

https://atcoder.jp/contests/abc222/tasks/abc222_c

解説

https://atcoder.jp/contests/abc222/submissions/26480601

シミュレーション問題、実装問題となる。
どういったことを問うているのかについては頑張って読み解いてほしい。
場合によってはサンプルを手計算でシミュレートすることも理解を助けるだろう。

問題を分割する

プログラミングにおいて一般的な話であるが、問題を小問題に分割することで
この問題に立ち向かうことにしよう。
各ラウンドでは

  1. 上位のユーザーから2人ずつグループにして戦わせる
  2. その後、これまでの勝敗を集計して、順位順に並びなおす

ということを行う。
問題の入力例1を使いながら理解を深めていくことにする。
あと、自分の実装と合わせるために、1 2 3 4ではなく0 1 2 3として説明していく。

1. 上位のユーザーから2人ずつグループにして戦わせる

順位を配列に入れておくことにしよう。自分はorderという名前で用意した。
最初の順位は0 1 2 3であり、これで上位2人ずつ戦わせていく。
勝敗は、どのラウンドかと、誰と誰の対戦であるかが分かれば決するので、それを引数としたbattle関数を用意した。
引き分けなら-1でそれ以外なら勝者を返す。
この中身は本当に根性実装なので、頑張って理解してほしい。

これで勝敗が分かれば勝者の勝ち点を+1すればいい。
win配列を用意して各人の勝ち数を記録しておくことにしよう。

2. その後、これまでの勝敗を集計して、順位順に並びなおす

どちらかというとこっちが問題である。
それほど厳しい制約でもないので、実装ができればTLEすることはなさそうだが、実装もちょっと大変かもしれない。
C++では、比較関数を自作したソートによって、これを簡単に実装することができる。

順位順に並びなおすというのと、特定のルール下におけるソート処理であると捉え、ルールを作る。
ルールは

  • 勝敗が違っていれば勝ち点が多い方が先に来る if (win[a] != win[b]) return win[a] > win[b];
  • 勝敗が同じであれば、番号が小さい方が先に来る else return a < b;

ということなので、これをソートの条件として指定する。
実装結果は↓にある通りなのだが、慣れないと少しパッと書くのは難しいかもしれない。
だが、このように特殊なソートをする場合にこれを使うことはよくあり、とても便利なのでこれを機に覚えるといい。

int N, M;
string A[101];
int win[101];
//---------------------------------------------------------------------------------------------------
int battle(int round, int userA, int userB) {
    char a = A[userA][round];
    char b = A[userB][round];

    if (a == b) return -1;

    if (a == 'G' && b == 'C') return userA;
    if (a == 'C' && b == 'P') return userA;
    if (a == 'P' && b == 'G') return userA;

    return userB;
}
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N >> M;
    rep(i, 0, N * 2) cin >> A[i];

    vector<int> order;
    rep(i, 0, N * 2) order.push_back(i);

    rep(round, 0, M) {
        rep(k, 0, N) {
            int res = battle(round, order[k * 2], order[k * 2 + 1]);
            if (0 <= res) win[res]++;
        }
        sort(all(order), [&](int a, int b) {
            if (win[a] != win[b]) return win[a] > win[b];
            else return a < b;
        });
    }

    rep(i, 0, N * 2) printf("%d\n", order[i] + 1);
}

Max Sum Counting [AtCoder Beginner Contest 216 F]

https://atcoder.jp/contests/abc216/tasks/abc216_f

前提知識

解説

https://atcoder.jp/contests/abc216/submissions/25449760

DPが分かっていることは前提なので勉強してきてほしい。

何から始めるのか

手がつかない場合は問題の弱点を探るのが良い。
今回は条件のうち、maxの上限が5000である部分が弱点となりうる部分である。
maxの上限が5000であるということはBの総和の上限も5000まで考えればいいことになる。
この条件は考察するうえでとても有用な条件であるように見える。

まあ、ここまではいいとして、ここからちょっと思考の飛躍が必要となる。
条件のmaxの値は選択された値の中での最大値によって決まり、
「それより小さい数についてはどのように選択されても変化しない」
といえる。
ここがとても重要な部分で、この「どのように選択されても変化しない」という部分にDPの同一視が適用できそうである。
さて、小さい数について、細かな状態は無視したいので、配列をまずはソートすることにする。
AとBを1つのグループにまとめてAでソートしておく。
max, sumや部分集合の選択方法に順番は影響しないので、ソートしても答えは変化しない。
この状態でDPを考える。

DP定義

dp[i][Btot] := i番目まで選択が確定していて、かつ、「i番目を選択していて」、かつ、選択した要素のBの総和がBtotである組合せ

このDPを更新していくことを考える。
これを貰うDPで実装していくことを考えると、

dp[to][Btot]の更新はto未満である全てのcuに対してdp[cu][Btot - B[to-1]]の総和

が更新ルールとなる。
これをそのまま実装するとO(N2 Bmax)になるので間に合わない。
更新時に利用している値は、toを1から順番に計算していることを考えるとdp[to未満の全て][とある値]の総和を取得していることになる。
これは事前に計算することができる。
よって、
tot[x] := dp[これまで][x]の総和
と定義して、dp[to][any]の更新が終わった直後に、totにその値を入れることで、
dp[to][any]を更新→tot[x]を更新→更にそれを使ってdp[to+1][any]を更新→…みたいな感じに高速に計算ができる。
この辺は累積和を使った更新最適化と捉えることもできる。
https://blog.hamayanhamayan.com/entry/2017/03/20/234711

答えを取得する

これでdp[i][Btot]について考えると、maxの値はA[i-1]になっているので、BtotがA[i-1]以下であるものが条件を満たすものになる。
なので、全てのdpの要素dp[i][Btot]についてBtot≦A[i-1]である組合せの総和を取れば答えになる。

int N;
int A[5010], B[5010];
pair<int, int> AB[5010];
mint dp[5010][5010];
mint tot[5010];
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N;
    rep(i, 0, N) cin >> A[i];
    rep(i, 0, N) cin >> B[i];
    rep(i, 0, N) AB[i] = { A[i], B[i] };

    sort(AB, AB + N);

    dp[0][0] = 1;
    tot[0] = 1;
    rep(to, 1, N + 1) {
        rep(Btot, 0, 5010) if (AB[to - 1].second <= Btot) dp[to][Btot] = tot[Btot - AB[to - 1].second];
        rep(Btot, 0, 5010) tot[Btot] += dp[to][Btot];
    }

    mint ans = 0;
    rep(to, 1, N + 1) rep(Btot, 0, AB[to - 1].first + 1) ans += dp[to][Btot];
    cout << ans << endl;
}