Keito

© 2024 Keito

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

【Go】

更新日2025/10/12/(日) 01:13
タグ
Goアルゴリズム

概要

  • Goで部分和問題を動的計画法で実装
  • 部分和問題は与えられた数値の集合から、合計が目標値になる部分集合が存在するかを判定する問題。動的計画法の典型的な応用例の一つ。

特徴

  • 効率的計算: 時間計算量O(n×target)で解ける
  • メモリトレードオフ: 空間O(n×target)または最適化でO(target)
  • 2つのアプローチ: 判定問題と個数カウント
  • バックトラック: 実際の解(部分集合)も復元可能
  • 実用性: ナップサック問題の基礎となる重要アルゴリズム

部分和問題とは

問題の定義

入力: 整数の配列 nums = [n₁, n₂, ..., nₙ] と目標値 target

出力: nums の部分集合で合計が target になるものが存在するか

具体例

配列: [3, 34, 4, 12, 5, 2] 目標値: 9 解: [4, 5] または [3, 4, 2] → 4 + 5 = 9 ✓ → 3 + 4 + 2 = 9 ✓

応用例

  • ナップサック問題
  • 予算配分問題
  • 硬貨での支払い問題
  • パーティション問題(配列を等しい和に分割)
  • 組み合わせ最適化

動的計画法のアプローチ

基本的な考え方

部分問題の定義:

dp[i][j] = nums[0..i-1] の要素を使って合計 j を作れるか(true/false)

漸化式:

dp[i][j] = dp[i-1][j] OR dp[i-1][j - nums[i-1]] ↑ ↑ 使わない場合 使う場合

初期条件:

dp[i][0] = true (合計0は常に作れる:何も選ばない) dp[0][j] = false (j > 0の場合、要素がないので作れない)

計算過程の可視化

配列 [3, 4, 5, 2] で目標値 9 の例:

DPテーブル (◯: 作れる, ×: 作れない): 0 1 2 3 4 5 6 7 8 9 初期 ◯ × × × × × × × × × 3 ◯ × × ◯ × × × × × × 4 ◯ × × ◯ ◯ × × ◯ × × 5 ◯ × × ◯ ◯ ◯ × ◯ ◯ ◯ 2 ◯ × ◯ ◯ ◯ ◯ ◯ ◯ ◯ ◯ 結果: dp[4][9] = ◯ → 合計9は作れる ✓

ステップバイステップ

Step 1: 初期状態

  • 合計0は常に作れる(何も選ばない)

Step 2: 要素3を追加

  • 3が作れるようになる

Step 3: 要素4を追加

  • 4が作れる(4単体)
  • 7が作れる(3 + 4)

Step 4: 要素5を追加

  • 5が作れる(5単体)
  • 8が作れる(3 + 5)
  • 9が作れる(4 + 5) ← 目標達成!

Step 5: 要素2を追加

  • さらに多くの合計が作れる

サンプルコード

基本実装(判定問題)

package main import "fmt" // SubsetSum は動的計画法で部分和問題を解く func SubsetSum(nums []int, target int) bool { n := len(nums) // dp[i][j]: nums[0..i-1]の要素を使って合計jを作れるか dp := make([][]bool, n+1) for i := range dp { dp[i] = make([]bool, target+1) } // 初期条件: 合計0は常に作れる(何も選ばない) for i := 0; i <= n; i++ { dp[i][0] = true } // ボトムアップで計算 for i := 1; i <= n; i++ { for j := 0; j <= target; j++ { // i番目の要素を使わない場合 dp[i][j] = dp[i-1][j] // i番目の要素を使う場合(値がjより小さい場合のみ) if j >= nums[i-1] { dp[i][j] = dp[i][j] || dp[i-1][j-nums[i-1]] } } } return dp[n][target] }

実際の要素を見つける(バックトラック)

