1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Go】部分和問題(動的計画法)のサンプルコードを書いてみた

Last updated at Posted at 2025-10-12

概要

  • 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次元DP O(n × target) n個の要素×targetまでの値
1次元DP(最適化) O(n × target) 同上

空間計算量

実装方法 空間計算量 説明
全探索 O(n) 再帰スタック
2次元DP O(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 < 1000 1次元DP 十分高速かつ省メモリ
1000 ≤ target < 10^5 1次元DP メモリ効率重視
target ≥ 10^5 別手法検討 Meet-in-the-middle など
n < 20 ビット全探索も可 実装がシンプル

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?