分割等和子集

给你一个只包含正整数的非空数组 nums

请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

每个数组元素只能放入其中一个子集,不能重复使用。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1,5,5] 和 [11]。

示例 2:

输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。

提示:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100

动态规划(0-1 背包):

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int total = 0;

        for (int num : nums)
        {
            total += num;
        }

        if (total % 2 == 1)
        {
            return false;
        }

        int target = total / 2;
        vector<bool> dp(target + 1, false);
        dp[0] = true;

        for (int num : nums)
        {
            for (int j = target; j >= num; j--)
            {
                dp[j] = dp[j] || dp[j - num];
            }
        }

        return dp[target];
    }
};

核心思想

假设数组可以被分成两个和相等的子集,并且数组所有元素的总和是 total

那么每个子集的目标和必须是:

total / 2

所以原问题可以转换成:

能不能从 nums 中选出一些元素,使它们的和恰好等于 total / 2

这就是一个典型的 0-1 背包问题:

  • 每个数字相当于一个物品
  • 数字的值相当于物品重量
  • 背包容量是 target
  • 每个数字只能选择一次
  • 目标是判断能不能恰好装满背包

这题最关键的观察是:

两个子集和相等,当且仅当其中一个子集的和等于整个数组总和的一半。

为什么总和为奇数时一定不可能

如果两个子集的和相等,设每个子集的和为 x,那么总和就是:

total = x + x = 2x

因此 total 一定是偶数。

如果 total 是奇数,就不可能被平均分成两个整数和相等的子集,可以直接返回 false

例如:

nums = [1,2,3,5]
total = 11

总和是奇数,所以不可能分成两个和相等的子集。

状态定义

定义:

dp[j] 表示从已经处理过的数字中,能否选出一些元素,使它们的和恰好等于 j

dp[j] == true 表示和 j 可以被凑出。

dp[j] == false 表示目前还不能凑出和 j

题目要求判断是否能凑出目标和 target,所以最终答案是:

dp[target]

初始化

不选择任何数字时,元素和为 0

所以:

dp[0] = true

其余状态开始时都不能被凑出:

vector<bool> dp(target + 1, false);
dp[0] = true;

如果没有 dp[0] = true,就无法从空集合开始接上第一个数字。

例如处理数字 5 时,只有当 dp[0]true,才能推出:

dp[5] = true

递推公式推导

现在处理一个数字 num

对于目标和 j,有两种选择。

1. 不选择 num

如果之前已经可以凑出 j,那么现在仍然可以凑出 j

dp[j]

2. 选择 num

如果之前可以凑出 j - num,那么加上当前数字 num 后,就可以凑出 j

dp[j - num]

所以递推公式是:

dp[j] = dp[j] || dp[j - num]

代码中写成:

dp[j] = dp[j] || dp[j - num];

其中 dp[j] 表示不选择当前数字,dp[j - num] 表示选择当前数字。

为什么容量必须倒序遍历

这是本题最容易出错的地方。

每个数字只能使用一次,所以处理 num 时,dp[j - num] 必须是处理当前数字之前的状态。

因此容量要从大到小遍历:

for (int j = target; j >= num; j--)

如果改成从小到大遍历:

for (int j = num; j <= target; j++)

那么当前轮刚刚更新的 dp[j - num] 可能又被拿来更新 dp[j]

这样相当于在同一轮中重复使用了当前数字,变成了完全背包,而不是 0-1 背包。

例如:

nums = [3]
target = 6

数字 3 只能使用一次,所以不应该凑出 6

如果从小到大遍历:

  • 先由 dp[0] 推出 dp[3] = true
  • 再用刚更新的 dp[3] 推出 dp[6] = true

这就错误地把同一个 3 使用了两次。

倒序遍历时,计算 dp[j] 所依赖的 dp[j - num] 仍然是上一轮的旧状态,因此每个数字最多使用一次。

为什么使用一维数组不会丢失状态

如果使用二维数组,可以定义:

dp[i][j] 表示只使用前 i 个数字时,能否凑出和 j

二维转移是:

dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]]

当前状态只依赖上一行,所以可以用一维数组复用空间。

关键仍然是倒序更新。

倒序更新时,dp[j - num] 还没有被当前数字更新,仍然相当于二维数组中的上一行状态。

因此一维数组既能保留正确的状态,又能把空间复杂度从 O(n * target) 降到 O(target)

正确性证明

我们证明:算法返回 true 当且仅当数组可以被分割成两个和相等的子集。

结论 1:如果总和为奇数,算法返回 false 是正确的

如果数组能被分成两个和相等的子集,设每个子集的和为 x

那么数组总和一定是:

2x

也就是偶数。

所以当 total 是奇数时,不可能存在这样的分割,算法直接返回 false 正确。

结论 2:dp[j] == true 表示可以从已处理数字中选出若干个,使它们的和为 j

初始时,dp[0] = true,表示不选任何数字可以凑出 0

其他状态为 false,表示暂时不能凑出这些和。

处理数字 num 时,对每个 j

  • 如果原来 dp[j] == true,说明不选 num 也能凑出 j
  • 如果原来 dp[j - num] == true,说明选上 num 后可以凑出 j

所以更新:

dp[j] = dp[j] || dp[j - num]

正好覆盖了“不选当前数字”和“选择当前数字”两种情况。

由于容量倒序遍历,dp[j - num] 是当前数字还没参与时的旧状态,因此不会重复使用 num

所以每轮处理后,dp[j] 的含义都保持正确。

结论 3:如果 dp[target] == true,数组可以分割成两个和相等的子集

dp[target] == true 表示可以从 nums 中选出一些数字,它们的和为 target

而:

target = total / 2

剩下没有被选中的数字之和就是:

total - target = target

因此选中的数字和剩下的数字可以组成两个和相等的子集。

结论 4:如果数组可以分割成两个和相等的子集,算法一定会令 dp[target] == true

如果存在一个合法分割,那么其中一个子集的和一定是 target

这个子集由数组中的若干元素组成,并且每个元素只使用一次。

算法按顺序处理所有元素,每个元素都有“选”或“不选”两种状态转移。

根据结论 2,所有能由已处理元素凑出的和都会被正确记录。

所以当所有数字处理完后,这个和为 target 的子集一定会使 dp[target] == true

得出结论

由结论 1 可知,总和为奇数时算法判断正确。

由结论 2 可知,动态规划状态含义正确。

由结论 3 和结论 4 可知,dp[target] 与能否分割成两个等和子集完全等价。

因此算法正确。

举例理解

以:

nums = [1,5,11,5]

为例。

数组总和是:

22

所以目标和是:

target = 11

接下来问题变成:能不能从数组中选出一些数字,使它们的和为 11

处理过程可以这样理解:

  • 看到 1,可以凑出 1
  • 看到 5,可以凑出 5,也可以凑出 1 + 5 = 6
  • 看到 11,可以直接凑出 11

所以 dp[11] == true

这说明可以选出子集:

[11]

剩下的数字是:

[1,5,5]

它们的和也是 11,因此返回 true

再看:

nums = [1,2,3,5]

总和是 11,是奇数。

所以不可能分成两个和相等的子集,直接返回 false

复杂度分析

设数组长度为 n,数组总和的一半为 target

外层遍历每个数字,内层遍历容量 target

所以时间复杂度是:

O(n * target)

一维动态规划数组长度是 target + 1

所以空间复杂度是:

O(target)