// FindSubsetSum は部分和を作る実際の要素を見つける func FindSubsetSum(nums []int, target int) []int { n := len(nums) dp := make([][]bool, n+1) for i := range dp { dp[i] = make([]bool, target+1) } for i := 0; i <= n; i++ { dp[i][0] = true } for i := 1; i <= n; i++ { for j := 0; j <= target; j++ { dp[i][j] = dp[i-1][j] if j >= nums[i-1] { dp[i][j] = dp[i][j] || dp[i-1][j-nums[i-1]] } } } // 解が存在しない場合 if !dp[n][target] { return nil } // バックトラック: 実際に使った要素を復元 result := []int{} i, j := n, target for i > 0 && j > 0 { // i番目の要素を使った場合 if !dp[i-1][j] && j >= nums[i-1] && dp[i-1][j-nums[i-1]] { result = append(result, nums[i-1]) j -= nums[i-1] } i-- } return result }

解の個数を数える

// CountSubsetSums は目標値を作る方法の数を数える func CountSubsetSums(nums []int, target int) int { n := len(nums) // dp[i][j]: nums[0..i-1]を使って合計jを作る方法の数 dp := make([][]int, n+1) for i := range dp { dp[i] = make([]int, target+1) } // 初期条件: 合計0を作る方法は1通り(何も選ばない) for i := 0; i <= n; i++ { dp[i][0] = 1 } // ボトムアップで計算 for i := 1; i <= n; i++ { for j := 0; j <= target; j++ { // i番目の要素を使わない場合 dp[i][j] = dp[i-1][j] // i番目の要素を使う場合 if j >= nums[i-1] { dp[i][j] += dp[i-1][j-nums[i-1]] } } } return dp[n][target] }

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

// SubsetSumOptimized は空間計算量を最適化した部分和問題 func SubsetSumOptimized(nums []int, target int) bool { // dp[j]: 合計jを作れるか dp := make([]bool, target+1) dp[0] = true for _, num := range nums { // 逆順で更新(同じ要素を複数回使わないため) for j := target; j >= num; j-- { if dp[j-num] { dp[j] = true } } } return dp[target] }

重要: 1次元配列で実装する場合、逆順で更新する必要がある。順方向だと同じ要素を複数回使ってしまう。

// ✗ NG: 順方向 → 同じ要素を複数回使う for j := num; j <= target; j++ { dp[j] = dp[j] || dp[j-num] } // ✓ OK: 逆順 → 各要素を1回だけ使う for j := target; j >= num; j-- { dp[j] = dp[j] || dp[j-num] }

計算量

時間計算量

実装方法時間計算量説明
全探索(ビット演算)O(2^n)すべての部分集合を調べる
2次元DPO(n × target)n個の要素×targetまでの値
1次元DP(最適化)O(n × target)同上

空間計算量

実装方法空間計算量説明
全探索O(n)再帰スタック
2次元DPO(n × target)dpテーブル
1次元DP(最適化)O(target)1次元配列のみ

パフォーマンス比較

配列サイズ n=20, target=1000 の場合: 全探索: 約1秒(2^20 = 100万回の計算) 2次元DP: 約0.001秒(20×1000 = 2万回の計算) 最適化DP: 約0.001秒(同上、メモリは1/20)

動作原理の詳細

なぜ漸化式が成り立つか

dp[i][j] を計算する際、nums[i-1] を使うか使わないかの2択:

1. 使わない場合

  • nums[0..i-2] で合計 j を作れればOK
  • dp[i][j] = dp[i-1][j]

2. 使う場合

  • nums[0..i-2] で合計 j - nums[i-1] を作れればOK
  • そこに nums[i-1] を足せば j になる
  • dp[i][j] = dp[i-1][j - nums[i-1]]

どちらか一方でも可能なら dp[i][j] = true

バックトラックの仕組み

dpテーブル構築後、逆順に辿って実際の解を復元:

