Keito

© 2024 Keito

技術ブログとポートフォリオ

【Go】

更新日2025/10/4/(土) 02:22
タグ
Goアルゴリズム

概要

  • Goでフィボナッチ数列を動的計画法で実装
  • 動的計画法(DP)は部分問題の解を保存し再利用することで、再帰の指数的計算量を線形に改善する手法。フィボナッチ数列はDPの典型例。

特徴

  • 効率的計算: 時間計算量O(n)で実用的
  • メモリトレードオフ: 空間をO(n)使って時間を節約
  • 2つのアプローチ: トップダウン(メモ化)とボトムアップ
  • 最適化可能: 空間計算量をO(1)に削減可能
  • DPの典型例: 動的計画法の学習に最適

動的計画法とは

動的計画法の基本原理:

  1. 部分問題への分割: 大きな問題を小さな部分問題に分解
  2. 重複の排除: 同じ部分問題を何度も解かない
  3. 結果の保存: 計算結果をテーブルやメモに保存
  4. 再利用: 保存した結果を使って効率的に解を求める

2つのアプローチ

トップダウン(メモ化)

  • 再帰的に計算しながら結果をメモ
  • 必要な部分だけ計算
  • 実装が直感的

ボトムアップ

  • 小さい問題から順に解いていく
  • 全ての部分問題を計算
  • 反復で実装、スタックオーバーフローなし

サンプルコード

ボトムアップ動的計画法

package main import "fmt" // FibonacciDP はボトムアップ動的計画法でフィボナッチ数列のn番目を計算 func FibonacciDP(n int) int { if n <= 1 { return n } // dpテーブルを作成(0からnまで) dp := make([]int, n+1) dp[0] = 0 dp[1] = 1 // ボトムアップで計算 for i := 2; i <= n; i++ { dp[i] = dp[i-1] + dp[i-2] } return dp[n] }

ステップ表示版

// FibonacciDPWithSteps はステップごとに計算過程を表示 func FibonacciDPWithSteps(n int) int { fmt.Printf("\\\\nFibonacci(%d) を動的計画法で計算:\\\\n", n) fmt.Println("=================================") if n <= 1 { fmt.Printf("n <= 1 のため、結果は %d\\\\n", n) return n } dp := make([]int, n+1) dp[0] = 0 dp[1] = 1 fmt.Printf("初期値: dp[0] = %d, dp[1] = %d\\\\n", dp[0], dp[1]) fmt.Println("\\\\n計算過程:") for i := 2; i <= n; i++ { dp[i] = dp[i-1] + dp[i-2] fmt.Printf("dp[%d] = dp[%d] + dp[%d] = %d + %d = %d\\\\n", i, i-1, i-2, dp[i-1], dp[i-2], dp[i]) } fmt.Printf("\\\\n最終的なdpテーブル: %v\\\\n", dp) fmt.Printf("結果: Fibonacci(%d) = %d\\\\n", n, dp[n]) return dp[n] }

空間最適化版(O(1))

// FibonacciDPOptimized は空間計算量を最適化した動的計画法 func FibonacciDPOptimized(n int) int { if n <= 1 { return n } // 直前の2つの値のみ保持(空間計算量O(1)) prev2, prev1 := 0, 1 for i := 2; i <= n; i++ { current := prev1 + prev2 prev2, prev1 = prev1, current } return prev1 }

数列生成版

// FibonacciSequenceDP は動的計画法でn番目までのフィボナッチ数列を生成 func FibonacciSequenceDP(n int) []int { if n < 0 { return []int{} } if n == 0 { return []int{0} } sequence := make([]int, n+1) sequence[0] = 0 sequence[1] = 1 for i := 2; i <= n; i++ { sequence[i] = sequence[i-1] + sequence[i-2] } return sequence }

動作原理

ボトムアップDPの計算過程

Fibonacci(8) の計算:

初期値: dp[0] = 0 dp[1] = 1 計算過程: dp[2] = dp[1] + dp[0] = 1 + 0 = 1 dp[3] = dp[2] + dp[1] = 1 + 1 = 2 dp[4] = dp[3] + dp[2] = 2 + 1 = 3 dp[5] = dp[4] + dp[3] = 3 + 2 = 5 dp[6] = dp[5] + dp[4] = 5 + 3 = 8 dp[7] = dp[6] + dp[5] = 8 + 5 = 13 dp[8] = dp[7] + dp[6] = 13 + 8 = 21 dpテーブル: [0, 1, 1, 2, 3, 5, 8, 13, 21] 結果: Fibonacci(8) = 21

トップダウン(メモ化)との比較

トップダウン(メモ化)

Fib(5) の計算: ├─ Fib(4) を計算 │ ├─ Fib(3) を計算 │ │ ├─ Fib(2) を計算 │ │ │ ├─ Fib(1) = 1 │ │ │ └─ Fib(0) = 0 │ │ └─ Fib(1) = 1 (既に計算済み) │ └─ Fib(2) = 1 (メモから取得) └─ Fib(3) = 2 (メモから取得)

ボトムアップ(DP)

Fib(5) の計算: dp[0] = 0 dp[1] = 1 dp[2] = dp[1] + dp[0] = 1 dp[3] = dp[2] + dp[1] = 2 dp[4] = dp[3] + dp[2] = 3 dp[5] = dp[4] + dp[3] = 5

計算量

時間計算量

実装方法時間計算量説明
純粋再帰O(2^n)指数的増加、重複計算多数
メモ化O(n)各値を1回だけ計算
ボトムアップDPO(n)0からnまで線形に計算
最適化DPO(n)同上

