动态规划的套路 【leetcode 494 为例】

本文详细解析了动态规划的核心概念,包括状态定义、状态转移方程及最优子结构,并通过LeetCode上的经典题目目标和(494题),展示了如何应用动态规划解决复杂问题。文章还对比了深度优先搜索(DFS)与动态规划的不同实现方式。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

动态规划的套路

leetcode 上练DP的顺序

动态规划核心就是定义出状态,然后思考状态转移方程。可以按照如下题册一道一道的练习,题册如下:
第 5 题、
第 53 题、
第 300 题、
第 72 题、
第 1143 题、
第 62 题、
第 63 题、
背包问题(第 416 题,第 494 题)、
硬币问题(第 322 题、第 518 题)、
打家劫舍问题(做头两题即可)、
股票问题、
第 96 题、
第 139 题、
第 10 题、
第 91 题、
第 221 题。
这些都是比较经典的问题,思路都可以“自底向上”。
祝学有所成

开始正题

记一道非常折腾很久的动态规划题

494. 目标和

难度中等213

给定一个非负整数数组,a1, a2, …, an, 和一个目标数,S。现在你有两个符号 +-。对于数组中的任意一个整数,你都可以从 +-中选择一个符号添加在前面。

返回可以使最终数组和为目标数 S 的所有添加符号的方法数。

示例 1:

输入: nums: [1, 1, 1, 1, 1], S: 3
输出: 5
解释: 

-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3

一共有5种方法让最终目标和为3。

注意:

  1. 数组非空,且长度不会超过20。
  2. 初始的数组的和不会超过1000。
  3. 保证返回的最终结果能被32位整数存下。

思路

首先对于从给定的例子入手分析 这道题 从 3 开始看 对于 数组里最后一个数 这里是 1,
有 3 +1 或者 3 -1 ,会有两个结果, 4 和 2. 后面的数也是同样的操作,不断的往前走。
这样就可以走到最后 看看 是否 为 结果 0, 如果结果为 0 就进行一次 计数,这是一种解,
如果结果不为0,这次的操作不满足 返回 0。
这里采用 DFS 深度搜索 的方法进行解决。

代码【DFS版本 】

import java.util.*;
class Solution {
    public int findTargetSumWays(int[] nums, int S) {
        //return backTracking(nums, S, 0, 0);// + backTracking(nums, S, 1, nums.length - 1);
        // return backTracking2(nums, S, nums.length - 1);// + backTracking(nums, S, 1, nums.length - 1);
        // return backTrackingMap(nums, S, nums.length - 1, new HashMap<Integer,Integer>());
        // 
    
    }
   // 这里函数名不更改了 改成 dfs.. 更好,本来想用回溯法解决的。。。
    public int backTracking2(int []nums, int S, int pos){
        // 从头开始 向后找
        if(pos == -1 && S == 0){
            return 1;
        }
        if(pos == -1){
            return 0;
        }
        return backTracking2(nums, S + nums[pos], pos-1) + backTracking2(nums, S - nums[pos], pos-1);
    }

    public int backTracking(int []nums, int S, int res, int pos){
        // 从尾开始 向前找 进行优化判断
        if(pos == nums.length && S == res){
            return 1;
        }
        
        return backTracking(nums, S ,res + nums[pos], pos+1) + backTracking(nums, S, res - nums[pos], pos+1);
    }
}
/*
其实画出 递归树可以发现, 这其实就是 二叉树的遍历(因为只有+,- 两个操作),求叶子节为 0 (或者为 S )的路线计数一次。
*/

上面两个解法都会比较耗时, 尝试用DP的思路解决

先来看看 DP 的套路:

动态规划问题的一般形式就是求最值

既然是要求最值,核心问题是什么呢?求解动态规划的核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值呗。

首先,动态规划的穷举有点特别,因为这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

而且,动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值。

另外,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,只有列出正确的「状态转移方程」才能正确地穷举。

以上提到的重叠子问题、最优子结构、状态转移方程就是动态规划三要素。具体什么意思等会会举例详解,但是在实际的算法问题中,写出状态转移方程是最困难的,这也就是为什么很多朋友觉得动态规划问题困难的原因,我来提供我研究出来的一个思维框架,辅助你思考状态转移方程:

明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case。

  • 举个烫手的栗子:凑零钱问题

先看下题目:给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。

  1. 发现最优子结构首先这道题 可以很快找到最优子结构:即 11 的 子问题 10 同样可以用 和 解决 11 的方法解决, 其他子问题 如9 8 7 … 都可以同样的解决。

找到最优子结构了。那就要看接下来的几个重点步骤:

  1. 先确定「状态」,也就是原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额 amount这里硬币数量是无法确定的,因为给定的不限量而不是限定范围,因此原问题和子问题都可以看成目标金额作为变量。

  2. 然后确定 dp 函数的定义:当前的目标金额是 n,至少需要 dp(n) 个硬币凑出该金额。这里的函数是关键,也就是所谓的状态转移方程。这里需要总结。

