动态规划系列—动态规划VS回溯算法

本篇题解

leetcode 494 目标和
leetcode 322 零钱兑换
leetcode 518 零钱兑换II

算法解决问题的核心实际上还是穷举。问题的关键在于如何聪明、有效率地进行进行穷举,这是我们构建合适算法的目标。

动态规划和回溯算法看起来有挺多共同之处,都涉及到了【递归】和【做选择】,那么他们之间区分在哪里呢?以及这两者之间是否能够转化?

通常来讲,我们使用回溯算法去遍历的时候,就是在使用暴力穷举的方法,当数据量很庞大的时候,显而易见地就会使算法效率变得很低。所以,我们通常会引入“剪枝”的思想来进一步优化,即在遍历的时候,我们往往能通过提前预判筛查掉一些肯定不可能的情况,来降低计算量的大小。这样一种回溯的结构,实际上就是在对树进行深度遍历的过程。不管是二叉树,还是N叉树也好,关键在于在【选择列表】中做选择。下面给出回溯算法的框架,就能很体会这个场景。(来自labuladong,文末附链接。)

动态规划系列—动态规划VS回溯算法_第1张图片

仔细一想,这当中实际上有不少节点都被重复运算了,但是计算机是没有记忆的,所以这其中我们消耗了不少精力在计算之前已经出现的结果上。所以我们提出了使用【动态规划】来解决这种“重复子问题”的情况。

通过leetcode题来看一下具体情况。

leetcode 494 目标和

动态规划系列—动态规划VS回溯算法_第2张图片

回溯解法

这道题一拿到手,你就想肯定能用回溯求解。对于每个数我有两种【选择】,“+”或者“-”,针对每种选择我们可以得到对应【选择】下的状态,也就是当前的算术结果,当算术结果等于目标和时,我们就得到了一种解法。浅显易懂,暴力也比没做出来好。
代码如下:
这里我们使用target==0来作为判断,可以减少用res值去记录当前的算术结果,所以之间把剩余的值放进去继续递归。

void backTrack(int i,vector& nums,long target){
        if(i == nums.size()){
            if(target == 0)
             res++; //得到一种解法
          return;  //结束的地方
        }
        backTrack(i+1,nums,target-nums[i]);  //选择加号
        backTrack(i+1,nums,target+nums[i]);  //选择减号
    }

动态规划–消除重叠子问题

你开始着手优化算法了。既然之间回溯存在了重复子问题的状况。则使用动态规划来解决,一般可以使用备忘录和“迭代填表”法。

备忘录

既然我们刚刚说有结果被重复的被计算?那么我们就可以把子问题记录起来,当同一个子问题再次出现的时候,就可以查询“备忘录”直接取出结果。这就是它的思想。
代码如下:

    map memo;
    dp代表当前状态下的方案数
    int dp(vector& nums, int i,long target){
        //退出状态
        if(i==nums.size()){
            if(target==0) return 1;
            return 0;
        }
        string key = to_string(i) + "," + to_string(target);
        //查询备忘录
        if(memo.find(key)!=memo.end()){
            return memo.find(key)->second;
        }
        //迭代计算
        long result = dp(nums,i+1,target-nums[i])+dp(nums,i+1,target+nums[i]);
        //记入备忘录
        memo.insert(pair(key,result));  
        return result;
    }

转化为子集划分问题

实际上上面使用备忘录的方法仍然还不够理想。当数据量很大时,查询也消耗了许多时间。最好的情况是我们能找到状态转换之间的关系,用填表的形式,用已知的状态来求解当前未知的状态。因此状态转移方程对动态规划是至关重要的。

我们可以把目标和的这个问题转化为一个子集划分问题。对于每个数我们要从两个选择“+”和“-”进行选择,因此nums中的所有数被划分为两个子集A和B,分别代表分配“+”和“-”的数。存在如下关系:

sum(A) - sum(B) = target
sum(A) = target + sum(B)
sum(A) + sum(A) = target + sum(B) + sum(A)
2 * sum(A) = target + sum(nums)

所以 sum(A) = (target + sum(nums)) / 2,问题转换成nums中可以找出几个子集A,使得A的元素和为(target + sum(nums)) / 2。

这里翻译一下就是,子集A中的元素代表背包中选中的物品。而A的元素和就相当于背包的容量大小((target + sum(nums)) / 2),而有几个子集A,就是找出符合条件(选中物品总和等于背包容量)的方案数。

那就直接按照背包问题的情况处理了。

int subset(vector&nums,int target){
       int N = nums.size();
       int sum = 0;
       for(int i = 0;i> dp(nums.size()+1, vector(total+1,0));
       dp[0][0]=1;

       for(int i =1 ;i<=nums.size();i++){
           for(int j = 0;j<=total;j++){
                   if(j==0) dp[i][0] = 1;
                   if(j>=nums[i-1]){
                       dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]];
                   }else
                       dp[i][j] = dp[i-1][j];
           }
       }
      return dp[N][total];
    }

其实还可以进一步优化,使用【状态压缩】的方法,将dp的二维数组转换成一维数组。因为当前的选择状态只与上一个状态有关,我们可以只存储上一个状态即可。

leetcode 322 零钱兑换

动态规划系列—动态规划VS回溯算法_第3张图片

涉及到最值问题,实际上只需要思考状态方程的关键。

class Solution {
public:
    int coinChange(vector& coins, int amount) {
       vector dp(amount+1,amount+1);   //金额为i需要dp[i]硬币
       dp[0] = 0;
       for(int i = 0;i

leetcode 518 零钱兑换II

动态规划系列—动态规划VS回溯算法_第4张图片

本质和目标和问题是一样的,直接给出代码。

class Solution {
public:
    int change(int amount, vector& coins) {
     //base line
     vector> dp(coins.size()+1,vector(amount+1,0));
     dp[0][0]=1;
      for(int i = 1;i<=coins.size();i++){
          for(int j=0;j<=amount;j++){
            if(j==0) dp[i][0] = 1;
            //if(coins[i]=coins[i-1]) dp[i][j] = dp[i-1][j]+dp[i][j-coins[i-1]];
            else dp[i][j]=dp[i-1][j]; 
          }
      }
    
    return dp[coins.size()][amount];

    }
};

写在最后

做动态规划题目,想清楚三点。

  1. 明确【状态】和【选择】
  2. 明确dp数组的含义
  3. 根据【选择】,明确状态转移方程的关键。
    以及考虑好baseline的情况。

参考

https://labuladong.gitbook.io/algo/dong-tai-gui-hua-xi-lie/targetsum

你可能感兴趣的:(动态规划,算法,数据结构)