📜  子集总和问题(1)

📅  最后修改于: 2023-12-03 14:53:24.984000             🧑  作者: Mango

子集总和问题

子集总和问题 (Subset Sum Problem) 是一种NP完全问题,其问题描述为 : 给定一个正整数集合和一个正整数目标,判断能否从集合中选出若干个数,它们的和刚好等于目标值。

该问题很容易产生暴力破解的想法,穷举法即将所有可能的子集都枚举, 对其进行累加计算寻找符合目标数的子集。但该方法的时间复杂度为 O(2^n),随着集合元素的增多,枚举次数急剧增大,这种算法是不切实际的。

以下我们介绍两种常用的优化方法,分别为 回溯算法 和 动态规划算法。

回溯算法
思路

尝试构建一个搜索树,从根节点开始,按照 DFS 的方式寻找符合目标数的子集。在搜索的过程中,从集合中每次选取一个数字,计算累加和,并与目标数进行比较,如果大于目标数或相等,则保留该节点以用于下一个选择,否则删除该节点。直到搜索完整棵树后,只要存在任意一条从根节点到叶子节点的路径,其路径上的节点构成的集合的和等于目标数即可。

实现

python 代码实现如下:

def subset_sum(nums, target):
    res = []
    def dfs(curr, path):
        if sum(path) == target:
            res.append(path[:])
            return 
        if not curr or sum(path) > target:
            return 

        for i in range(len(curr)):
            path.append(curr[i])
            dfs(curr[i+1:], path)
            path.pop()
    
    dfs(nums, [])
    return res

C++ 代码实现如下:

vector<vector<int>> subset_sum(vector<int>& nums, int target) {
    vector<vector<int>> res;
    vector<int> path;
    function<void(int,vector<int>&)> dfs = [&](int start, vector<int>& path) {
        if (accumulate(path.begin(), path.end(), 0) == target) {
            res.push_back(path);
            return;
        } 
        if (start >= nums.size() || accumulate(path.begin(), path.end(), 0) > target) {
            return;
        }

        for (int i = start; i < nums.size(); ++i) {
            path.push_back(nums[i]);
            dfs(i + 1, path);
            path.pop_back();
        }
    };

    dfs(0, path);
    return res;
}
性能分析

时间复杂度: O(2^n)

空间复杂度: O(n)

从时间复杂度的角度来看,该算法无论如何都无法逃脱指数级别的时间复杂度,因此尽量避免使用暴力穷举的思路。

动态规划算法
思路

将问题转换为背包问题,对于每个数字,有两种情况,加入或不加入集合。通过对所有数字进行迭代,构建整个背包,不断更新小背包的最大值,最终只需要查询最大的小背包是否符合目标数即可。

实现

python 代码实现如下:

def subset_sum(nums, target):
    dp = [False] * (target + 1)
    dp[0] = True

    for num in nums:
        for i in range(target, num-1, -1):
            if dp[i-num]:
                dp[i] = True

    res = []
    if dp[target]:
        path = []
        dfs(0, nums, target, path, res)
    return res

def dfs(start, nums, target, path, res):
    if target == 0:
        res.append(path[:])
        return
    for i in range(start, len(nums)):
        if target >= nums[i]:
            path.append(nums[i])
            dfs(i+1, nums, target-nums[i], path, res)
            path.pop()

C++ 代码实现如下:

vector<vector<int>> subset_sum(vector<int>& nums, int target) {
    vector<vector<bool>> dp(nums.size() + 1, vector<bool>(target + 1));

    for (int i = 0; i <= nums.size(); ++i) {
        dp[i][0] = true;
    }

    for (int i = 1; i <= nums.size(); ++i) {
        for (int j = 1; j <= target; ++j) {
            if (j < nums[i-1]) {
                dp[i][j] = dp[i-1][j];
            } else {
                dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i-1]];
            }
        }
    }

    vector<vector<int>> res;
    if (dp[nums.size()][target]) {
        vector<int> path;
        dfs(0, nums, target, path, res);
    }
    return res;
}

void dfs(int start, vector<int>& nums, int target, vector<int>& path, vector<vector<int>>& res) {
    if (target == 0) {
        res.push_back(path);
        return;
    }
    for (int i = start; i < nums.size(); ++i) {
        if (target >= nums[i]) {
            path.push_back(nums[i]);
            dfs(i+1, nums, target - nums[i], path, res);
            path.pop_back();
        }
    }
}
性能分析

时间复杂度: O(n * target)

空间复杂度: O(n * target)

从时间复杂度的角度来看,该算法在能够接受的范围内,同时空间复杂度优于回溯算法。因此,在实际问题中,我们采用动态规划的算法会更好。

总结

对于子集总和问题,我们可以通过回溯算法和动态规划算法来解决。虽然回溯算法能够遍历所有的可能解,但其时间复杂度远远高于动态规划算法。因此,在实验场景中,我们采用动态规划算法相对更加可取,因为其不仅可以满足准确的解决方案,而且其时间和空间的复杂度都比较可控,可以有效节省计算资源。