14.5 背包问题
背包问题: 给定一组物品, 物品有价格和重量, 在限定重量的情况下, 如何选择使得物品的总价格最大.
- 0-1背包问题: 每种物品只有一个, 选择放入还是不放入.
 - 有界背包问题(或多重背包问题): 每种物品是有限的
 - 无界背包问题(或完全背包问题): 每种物品是无限的
 
14.5.1 问题101: 分割等和子集
给定一个非空的正整数数组
nums,请判断能否将这些数字分成元素和相等的两部分。示例 1:
输入:nums = [1,5,11,5] 输出:true 解释:nums 可以分割成 [1, 5, 5] 和 [11] 。示例 2:
输入:nums = [1,2,3,5] 输出:false 解释:nums 不可以分为和相等的两部分提示:
1 <= nums.length <= 2001 <= nums[i] <= 100
14.5.1 分析&题解
将问题转化为, 对于数字和为sum/2的数组(背包), 能否将若干数字(物品)放满数组(背包), 每个数字只能用一次(0-1背包问题).
每次有两个选择, 放入还是不放入, 若需要得出所有结果用回溯法. 需要得出解的数量使用动态规划.
状态转移方程
假设表示能否从[0,i-1]中选择数字放入容量为j的背包. 若数组大小为n, 背包容量为t, 那么就是问题的解.
对于, 有两个选择:
- 放入数字
nums[i-1], 若能从[0, i-2]中选择数字放满容量t-nums[i-1]背包, 即, 那么 即 - 不放入数字
nums[i-1], 
初始值(最小问题解):
- : 背包容量为0, 只要不选择任何物品就行, 值为 T
 - : 没有数字放入背包, 不可能有解, 值为 F
 
迭代+二维数组
使用二维数组存放计算结果, dp[n-1][sum/2], 为解
func canPartition(nums []int) bool {
    n := len(nums)
    t := 0 // 背包容量
    for _, n := range nums {
        t += n
    }
    // 不能拆分
    if t % 2 == 1 {
        return false
    }
    t = t / 2
    
    // 缓存
    // dp[0][j], j in [1, t+1) 均为 F
    dp := make([][]bool, n+1)
    for i := range dp {
        dp[i] = make([]bool, t+1)
    }
    // 初始值
    for i := 0; i < n+1; i++ {
        dp[i][0] = true
    } 
    // 计算
    for i := 1; i < n+1; i++ {
        for j := 1; j < t+1; j++ {
            // 不放进背包
            dp[i][j] = dp[i-1][j]
            // 放进背包
            if !dp[i][j] && j >= nums[i-1] {
                dp[i][j] = dp[i-1][j-nums[i-1]]
            }
        }
    }
    return dp[n][t]
}
迭代+两行二维数组
计算只需要两行数据, 可减小二维数组大小到两行
func canPartition(nums []int) bool {
    n := len(nums)
    t := 0 // 背包容量
    for _, n := range nums {
        t += n
    }
    // 无法拆分
    if  t % 2 == 1 {
        return false
    }
    t = t / 2
    // 缓存
    dp := make([][]bool, 2)
    for i := range dp {
        dp[i] = make([]bool, t+1)
    }
    // 初始值
    dp[0][0] = true
    dp[1][0] = true
    // 计算
    for i := 1; i < n+1; i++ {
        for j := 1; j < t+1; j++ { 
            // 不放入
            dp[i%2][j] = dp[(i-1)%2][j]
            // 放入
            if !dp[i%2][j] && j >= nums[i-1] {
                dp[i%2][j] = dp[(i-1)%2][j-nums[i-1]]
            }
        }
    }
    return dp[n%2][t]
}
迭代+一维数组
计算需要用到
用dp[j]存储上一行的值,dp[j-1]存储
计算dp[j+1]即需要dp[j]即的值, 故计算之后不能直接替换dp[j].
从右往左计算的话, 使用玩之后就不会被用到.
func canPartition(nums []int) bool {
    n := len(nums)
    t := 0 // 背包容量
    for _, n := range nums {
        t += n
    }
    // 无法拆分
    if t % 2 == 1 {
        return false
    }
    t = t / 2
    // 缓存
    dp := make([]bool, t+1) 
    dp[0] = true
    // 计算
    for i := 1; i < n+1; i++ {
        // 从右到左
        for j := t; j >= 1; j-- {
            // 放入背包
            // dp[j] = dp[j]
            // 不放入
            if  !dp[j] && j >= nums[i-1] {
                dp[j] = dp[j-nums[i-1]]
            }
        }
    }
    
    return dp[t]
}
14.5.2 问题102: 加减的目标值
给定一个正整数数组
nums和一个整数target。向数组中的每个整数前添加
'+'或'-',然后串联起所有整数,可以构造一个 表达式 :
- 例如,
 nums = [2, 1],可以在2之前添加'+',在1之前添加'-',然后串联起来得到表达式"+2-1"。返回可以通过上述方法构造的、运算结果等于
