【动态规划】LC 416.分割等和子集

文章目录

前言

本专栏文章为《LeetCode 热题 100》的刷题题解,相关内容如有侵权,立即删除。

一、题目

1、原题链接

416.分割等和子集

2、题目描述

二、个人思路整理

1、思路分析

核心思路:转化为标准的0-1背包问题

  1. 问题转化
    题目要求将数组分成两个和相等的子集:
    • 设数组所有元素和为 sum。如果 sum 是奇数,显然无法平分成两个整数和,直接返回 false。
    • 如果 sum 是偶数,目标就转变为:能否在数组中挑选出若干个数字,使其和恰好等于 target = sum / 2。
    • 每个元素只能使用一次,这恰好对应容量为 target 的 0-1 背包问题(判断是否能恰好装满)。
  2. 动态规划定义与转移
    • 状态定义:定义一维布尔数组 dp[j],表示是否能选出某些元素使其和恰好为 j( 0 ≤ j ≤ t a r g e t 0 \le j \le target 0≤j≤target)。
    • 初始状态:
      • dp[0] = true:和为 0 时不选任何元素即可满足。
      • 其余 dp[j] = false。
    • 状态转移方程:遍历每个元素 x,对于每个容量 j(从 target 倒序遍历到 x): d p j = d p j ∨ d p j − x dpj = dpj \lor dpj - x dpj=dpj∨dpj−x

注意:为了保证每个元素只被计算一次(0-1 背包特性),内层循环必须从大到小倒序遍历。若正序遍历则会变成完全背包(元素可重复使用)。

2、解题代码

cpp 复制代码
class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;
        int max_val = 0;

        // 1. 统计数组总和与最大元素
        for (int x : nums) {
            sum += x;
            max_val = max(max_val, x);
        }

        // 2. 如果总和为奇数,不可能拆分两个和相等的整数子集
        if (sum % 2 != 0) {
            return false;
        }

        // 目标容量:寻找一个子集使其和等于 sum / 2
        int target = sum / 2;

        // 剪枝:如果单个元素已经超过目标和,其余元素之和必然小于 target,直接返回 false
        if (max_val > target) {
            return false;
        }

        // 3. 动态规划定义:dp[j] 表示是否能挑选若干元素使得和恰好为 j
        // 范围为 0 到 target,初始全为 false
        vector<bool> dp(target + 1, false);
        // 基本状态:和为 0 时不选择任何元素即可达成
        dp[0] = true;

        // 4. 0-1 背包状态转移
        for (int x : nums) {
            // 内层循环必须从大到小倒序遍历:
            // 确保当前物品 x 只被使用一次(若正序则会复用本轮刚更新的状态,退化为完全背包)
            for (int j = target; j >= x; j--) {
                dp[j] = dp[j] || dp[j - x];
            }

            // 贪心剪枝:如果已经能够凑出 target,无需继续遍历剩余元素
            if (dp[target]) {
                return true;
            }
        }

        return dp[target];
    }
};

复杂度分析

  • 时间复杂度: O ( n × t a r g e t ) O(n \times target) O(n×target),其中 n n n 为数组长度, t a r g e t = sum 2 target = \frac{\text{sum}}{2} target=2sum。本题数据范围 n ≤ 200 , n u m s i ≤ 100    ⟹    t a r g e t ≤ 10000 n \le 200, numsi \le 100 \implies target \le 10000 n≤200,numsi≤100⟹target≤10000,运算量约 2 × 10 6 2 \times 10^6 2×106,完全可以在毫秒级通过。
  • 空间复杂度: O ( t a r g e t ) O(target) O(target),通过一维滚动数组将空间压缩到几千个 bool。

三、知识风暴

动态规划(Dynamic Programming) 是本题的核心算法思想。它通过将原问题拆解为若干重叠子问题,并利用「最优子结构」性质,用子问题的最优解递推得到全局最优解。对于「分割等和子集」这类 0-1 背包问题,动态规划能以 O ( n × t a r g e t ) O(n \times target) O(n×target) 的复杂度高效求解。

