garakutagoya

興味、関心のあったこと、そして私の気持ちなどを残していきたい

AtCoder_Beginner_Contest_240_C Jumping Takahashi

いよいよDP.最初は優しめの問題から.

atcoder.jp

問題の説明

高橋君がNa_iまたはb_i段のジャンプを行います.N回のジャンプの後にXにいる可能性があるか答えなさい.

条件

  • 1≦N≦100
  • 1≦a_i\lt b_i≦100
  • 1≦X≦10,000
  • 入力は全て整数.

 

考えた事

まず,DPとは何をする手法なのか.端的に言えば値を記憶する配列を作り,それを使い回すものであろう.この問題においてそれは出来るのだろうか.

例えば1回目にa_1段上がり,2回目にb_2段上がったものと,1回目にa_1段上がり,2回目にa_2段上がったものでは当然現在位置が異なる.Greedyと違って今回はX段まで上る最適な方法は存在しない(入力によって方法を変えなければならない)ため,X段に辿り着けるかどうかは全てを試さなければ分からない.

ところがこれを全探索で実装しようとするとTLEエラーとなってしまう.全てのiについてa_ib_iを選んだ場合を考えると計算量はO(2^N)乗となり,N=100が上限では計算が間に合わない.

なぜ全探索ではダメなのか.無駄が多いのである.最後だけa_Nb_Nで異なるが,それ以外は全く同じものを選んだ場合を考えてもらいたい.全探索ではこれを一々計算し直すため,結果として非常に遅い手法となってしまう.

これを解消するのがDPである.配列にN-1回目までに行ける部分を記憶させる事で,N回目に行ける部分はN-1回目までに行ける部分にa_Nまたはb_Nを足せば求められる.DPがメモ化再帰と呼ばれる所以である.*1裏を返せばN-1回の状態を記憶させる事に意味のない問題の場合,DPは不適当な解法だといえる.

f:id:hikki-dominion:20220411182404j:plain

状態の遷移を上の図*2に表した.i回目に座標jにいる場合は1を,そうでない場合は0を記している.0回目(登り始める前)は0にいるため,0にのみ1がつき,そうでない部分は0が記される.

1回目のジャンプを行うと0"+"a_1または0"+"b_1のジャンプを行う.ここで重要なのはi回目のジャンプで行ける先を求める場合,i-1回までの結果にそのままa_iまたはb_iを足せば求められるという点だ.これが可能であるからこそ,これを帰納的にN回繰り返してN回目に行ける場所が求められる.

少し前の繰り返しになるが,「繰り返し」が出来るかどうかがDPの使用可否を決める.どれを変数とすれば状態を遷移させられるか,繰り返しを作る事が出来るか,もし出来るならばどのような遷移となるか,これを考える事が非常に重要である.

競技プログラミングにおいては条件のところで変数のヒントが与えられている事も多い.今回においてパラメータ(変数)はi(1≦i≦N)j(1≦j≦100×100=10,000)であり,N,X,a_i,b_iは定数である.問題文にパラメータとしてiは既に記されており,後は自分でjを置けばDPの遷移を考えられる.この知見は大事にしたい.

さあ実装だ.DPの実装は上の配列を埋める事に終始する.配列を埋めるにはどうすればいいのか.ここで出てくるのが漸化式である.

i-1回目までの遷移とi回目の遷移を結び付ける式があれば配列を埋めて行ける.DPの最終工程はこれである.

今回であればdp_{i-1,j}=1ならばdp_{i,j+a_i}=dp_{i,j+b_i}=1となる.それ以外は0となる.つまり,

dp_{i,j+a_i}=dp_{i,j+b_i}=1(dp_{i-1,j}=1)

dp_{i,j}=1=0(otherwise.)

となる.まとめてコードにすると,

vvll dp(N + 1, vll(10001, 0));
    dp.at(0).at(0) = 1;
    rrep(i, N) {
        rep(j, 10001) {
            if (dp.at(i - 1).at(j)) {
                dp.at(i).at(j + a.at(i - 1)) = 1;
                dp.at(i).at(j + b.at(i - 1)) = 1;
            }
        }
    }

となる.プログラム上は0からスタートしているため,iの添え字が1つずれているのには注意したい.0を配列のデフォルトにし,ijを動かして,上の条件を満たすものだけに1をつけていく.

計算量に注目するとこれはO(N×10,000)であり,全探索のO(2^N)と比べ,大幅に短縮されている事が分かるだろう.

今回は制限に余裕があるため行っていないが,例えばj+a_{i-1}≦X,j+b_{i-1}≦Xをif文に追加し,X以上となった場合は考えない*3ように実装すれば計算量はO(N×X)となり更に短縮できる.

DPの流れをまとめると

  1. 全探索出来るかどうかを考える.(計算量が大きな基準となる)
  2. 状態が遷移するか,前の状態から今の状態を帰納的に導き出せるかを考える.(前の状態に足せるかどうかを見ると良さげ.)
  3. 変数を見つけ,小さな値の範囲でいいから配列を書いてみる.
  4. それを基に漸化式を作る.
  5. 漸化式を元に変数を動かし配列を全て埋めていく.
  6. 求めたい値に対応する配列を見て,答えを導き出す.

となるだろう.最後はもちろん問題によって異なるが今回であればdp_{N,X}である.これが10かがジャンプで辿り着けるかどうかを決める為,それを参照して出力すればいい.

プログラム

#include <bits/stdc++.h>
using namespace std;
#include <atcoder/all>
using namespace atcoder;
using ll = long long;
using ld = long double;
using P = pair<int, int>;
using Graph = vector<vector<int>>;
using vi = vector<int>;
using vl = vector<long>;
using vll = vector<long long>;
using vvi = vector<vi>;
using vvl = vector<vl>;
using vvll = vector<vll>;
using vs = vector<string>;
using pii = pair<long long, long long>;
using mint = modint1000000007;
const long double EPS = 1e-10;
const long long INF = 1e18;
const long double PI = acos(-1.0L);
#define reps(i, a, n) for (ll i = (a); i < (ll)(n); i++)
#define rep(i, n) for (ll i = (0); i < (ll)(n); i++)
#define rrep(i, n) for (ll i = (1); i < (ll)(n + 1); i++)
#define repd(i, n) for (ll i = n - 1; i >= 0; i--)
#define rrepd(i, n) for (ll i = n; i >= 1; i--)
#define ALL(n) begin(n), end(n)
#define IN(a, x, b) (a <= x && x < b)
#define INIT                          \
    std::ios::sync_with_stdio(false); \
    std::cin.tie(0);
template <class T>
inline T CHMAX(T& a, const T b) {
    return a = (a < b) ? b : a;
}
template <class T>
inline T CHMIN(T& a, const T b) {
    return a = (a > b) ? b : a;
}

int main() {
    ll N, X;
    cin >> N >> X;
    vll a(N);
    vll b(N);
    rep(i, N) {
        ll aa, bb;
        cin >> aa >> bb;
        a.at(i) = aa;
        b.at(i) = bb;
    }
    vvll dp(N + 1, vll(10001, 0));
    dp.at(0).at(0) = 1;
    rrep(i, N) {
        rep(j, 10001) {
            if (dp.at(i - 1).at(j)) {
                dp.at(i).at(j + a.at(i - 1)) = 1;
                dp.at(i).at(j + b.at(i - 1)) = 1;
            }
        }
    }

    if (dp.at(N).at(X)) {
        cout << "Yes" << endl;
    } else {
        cout << "No" << endl;
    }
}

*1:正確にはメモ化+分割統治法である.

*2:この図の事をDPテーブルと言う人もいる

*3:これを枝刈りという