target的不同 表达式 的数目。示例 1:
输入:nums = [1,1,1,1,1], target = 3 输出:5 解释:一共有 5 种方法让最终目标和为 3 。 -1 + 1 + 1 + 1 + 1 = 3 +1 - 1 + 1 + 1 + 1 = 3 +1 + 1 - 1 + 1 + 1 = 3 +1 + 1 + 1 - 1 + 1 = 3 +1 + 1 + 1 + 1 - 1 = 3示例 2:
输入:nums = [1], target = 1 输出:1提示:
1 <= nums.length <= 200 <= nums[i] <= 10000 <= sum(nums[i]) <= 1000-1000 <= target <= 1000
14.5.2.1 分析&题解
假设表达式中, 添加+的数字之和为p, 添加-的数字之和为q, 则有目标值 . 假设所有的数字和为Sum, 则有
此时问题就变成了0-1背包问题, 从数组中选择数字放入容量为(S+Sum)/2的背包中.
状态转移方程
假设表示从[0,i-1]选择数字放入j容量的背包中的方法数量,那么最终解就是, n为数组长度, t为背包容量.
对于, 数字nums[i]:
- : 
- 不能放入,
 
 - 放入, 则
 - 不放入, 则
 
 则
初始值(最小问题解):
- : 不放入背包即可, 方法数为 1
 - : 无法满足, 方法数为 0
 
综上, 状态转移方程:
迭代+一维数组
使用dp[j]存储和的内容, 又因为下次运算需要用到, 所以不能直接用计算后的结果替换.
需要从右到左计算, 这样计算完毕后的内容将不会在使用, 放心替换.
func findTargetSumWays(nums []int, target int) int {
	n := len(nums)
	sum := 0 // 数组元素和
	for _, n := range nums {
		sum += n
	}
	// 不能拆分成两个背包
	// 或 所有数加起来都小于 target
	if (sum+target)%2 != 0 || sum < target {
		return 0
	}
	// 背包容量
	t := (sum + target) / 2
	// 缓存
	dp := make([]int, t+1)
	// 初始值(最小问题解)
	dp[0] = 1
	// 计算
	for i := 1; i < n+1; i++ {
		// 从右向左计算, 避免覆盖原数据
		for j := t; j >= nums[i-1]; j-- {
			dp[j] += dp[j-nums[i-1]]
		}
	}
	return dp[t]
}
注意: 从右向左计算的循环条件, for j := t; j >= nums[i-1]; j--
14.5.3 问题103: 最小的硬币数目
给定不同面额的硬币
coins和一个总金额amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回-1。你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11 输出:3 解释:11 = 5 + 5 + 1示例 2:
输入:coins = [2], amount = 3 输出:-1示例 3:
输入:coins = [1], amount = 0 输出:0示例 4:
输入:coins = [1], amount = 1 输出:1示例 5:
输入:coins = [1], amount = 2 输出:2提示:
1 <= coins.length <= 121 <= coins[i] <= 2^31 - 10 <= amount <= 10^4
14.5.3.1 分析&题解
将硬币看作物品, 总金额看作背包容量, 那么问题等价于求将背包放满的最小件数. 因为硬币可以使用无限次, 不再是0-1背包问题, 而是无界背包问题.
状态转移方程
假设为组成金额  的最小硬币数, 那么要得到, 有n(硬币种类)种选择, 当选择硬币时有 .
由于需要求最小硬币数, 那么需要得出所有情况的最小值, 最终状态转移方程为:
为0, 表示金额为0时, 硬币数为0.
func coinChange(coins []int, amount int) int {
    // 缓存
    dp := make([]int, amount+1)
    for i := 1; i < amount+1; i++ {
        dp[i] = amount + 1 // 存放最小值
        for _, c := range coins {
            if i >= c {
                dp[i] = min(dp[i], dp[i-c]+1)
            }
        }
    }
    if dp[amount] > amount {
        return -1
    }
    return dp[amount]
}
func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}
14.5.4 问题104: 排列的数目
给定一个由 不同 正整数组成的数组
nums,和一个目标整数target。请从nums中找出并返回总和为target的元素组合的个数。数组中的数字可以在一次排列中出现任意次,但是顺序不同的序列被视作不同的组合。题目数据保证答案符合 32 位整数范围。
示例 1:
输入:nums = [1,2,3], target = 4 输出:7 解释: 所有可能的组合为: (1, 1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 3) (2, 1, 1) (2, 2) (3, 1) 请注意,顺序不同的序列被视作不同的组合。示例 2:
输入:nums = [9], target = 3 输出:0提示:
1 <= nums.length <= 2001 <= nums[i] <= 1000nums中的所有元素 互不相同1 <= target <= 1000
14.5.5 分析&题解
状态转移方程
表示和为 的排列数, 问题的解为 .
, 只有空排列的和为0
func combinationSum4(nums []int, target int) int {
    dp := make([]int, target+1)
    dp[0] = 1
    
    for i := 1; i < target+1; i++ {
        for _, n := range nums {
            if i >= n {
                dp[i] += dp[i-n]
            } 
        }
    }
    return dp[target]
}
