一、分析
设 $\textit{nums}$ 的元素和为 $s$。
两个子集的元素和相等,意味着:
- 把 $\textit{nums}$ 分成两个子集,每个子集的元素和恰好等于 $\dfrac{s}{2}$。
- $s$ 必须是偶数。
如果 $s$ 是奇数,$\dfrac{s}{2}$ 不是整数,直接返回 $\texttt{false}$。
如果 $s$ 是偶数,问题相当于:
- 能否从 $\textit{nums}$ 中选出一个子序列,其元素和恰好等于 $\dfrac{s}{2}$?
这可以用「恰好装满」型 0-1 背包解决,请看视频讲解:0-1 背包和完全背包【基础算法精讲 18】。制作不易,欢迎点赞关注~
二、记忆化搜索
定义 $\textit{dfs}(i,j)$ 表示能否从 $\textit{nums}[0]$ 到 $\textit{nums}[i]$ 中选出一个和恰好等于 $j$ 的子序列。
考虑 $\textit{nums}[i]$ 选或不选:
- 选:问题变成能否从 $\textit{nums}[0]$ 到 $\textit{nums}[i-1]$ 中选出一个和恰好等于 $j-\textit{nums}[i]$ 的子序列,即 $\textit{dfs}(i-1,j-\textit{nums}[i])$。
- 不选:问题变成能否从 $\textit{nums}[0]$ 到 $\textit{nums}[i-1]$ 中选出一个和恰好等于 $j$ 的子序列,即 $\textit{dfs}(i-1,j)$。
这两个只要有一个成立,$\textit{dfs}(i,j)$ 就是 $\texttt{true}$。所以有
$$
\textit{dfs}(i,j) = \textit{dfs}(i-1,j-\textit{nums}[i]) \vee \textit{dfs}(i-1,j)
$$
其中 $\vee$ 即编程语言中的 ||
。代码实现时,可以只在 $j\ge \textit{nums}[i]$ 时才调用 $\textit{dfs}(i-1,j-\textit{nums}[i])$,因为任何子序列的和都不会是负的。
递归边界:$\textit{dfs}(-1,0) = \texttt{true},\ \textit{dfs}(-1,>0) = \texttt{false}$。如果所有数都考虑完了,且 $j=0$,表示找到了一个和为 $s/2$ 的子序列,返回 $\texttt{true}$,否则返回 $\texttt{false}$。
递归入口:$\textit{dfs}(n-1,s/2)$,即答案。
###py
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)
###java
class Solution {
public boolean canPartition(int[] nums) {
int s = 0;
for (int num : nums) {
s += num;
}
if (s % 2 != 0) {
return false;
}
int n = nums.length;
int[][] memo = new int[n][s / 2 + 1];
for (int[] row : memo) {
Arrays.fill(row, -1); // -1 表示没有计算过
}
return dfs(n - 1, s / 2, nums, memo);
}
private boolean dfs(int i, int j, int[] nums, int[][] memo) {
if (i < 0) {
return j == 0;
}
if (memo[i][j] != -1) { // 之前计算过
return memo[i][j] == 1;
}
boolean res = j >= nums[i] && dfs(i - 1, j - nums[i], nums, memo) || dfs(i - 1, j, nums, memo);
memo[i][j] = res ? 1 : 0; // 记忆化
return res;
}
}
###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 表示没有计算过
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;
}
return res = j >= nums[i] && dfs(i - 1, j - nums[i]) || dfs(i - 1, j);
};
return dfs(n - 1, s / 2);
}
};
###c
bool dfs(int i, int j, int* nums, int** memo) {
if (i < 0) {
return j == 0;
}
if (memo[i][j] != -1) { // 之前计算过
return memo[i][j] == 1;
}
return memo[i][j] = j >= nums[i] && dfs(i - 1, j - nums[i], nums, memo) || dfs(i - 1, j, nums, memo);
}
bool canPartition(int* nums, int numsSize) {
int s = 0;
for (int i = 0; i < numsSize; i++) {
s += nums[i];
}
if (s % 2) {
return false;
}
int** memo = malloc(numsSize * sizeof(int*));
for (int i = 0; i < numsSize; i++) {
memo[i] = malloc((s / 2 + 1) * sizeof(int));
memset(memo[i], -1, (s / 2 + 1) * sizeof(int)); // -1 表示没有计算过
}
int ans = dfs(numsSize - 1, s / 2, nums, memo);
for (int i = 0; i < numsSize; i++) {
free(memo[i]);
}
free(memo);
return ans;
}
###go
func canPartition(nums []int) bool {
s := 0
for _, x := range nums {
s += x
}
if s%2 != 0 {
return false
}
n := len(nums)
memo := make([][]int8, n)
for i := range memo {
memo[i] = make([]int8, s/2+1)
for j := range memo[i] {
memo[i][j] = -1 // -1 表示没有计算过
}
}
var dfs func(int, int) bool
dfs = func(i, j int) bool {
if i < 0 {
return j == 0
}
p := &memo[i][j]
if *p != -1 { // 之前计算过
return *p == 1
}
res := j >= nums[i] && dfs(i-1, j-nums[i]) || dfs(i-1, j)
if res {
*p = 1 // 记忆化
} else {
*p = 0
}
return res
}
return dfs(n-1, s/2)
}
###js
const canPartition = function(nums) {
const s = _.sum(nums);
if (s % 2) {
return false;
}
const n = nums.length;
const memo = Array.from({length: n}, () => Array(s / 2 + 1).fill(-1)); // -1 表示没有计算过
function dfs(i, j) {
if (i < 0) {
return j === 0;
}
if (memo[i][j] !== -1) { // 之前计算过
return memo[i][j] === 1;
}
const res = j >= nums[i] && dfs(i - 1, j - nums[i]) || dfs(i - 1, j);
memo[i][j] = res ? 1 : 0; // 记忆化
return res;
}
return dfs(n - 1, s / 2);
};
###rust
impl Solution {
pub fn can_partition(nums: Vec<i32>) -> bool {
let s = nums.iter().sum::<i32>() as usize;
if s % 2 != 0 {
return false;
}
fn dfs(i: usize, j: usize, nums: &[i32], memo: &mut [Vec<i32>]) -> bool {
if i == nums.len() {
return j == 0;
}
if memo[i][j] != -1 { // 之前计算过
return memo[i][j] == 1;
}
let x = nums[i] as usize;
let res = j >= x && dfs(i + 1, j - x, nums, memo) || dfs(i + 1, j, nums, memo);
memo[i][j] = if res { 1 } else { 0 }; // 记忆化
res
}
let n = nums.len();
let mut memo = vec![vec![-1; s / 2 + 1]; n]; // -1 表示没有计算过
// 为方便起见,改成 i 从 0 开始
dfs(0, s / 2, &nums, &mut memo)
}
}
复杂度分析
- 时间复杂度:$\mathcal{O}(ns)$,其中 $n$ 是 $\textit{nums}$ 的长度,$s$ 是 $\textit{nums}$ 的元素和(的一半)。由于每个状态只会计算一次,动态规划的时间复杂度 $=$ 状态个数 $\times$ 单个状态的计算时间。本题状态个数等于 $\mathcal{O}(ns)$,单个状态的计算时间为 $\mathcal{O}(1)$,所以动态规划的时间复杂度为 $\mathcal{O}(ns)$。
- 空间复杂度:$\mathcal{O}(ns)$。保存多少状态,就需要多少空间。
三、1:1 翻译成递推
我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。
具体来说,$f[i][j]$ 的定义和 $\textit{dfs}(i,j)$ 的定义是一样的,都表示能否从 $\textit{nums}[0]$ 到 $\textit{nums}[i]$ 中选出一个和恰好等于 $j$ 的子序列。
相应的递推式(状态转移方程)也和 $\textit{dfs}$ 一样:
$$
f[i][j] = f[i-1][j-\textit{nums}[i]] \vee f[i-1][j]
$$
但是,这种定义方式没有状态能表示递归边界,即 $i=-1$ 的情况。
解决办法:在二维数组 $f$ 的最上边插入一排状态,那么其余状态全部向下偏移一位,把 $f[i]$ 改为 $f[i+1]$,把 $f[i-1]$ 改为 $f[i]$。
修改后 $f[i+1][j]$ 表示能否从 $\textit{nums}[0]$ 到 $\textit{nums}[i]$ 中选出一个和为 $j$ 的子序列。$f[0]$ 对应递归边界。
修改后的递推式为
$$
f[i+1][j] = f[i][j-\textit{nums}[i]] \vee f[i][j]
$$
问:为什么 $\textit{nums}$ 的下标不用变?
答:既然是在 $f$ 的最上边插入一排状态,那么就只需要修改和 $f$ 有关的下标,其余任何逻辑都无需修改。或者说,如果把 $\textit{nums}[i]$ 也改成 $\textit{nums}[i+1]$,那么 $\textit{nums}[0]$ 就被我们给忽略掉了。
初始值 $f[0][0]=\texttt{true}$,翻译自递归边界 $\textit{dfs}(-1,0)=\texttt{true}$。其余值初始化成 $\texttt{false}$。
答案为 $f[n][s/2]$,翻译自递归入口 $\textit{dfs}(n-1,s/2)$。
###py
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]
###java
class Solution {
public boolean canPartition(int[] nums) {
int s = 0;
for (int num : nums) {
s += num;
}
if (s % 2 != 0) {
return false;
}
s /= 2; // 注意这里把 s 减半了
int n = nums.length;
boolean[][] f = new boolean[n + 1][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];
}
}
###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];
}
};
###c
bool canPartition(int* nums, int numsSize) {
int s = 0;
for (int i = 0; i < numsSize; i++) {
s += nums[i];
}
if (s % 2) {
return false;
}
s = s / 2 + 1;
bool* f = calloc((numsSize + 1) * s, sizeof(bool));
f[0] = true;
for (int i = 0; i < numsSize; i++) {
int x = nums[i];
for (int j = 0; j < s; j++) {
f[(i + 1) * s + j] = j >= x && f[i * s + j - x] || f[i * s + j];
}
}
bool ans = f[(numsSize + 1) * s - 1];
free(f);
return ans;
}
###go
func canPartition(nums []int) bool {
s := 0
for _, num := range nums {
s += num
}
if s%2 != 0 {
return false
}
s /= 2 // 注意这里把 s 减半了
n := len(nums)
f := make([][]bool, n+1)
for i := range f {
f[i] = make([]bool, s+1)
}
f[0][0] = true
for i, x := range nums {
for j := 0; j <= s; j++ {
f[i+1][j] = j >= x && f[i][j-x] || f[i][j]
}
}
return f[n][s]
}
###js
var canPartition = function(nums) {
let s = _.sum(nums);
if (s % 2) {
return false;
}
s /= 2; // 注意这里把 s 减半了
const n = nums.length;
const f = Array.from({length: n + 1}, () => Array(s + 1).fill(false));
f[0][0] = true;
for (let i = 0; i < n; i++) {
const x = nums[i];
for (let j = 0; j <= s; j++) {
f[i + 1][j] = j >= x && f[i][j - x] || f[i][j];
}
}
return f[n][s];
};
###rust
impl Solution {
pub fn can_partition(nums: Vec<i32>) -> bool {
let s = nums.iter().sum::<i32>();
if s % 2 != 0 {
return false;
}
let s = s as usize / 2; // 注意这里把 s 减半了
let n = nums.len();
let mut f = vec![vec![false; s + 1]; n + 1];
f[0][0] = true;
for (i, &x) in nums.iter().enumerate() {
let x = x as usize;
for j in 0..=s {
f[i + 1][j] = j >= x && f[i][j - x] || f[i][j];
}
}
f[n][s]
}
}
复杂度分析
- 时间复杂度:$\mathcal{O}(ns)$,其中 $n$ 是 $\textit{nums}$ 的长度,$s$ 是 $\textit{nums}$ 的元素和(的一半)。
- 空间复杂度:$\mathcal{O}(ns)$。
四、空间优化
观察上面的状态转移方程,在计算 $f[i+1]$ 时,只会用到 $f[i]$,不会用到比 $i$ 更早的状态。
因此可以去掉第一个维度,反复利用同一个一维数组。
状态转移方程改为
$$
f[j] = f[j] \vee f[j-\textit{nums}[i]]
$$
初始值 $f[0]= \texttt{true}$。
答案为 $f[s/2]$。
具体例子,以及为什么要倒序遍历 $j$,请看 0-1 背包视频讲解。
此外,设前 $i$ 个数的和为 $s'$,由于子序列的元素和不可能比 $s'$ 还大,$j$ 可以从 $\min(s',s/2)$ 开始倒着枚举。比如 $\textit{nums}$ 前两个数的和等于 $5$,那么我们无法在前两个数中,选出一个元素和大于 $5$ 的子序列,所以对于 $j>5$ 的 $f$ 值,一定是 $\texttt{false}$,无需计算。
此外,可以在循环中提前判断 $f[s/2]$ 是否为 $\texttt{true}$,是就直接返回 $\texttt{true}$。
###py
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 False
###java
class Solution {
public boolean canPartition(int[] nums) {
int s = 0;
for (int x : nums) {
s += x;
}
if (s % 2 != 0) {
return false;
}
s /= 2; // 注意这里把 s 减半了
boolean[] f = new boolean[s + 1];
f[0] = true;
int s2 = 0;
for (int x : nums) {
s2 = Math.min(s2 + x, s);
for (int j = s2; j >= x; j--) {
f[j] = f[j] || f[j - x];
}
if (f[s]) {
return true;
}
}
return false;
}
}
###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;
}
};
###c
#define MIN(a, b) ((b) < (a) ? (b) : (a))
bool canPartition(int* nums, int numsSize) {
int s = 0;
for (int i = 0; i < numsSize; i++) {
s += nums[i];
}
if (s % 2) {
return false;
}
s /= 2; // 注意这里把 s 减半了
bool* f = calloc(s + 1, sizeof(bool));
f[0] = true;
int s2 = 0;
for (int i = 0; i < numsSize; i++) {
int x = nums[i];
s2 = MIN(s2 + x, s);
for (int j = s2; j >= x; j--) {
f[j] |= f[j - x];
}
if (f[s]) {
free(f);
return true;
}
}
free(f);
return false;
}
###go
func canPartition(nums []int) bool {
s := 0
for _, x := range nums {
s += x
}
if s%2 != 0 {
return false
}
s /= 2 // 注意这里把 s 减半了
f := make([]bool, s+1)
f[0] = true
s2 := 0
for _, x := range nums {
s2 = min(s2+x, s)
for j := s2; j >= x; j-- {
f[j] = f[j] || f[j-x]
}
if f[s] {
return true
}
}
return false
}
###js
var canPartition = function(nums) {
let s = _.sum(nums);
if (s % 2) {
return false;
}
s /= 2; // 注意这里把 s 减半了
const f = Array(s + 1).fill(false);
f[0] = true;
let s2 = 0;
for (const x of nums) {
s2 = Math.min(s2 + x, s);
for (let j = s2; j >= x; j--) {
f[j] = f[j] || f[j - x];
}
if (f[s]) {
return true;
}
}
return false;
};
###rust
impl Solution {
pub fn can_partition(nums: Vec<i32>) -> bool {
let s = nums.iter().sum::<i32>();
if s % 2 != 0 {
return false;
}
let s = s as usize / 2; // 注意这里把 s 减半了
let mut f = vec![false; s + 1];
f[0] = true;
let mut s2 = 0;
for x in nums {
let x = x as usize;
s2 = (s2 + x).min(s);
for j in (x..=s2).rev() {
f[j] = f[j] || f[j - x];
}
if f[s] {
return true;
}
}
false
}
}
复杂度分析
- 时间复杂度:$\mathcal{O}(ns)$,其中 $n$ 是 $\textit{nums}$ 的长度,$s$ 是 $\textit{nums}$ 的元素和(的一半)。
- 空间复杂度:$\mathcal{O}(s)$。
附:bitset 做法
把布尔数组压缩成一个二进制数,二进制数从低到高第 $i$ 位是 $0$,表示布尔数组的第 $i$ 个元素是 $\texttt{false}$;从低到高第 $i$ 位是 $1$,表示布尔数组的第 $i$ 个元素是 $\texttt{true}$。
转移方程等价于,把 $f$ 中的每个比特位增加 $x=\textit{nums}[i]$,即左移 $x$ 位,然后跟原来 $f$ 计算 OR。前者对应选 $x$,后者对应不选 $x$。
判断 $f[s]$ 是否为 $\texttt{true}$,等价于判断 $f$ 的第 $s$ 位是否为 $1$,即 (f >> s & 1) == 1
。
###py
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) == 1
###java
import java.math.BigInteger;
class Solution {
public boolean canPartition(int[] nums) {
int s = 0;
for (int x : nums) {
s += x;
}
if (s % 2 != 0) {
return false;
}
s /= 2;
BigInteger f = BigInteger.ONE;
for (int x : nums) {
f = f.or(f.shiftLeft(x)); // f |= f << x;
}
return f.testBit(s); // 判断 f 中第 s 位是否为 1
}
}
###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
}
};
###go
func canPartition(nums []int) bool {
s := 0
for _, x := range nums {
s += x
}
if s%2 != 0 {
return false
}
s /= 2
f := big.NewInt(1)
p := new(big.Int)
for _, x := range nums {
f.Or(f, p.Lsh(f, uint(x)))
}
return f.Bit(s) == 1
}
###js
var canPartition = function(nums) {
let s = _.sum(nums);
if (s % 2) {
return false;
}
s /= 2;
let f = 1n;
for (const x of nums) {
f |= f << BigInt(x);
}
return (f >> BigInt(s) & 1n) === 1n;
};
复杂度分析
- 时间复杂度:$\mathcal{O}(ns/w)$,其中 $n$ 是 $\textit{nums}$ 的长度,$s$ 是 $\textit{nums}$ 的元素和(的一半),$w=32$ 或者 $64$。
- 空间复杂度:$\mathcal{O}(s/w)$。
思考题
改成计算分割的方案数,要怎么做?
欢迎在评论区分享你的思路/代码。
更多相似题目,见下面动态规划题单中的「§3.1 0-1 背包」。
分类题单
如何科学刷题?
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/最短路/最小生成树/二分图/基环树/欧拉路径)
- 动态规划(入门/背包/状态机/划分/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
我的题解精选(已分类)
欢迎关注 B站@灵茶山艾府