力扣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)等于总和。
示例 2:
输入: nums = [1,2,3,4], k = 3
输出: false
提示:
1 <= k <= len(nums) <= 16
0 < nums[i] < 10000
每个元素的频率在 [1,4] 范围内
思路
对于一组数据nums[]
能否放进k
个集合并使得各个集合的总和相等,首先我们进行一些基础的判断:
- 初始数组
nums[]
的总和是否为k
的倍数。 - 如果把
nums[]
划分为k
个相等的集合,则每个集合的和应该为
per = total(nums[]) / k
, - 如果数组
nums[]
中的最大值maxNum
比per
还要大的话,显然是不符合要求的。
状态
在进行完基础判断后,我们先想一下如果要使用动态规划进行解决,那么应该把何种情况当成一种状态,即每一个状态应该是怎么样的?
我们不妨假设对于nums[]
中的1~n
个值,在每一个状态下是对应的情况是哪些值已经被使用过了,那么如果这样想的话会有多少种状态呢?
聪明的同志已经想到了,这不就是一个集合有多少个子集合吗,每个集合都对应一种状态。举一个例子:
假设int[] nums = {1, 2,3}
,它会有多少个子集合呢
集合 | 状态 |
---|---|
{ } | 没有值被使用 |
{1} | 1被使用 |
{2} | 2被使用 |
{3} | 3被使用 |
{1, 2} | 1,2被使用 |
{1, 3} | 1,3被使用 |
{2, 3} | 2,3被使用 |
{1, 2, 3} | 1,2,3被使用 |
可见,当nums[]
中有3个值时,一共有8种情况。对于每个值是否存在于子集中都有两种情况(存在/不存在),则一共有
2
3
2^3
23 种子集合,换而言之,有
2
3
2^3
23 种状态。
二进制表示
基于上述的想法,我们是否能用二进制表示这种情况呢?
这当然是可以的,再次使用上述例子:int[] nums = {1, 2,3}
,一共三个值,则可以用3位二进制表示,从低位到高位,分别表示这三个值,对应二进制数位为1表示该值已被使用,如下图所示:
集合 | 二进制 | 状态 |
---|---|---|
{ } | 000 | 没有值被使用 |
{1} | 001 | 1被使用 |
{2} | 010 | 2被使用 |
{3} | 100 | 3被使用 |
{1, 2} | 011 | 1,2被使用 |
{1, 3} | 101 | 1,3被使用 |
{2, 3} | 110 | 2,3被使用 |
{1, 2, 3} | 111 | 1,2,3被使用 |
状态转移
这一部分我们结合代码来看,首先先建立两个数组
boolean[] dp = new boolean[1 << n]; // 当前状态是否可达
int[] curSum = new int[1 << n]; // 当前状态中所有已经使用的值的和
1 << n
表示有
2
n
2^n
2n 种不同的子集(状态)
dp[i]
是否可达的依据为:是否可以通过前面的状态达成。
可达意思:当前状态下所有已经使用过的值(即二进制中数位上为1的值)是否存在被分成相等集合的可能性。即需要当前状态的值的总和sum <= per
初始时设置dp[0] = true;
,一切尚未开始,一切尚有可能。
什么时候进行状态转移呢?
如果当前子集(状态)的总和加上一个值后的和小于或等于per
,则获得一个新的子集(状态),并得到新子集的总和,这样就实现了状态转移。
算法:
遍历每一个状态:
- 如果不可达,则继续遍历下一个;
- 如果可达,进行以下操作:
遍历nums[]
中每一个值:
a. 如果当前状态中所有已使用值的总和curSum[i]
加上当前值nums[j]
的和大于per
,则直接break
,理由是在前面我们已经将nums[]
数组排好序,如果curSum[i] + nums[j] > per
那么同样curSum[i] + nums[j + 1] > per
。
b. 判断当前状态上二进制对应第j
位的值是否已经被在该子集中,如果是,则跳过;否则把第j
位的数位置为1,即把第j
位的值加入到集合中,获得下一个状态next
,对于curSum[next]
的值应该为curSum[i] + nums[j + 1] % per
,同时把对应的dp[next] = true
设置为可达。
有人会奇怪:不对,为什么从始至终都没有讲到如何把nums[]
中的值划分到k
个集合中,而且更新curSum[]
的时候,为什么curSum[next]=curSum[i] + nums[j] % per
这里要有% per
的操作?
我们不妨这样想,什么时候能够往集合中添加新元素呢,是当把新元素加入后的总和小于或等于per
的时候。如果curSum[i] + nums[j] < per
,那么%per
对它没有任何影响,值是相同的;只有当curSum[i] + nums[j] = per
,%per
发挥作用,把新状态的值重置为0,表示当前集合中的值可以被分成m
个总和为per
的子集,其中0 <= m < k
。从当前状态开始寻找新的第m + 1
个满足条件的集合。我们就是通过这种方式将nums[]
进行划分。
代码
public static boolean canPartitionKSubsets2(int[] nums, int k) {
int all = Arrays.stream(nums).sum();
if (all % k != 0) {
return false;
}
int per = all / k;
Arrays.sort(nums);
int n = nums.length;
if (nums[n - 1] > per) {
return false;
}
boolean[] dp = new boolean[1 << n];
int[] curSum = new int[1 << n];
dp[0] = true;
for (int i = 0; i < 1 << n; i++) {
if (!dp[i]) {
continue;
}
for (int j = 0; j < n; j++) {
if (curSum[i] + nums[j] > per) {
break;
}
if (((i >> j) & 1) == 0) { // 判断第j为是否已经在集合中
int next = i | (1 << j); // 将第j位置为1
if (!dp[next]) {
curSum[next] = (curSum[i] + nums[j]) % per;
dp[next] = true;
}
}
}
}
return dp[(1 << n) - 1];
}