配列: [3, 4, 5, 2], 目標: 9 dpテーブルから逆順に辿る: i=4, j=9: nums[3]=2 を使ったか? → dp[3][9]=true なので使わない i=3, j=9: nums[2]=5 を使ったか? → dp[2][9]=false, dp[2][4]=true なので使う! → j = 9 - 5 = 4 i=2, j=4: nums[1]=4 を使ったか? → dp[1][4]=false, dp[1][0]=true なので使う! → j = 4 - 4 = 0 i=1, j=0: 終了 解: [5, 4]

使いどころ

向いてる場面

  • ナップサック問題: 重さと価値のある品物を選ぶ
  • 予算配分: 限られた予算で目標額を達成
  • 組み合わせ最適化: 複数の選択肢から最適な組み合わせ
  • パーティション問題: 配列を等しい和に分割できるか

実例1: 硬貨での支払い

func canPayExactly(coins []int, price int) bool { return SubsetSum(coins, price) } // 使用例 coins := []int{1, 5, 10, 25, 50, 100} price := 87 if canPayExactly(coins, price) { subset := FindSubsetSum(coins, price) fmt.Printf("87円の支払い: %v円硬貨\\n", subset) // 例: [50, 25, 10, 1, 1] など }

実例2: 配列の分割(Equal Partition)

配列を等しい和の2グループに分けられるか:

// CanPartition は配列を等しい和に分割できるか判定 func CanPartition(nums []int) bool { sum := 0 for _, num := range nums { sum += num } // 合計が奇数なら分割不可能 if sum%2 != 0 { return false } // 半分の値を作れるか? return SubsetSum(nums, sum/2) } // 使用例 nums := []int{1, 5, 11, 5} // 合計 = 22, 半分 = 11 // [1, 5, 5] と [11] に分割可能 → true

実例3: ターゲットサム問題

配列の各要素に+または-を付けて目標値を作る:

// FindTargetSumWays は目標値を作る方法の数 func FindTargetSumWays(nums []int, target int) int { sum := 0 for _, num := range nums { sum += num } // 不可能な場合 if sum < target || (sum+target)%2 != 0 { return 0 } // P - N = target かつ P + N = sum // → P = (sum + target) / 2 // Pの部分集合を作る方法の数を求める return CountSubsetSums(nums, (sum+target)/2) }

関連する問題

1. 0/1ナップサック問題

部分和問題の発展版(価値の概念を追加):

// Knapsack は0/1ナップサック問題を解く func Knapsack(weights []int, values []int, capacity int) int { n := len(weights) dp := make([][]int, n+1) for i := range dp { dp[i] = make([]int, capacity+1) } for i := 1; i <= n; i++ { for w := 0; w <= capacity; w++ { // 入れない場合 dp[i][w] = dp[i-1][w] // 入れる場合 if w >= weights[i-1] { value := dp[i-1][w-weights[i-1]] + values[i-1] if value > dp[i][w] { dp[i][w] = value } } } } return dp[n][capacity] }

2. 完全部分和問題(無制限使用可)

各要素を何回でも使える場合:

// SubsetSumUnlimited は要素を無制限に使える部分和問題 func SubsetSumUnlimited(nums []int, target int) bool { dp := make([]bool, target+1) dp[0] = true // 順方向で更新(同じ要素を複数回使えるため) for j := 1; j <= target; j++ { for _, num := range nums { if j >= num && dp[j-num] { dp[j] = true break } } } return dp[target] }

3. 最小要素数の部分和

目標値を作る最小の要素数:

// MinSubsetSum は目標値を作る最小要素数 func MinSubsetSum(nums []int, target int) int { dp := make([]int, target+1) for i := range dp { dp[i] = target + 1 // 十分大きい値 } dp[0] = 0 for _, num := range nums { for j := target; j >= num; j-- { dp[j] = min(dp[j], dp[j-num]+1) } } if dp[target] > target { return -1 // 不可能 } return dp[target] } func min(a, b int) int { if a < b { return a } return b }

最適化テクニック

1. 早期終了

