从回溯到动态规划

〇、例题

LC 494 目标和

给你一个非负整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

通过本题,可以复习回溯算法 , 并引出动态规划

一、回溯算法

本题不难想到 ,对于数组中的每一个数字, 要么添加负号 , 要么添加正号 , 我们可以通过递归,枚举出每一种情况 ,并找到满足题意的情况。

回溯算法的大致框架:

0.明确原始问题和子问题

1.确定递归函数的入参和返回值

2.明确递归结束条件

3.写单层处理(即递归逻辑)

1.无返回值的回溯(显式回溯)

class Solution {
    private int count = 0;
    private int curSum = 0 ;
    public int findTargetSumWays(int[] nums, int target) {
        dfs(nums , target , 0);
        return count;
    }

    public void dfs(int[] nums , int target , int index){
        if(index == nums.length){
            if(curSum == target){
                count ++;
                return;
            }
            return;
        }
        curSum += nums[index];
        dfs(nums , target , index + 1);
        curSum -= nums[index];

        curSum -= nums[index];
        dfs(nums , target , index + 1);
        curSum += nums[index];
    }
}

这里使用了一个全局变量curSum , 用来记录某一条路径的数字之和 , 对于当前index元素,我们有两种符号 。那么我们先选加号,curSum更新为curSum + nums[index] , 问题缩小为在(index + 1)及之后的元素中选值,使得总和为target,于是递归到下一层,在这一次递归结束后,我们需要回复curSum!

因为我们接下来还要再选减号 , 若不回复 ,是无法回退到当前节点的curSum的。

2.有返回值的回溯(隐式回溯)

在上面的做法中 ,我们用到了两个全局变量 , count和 curSum

事实上, 我们也可以将其作为递归函数的参数传递。

递归三部曲如下:

0.明确原始问题和子问题

原始问题即:从数组的 ​​所有元素​​ 中,通过加减组合得到 target

子问题是:从数组的 ​​第 index 个元素开始​​,通过加减后续元素得到 target

当index到达nums.length时,没有后续元素了,于是必须返回了。

1.递归函数的入参及返回值

入参: nums数组 , target , 递归到当前节点是的curSum , 记录方案数量 ,遍历数组的索引 index

返回值:从当前 index 和 curSum 出发,后续所有元素能组合出 target 的 ​​有效路径数目​

2.递归终止条件:

即:index = nums.length ,到达叶子节点,我们必须返回 “从当前 index 和 curSum 出发,后续所有元素能组合出 target 的 ​​有效路径数目​” , 而后续以及没有节点了 ,这条路径是否合法是由叶子节点时的curSUM所决定 ,要么是一条合法的 ,返回1 , 要么是不合法的 ,返回 0 。

底层逻辑:

  • 计数逻辑​​:每个叶子节点代表一条完整的路径,有效路径返回 1,无效路径返回 0
  • ​逐层累加​​:递归过程中,每个节点的返回值是其所有子路径的解数之和,最终根节点的返回值就是全局解数。

3.单层处理及向下一层递归

对于当前节点 ,处理无非是选加号或者减号,

若选加号 , 从下一层的index和curSum出发 , 会有 若干种 有效路径数目​

同理减号。

那么总数 就是 当前节点 选加号和减号之后 ,路径数目之和!

于是,代码如下

class Solution {

    public int findTargetSumWays(int[] nums, int target) {
        return bk(nums , target , 0 , 0);
    }

    public int bk(int[] nums , int target , int index , int curSum){
        if(index == nums.length){
            if(target == curSum){
                return 1;
            }
            else{
                return 0;
            }
        }

        int add = bk(nums , target , index + 1 , curSum + nums[index]);
        int sub = bk(nums , target , index + 1 , curSum - nums[index]);
        return add + sub;
    }
}

3.小结

要把递归想象成一棵树 , 递归终止条件就是树的叶子节点

二、回溯+记忆化

上述的回溯算法,是没有任何剪枝的 ,往往只适用于枚举总数小于 20 的情况。

而对于某些回溯题 ,观察不同路径是否可能到达相同的 index 和中间状态(如 curSum)。

若能,则可以考虑记忆化 ,避免重复递归搜索。

