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

hamayanhamayan's blog

Factorial Yen Coin [AtCoder Beginner Contest 208 B]

https://atcoder.jp/contests/abc208/tasks/abc208_b

解説

https://atcoder.jp/contests/abc208/submissions/23993696

今回は貪欲解で解くことができる。
だが、制約を見ると動的計画法を使っても解くことができるようになっている。
ここは恐らく優しさであると思う。

貪欲?

貪欲解とは、特定のアルゴリズムではなく、特定のルールに従って、ある意味で貪欲に処理を進めていくことで
最適解を得ようとする方針である。
特定のルールといわれるとさらに分かりにくいかもしれないので、今回のルールを説明する。

今回は、「値段が高い硬貨が使えるときは使うようにして支払う」というルールに従って処理を進めていくと最適解が得られる。
実装的には、最初に10!を使うだけ使ってPを減らして答えをインクリメントしていく。
Pが10!未満になったら、次は9!を使うだけ使ってPを減らして答えをインクリメントしていく。
これを繰り返していく。

動的計画法

これは補足であるが、DP(=動的計画法)で解ける問題が貪欲で解けるように見えてしまう場合が多々存在する。
慣れるまでは、貪欲なら解けるからとりあえずそっちでやってみるかでWAで終了というパターンに多く遭遇するかもしれない。
自分は割と、ペナルティが深刻な場合は、少し時間がかかってもDPで解けそうならそっちで解くようにしている。

int P;
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> P;

    int ans = 0;
    rrep(x, 10, 1) {
        int tot = 1;
        rep(i, 1, x + 1) tot *= i;
        while (tot <= P) {
            P -= tot;
            ans++;
        }
    }
    cout << ans << endl;
}

Rolling Dice [AtCoder Beginner Contest 208 A]

https://atcoder.jp/contests/abc208/tasks/abc208_a

解説

https://atcoder.jp/contests/abc208/submissions/23993338

A問題にしては少し難しかったかもしれない。
だが、テクニックとしてはそれほど難しくないので、もし分からなかった場合は覚えておこう。
問題によっては、作りうる最小と最大の間は全部作れるという場合がある。
今回もそういう性質を持っていて、不安な場合は少し実験すると、簡単に調整できることが分かるだろう。

最小値は全部1が出たときなのでA、最大値は全部6が出たときなので6A。
出た目の合計Bがこの間にあれば作れるのでYesを返す。
そうでないならNoを返す。

int A, B;
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> A >> B;

    int mi = A;
    int ma = A * 6;

    if (mi <= B && B <= ma) cout << "Yes" << endl;
    else cout << "No" << endl;
}

Tree Patrolling [AtCoder Beginner Contest 207 F]

https://atcoder.jp/contests/abc207/tasks/abc207_f

前提知識

解説

https://atcoder.jp/contests/abc207/submissions/23806639

二乗の木DPという手法を用いる。
知らないと解けない気もするが、計算量的なセンスがあれば自ら生み出せるかもしれない。
(念のため補足しておくと、生み出せたならあなたは天才です)

木DP

二乗の木DPについての前に木DPが今回は思いつかないと先には進めない。
木DPが分からない場合はどこかで勉強してきてほしい。
上のリンクにも練習問題なら用意している。

木上での数え上げといえば木DPなので、とりあえず木DPでやれないか検討してみる。
親から見た子について必要な情報を考えてみると、以下のようなDPが立つ。

dp[cu][cnt][placed][colored] :=
頂点cuを親とした部分木において、
警備されている頂点数がcntで
頂点cuに高橋君がいるかの情報がplaced(0/1)で、
頂点cuが警備されているかの情報がcolored(0/1)のときの組合せ

これを計算して、木全体の根を0とすると、警備された頂点数がちょうどK個の時の組み合わせはdp[0][K][any][any]の総和となる。
まずはこのDPテーブルの定義に納得することが第一段階。
とある頂点cuとその子供の頂点toについて、toに高橋君がもしいれば頂点cuは警備されるので、子供について高橋君がいるかどうかの情報は必要だし、
cuに高橋君を配置すれば頂点toが警備されていないなら警備されている状態になるので、子供について子供の頂点が警備されているかの情報は必要である。
だが、この警備関係は直接の子供についてのみ作用するので、木DPで根について以上の情報を持っておけば、他のことは抽象化しても問題ない。
よって、こんな感じの木DPかなという感じ。

