LeetCode 第 416 题:分割等和子集(动态规划)
地址:https://leetcode-cn.com/problems/partition-equal-subset-sum/
我写的题解地址:https://leetcode-cn.com/problems/partition-equal-subset-sum/solution/0-1-bei-bao-wen-ti-xiang-jie-zhen-dui-ben-ti-de-yo/
思路:首先思考为什么题目说是正整数,有 是否可以,有实数可以吗,有负数可以吗?
- 的存在意义不大,放在哪个子集都是可以的;
- 实数有可能是无理数,也可能是无限不循环小数,在计算整个数组元素的和的一半,要除法,然后在比较两个子集元素的和是否相等的时候,就会遇到精度的问题;
- 再说负数,负数其实也是可以存在的,但要用到“回溯搜索”解决。
事实上,这是一个典型的“动态规划”问题,并且它的“原形”是“0-1 背包问题”。使用“动态规划”解决问题的思路是“以空间换时间”,“规划”这个词在英文中就是“填表格”的意思,因此,下面我们代码执行的过程,也称之为“填表格”。
只要注意到,题目其实是在问:是否可以从这个数组中挑选出一些正整数,使得这些数的和等于整个数组元素的和的一半。前提条件是:数组的和一定得是偶数,即数组的和一定得被 整除,这一点是特判。
作为“0-1 背包问题”,它的特点是:“每个数只能用一次”。思路是:物品一个一个选,容量也逐个放大考虑。我们实际生活中也是这样做的,尝试一个一个把候选物品放入“背包”。
- 状态定义:
dp[i][j]
表示从数组的[0, i]
这个子区间内挑选一些正整数,每个数只能用一次,使得这些数的和恰好等于j
。 - 状态转移方程:很多时候,状态转移方程思考的角度是“分类讨论”,对于“0-1 背包问题”而言就是“当前考虑到的数字选与不选”。
1、不选择 nums[i]
,如果在 [0, i - 1]
这个子区间内已经有一部分元素,使得它们的和为 j
,那么 dp[i][j] = true
;
2、选择 nums[i]
,如果在 [0, i - 1]
这个子区间内就得找到一部分元素,使得它们的和为 j - nums[i]
。
状态转移方程是:
dp[i][j] = dp[i - 1][j] or dp[i - 1][j - nums[i]]
一般写出状态转移方程以后,就需要考虑边界条件(一般而言也是初始化条件)。
1、j - nums[i]
作为数组的下标,一定得保证大于等于 0
,因此 nums[i] <= j
;
2、注意到一种非常特殊的情况:j
恰好等于 nums[i]
,即单独 nums[j]
这个数恰好等于此时“背包的容积” j
,这也是符合题意的。
因此完整的状态转移方程是:
说明:虽然写成花括号,但是它们的关系是或者。
- 初始化:
dp[0][0] = false
,因为是正整数,当然凑不出和为0
。 - 输出:
dp[len - 1][target]
,这里len
表示数组的长度,target
是数组的元素之和(必须是偶数)的一半。
参考代码 1:
Java 代码:
public class Solution {
public boolean canPartition(int[] nums) {
int len = nums.length;
if (len == 0) {
return false;
}
int sum = 0;
for (int num : nums) {
sum += num;
}
// 特判:如果是奇数,就不符合要求
if ((sum & 1) == 1) {
return false;
}
int target = sum / 2;
// 创建二维状态数组,行:物品索引,列:容量(包括 0)
boolean[][] dp = new boolean[len][target + 1];
// 先填表格第 0 行,第 1 个数只能让容积为它自己的背包恰好装满
if (nums[0] <= target) {
dp[0][nums[0]] = true;
}
// 再填表格后面几行
for (int i = 1; i < len; i++) {
for (int j = 0; j <= target; j++) {
// 直接从上一行先把结果抄下来,然后再修正
dp[i][j] = dp[i - 1][j];
if (nums[i] == j) {
dp[i][j] = true;
continue;
}
if (nums[i] < j) {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
}
}
}
return dp[len - 1][target];
}
}
复杂度分析:
- 时间复杂度::这里 是数组元素的个数, 是数组元素的和的一半。
- 空间复杂度:。
下面是几点说明:
1、修改代码初始化的定义:dp[0][0] = true
。
注意到:容量为 0
的时候,即 dp[i][0]
按照本意来说,应该设置为 false
,但是注意到状态转移方程(代码中):
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
j - nums[i] == 0
成立的时候,根据上面分析,就说明单独的 nums[i]
这个数就恰好能够在被分割为单独的一组,其余的数分割成为另外一组,因此,我们把初始化的 dp[i][0]
设置成为 true
在代码运行层面是完全没有问题的。
2、观察状态转移方程的特点,or
的结果只要为真,表格下面所有的值都为真,因此在填表的时候,只要表格的最后一列是 true
,代码就可以结束,直接返回 true
即可。
参考代码 2:
Java 代码:
public class Solution2 {
public boolean canPartition(int[] nums) {
int len = nums.length;
if (len == 0) {
return false;
}
int sum = 0;
for (int num : nums) {
sum += num;
}
if ((sum & 1) == 1) {
return false;
}
int target = sum / 2;
boolean[][] dp = new boolean[len][target + 1];
// 初始化成为 true 虽然不符合状态定义,但是从状态转移来说是完全可以的
dp[0][0] = true;
if (nums[0] <= target) {
dp[0][nums[0]] = true;
}
for (int i = 1; i < len; i++) {
for (int j = 0; j <= target; j++) {
dp[i][j] = dp[i - 1][j];
if (nums[i] <= j) {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
}
}
// 由于状态转移方程的特殊性,提前结束,可以认为是剪枝操作
if (dp[i][target]) {
return true;
}
}
return dp[len - 1][target];
}
}
3、“0-1 背包问题”常规优化:“装填数组”从二维降到一维,减少空间复杂度
-
在“填表格”的时候,当前行只参考了上一行的值,因此状态数组可以只设置 行,使用“滚动数组”的技巧“填表格”即可;
-
实际上连“滚动数组”都不必,在“填表格”的时候,当前行总是参考了它上面一行 “头顶上” 那个位置和“左上角”某个位置的值。因此,我们可以只开一个一维数组,从后向前依次填表即可。
这一点第 1 次接触的时候,可能会觉得很奇怪,理解的办法是,就拿题目中的示例,画一个表格,自己模拟一遍程序是如何“填表”的行为,就很清楚为什么状态数组压缩到 1 行的时候,需要“从后前向”填表。
- “从后向前” 写的过程中,一旦
nums[i] <= j
不满足,可以马上退出当前循环,因为后面的j
的值肯定越来越小,没有必要继续做判断,直接进入外层循环的下一层。相当于也是一个剪枝,这一点是“从前向后”填表所不具备的。
参考代码 3:只展示了状态数组压缩到一维,并且“从后向前”填表格的代码。
public class Solution3 {
public boolean canPartition(int[] nums) {
int len = nums.length;
if (len == 0) {
return false;
}
int sum = 0;
for (int num : nums) {
sum += num;
}
if ((sum & 1) == 1) {
return false;
}
int target = sum / 2;
boolean[] dp = new boolean[target + 1];
dp[0] = true;
if (nums[0] <= target) {
dp[nums[0]] = true;
}
for (int i = 1; i < len; i++) {
for (int j = target; nums[i] <= j; j--) {
if (dp[target]) {
return true;
}
dp[j] = dp[j] || dp[j - nums[i]];
}
}
return dp[target];
}
}
复杂度分析:
- 时间复杂度::这里 是数组元素的个数, 是数组元素的和的一半。
- 空间复杂度::减少了物品那个维度,无论来多少个数,用一行表示状态就够了。
下一篇: 559.N叉树的最大深度