空間計算量

実装方法空間計算量説明
純粋再帰O(n)再帰スタックの深さ
メモ化O(n)メモテーブル + スタック
ボトムアップDPO(n)dpテーブル
最適化DPO(1)2つの変数のみ

実行時間比較(n=40)

純粋再帰: 約2秒 メモ化: 0.00001秒 ボトムアップDP: 0.00001秒 最適化DP: 0.00001秒

使いどころ

向いてる場面

  • 実用的なフィボナッチ数の計算
  • 大きなn(n > 30)でも高速に計算
  • 動的計画法の学習
  • 最適化の比較(空間vs時間)

実例:フィボナッチ数列の生成

func main() { // 最初の20項を生成 sequence := FibonacciSequenceDP(20) fmt.Printf("フィボナッチ数列: %v\\\\n", sequence) // 出力: [0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765] // 大きな値でも高速 fib100 := FibonacciDPOptimized(100) fmt.Printf("Fibonacci(100) = %d\\\\n", fib100) }

実装の比較

アプローチ時間空間実装の複雑さスタックオーバーフロー
純粋再帰O(2^n)O(n)簡単あり
メモ化O(n)O(n)やや複雑あり(大きなn)
ボトムアップDPO(n)O(n)やや複雑なし
最適化DPO(n)O(1)やや複雑なし

コード行数の比較

// 純粋再帰: 4行 func Fib(n int) int { if n <= 1 { return n } return Fib(n-1) + Fib(n-2) } // ボトムアップDP: 8行 func FibDP(n int) int { if n <= 1 { return n } dp := make([]int, n+1) dp[0], dp[1] = 0, 1 for i := 2; i <= n; i++ { dp[i] = dp[i-1] + dp[i-2] } return dp[n] } // 最適化DP: 6行 func FibDPOpt(n int) int { if n <= 1 { return n } prev2, prev1 := 0, 1 for i := 2; i <= n; i++ { prev2, prev1 = prev1, prev2+prev1 } return prev1 }

最適化テクニック

1. 空間計算量の削減

dpテーブル全体ではなく、直前の2つの値のみ保持:

// O(n) 空間 dp := make([]int, n+1) dp[i] = dp[i-1] + dp[i-2] // O(1) 空間 prev2, prev1 := 0, 1 current := prev1 + prev2 prev2, prev1 = prev1, current

2. 行列累乗法(O(log n))

さらに高速化したい場合:

// 行列[[1,1],[1,0]]のn乗を計算 // 時間計算量: O(log n) // 空間計算量: O(1) type Matrix [2][2]int func matrixMultiply(a, b Matrix) Matrix { return Matrix{ {a[0][0]*b[0][0] + a[0][1]*b[1][0], a[0][0]*b[0][1] + a[0][1]*b[1][1]}, {a[1][0]*b[0][0] + a[1][1]*b[1][0], a[1][0]*b[0][1] + a[1][1]*b[1][1]}, } } func matrixPower(mat Matrix, n int) Matrix { if n == 1 { return mat } if n%2 == 0 { half := matrixPower(mat, n/2) return matrixMultiply(half, half) } return matrixMultiply(mat, matrixPower(mat, n-1)) } func FibonacciMatrix(n int) int { if n <= 1 { return n } base := Matrix{{1, 1}, {1, 0}} result := matrixPower(base, n) return result[0][1] }

3. 並列計算

大量のフィボナッチ数を並列生成:

func FibonacciConcurrent(n int) []int { results := make([]int, n+1) var wg sync.WaitGroup // 各値を並列計算(実際はDPの方が速い) for i := 0; i <= n; i++ { wg.Add(1) go func(index int) { defer wg.Done() results[index] = FibonacciDPOptimized(index) }(i) } wg.Wait() return results }

パフォーマンス測定

import "time" func benchmark(name string, n int, fn func(int) int) { start := time.Now() result := fn(n) duration := time.Since(start) fmt.Printf("%s: Fib(%d) = %d, 実行時間: %v\\\\n", name, n, result, duration) } func compareMethods() { n := 40 // 純粋再帰は遅いのでn=40でも数秒かかる benchmark("純粋再帰", n, FibonacciRecursive) // DPは高速 benchmark("メモ化", n, FibonacciMemoization) benchmark("ボトムアップDP", n, FibonacciDP) benchmark("最適化DP", n, FibonacciDPOptimized) // 大きなnでも高速 benchmark("最適化DP(90)", 90, FibonacciDPOptimized) }

まとめ

メリット

  • 時間計算量O(n)で実用的
  • 大きなnでも高速に計算可能
  • スタックオーバーフローのリスクなし
  • 空間をO(1)に最適化可能

デメリット

  • 純粋再帰より実装が複雑
  • dpテーブル版は空間O(n)必要
  • 小さなn(< 10)では再帰の方がシンプル

使うべき時

  • 実用的なフィボナッチ数の計算
  • n > 30 の場合
  • 動的計画法の学習
  • パフォーマンスが重要な場合

選択基準

n の範囲推奨実装理由
n < 10純粋再帰シンプルで十分速い
10 ≤ n < 30メモ化 or DPどちらでも高速
n ≥ 30最適化DP最速かつ省メモリ
n ≥ 10^6行列累乗O(log n)で超高速

動的計画法はフィボナッチ数列の実用的な計算方法であり、DPの基本概念を学ぶ優れた例。ボトムアップとトップダウンの違い、時間と空間のトレードオフを理解するのに最適。