遷移について

定義は多次元DPに慣れていればそれほど難しくないが、遷移が難しい。
今回の木DPは子供の情報を単に足し合わせるだけでなく、根の情報をうまく組合せながらまとめ上げていく必要がある。
こういう場合は、DPをマージしていくような感じで遷移を進めていく。

初期状態について

全く子供を持っていない葉の状態では、単に高橋君を置くかおかないかという感じになるので、

dp[cu][0][0][0] = 1 (おかない)
dp[cu][1][1][1] = 1 (おく)

という感じになる。

子を親にマージするとどうなるか

実装を見てもらうといいが、本当にマージしてる感じがでている。
親も上の初期状態DPから始まって子のDPをマージして新しく親のDPを再構築するような形で計算を進めていく。

rep(c0, 0, size0) rep(placed0, 0, 2) rep(colored0, 0, 2) rep(c1, 0, size1) rep(placed1, 0, 2) rep(colored1, 0, 2) {  
    int c2 = c0 + c1;  
    int placed2 = placed0;  
    int colored2 = colored0 | placed1;  
    if (colored0 == 0 && placed1 == 1) c2++;  
    if (colored1 == 0 && placed0 == 1) c2++;  
  
    if (c2 < size0 + size1 - 1 && 0 < (dp[id0][c0][placed0][colored0] * dp[id1][c1][placed1][colored1]).get()) {  
        nxt[c2][placed2][colored2] += dp[id0][c0][placed0][colored0] * dp[id1][c1][placed1][colored1];  
    }  
}  

重要な遷移部分を以上に再掲した。
親dp[cu]と子dp[to]をdp'cuとして再構築している感じになる。

