划分为k个相等的子集
这几个问题,缩小视野、限于特例就会变得困难,如果跳出来发现一般形式,问题反而更明朗,更容易解决。
让我们从最普遍的问题开始:
698. 划分为k个相等的子集
给定一个整数数组 nums 和一个正整数 k,找出是否有可能把这个数组分成 k 个非空子集,其总和都相等。
示例 1:
输入: nums = [4, 3, 2, 3, 5, 2, 1], k = 4
输出: True
说明: 有可能将其分成 4 个子集(5),(1,4),(2,3),(2,3)等于总和。
注意:
1 <= k <= len(nums) <= 16
0 < nums[i] < 10000
函数定义如下:
func canPartitionKSubsets(nums []int, k int) bool
分析
穷举搜索,适时回溯:
对于 nums 中的每个数字,可以将其添加到 k 个子集中的一个,只要该子集的和不会超过目标值;
在不满足预期的时候做好回溯。
程序框架:
func canPartitionKSubsets(nums []int, k int) bool {
sum, max := 0, 0 // 注意到输入限制为非负,且和不会溢出
for _, v := range nums {
sum += v
if v > max {
max = v
}
}
target := sum / k // 尝试把所有元素放到k个组中,每组元素和为target
if sum%k != 0 || max > target {
return false
}
used := make([]bool, len(nums))
return backTracking(k, 0, 0, target, nums, used)
}
核心的穷举回溯函数backTracking()
// 尝试把所有元素放到k个组中,每组元素和为target
// currSubSum 记录当前组累加的结果,start指明从nums的哪个位置开始
func backTracking(k, currSubSum, start, target int, nums []int, used []bool) bool {
if k == 0 { // 说明所有数字都放入了对应组
return true
}
if currSubSum == target { // 已经构建了若干组
// 构建下一组
return backTracking(k-1, 0, 0, target, nums, used)
}
for i := start; i < len(nums); i++ {
if used[i] || currSubSum+nums[i] > target {
continue
}
used[i] = true // 当前值放入组
if backTracking(k, currSubSum+nums[i], i+1, target, nums, used) {
return true
}
used[i] = false // 说明将当前值放入一组不能得到结果,回溯
}
return false
}
473. 火柴拼正方形
还记得童话《卖火柴的小女孩》吗?
现在,你知道小女孩有多少根火柴,请找出一种能使用所有火柴拼成一个正方形的方法。
不能折断火柴,可以把火柴连接起来,并且每根火柴都要用到。
输入为小女孩拥有火柴的数目,每根火柴用其长度表示。输出即为是否能用所有的火柴拼成正方形。
示例 1:
输入: [1,1,2,2,2]
输出: true
解释: 能拼成一个边长为2的正方形,每边两根火柴。
示例 2:
输入: [3,3,3,3,4]
输出: false
解释: 不能用所有火柴拼成一个正方形。
注意:
给定的火柴长度和在 0 到 10^9之间。
火柴数组的长度不超过15。
即问题698中k为4的特例
func makesquare(nums []int) bool {
const k = 4
if len(nums) < k {
return false
}
sum, max := 0, 0
for _, v := range nums {
sum += v
if v > max {
max = v
}
}
target := sum / k
if sum % k != 0 || max > target {
return false
}
return backTracking(k, 0, 0, target, nums, make([]bool, len(nums)))
}
416. 分割等和子集
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:
每个数组中的元素不会超过 100
数组的大小不会超过 200
示例 1:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].
示例 2:
输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.
即问题698中k为2的特例,但直接套框架会有部分用例超时,可以预先将数组从大到小排序,减少递归次数来优化,但实测依然超时。
需要改用 01 背包的解法:
给定一个可装载重量为 sum / 2 的背包和 N 个物品,
每个物品的重量为 nums[i]。现在让你装物品,是否存在一种装法,能够恰好将背包装满?
时间复杂度O(n * c),其中c背包容量,即所有元素和的一半。
空间复杂度O((n+1)(c+1)) = O(n c),dp数组的大小;通过状态压缩,可以将dp压缩为一维数组,空间复杂度降为O(c)
func canPartition(nums []int) bool {
if len(nums) < 2 {
return false
}
sum := 0 // 根据题目限制, sum ∈ [0, 20000]
max := 0
for _, v := range nums {
sum += v
if max < v {
max = v
}
}
if sum%2 == 1 {
return false
}
c := sum / 2 // c ∈ [0, 10000]
if max > c {
return false
}
dp := make([]bool, c+1)
dp[0] = true // 容量为0相当于装满
for _, v := range nums {
if dp[c] {
return true
}
for j := c; j >= v; j-- {
dp[j] = dp[j] || dp[j-v] // 不装或装
}
}
return dp[c]
}