Java | 「動的計画法」を体系的に理解する

Java Java
スポンサーリンク

Javaで学ぶ「動的計画法」を体系的に理解する(前半)

動的計画法(Dynamic Programming, DP)は、アルゴリズムの世界で非常に重要なテクニックです。 「難しそう」「数学っぽくて怖い」と感じる読者も多いですが、本質はとてもシンプルで、 一言でいえば「同じ計算を二度しないように、うまく結果を使い回す方法」です。

前半では、Javaを使いながら

  • 動的計画法とは何か
  • 再帰との関係
  • フィボナッチ数列を題材にした基本パターン
  • 一次元DPの考え方

を、初心者向けにかみ砕いて解説していきます。 後半では、二次元DP(表を使うDP)や、典型問題(ナップサック、最長共通部分列など)に踏み込みます。

動的計画法とは何か(やさしい定義)

動的計画法を一言で説明すると、

「問題を小さな部分問題に分けて、その結果を表や配列に保存しながら、 小さいものから順番に解いていく方法」

です。

ここで重要なのは次の二つのポイントです。

再利用できる「部分問題」があること 一度解いた部分問題の結果を、別の計算で何度も使うことができる。

その結果を保存しておくこと 配列やテーブルに結果を記録し、後から参照することで、同じ計算を二度しない。

この二つがそろったとき、動的計画法が威力を発揮します。

再帰と動的計画法の関係

前のテーマで扱ったように、再帰は「大きな問題を小さな問題に分割して、自分自身に任せる」スタイルでした。 動的計画法は、その再帰の考え方を「より効率的に」「より体系的に」したものだと捉えると分かりやすいです。

再帰は「上から下へ」大きな問題を分割していくイメージ。 動的計画法は「下から上へ」小さな問題から順番に積み上げていくイメージ。

どちらも「部分問題を使って全体を解く」点では同じですが、 動的計画法は「重複計算をしない」「配列や表を使う」という点で、より実務向きです。

フィボナッチ数列で見る「再帰」と「DP」の違い

動的計画法の入門として、定番のフィボナッチ数列を題材にします。

フィボナッチ数列は次のように定義されます。

F(0) = 0 F(1) = 1 F(n) = F(n – 1) + F(n – 2)

まずは、素朴な再帰で書いてみます。

public static int fibRecursive(int n) {
    if (n == 0) return 0;
    if (n == 1) return 1;
    return fibRecursive(n - 1) + fibRecursive(n - 2);
}
Java

このコードは「定義そのもの」を表現していて、とても美しいです。 しかし、fibRecursive(40) などを計算すると、非常に時間がかかります。

理由は、同じ値を何度も何度も計算しているからです。 例えば fibRecursive(5) の中で fibRecursive(3)fibRecursive(2) が何度も登場します。

フィボナッチを動的計画法で書く(一次元DP)

動的計画法を使うと、この重複計算を完全に消すことができます。

フィボナッチを一次元配列で解くコードは次のようになります。

public static int fibDP(int n) {
    if (n == 0) return 0;
    if (n == 1) return 1;

    int[] dp = new int[n + 1];
    dp[0] = 0;
    dp[1] = 1;

    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }

    return dp[n];
}
Java

ここで dp[i] は「F(i) の値」を表しています。 小さい i から順番に dp[2], dp[3], ... dp[n] を計算していき、 最後に dp[n] を返します。

深掘り:このコードのどこが「動的計画法」なのか

フィボナッチの DP コードを、動的計画法の観点から分解してみます。

部分問題の定義 「F(i) を求める」という小さな問題を、dp[i] という形で表現している。

漸化式(状態遷移)の定義 dp[i] = dp[i - 1] + dp[i - 2] これは「F(i) は F(i-1) と F(i-2) から計算できる」という関係をコードにしたもの。

初期条件(ベースケース)の設定 dp[0] = 0 dp[1] = 1 ここからスタートして、順番に値を埋めていく。

この三つがそろったとき、「動的計画法」と呼べる形になります。

部分問題 漸化式(状態遷移) 初期条件

この三つは、動的計画法を体系的に学ぶうえで非常に重要なキーワードです。

一次元DPの基本パターンを体に染み込ませる

フィボナッチは一次元DPの最も基本的な例です。 一次元DPとは、「一つの整数 i に対して dp[i] を定義する」タイプの動的計画法です。

他にも、次のような問題が一次元DPの典型です。

階段の登り方の数(1段 or 2段ずつ登る場合の通り数) 最小コストである地点まで進む問題 連続部分列の最大和(Kadane のアルゴリズムに近いもの)

例えば「階段の登り方」を考えてみましょう。 1段または2段ずつ登れるとき、n段の階段を登る方法の数を求める問題です。

この問題も、フィボナッチとほぼ同じ構造を持っています。

dp[i] = dp[i – 1] + dp[i – 2]

「最後に1段登った場合」と「最後に2段登った場合」の合計が dp[i] になるからです。

コードは次のように書けます。

public static int waysToClimb(int n) {
    if (n == 0) return 1; // 何もしない1通り
    if (n == 1) return 1;

    int[] dp = new int[n + 1];
    dp[0] = 1;
    dp[1] = 1;

    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }

    return dp[n];
}
Java

このように、「dp[i] をどう定義するか」「dp[i] を dp[i-1], dp[i-2] からどう計算するか」を考えるのが、一次元DPの基本パターンです。

再帰との違いをもう一度整理する

同じ階段の問題を再帰で書くこともできます。

public static int waysRecursive(int n) {
    if (n == 0) return 1;
    if (n == 1) return 1;
    return waysRecursive(n - 1) + waysRecursive(n - 2);
}
Java

見た目はシンプルですが、n が大きくなると急激に遅くなります。 理由は、前半で説明した通り「同じ値を何度も計算している」からです。

動的計画法は、この重複計算を完全に消しつつ、 再帰と同じ「部分問題の構造」を保っています。

再帰 大きな問題を小さな問題に分割して、自分自身に任せる。 重複計算が多いと遅くなる。

動的計画法 小さな問題から順番に解き、結果を配列に保存して使い回す。 重複計算がなく、計算量が劇的に改善される。

この違いを理解すると、「再帰で考えてからDPに落とし込む」という流れが自然にできるようになります。

前半のまとめと後半への橋渡し

前半では、動的計画法の入り口として

動的計画法のやさしい定義 再帰との関係 フィボナッチ数列の一次元DP 階段の登り方の例

を通して、「部分問題」「漸化式」「初期条件」という三つのキーワードを体に入れていきました。

後半では、さらに一歩進んで

二次元DP(表を使うDP:ナップサック、最長共通部分列など) 状態を工夫するDP(「何を dp に持たせるか」を設計する) 実務でのDPの使いどころ セキュリティ・パフォーマンスの観点から見たDP

といったテーマを、Javaコードとともに深掘りしていきます。

動的計画法は、最初のハードルこそ少し高く感じますが、 一度「部分問題」「漸化式」「初期条件」の三つが腑に落ちると、一気に世界が広がります。 後半では、その世界をもう少し広く、実務寄りに見ていきましょう。

タイトルとURLをコピーしました