算法核心思想:

  • 最优子结构 :能否选出若干元素使其和恰好为 j j j,可以由「前 i − 1 i-1 i−1 个元素能否凑出 j j j」与「前 i − 1 i-1 i−1 个元素能否凑出 j − n u m s i j - numsi j−numsi」共同决定。只要子问题 d p j dpj dpj、 d p j − x dpj - x dpj−x 已知,就能递推得到当前位置的最优解。
  • 重叠子问题 :在递推过程中,同一个容量 j j j 会被多个元素反复查询。例如计算 d p j dpj dpj 时,不同元素 x x x 都会访问 d p j − x dpj - x dpj−x 的状态,因此用滚动数组缓存结果可避免重复计算。
  • 与贪心的区别:贪心每一步只做当前最优选择、不回溯;而动态规划会枚举「选」与「不选」当前元素两种可能来源,从而保证结果的正确性。

常见对比:动态规划 vs 贪心

  • 动态规划 :时间复杂度 O ( n × t a r g e t ) O(n \times target) O(n×target),空间复杂度 O ( t a r g e t ) O(target) O(target)。适合需要同时考虑「选/不选」两种决策、且局部最优不能直接决定全局最优的场景,通用性更强。
  • 贪心算法 :时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)。适合每一步的局部最优能直接推导全局最优的场景,代码简洁高效,但本题无法直接证明贪心成立(如 nums = [1, 5, 11, 5],若贪心只取当前最大元素,就可能错过恰好凑出 target 的组合)。
  • 共同点:两者都依赖「最优子结构」性质。区别在于贪心只保留一个当前最优状态,而动态规划需要同时维护「选」与「不选」两种决策状态。

动态规划的设计思想:

  • 核心思想 :把大问题拆成小问题,先解决小问题,再用小问题的答案拼出大问题的答案。本题中,先求出容量为 0 0 0 的 d p 0 = t r u e dp0 = true dp0=true,再逐个元素递推出后续容量的状态。
  • 与本题的联系 :分割等和子集问题天然具有递推结构------每个容量 j j j 的最优解都可以由「不选当前元素 x x x 时的 d p j dpj dpj」与「选当前元素 x x x 时的 d p j − x dpj - x dpj−x」共同得到。因此无需回溯或搜索,只需按顺序滚动更新即可。
  • 注意事项 :动态规划的正确性依赖于「最优子结构」与「无后效性」。本题中 d p j dpj dpj 只由前一个元素的状态决定,与未来的状态无关,因此递推顺序合法。

使用要点:

  • 状态变量 :dp[j] 记录是否能选出若干元素使其和恰好为 j j j( 0 ≤ j ≤ t a r g e t 0 \le j \le target 0≤j≤target),用于判断能否恰好装满容量为 t a r g e t target target 的背包。
  • 初始化 :dp[0] = true,其余 dp[j] = false,以「不选任何元素」作为递推基准。
  • 转移时机 :外层循环遍历每个元素 x x x,内层循环从 t a r g e t target target 倒序遍历到 x x x,执行 dp[j] = dp[j] || dp[j - x]。倒序保证每个元素只被使用一次(0-1 背包特性)。
  • 结果返回 :遍历结束后,返回 dp[target],表示能否凑出目标和的一半。

算法变体与扩展:

  • 0-1 背包问题(LeetCode 494):一维动态规划的经典入门题,只需维护一个计数状态即可,思路与本题一脉相承。
  • 完全背包问题(LeetCode 322):元素可重复使用,内层循环需正序遍历,与本题的「倒序」形成鲜明对比。
  • 子集和问题(LeetCode 416 变体):在本题基础上引入「恰好装满」的约束,需要同时考虑「选」与「不选」两种情形。
  • 多重背包问题(LeetCode 1049):多状态动态规划的经典题目,通过维护多个状态变量来刻画不同阶段的约束。

相关 LeetCode 例题:

相关推荐
信奥卷王2 小时前
[GESP202609 六级] 分树规划
算法
夏幻灵2 小时前
LeetCode 21:合并两个有序链表——双指针与哑节点详解
算法·leetcode·链表
徐小黑ACG3 小时前
Golang 基础05 结构体struct
开发语言·算法·golang
Navigator_Z4 小时前
LeetCode //C - 1286. Iterator for Combination
c语言·算法·leetcode
北极有牛4 小时前
cpp学习笔记--常量指针
java·开发语言·算法
GPU实战笔记4 小时前
云端 GPU 训练的账户余额提醒:它能说明什么,不能说明什么?
深度学习·算法·成本管理·gpu云计算·云端训练
浮链序5 小时前
怎么证明"你这个模型是偷我的"——把蒸馏变成取证工具
算法·安全·llm
小O的算法实验室5 小时前
IEEE TMC,面向异构物联网的多目标量子行为拓扑优化算法
算法
liliangcsdn5 小时前
派息率指标的应用探索和分析
大数据·算法