基本的にはnxt[cnt0+cnt1] += dp[cu][cnt0] * dp[to][cnt1]をしていく単純な遷移なのだが、
親と子のplacedとcoloredの関係により、警備されている個数が増加したりするのでその辺をまとめるのに6重ループになってしまっている。
説明するより実装を見てもらう方がDP構造が分かっている場合は早いと思う。
c2(=dp'のcnt)をインクリメントしている部分は、さっき話した

とある頂点cuとその子供の頂点toについて、toに高橋君がもしいれば頂点cuは警備されるので、子供について高橋君がいるかどうかの情報は必要だし、
cuに高橋君を配置すれば頂点toが警備されていないなら警備されている状態になるので、子供について子供の頂点が警備されているかの情報は必要である。

の部分であり、更新前にif文を用意しているのは更新時にDPテーブルをはみ出してしまう(はみ出ている部分はありえない場面であるため無視していい)ためである。
これでマージしていけば最終的に親のDPが答えになっている。

二乗の木DPはどこへ?

ここまで言われていることが分かっていれば問題は解けている。
理解が難しい場合は簡単な二乗の木DPを解いて戻ってくれば恐らく理解できるようになるだろう。

そういえば二乗の木DPの説明をしていなかったが、これは枝刈り木DPのことである。
通常のDPではdp[cu][cnt]みたいにして、どちらも103が上限だとすると、dp[cu][cnt]の全ての要素を使って計算が行われる。
だが、一部の木DP、今回のようなDPの場合にはcnt≦|cuを根とした部分木の要素数|が成り立つ場合がある。
この場合にはdp[103][103]のように定義したとしても、部分木の要素数以上は使用されないので大部分は計算に用いられないことになる。
この用いられない部分に対して明確に計算しないように計算を進める、つまり、適切に枝刈りを行って計算を進めることで、
通常はO(N3)になるはず(雰囲気的にはパッと見てO(N3)っぽい)計算量をO(N2)に抑えることができる。
これが二乗の木DPである。

今回は二乗の木DPにできそうなcnt≦|cuを根とした部分木の要素数|があるので、通常の木DPでは見られにくいmergeを行うことで適切に枝刈りをして計算量を落としている。

実装

自分の実装では部分木の個数で要素数が変わるので毎回vectorでDPを作り直して、merge関数でゴリゴリ更新を行っている。
vector使うと生成コストが気になってしまうとも思うので、他の人の実装も参考にしてみるといいかもしれない。
自分はまだ間に合わなかったことがないので、とりあえず馴染みのこんな実装で通した。

int N;
vector<int> E[2010];
vector<vector<vector<mint>>> dp[2010];
//---------------------------------------------------------------------------------------------------
void merge(int id0, int id1) {
    int size0 = dp[id0].size();
    int size1 = dp[id1].size();
    vector<vector<vector<mint>>> nxt(size0 + size1 - 1, vector<vector<mint>>(2, vector<mint>(2, 0)));

    rep(c0, 0, size0) rep(placed0, 0, 2) rep(colored0, 0, 2) rep(c1, 0, size1) rep(placed1, 0, 2) rep(colored1, 0, 2) {
        int c2 = c0 + c1;
        int placed2 = placed0;
        int colored2 = colored0 | placed1;
        if (colored0 == 0 && placed1 == 1) c2++;
        if (colored1 == 0 && placed0 == 1) c2++;

        if (c2 < size0 + size1 - 1 && 0 < (dp[id0][c0][placed0][colored0] * dp[id1][c1][placed1][colored1]).get()) {
            nxt[c2][placed2][colored2] += dp[id0][c0][placed0][colored0] * dp[id1][c1][placed1][colored1];
        }
    }

    swap(dp[id0], nxt);
}
//---------------------------------------------------------------------------------------------------
void dfs(int cu, int pa = -1) {
    dp[cu] = vector<vector<vector<mint>>>(2, vector<vector<mint>>(2, vector<mint>(2, 0)));
    dp[cu][0][0][0] = 1;
    dp[cu][1][1][1] = 1;

    fore(to, E[cu]) if (to != pa) {
        dfs(to, cu);
        merge(cu, to);
    }
}
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N;
    rep(i, 0, N - 1) {
        int a, b; cin >> a >> b;
        a--; b--;
        E[a].push_back(b);
        E[b].push_back(a);
    }

    dfs(0);
    rep(k, 0, N + 1) printf("%d\n", (dp[0][k][0][0] + dp[0][k][0][1] + dp[0][k][1][1]).get());
}

Mod i [AtCoder Beginner Contest 207 E]

https://atcoder.jp/contests/abc207/tasks/abc207_e

前提知識

解説

https://atcoder.jp/contests/abc207/submissions/23805901

高度なDP。慣れていないと簡単ではないと思うのだが、500人も解くんですね…

DPっぽさがあるか?

mod109+7で制約もdp[N][N]っぽい感じがあるので、とりあえずDP方針で考えていくというのが順当な所だろう。
よくあるDPとして、「dp[i] := i番目までは確定している」みたいなものがあるので、それをベースとしてみる。
追加すべき情報として何個に切り分けたかというのを追加する。
これは問題の条件の肝となる情報であり、かつ、何個で切り分けたかだけが重要でそれ以降の遷移に
どこで切り分けたかの情報は必要ないということから添え字として使えそうな感じがする。
これらの点から以下のDPで解けるのではと予想がつく。

dp[i][k] := 先頭からi番目の数までで条件に合うようk組作ったときの組み合わせ

これを構築できれば、dp[N][1]~dp[N][N]の総和が答えになる。
ここまでは比較的作れる人も多いと思う。
問題はこれの更新最適化である。

愚直に考えると…

dp[i][k]から遷移させるときはdp[i+j][k+1]に遷移が可能で、かつA[i+1]+A[i+2]+...+A[i+j]がk+1の倍数である必要がある。
このように遷移を考えていく方式を遷移元を起点としているので配るDPと表現したりするが、更新最適化では逆にして考える、つまり貰うDPの方が都合がいい場合が多い。
貰うDPとは遷移先を起点として考える捉え方である。
今回で言うと、dp[i][k]へ遷移できるのは、j<iかつA[j+1]+A[j+2]+...+A[i]がkの倍数であるdp[j][k-1]からであるという風に言い換える。

