深度拆解分割等和子集:一维DP数组与倒序遍历的本质

写在前面

在动态规划的学习路径中,0/1背包问题始终是一座绕不开的大山

而力扣上的"分割等和子集"416. 分割等和子集 - 力扣(LeetCode)

则是检验是否真正理解背包问题状态压缩技巧的试金石。

很多开发者在初次接触这道题时,往往能直观地想到二维DP解法,却在看到优化后的一维数组解法时产生困惑:

为什么内层循环必须倒序遍历?那个看似简单的布尔数组dp究竟在记录什么状态?

本文将剥离掉繁琐的公式推导,直接切入代码核心,深度拆解一维DP数组在状态转移过程中的真实含义,并从根本上剖析倒序遍历对于保证"每个元素仅使用一次"这一约束的决定性作用,帮助你彻底打通从二维思维到一维空间优化的任督二脉。

深入分割等和子集

当我们计算出数组总和sum并确定目标值target为sum的一半后,问题的本质就变成了判断能否从给定的数字集合中选出若干元素,使其累加和恰好等于target。

代码中定义的一维布尔数组dp,其中 dp[j] 的含义非常明确且单一,它代表了在当前已经遍历过的数字范围内,是否存在一个子集的和恰好等于j。

如果dp[j]为true,说明和j是可达的;若为false,则说明不可达。

初始状态下,我们将dp[0]设为true,因为和为0总是可以通过不选任何数字来实现,这是整个动态规划过程的基石,而其余位置均初始化为false,表示尚未发现任何非零和的组合。

接下来的两个for循环是算法的灵魂所在

js 复制代码
for(let i = 0;i<nums.length;i++){
        for(let j = target;j >= nums[i];j--){
            dp[j] = dp[j] || dp[j-nums[i]]  // 状态转移方程
        }
    }

外层循环逐个遍历数组中的每个数字nums[i],这相当于在背包问题中依次考虑每一件物品是否放入背包。

对于每一个新引入的数字nums[i],内层循环负责更新dp数组的状态

这里有一个极其关键的技术细节,即内层循环必须从target倒序遍历至num[i] 。这种倒序遍历的策略并非随意选择,而是由0/1背包问题中每个物品只能使用一次的约束条件决定的。

如果我们采用正序遍历,当计算较大的索引 j 时,所依赖的 dp[j-nums[i]] 可能已经在当前轮次中被更新过,这意味着数字num可能被重复使用了多次,从而将问题错误地变成了完全背包问题。

例如,若nums[i]为2,正序遍历会导致dp[2]变为true后,紧接着计算dp[4]时利用刚更新的dp[2]再次将dp[4]置为true,这等价于使用了两个2,违背了每个数字只能用一次的题意。

通过倒序遍历,我们确保了在计算dp[j]时,所引用的dp[j-nums[i]]一定是上一轮外层循环结束后的状态,也就是不包含当前数字num的状态。

这样,状态转移方程

js 复制代码
dp[j] = dp[j] || dp[j-num]

能准确地表达出两种情况:要么不使用当前数字num也能凑出和j(即dp[j]原本就是true),要么使用当前数字num,前提是之前能凑出和j-num。

随着外层循环的不断推进,dp数组逐步记录了引入更多数字后可达成的所有和值。

当所有数字遍历完毕后,dp[target]的值就直接给出了问题的答案,若为true则说明存在一个子集和为总和的一半,原数组可以被分割成两个等和子集,否则无法分割。

这种空间优化后的写法将空间复杂度从二维的O(N * target)降低到了一维的O(target),同时保持了时间复杂度O(N * target)不变,是解决此类背包问题的标准且高效的范式,理解其中的倒序逻辑和一维状态定义是掌握动态规划空间优化的关键。

算法题解

js 复制代码
/**
 * @param {number[]} nums
 * @return {boolean}
 */
var canPartition = function(nums) {
    let sum = 0;
    for(let i = 0;i<nums.length;i++){
        sum +=nums[i];
    }
    if(sum%2!==0){
        return false;
    }
    
    const target = sum/2;
    let dp = Array(target + 1).fill(false);
    dp[0] = true;

    for(let i = 0;i<nums.length;i++){
        for(let j = target;j >= nums[i];j--){
            dp[j] = dp[j] || dp[j-nums[i]]
        }
    }
    return dp[target];
};

写在最后

掌握分割等和子集问题的解法,其意义远不止于通过一道算法题,更在于深刻理解动态规划中空间优化的核心思想。

当我们能够熟练运用一维数组配合倒序遍历来解决0/1背包类问题时,实际上已经掌握了处理"状态依赖上一轮结果"这一类问题的通用钥匙。

这种思维模式可以无缝迁移到目标和问题、最后一块石头重量II等众多变种题目中,帮助我们在面对复杂约束时迅速构建出时间与空间效率最优的解决方案。

相关推荐
码海扬帆:前端探索之旅1 天前
深度定制 uni-combox:新增功能详解与实战指南
前端·vue.js·uni-app
谷雨不太卷1 天前
进程的状态码
java·前端·算法
打小就很皮...1 天前
基于 Python + LangChain + RAG 的知识检索系统实战
前端·langchain·embedding·rag
BJ-Giser1 天前
Cesium 烟雾粒子特效
前端·可视化·cesium
空中海1 天前
02 ArkTS 语言与工程规范
java·前端·spring
YJlio1 天前
7.4.5 Windows 11 企业网络连接与网络重置实战:远程访问、本地策略与故障恢复
前端·chrome·windows·python·edge·机器人·django
散峰而望1 天前
【算法竞赛】C/C++ 的输入输出你真的玩会了吗?
c语言·开发语言·数据结构·c++·算法·github
躺不平的理查德1 天前
时间复杂度与空间复杂度备忘录
数据结构·算法
yaki_ya1 天前
yaki-C语言:从概念基础到内存解析---数组(array)完全指南
java·c语言·算法
刃神太酷啦1 天前
扒透 STL 底层!map/set 如何封装红黑树?迭代器逻辑 + 键值限制全手撕----《Hello C++ Wrold!》(23)--(C/C++)
java·c语言·javascript·数据结构·c++·算法·leetcode