力扣698 划分为k个相等的子集 动态规划求解原理

力扣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个集合并使得各个集合的总和相等,首先我们进行一些基础的判断:

  1. 初始数组nums[]的总和是否为k的倍数。
  2. 如果把nums[]划分为k个相等的集合,则每个集合的和应该为
    per = total(nums[]) / k
  3. 如果数组nums[]中的最大值maxNumper还要大的话,显然是不符合要求的。

状态

在进行完基础判断后,我们先想一下如果要使用动态规划进行解决,那么应该把何种情况当成一种状态,即每一个状态应该是怎么样的?

我们不妨假设对于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}0011被使用
{2}0102被使用
{3}1003被使用
{1, 2}0111,2被使用
{1, 3}1011,3被使用
{2, 3}1102,3被使用
{1, 2, 3}1111,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,则获得一个新的子集(状态),并得到新子集的总和,这样就实现了状态转移。

算法:
遍历每一个状态:

  1. 如果不可达,则继续遍历下一个;
  2. 如果可达,进行以下操作:
    遍历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];
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值