数式っぽく書くと、
dp[i][k] = sum_{j<iかつA[j+1]+A[j+2]+...+A[i]がkの倍数であるj} dp[j][k-1]
となる。
今回の問題は、このsum部分をO(1)でやる必要があり、実は方法がある。

条件を言い換える

dp[i][k] = sum_{j<iかつA[j+1]+A[j+2]+...+A[i]がkの倍数であるj} dp[j][k-1]

区間というのは扱いにくく、特に条件としてj<iはしょうがないのだが、区間の特徴にiが入ってしまうと区間計算を毎回する必要があり、あまりうれしくない。
ここで累積和としてB[i] = A[1] + A[2] + ... + A[i]を導入し、かつ、modを使うことで、

A[j+1]+A[j+2]+...+A[i]がkの倍数である

というのを

B[i] mod kとB[j] mod kが一致する

という風に言い換える。ことにする。
これで区間についての条件というよりかは点についての条件に言い換えることができる。

dp[i][k] = sum_{j<iかつA[j]=Aiであるj} dp[j][k-1]

ちょっと文字量が減っただけではという感じもするかもしれないが、このように変換することでメモ配列を用意することができる。

メモ配列tot

以下のようなメモっておくための配列を更新しながらDP更新に用いることにする。

tot[k][mo] := これまで計算を終えたdpテーブルについて、sum_{A[j]を(k+1)で割った余りがmoであるj} dp[j][k]

「これまで計算を終えたdpテーブルで」というのが重要で、dp[i][k]の計算時にこのtot配列を使用すれば、i番目の計算時までのdpテーブルについて参照しているので、
j<iというのが自動的に守られることになる。よって、計算は単にdp[i][k] = tot[k - 1][A[i] % k]とすることができる。
よくよく見てみると、ちゃんと計算できていることが見て取れる。

このメモ配列の構築方法は、dpテーブルの1要素の計算を終えた段階でtotの方へ反映していけばいい。
つまり、dp[i][k]が確定したら、それをtot[k][A[i] % (k + 1)]に足し合わせていけばいい。
すると、今後のk+1組目の確定時にそれが利用される。

実装上の注意として実際にはdp[i][k]が確定した直後にtotも更新してしまうと、同じiでの更新時に影響が出てしまう可能性があるので、
i番目を処理するときはdp[i][k]をすべて計算してから、そのあとにdp[i][k]をtotに足し合わせる作業をするようにすること。

ちょっと分からないんですが…

DP高速化(特に今回みたいな高度なメモ化というか前計算)はコードだけ見るとむっちゃ短いけど、何やってるか分からないことが多いと思う。
自分もなるべく細かく書くように心がけたが、理解が難しかった場合は他にも解説が無いか探して読んでみることを勧める。
同じ解法でも言い方の違いで理解できたりするので、多読してみるといい(公式の動画解説を見れば大体理解できそうではあるけれど)

int N; ll A[3010];
mint dp[3010][3010];  // dp[i][k] := i番目まででk組作った
mint tot[3010][3010]; // tot[k][mo] := k組作っていてこれまでの総和を(k+1)で割った余りがmoである
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N;
    rep(i, 0, N) cin >> A[i];

    dp[0][0] = 1;
    tot[0][0] = 1;
    ll sum = 0;
    rep(i, 0, N) {
        sum += A[i];
        rep(k, 1, N + 1) dp[i + 1][k] = tot[k - 1][sum % k];
        rep(k, 1, N + 1) tot[k][sum % (k + 1)] += dp[i + 1][k];
    }
    
    mint ans = 0;
    rep(k, 1, N + 1) ans += dp[N][k];
    cout << ans << endl;
}

Congruence Points [AtCoder Beginner Contest 207 D]

https://atcoder.jp/contests/abc207/tasks/abc207_d

前提知識

解説

https://atcoder.jp/contests/abc207/submissions/23804855

微妙に幾何知識が必要。
自分の解法は想定解法ではなく、かつ、コンパイル時最適化でTLEをごり消した解法です。
慣れずに通すのは難しいと思うので、こういう解法もあります程度に見るといいです。
公式解法の重心を使う方針の方が、凸包とかでも使えて汎用的でオススメです…

