主题
一、分析
设
两个子集的元素和相等,意味着:
- 把
分成两个子集,每个子集的元素和恰好等于 。 必须是偶数。
如果
如果
- 能否从
中选出一个子序列,其元素和恰好等于 ?
这可以用「恰好装满」型 0-1 背包解决,请看视频讲解:0-1 背包和完全背包【基础算法精讲 18】。制作不易,欢迎点赞关注~
二、记忆化搜索
定义
考虑
- 选(前提是
):问题变成能否从 到 中选出一个和恰好等于 的子序列,即 。 - 不选:问题变成能否从
到 中选出一个和恰好等于 的子序列,即 。
这两个只要有一个成立,
这里
相当于编程语言中的 ||。
递归边界:
递归入口:
答疑
问:是否需要在递归中途判断
答:可以,但没有必要。因为我们有记忆化,这些
问:什么情况下,返回值是布尔类型,什么情况下是整型?
答:通常来说,判断能不能的问题,返回值是布尔类型;计算最大、最小、方案数等问题,返回值是整型。
写法一
python
class Solution:
def canPartition(self, nums: List[int]) -> bool:
@cache # 缓存装饰器,避免重复计算 dfs 的结果(记忆化)
def dfs(i: int, j: int) -> bool:
if i < 0:
return j == 0
if j < nums[i]:
return dfs(i - 1, j) # 只能不选
return dfs(i - 1, j - nums[i]) or dfs(i - 1, j) # 选或不选
s = sum(nums)
return s % 2 == 0 and dfs(len(nums) - 1, s // 2)cpp
// C++ 版待补充cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int s = reduce(nums.begin(), nums.end());
if (s % 2) {
return false;
}
int n = nums.size();
vector memo(n, vector<int>(s / 2 + 1, -1)); // -1 表示没有计算过
// lambda 递归函数
auto dfs = [&](this auto&& dfs, int i, int j) -> bool {
if (i < 0) {
return j == 0;
}
int& res = memo[i][j]; // 注意这里是引用
if (res != -1) { // 之前计算过
return res;
}
if (j < nums[i]) {
return res = dfs(i - 1, j); // 只能不选
}
return res = dfs(i - 1, j - nums[i]) || dfs(i - 1, j); // 选或不选
};
return dfs(n - 1, s / 2);
}
};写法二
简化了
python
class Solution:
def canPartition(self, nums: List[int]) -> bool:
@cache # 缓存装饰器,避免重复计算 dfs 的结果(记忆化)
def dfs(i: int, j: int) -> bool:
if i < 0:
return j == 0
return j >= nums[i] and dfs(i - 1, j - nums[i]) or dfs(i - 1, j)
s = sum(nums)
return s % 2 == 0 and dfs(len(nums) - 1, s // 2)cpp
// C++ 版待补充cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int s = reduce(nums.begin(), nums.end());
if (s % 2) {
return false;
}
int n = nums.size();
vector memo(n, vector<int>(s / 2 + 1, -1)); // -1 表示没有计算过
// lambda 递归函数
auto dfs = [&](this auto&& dfs, int i, int j) -> bool {
if (i < 0) {
return j == 0;
}
int& res = memo[i][j]; // 注意这里是引用
if (res != -1) { // 之前计算过
return res;
}
res = j >= nums[i] && dfs(i - 1, j - nums[i]) || dfs(i - 1, j);
return res;
};
return dfs(n - 1, s / 2);
}
};复杂度分析
- 时间复杂度:
,其中 是 的长度, 是 的元素和(的一半)。由于每个状态只会计算一次,动态规划的时间复杂度 状态个数 单个状态的计算时间。本题状态个数等于 ,单个状态的计算时间为 ,所以动态规划的时间复杂度为 。 - 空间复杂度:
。保存多少状态,就需要多少空间。
三、1:1 翻译成递推
我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。
具体来说,
相应的递推式(状态转移方程)也和
但是,这种定义方式没有状态能表示递归边界,即
解决办法:在二维数组
修改后,
修改后的递推式为
问:为什么
的下标不用变? 答:既然是在
的最上边插入一排状态,那么就只需要修改和 有关的下标,其余任何逻辑都无需修改。或者说,如果把 也改成 ,那么 就被我们给忽略掉了。
初始值
答案为
python
class Solution:
def canPartition(self, nums: List[int]) -> bool:
s = sum(nums)
if s % 2:
return False
s //= 2 # 注意这里把 s 减半了
n = len(nums)
f = [[False] * (s + 1) for _ in range(n + 1)]
f[0][0] = True
for i, x in enumerate(nums):
for j in range(s + 1):
f[i + 1][j] = j >= x and f[i][j - x] or f[i][j]
return f[n][s]cpp
// C++ 版待补充cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int s = reduce(nums.begin(), nums.end());
if (s % 2) {
return false;
}
s /= 2; // 注意这里把 s 减半了
int n = nums.size();
vector f(n + 1, vector<int>(s + 1));
f[0][0] = true;
for (int i = 0; i < n; i++) {
int x = nums[i];
for (int j = 0; j <= s; j++) {
f[i + 1][j] = j >= x && f[i][j - x] || f[i][j];
}
}
return f[n][s];
}
};复杂度分析
- 时间复杂度:
,其中 是 的长度, 是 的元素和(的一半)。 - 空间复杂度:
。
四、空间优化
观察上面的状态转移方程,在计算
因此可以去掉第一个维度,反复利用同一个一维数组。
状态转移方程改为
初始值
答案为
具体例子,以及为什么要倒序遍历
此外,设前
此外,可以在循环中提前判断
python
class Solution:
def canPartition(self, nums: List[int]) -> bool:
s = sum(nums)
if s % 2:
return False
s //= 2 # 注意这里把 s 减半了
f = [True] + [False] * s
s2 = 0
for i, x in enumerate(nums):
s2 = min(s2 + x, s)
for j in range(s2, x - 1, -1):
f[j] = f[j] or f[j - x]
if f[s]:
return True
return Falsecpp
// C++ 版待补充cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int s = reduce(nums.begin(), nums.end());
if (s % 2) {
return false;
}
s /= 2; // 注意这里把 s 减半了
vector<int> f(s + 1);
f[0] = true;
int s2 = 0;
for (int x : nums) {
s2 = min(s2 + x, s);
for (int j = s2; j >= x; j--) {
f[j] |= f[j - x];
}
if (f[s]) {
return true;
}
}
return false;
}
};复杂度分析
- 时间复杂度:
,其中 是 的长度, 是 的元素和(的一半)。 - 空间复杂度:
。
附:bitset 做法
把布尔数组压缩成一个二进制数,二进制数从低到高第
转移方程等价于,把
判断 (f >> s & 1) == 1。
python
class Solution:
def canPartition(self, nums: List[int]) -> bool:
s = sum(nums)
if s % 2:
return False
s //= 2
f = 1
for x in nums:
f |= f << x
return (f >> s & 1) == 1cpp
// C++ 版待补充cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int s = reduce(nums.begin(), nums.end());
if (s % 2) {
return false;
}
s /= 2;
bitset<10001> f; // sum(nums[i]) / 2 <= 10000
f[0] = 1;
for (int x : nums) {
f |= f << x;
}
return f[s]; // 判断 f 中第 s 位是否为 1
}
};复杂度分析
- 时间复杂度:
,其中 是 的长度, 是 的元素和(的一半), 或者 。 - 空间复杂度:
。
思考题
改成计算分割的方案数,要怎么做?
欢迎在评论区分享你的思路/代码。
专题训练
见下面动态规划题单的「§3.1 0-1 背包」。
分类题单
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
- 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
欢迎关注 B站@灵茶山艾府