函数的定义 也就是 dp的状态转移方程

这里 先看是否能写出递归解决方案,如果可以就很容易找到状态转移方程。

比如这里 dp(n) = dp(n-1) + 1 , 比如 n = 11, dp(11) = dp(10) + 1

即 求 11 的问题可以去求解 dp(10) 的解决方案, 这里 + 1 是使用了 11 -1 = 10 正好有 1 的硬币;

由于 取最少 所以会有 $ dp(n) = min( dp(n), 1+dp(n-1) ) $ 这里 min 里面的 dp(n) 可以在其他方案中 比如 找 8 的时候已经解决 6 的问题,那么 找 7 的时候就也可以使用了。因此 完整状态转移方程:

$ dp(n) = min( dp(n), 1+dp(n-1) ) $

  1. 最后明确 base case,显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1:

    即 相当于 使用递归的时候 递归啥时候退出。

    凑零钱问题

    dp 数组的迭代解法

    当然,我们也可以自底向上使用 dp table 来消除重叠子问题,dp 数组的定义和刚才 dp 函数类似,定义也是一样的:

    dp[i] = x 表示,当目标金额为 i 时,至少需要 x 枚硬币

    参考解决代码

    int coinChange(vector<int>& coins, int amount) {
        // 数组大小为 amount + 1,初始值也为 amount + 1
        vector<int> dp(amount + 1, amount + 1);
        // base case
        dp[0] = 0;
        for (int i = 0; i < dp.size(); i++) {
            // 内层 for 在求所有子问题 + 1 的最小值
            for (int coin : coins) {
                // 子问题无解,跳过
                if (i - coin < 0) continue;
                dp[i] = min(dp[i], 1 + dp[i - coin]);
            }
        }
        return (dp[amount] == amount + 1) ? -1 : dp[amount];
    }
    

尝试解决最开始的 目标和问题 先放代码 再解释

 public int findTargetSumWays(int[] nums, int S) {
        // return backTracking(nums, S, 0, 0);// + backTracking(nums, S, 1, nums.length - 1);
        // return backTracking2(nums, S, nums.length - 1);// + backTracking(nums, S, 1, nums.length - 1);
        // return backTrackingMap(nums, S, nums.length - 1, new HashMap<Integer,Integer>());
        // 下面尝试用 DP 解决问题
        int expand = 0;
        for(int ele:nums) expand += ele;
        //expand = expand * 2 + 1; //扩展数组列的大小 相当于 以 expand 为对称位(0)进行存储数据 
        /*
            最开始列出的状态转移方程是                  1                     (S==0) 即找到一个目标和 计数 为 1
                                         dp(S) =     0                      (S!=0 && pos == -1) 找到最后未找到符合的 返回 0
                                                     dp(S) + nums[i]  + dp(S) - nums[i]     (S!=0)     状态转移方程(这是根据递归写的)
            由于 是个不定式方程,因此需要转为定式方程。
            再加上不是跑一边循环就 能搞定的事,需要让当前即每个 当前位置 记下 到他这里所能得到的结果。因此有:
            dp[i][j] 这里 i 表示用数组中的前 i 个元素,组成和为 j 的方案数。
            dp[i][j] = dp[i - 1][j - nums[i]] + dp[i - 1][j + nums[i]]
            写成递推形式:
            dp[i][j + nums[i]] = dp[i][j + nums[i]] + dp[i-1][j]
            dp[i][j - nums[i]] = dp[i][j - nums[i]] + dp[i-1][j]
            这里 由于 每一个子问题 都有不是唯一的 组成方案, 所以要用 二维数组 [j] 这里正是用于记录 前面 i 各元素所 +/- 得到的和。
            即 每个循环 都要计算两个和
        */
        // 先初始化 0 位
        int[][] dp = new int[nums.length][expand * 2 + 1];
        dp[0][0 + nums[0] + expand] += 1;  // 初始化 此时 S == 0 默认为已经找到一条
        dp[0][0 - nums[0] + expand] += 1;  // 同理 这里不同的是 上面一条 那个 += 中的 + 不是必须 这里必须是 += 即先初始化谁谁就不用 +=
        // 这是防止一开头 nums 前面几个数组一堆 0 , 以免 +/- nums[i]  都占了同样的位置
        // 下面开始 进行穷举 搜索所有符合条件的结果
        for(int i = 1; i < nums.length; i++){
            // 寻找基数
            // 这里需要注意一点 sum <= expand 即要能达到 expand 边界值 可能会出现 nums 中最后一位为 0 的情况 在这里要计算
            for(int sum = -expand; sum <= expand; sum++ ){  
                if(dp[i-1][sum + expand]>0){
                    // 这里说明找到了基数 可以从此开始快乐的寻找 它往后 +/-得到的两个数
                    // 调用上面的两个公式
                    dp[i][sum + nums[i] + expand] += dp[i-1][sum + expand];  
                    dp[i][sum - nums[i] + expand] += dp[i-1][sum + expand];
                }
            }
        }
        // 最后进行判断返回
        return S > expand ? 0 : dp[nums.length - 1][S + expand];
    
    }