何から始めるか

全探索できそうな所を一通り考えていく。
制約から解法を考えていくと、とりあえずどの点とどの点が対応しているかを1組は全探索できそうである。
1組対応する点が決まれば、各点の集合をその点を中心に変更移動させて、あとは片方を適当に回転させて一致するかを確認すればよくなる。
つまり、平行移動について考える必要がなくなるのがいい所。
あとは回転させて一致するかの判定問題となった。

回転させて一致するか

ただ単に回転といっても回転は実数なので、回転角度を全探索する方針は難しい。
よってある程度回転角度を絞って確認する必要がある。

点の集合Tについて中心として選んだ点以外に1つ点を選択して、その点が回転後にどうなるかについて考えてみる。
答えとなる回転角度は少なくとも適当に選んだ1つのその点の移動先が格子上(xy座標どちらも整数)に移る必要がある。
この回転によって移動可能な格子上の点はそれほど多くなく、というかかなり少なく、全探索しても問題無さそうな雰囲気がある。
なので、候補の回転角については、適当に選んだ1つの点が回転によって格子上の点に移動するものを利用する。

候補の回転角を探すには遷移先の格子上の点を全探索して、「中心からの距離が等しい」ならば回転によって移動可能であると判断する方法を取る。
今回は[-10,10]の範囲に点が与えられているので中心で平行移動しても[-20,20]の範囲に点が収まることになる。
よって、xy座標について[-20,20]を全探索して中心からの距離が、適当に選んだ1つの点と等しくなる点を探すことで回転によって移動可能な点を探す。

回転によって移動可能な点が分かれば、「回転によって移動可能な点」と「中心」と「適当に選んだ1つの点」の角度を調べれば回転角を求めることができる。
実装ではthreePointArgumentTokei関数として自分は用意している。

回転角が分かったら…

回転角が分かったら、あとは、点集合Tについてその回転をすべて適用して、点集合Sと一致するか確認する。
確認方法は、回転後の2つの集合を普通にソートして、ソート後に先頭から順に点が一致するかを判定していく方式を取る。
正直ここがかなり嘘解法っぽいんだけれど、集合が仮に一致していた場合、ソートをしたら同じようにソートされるはずで理論的には正しく判定されるとは思うんだけれど、
doubleで値を持っているので、誤差でソートが壊れるということは大いに考えられたが、出したら大丈夫だった。

これで完全に一致していたらYesで答えるし、すべての場合でダメだったらNo

実装について

小数比較について

「中心からの距離が等しい」というのを判定するのにlen == len2みたいな比較をしたくなるが、こういう場合はabs(len - len2) < EPSとすると誤差を吸収できる。
あと、2点が一致するという部分も2点間の距離がゼロという風に変換をしてabs(X - Y) == 0として、誤差を考慮してabs(X - Y) < EPSと言う風にして誤差を吸収する。

間に合う?

実際テンプレで長らく使ってなかった#pragma GCC optimize ("-O3")を使って最適化させてTLEを回避した。
O(N3logN|回転角候補最大数|)
みたいな感じになるから相当ギリギリ。

int N; P ps[101], pt[101], pt2[101];
//---------------------------------------------------------------------------------------------------
#define yes "Yes"
#define no "No"
string solve() {
    if (N == 1) return yes;

    rep(centerS, 0, N) rep(centerT, 0, N) {
        rep(k, 0, N) if (centerS != k) ps[k] -= ps[centerS];
        ps[centerS] = P(0, 0);

        rep(k, 0, N) if (centerT != k) pt[k] -= pt[centerT];
        pt[centerT] = P(0, 0);

        int t = (centerT == 0) ? 1 : 0;
        double len = abs(pt[centerT] - pt[t]);
        
        vector<double> rots;
        rep(x, -20, 21) rep(y, -20, 21) {
            P p(x, y);
            double len2 = abs(p);
            if (abs(len - len2) < EPS) rots.push_back(threePointArgumentTokei(p, pt[centerT], pt[t]));
        }

        fore(rot, rots) {
            rep(k, 0, N) pt2[k] = rotate(pt[k], rot);

            sort(ps, ps + N);
            sort(pt2, pt2 + N);

            bool ok = true;
            rep(k, 0, N) if (EPS < abs(pt2[k] - ps[k])) ok = false;
            if (ok) return yes;
        }
    }

    return no;
}
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N;
    rep(i, 0, N) {
        int a, b; cin >> a >> b;
        ps[i] = P(a, b);
    }
    rep(i, 0, N) {
        int a, b; cin >> a >> b;
        pt[i] = P(a, b);
    }
    cout << solve() << endl;
}