目標値に到達したら計算を打ち切る:

func SubsetSumEarlyExit(nums []int, target int) bool { dp := make([]bool, target+1) dp[0] = true for _, num := range nums { for j := target; j >= num; j-- { if dp[j-num] { dp[j] = true if j == target { return true // 早期終了 } } } } return dp[target] }

2. 事前ソート

配列をソートして枝刈り:

import "sort" func SubsetSumPruned(nums []int, target int) bool { sort.Ints(nums) // 合計が目標値未満なら不可能 sum := 0 for _, num := range nums { sum += num } if sum < target { return false } // 最大値が目標値を超える要素は除外 validNums := []int{} for _, num := range nums { if num <= target { validNums = append(validNums, num) } } return SubsetSum(validNums, target) }

3. ビットセット最適化

ビット演算で高速化(targetが小さい場合):

// SubsetSumBitset はビットセットを使った高速化版 func SubsetSumBitset(nums []int, target int) bool { // dp を uint64 のビットで表現(target < 64の場合) if target >= 64 { return SubsetSum(nums, target) } var dp uint64 = 1 // dp[0] = true for _, num := range nums { if num < 64 { dp |= dp << uint(num) } } return (dp & (1 << uint(target))) != 0 }

デバッグとテスト

テストケース

func TestSubsetSum() { testCases := []struct { nums []int target int want bool }{ {[]int{3, 34, 4, 12, 5, 2}, 9, true}, // [4, 5] {[]int{3, 34, 4, 12, 5, 2}, 30, false}, // 不可能 {[]int{1, 2, 3, 7}, 6, true}, // [1, 2, 3] {[]int{1, 2, 7, 1, 5}, 10, true}, // [1, 2, 7] {[]int{1, 5, 11, 5}, 11, true}, // [1, 5, 5] {[]int{2, 4, 6}, 5, false}, // 奇数は作れない } for _, tc := range testCases { got := SubsetSum(tc.nums, tc.target) if got != tc.want { fmt.Printf("FAIL: nums=%v, target=%d, got=%v, want=%v\\n", tc.nums, tc.target, got, tc.want) } } }

デバッグ用可視化

// PrintDPTable はdpテーブルを見やすく表示 func PrintDPTable(dp [][]bool, nums []int, target int) { fmt.Print(" ") for j := 0; j <= target; j++ { fmt.Printf("%3d", j) } fmt.Println() for i := 0; i < len(dp); i++ { if i == 0 { fmt.Print("初期 ") } else { fmt.Printf("%3d ", nums[i-1]) } for j := 0; j <= target; j++ { if dp[i][j] { fmt.Print(" ◯") } else { fmt.Print(" ×") } } fmt.Println() } }

まとめ

メリット

  • 時間計算量O(n×target)で効率的
  • 解の存在判定だけでなく、実際の解や解の個数も求められる
  • 空間を最適化してO(target)に削減可能
  • ナップサック問題の基礎となる重要アルゴリズム
  • 実用的な応用が多い

デメリット

  • targetが大きいとメモリと時間が問題に
  • 浮動小数点数には適用できない
  • targetが非常に大きい場合は別のアプローチが必要

使うべき時

  • 判定問題: 特定の合計を作れるか
  • 組み合わせ最適化: 複数の選択肢から最適な組み合わせ
  • ナップサック系問題: 容量制約のある選択問題
  • パーティション問題: 配列の分割

targetの大きさによる選択

targetの範囲推奨実装理由
target < 10001次元DP十分高速かつ省メモリ
1000 ≤ target < 10^51次元DPメモリ効率重視
target ≥ 10^5別手法検討Meet-in-the-middle など
n < 20ビット全探索も可実装がシンプル

部分和問題は動的計画法の典型問題であり、ナップサック問題や組み合わせ最適化の基礎となる。dpテーブルの構築、漸化式の立て方、バックトラックによる解の復元など、DPの重要な概念を学ぶのに最適なアルゴリズム。