看看题中所给的样例:[1,1,1,1,1] 3

  1. 看是否有最优子结构: 即 3 可以转化为 3 - 1 = 2 或者 3 + 1 = 4 (3+/- 数组的最后一个数)

    可以看到 子结构 与 原问题可以使用同样的方法进行解决;

  2. 确定状态: 子问题(子结构)和原问题中都存在的变量 就是 当前值 S 和 位置 index(下标/索引);

  3. 确定DP函数: 这里比较难定义

    d p ( S ) = { 1 , S = = 0 i = = 0 0 S ! = 0 i = = 0 d p ( S ) + n u m s [ i − 1 ] + d p ( S ) − n u m s [ i − 1 ] o t h e r s dp(S)=\left\{ \begin{aligned} 1 &&,S==0 && i == 0 \\ 0 && S!=0 && i == 0 \\ dp(S)+nums[i-1]\\+dp(S)-nums[i-1] && others \end{aligned} \right. dp(S)=10dp(S)+nums[i1]+dp(S)nums[i1],S==0S!=0othersi==0i==0
    根据上面的公式可以写出 状态转移方程

    不过由于上面的状态转移方程比较粗略,而且 从第2步知道 有两个变量,

    因此考虑考虑使用二维数组 dp[ i ][ j ] ,

    其中 i 表示l两个变量中的 位置 indxj 则表示两个变量中的 S

    dp[ i ][ j ] : 则表示 当前 前 i 个元素 组合成 j 的方案个数有多少。

    因此 **可以确定状态转移方程: **

    d p [ i ] [ j ] = d p [ i − 1 ] [ j + n u m s [ i ] ] + d p [ i − 1 ] [ j − n u m s [ i ] ] dp[i][j] = dp[i-1][j + nums[i]] + dp[i-1][j - nums[i]] dp[i][j]=dp[i1][j+nums[i]]+dp[i1][jnums[i]]

    note: 这里的 dp 不能和上面的 dp(S) 一一直接简单对应,变通理解。

    上面是个不定式 进行改写成 两个递推定式:

    d p [ i ] [ j + n u m s [ i ] ] = d p [ i ] [ j + n u m s [ i ] ] + d p [ i − 1 ] [ j ] dp[i][j + nums[i]] = dp[i][j + nums[i]] + dp[i-1][j] dp[i][j+nums[i]]=dp[i][j+nums[i]]+dp[i1][j]

    d p [ i ] [ j − n u m s [ i ] ] = d p [ i ] [ j − n u m s [ i ] ] + d p [ i − 1 ] [ j ] dp[i][j - nums[i]] = dp[i][j - nums[i]] + dp[i-1][j] dp[i][jnums[i]]=dp[i][jnums[i]]+dp[i1][j]

    上面的意思 表示 当前已有的方案数 是由 自身当前已有的方案数 + 自己前一个数的方案数。

    打个比方: 当前可能 0个或者只有1个方案数(可能在其他计算中获得的), 现在自己前面的那个数nums[i-1] 又有了方案数,给自己送过来,那当然是要接收了。

    1. 最后是base 基数问题: 也就相当于写成递归时的退出条件,也就是 上面公式中S==0 事的条件,

      这里由于使用DP 解决问题,数组初始化皆为0,所以不用再考虑 0 的情况,直接考虑 1 的情况,

      从尾巴 开始往前找的话, 如果总等找到 一个 相加和 符合的,就把 进行 +1 或者说返回 1,又或者说 这条方案 获得 1;现在开始是从头开始往后找,相当于对递归直接剪枝了。这里 是 初始化 起始的方案数为 1, 即 dp[0][0] = 1 .

      由于不允许数组下标为负数, 比如[0 - 1] = [-1] 下标 为负数 所以 考虑不以 0 为对称位, 调整大小进行上下扩展。[ -5 , -4, -3, -2, -1, 0, 1, 2, 3, 4, 5] 可以扩展 5 ->[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

      比如 nums=[2, 3, 5] 进行 sum = 2+3+5 = 10 最终的扩展则是 2*10+1 = 21

      int[][] dp = new int[nums.length][2*sum+1];
      

      至此,大致如上,具体一些的细节可以参见代码中的部分注释。欢迎讨论进行纠正。

    参考链接1:动态规划解题套路框架
    参考链接2:LeetCode 494 官方题解

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值