LeetCode 416. 分割等和子集(bitset优化)

LeetCode 416. 分割等和子集

1 题目描述

给你一个只包含正整数非空数组nums。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

1.1 输入测试

示例 1:

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

示例 2:

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

1.2 数据范围

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


2 解题思路

将数组分割成两个元素和相等的子集,意味着这两个子集的元素和都是总元素和sum的一半。
所以,道题的目标就是判断是否存在元素和为sum/2的子集。

其中包含的一个特殊情况是,当sum为奇数时,结果明显是不存在的。


2.1 DFS枚举

初看数据量比较小,于是就尝试DFS暴力枚举,最后果不其然超时了。

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

        for (auto &num : nums)
        {
            tar += num;
        }

        if (tar & 1)
        {
            return false;
        }
        return dfs(nums, 0, 0, tar >> 1);
    }

    bool dfs(vector<int>& nums, int i, int sum, int tar)
    {
        if (sum == tar)
        {
            return true;
        }
        if (sum > tar)
        {
            return false;
        }

        return i == nums.size() ? false : dfs(nums, i + 1, sum + nums[i], tar) || dfs(nums, i + 1, sum, tar);
    }
};

2.2 记忆化搜索

既然暴力枚举不行,那就加上记忆化搜索,通过记录相同sum和i时的结果,达到减少重复计算的目的。

最终成功通过,运行时间只有55ms,但其实还有更优的解法

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

        for (auto &num : nums)
        {
            tar += num;
        }

        if (tar & 1)
        {
            return false;
        }
        return dfs(nums, 0, 0, tar >> 1);
    }

    bool vis[10001][201]{};
    bool mem[10001][201]{};

    bool dfs(vector<int>& nums, int i, int sum, int tar)
    {
        if (sum == tar)
        {
            return true;
        }
        if (sum > tar)
        {
            return false;
        }

        if (vis[sum][i])
        {
            return mem[sum][i];
        }

        vis[sum][i] = true;

        return mem[sum][i] = i == nums.size() ? false : dfs(nums, i + 1, sum + nums[i], tar) || dfs(nums, i + 1, sum, tar);
    }
};

2.3 DP

重新观察上面的计算过程就能发现,实际上我们所做的就是在枚举所有可能的子集和,这其实就是典型的0/1背包问题。
因此,我们可以采用动态规划的思想,构造一个初始只包含一个数字0的子集和数组v,接着依次从nums集合中取出一个数,与数组v中的子集和逐个相加得到一批新的子集和,并添加进子集和数组v。直到求得目标结果,或是所有数字都被取出。

这种写法的运行时间仅为15ms,但接下来我们还要介绍另一种DP的写法,为后面的bitset优化作铺垫。

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int tar = 0;
        vector<int> v{0};
        bool vis[10001]{};

        for (auto &num : nums)
        {
            tar += num;
        }

        if (tar & 1)
        {
            return false;
        }
        tar >>= 1;
        
        for (auto &num : nums)
        {
            for (int i = v.size() - 1; i >= 0; i--)
            {
                int n = num + v[i];
                
                if (n == tar)
                {
                    return true;
                }
                if (n > tar || vis[n])
                {
                    continue;
                }
                vis[n] = true;
                v.push_back(n);
            }
        }
        
        return false;
    }
};

2.4 DP + bitset

在上面的DP写法中,我们使用了数组v记录已知的子集和,但也可以看出vis数组同时也记录了对应下标值的子集和是否存在。
因此,我们可以不使用v数组,而是通过遍历vis数组的方式来判断子集和是否存在。当然,根据0/1背包的特性,遍历的过程需要从后往前。

最终运行时间为23ms,比第一个DP方法稍慢,毕竟我们是遍历范围内所有可能的下标,而不像之前有额外的记录可以快速访问,但这么做也能相应地减少内存消耗。

不过这并不是我们的最终目标。

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int tar = 0;
        bool vis[10001]{1};

        for (auto &num : nums)
        {
            tar += num;
        }

        if (tar & 1)
        {
            return false;
        }
        tar >>= 1;
        
        for (auto &num : nums)
        {
            for (int i = tar; i >= num; i--)
            {
                // 等价于 vis[i] |= vis[i - num];
                // if (vis[i - num])
                // {
                //     vis[i] = true;
                // }
                vis[i] |= vis[i - num];
            }
        }
        
        return vis[tar];
    }
};

其实从上面的内层for循环中可以看出,我们每次所做的操作都是将第i位元素与第i偏移num位的元素进行或运算。所以,我们可以把内层循环的结果等价为,对整个vis数组做移位或运算,也就是vis |= vis << num

但即使我们知道这样的等价操作,也没办法直接对整个bool数组进行移位,这时候就需要用到支持<<>>位移操作的bitset容器来实现了。

其使用方法也很简单,只需要将原来的bool vis[N]替换成bitset<N> vis即可。

于是,我们的运行时间成功缩短到了5ms。对比第一种DP算法,这种方法在减少内存占用的同时也缩短了运行时间。

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int tar = 0;
        bitset<10001> vis{1};

        for (auto &num : nums)
        {
            tar += num;
        }

        if (tar & 1)
        {
            return false;
        }
        tar >>= 1;
        
        for (auto &num : nums)
        {
            vis |= vis << num;
        }
        
        return vis[tar];
    }
};

其实到了这一步,我们还能将代码逻辑进行进一步的合并。将tar的计算合并到vis的for循环中,并在结尾统一对tar和vis的结果进行判断。

最终版本如下所示

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int tar = 0;
        bitset<10001> vis{1};

        for (auto &num : nums)
        {
            tar += num;
            vis |= vis << num;
        }
        
        return !(tar & 1) && vis[tar >> 1];
    }
};

本文发布于2024年3月26日

最后编辑于2024年3月26日

posted @ 2024-03-26 10:12  千松  阅读(259)  评论(0)    收藏  举报