本题中,

或者画一画递归树,发现不同路径中会有相同的index和curSum出现,于是可以使用一个缓存Map,每次递归之前,先在这个map中找,是否已经计算过了,若已经计算过,直接return,反之才递归搜索下一层 , 并且递归后,缓存一下curSum。

class Solution {
    private HashMap<String , Integer> memo = new HashMap<>();
    public int findTargetSumWays(int[] nums, int target) {
        return dfs(nums , target , 0 , 0);
    }

    public int dfs(int[] nums , int target , int index , int curSum){
        if(index == nums.length){
            if(curSum == target){
                return 1;
            }else{
                return 0;
            }
        }

        String key = index + "," + curSum;
        if(memo.containsKey(key)){
            return memo.get(key);
        }
        int add = dfs(nums , target , index + 1 , curSum + nums[index]);
        int sub = dfs(nums , target , index + 1 , curSum - nums[index]);
        memo.put(key , add + sub);
        return add + sub;
    }
}

注意, 缓存键的设计要满足唯一性!

三、动态规划

1.二维dp

//动态规划 二维数组版
//dp[i][j]的含义: 从 0-i种物品中选 , 恰好填满容量为j的背包共有dp[i][j] 种方案
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0 ;
        for(int num : nums){
            sum += num;
        }
        if((sum + target ) % 2 != 0 || sum < Math.abs(target)) return 0;

        int bagSize = (sum + target) / 2;
        int m = nums.length;
        int[][] dp = new int[m][bagSize + 1];
        
        //初始化第一行 、 第一列
        //1.第一行 , if判断是为了避免数组越界
        //对于第一行 , 物品始终只有nums[0] , 背包容量从0 到 bagsize
        //当且仅当bagSize大于等于nums[0]时 , 在遍历过程中能取得到 j = nums[0],
        //那么此时 能填满容量为j的方案就是一种 , 就是取nums[0]这个物品 ,
        //反之 , nums[0]直接就大于 bagSize , 恰好填满的方案为 0.
        if(nums[0] <= bagSize){
        dp[0][nums[0]] = 1;
        }
        //2.初始化第一列 , 物品从0 - n , 背包容量为 0 
        //很显然 , 对于背包容量为0的情况 , 物品重量只要不为0 , 方案数就为1(只能不选)
        //而只要 第i个物品重量为0 , 第i个物品就有2种选择 ; 若后面又遇到了0 ; 方案数等于这一列上的0的个数的2次方
        //假如:物品重量为 1 0 0 2 3 4 ,索引从0-5 , dp[0][0] = 1 ,dp[0][1] = 2
        //dp[0][2] = 1 * 2*2(两个零都是可选可不选) , dp[0][3] = 1*2*2*1 (乘法原理) 以此类推
        //综上可见, 第一列的初始化跟第一列物品中重量为0的个数有关
        int zeroCount = 0;
        for(int i = 0 ; i < m ; i ++){
            if(nums[i] == 0) zeroCount ++;
            if(zeroCount == 0){
                dp[i][0] = 1;
            }else{
                int temp = 1;
                for(int cnt = 0 ; cnt < zeroCount ; cnt ++){
                    temp *= 2;
                }
                dp[i][0] = temp;
            }
        }
        //递推 
        for(int i = 1 ; i < m ; i ++){
            for(int j = 1 ; j < bagSize + 1 ; j ++){
                if(nums[i] > j ){
                    dp[i][j] = dp[i-1][j];
                }else{
                dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i]];
                }
            }
        }
        return dp[m-1][bagSize];      
    }
}

2.一维dp

//动态规划 一维数组版
//dp[j]的含义 容量为j的背包 , 要装满有dp[j]种方法
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        for(int num : nums){
            sum += num;
        }

        if((sum + target) % 2 != 0 || Math.abs(target) > sum){
            return 0;
        }

        int postive = (sum + target) / 2;
        int[] dp = new int[postive + 1];
        dp[0] = 1;
        for(int i = 0 ; i < nums.length ; i ++){
            for(int j = postive ; j >= nums[i] ; j --){
                dp[j] += dp[j-nums[i]];
            }
        }
        return dp[postive];
        
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值