Many Segments [AtCoder Beginner Contest 207 C]

https://atcoder.jp/contests/abc207/tasks/abc207_c

解説

https://atcoder.jp/contests/abc207/submissions/23803776

区間をすべて[l,r)の半開区間に変換して比較をしよう。

[l,r] -> [l,r + 0.5)
[l,r) -> [l,r)
(l,r] -> [l + 0.5, r + 0.5)
(l,r) -> [l + 0.5, r)

という風に中間をうまく利用することで区間を変換していく。
だが、これだと小数が入ってしまうので、すべての座標を2倍にして考えることにする。
今回考慮されるのは包含関係であり、すべての座標を2倍にしても包含関係には影響しない。
なので、以下のように変換する

[l,r] -> [2l,2r + 1)
[l,r) -> [2l,2r)
(l,r] -> [2l + 1, 2r + 1)
(l,r) -> [2l + 1, 2r)

これですべて同じ様式の区間になったので、全ての(i,j)の組について包含関係を判定して、包含しているならカウントしていけばいい。
[a,b)と[c,d)を合成した区間は[max(a,c), min(b,d))となるので、このルールで合成して、要素が存在する、つまり、max(a,c)<min(b,d)であるかを判定すればいい。

int N, A[2010], B[2010];
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N;
    rep(i, 0, N) {
        int t, l, r; cin >> t >> l >> r;
        if (t == 1) A[i] = l * 2, B[i] = r * 2 + 1;
        else if (t == 2) A[i] = l * 2, B[i] = r * 2;
        else if (t == 3) A[i] = l * 2 + 1, B[i] = r * 2 + 1;
        else A[i] = l * 2 + 1, B[i] = r * 2;
    }

    int ans = 0;
    rep(i, 0, N) rep(j, i + 1, N) {
        int AA = max(A[i], A[j]);
        int BB = min(B[i], B[j]);
        if (AA < BB) ans++;
    }
    cout << ans << endl;
}

Hydrate [AtCoder Beginner Contest 207 B]

https://atcoder.jp/contests/abc207/tasks/abc207_b

解説

https://atcoder.jp/contests/abc207/submissions/23803329

愚直にシミュレーションをしていって、目標が達成されれば即座に回数を答えれば答えになる。
ただ、目標が達成可能でない場合は無限に操作が行われてしまうので適当な所で打ち切って-1を出力する。

解法を聞くとそれほど難しくないのだが、これで本当にいいのか?という疑問が残る。
正直、雰囲気で自分は解いたところがあるが、回数をcntと置くと、数式で整理ができて、これでよさそうという結論になる。

数式

操作回数がcntとすると、

水色のボールは A + B * cnt
赤色のボールは C * cnt

となっていることになる。水色が赤色のD倍以下ということは、

A + B * cnt ≦ C * cnt
A ≦ C * cnt - B * cnt
A ≦ (C - B) * cnt

という感じになる。左辺は正なので、C - Bが負になれば条件は満たされない。
正として考えると、

A / (C - B) ≦ cnt

となるので、条件を満たす最小のcntはAが上限になることが分かる。
つまり、答えがある場合は、105回くらいやれば達成できるということが分かる。

シミュレーション

なので105回を上限に(自分は心配症なので念のため計算量が余裕で間に合う106で本番はやったけれど)シミュレーションすればいい。

int A, B, C, D;
//---------------------------------------------------------------------------------------------------
int solve() {
    ll mizu = A;
    ll aka = 0;
    rep(cnt, 1, 1010101) {
        mizu += B;
        aka += C;

        if (mizu <= aka * D) return cnt;
    }
    return -1;
}
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> A >> B >> C >> D;
    cout << solve() << endl;
}