从初学者的角度浅析动态规划(1)

导言

  在刚开始学习动态规划的时候作者觉得动态规格是一个只能读得懂但是做不出来的一类题目,作者觉得问题出在算法书没有按照一个递进的顺序引出动态规划,而是开始就根据要解决的实际问题引出动态规划,这让读者会觉得动态规划是一个与其他之前学的知识毫无关系的一种新算法
  其实不然,动态规划的本质是聪明的穷举,而这刚好和我们学习的DFS有着千丝万缕的联系,换句话说,通过DFS是可以一步一步的推到出动态规划的,但并不是每一次DFS的题目都可以用动态规划解决,但是动态规划的题目几乎都可以通过DFS再一步一步过渡到动态规划,从而进行聪明的穷举,接下来我们通过DFS->记忆化搜索->DP逐步向DP靠近
  动态规划问题一般是无法直接一眼看不出来的(学霸请收下我的膝盖),因此它考察的除了动态规划的常用模型之外还更多考察了我们对动态规划的建模能力,如何将一道看起来与动态规划不相关的题进行建模给联系起来

详解动态规划的本质

什么是动态规划

动态规划(Dynamic Programming),俗称DP,具有最优子结构、无后效性、子问题重叠等3大性质,动态规划实际上还是DFS,只不过它是聪明的DFS,可能这样讲比较抽象,下面我们逐个特性进行讲解

最优子结构

  情景假设:我们现在有一个背包,有2种物品数量无限个,背包体积为11,第一种物品a体积为3、价值为5,第二种物品b体积为5、价值为4,求现在2种物品能装进背包的最大价值
  此时我们第一反应肯定是穷举,是的,恭喜你开始入门DP了,先上图
最优子结构

  我们可以看到通过穷举最终可以得到在体积为11情况下最大价值为15,此时我们可以认为在体积为11的情况下最大价值为15是最优解
  再拓展:如果假设背包体积为23,有4个物品为a、b、c、d,a的体积为6,价值为1,b的体积为12,价值为3,c的体积为3,价值为5,d的体积为5,价值为4(注意了,此时的c和d就相当于上图的a和b),那么我们可以知道2a = b,即在背包体积允许情况下装入一个2个a和1个b所占的体积是相同的,如果此时背包最大体积为23,那么考虑以下方案:
  枚举过程中我们会枚举到挑选挑选1个b而不挑选a,2个a而不挑选b,此时剩余的背包体积都是(23 - 12 = 11),那么因为第一次挑选1个b而不挑选a的时候最大体积为11,我们第一次会经历上图的计算过程可知其体积为11的最大价值(即最优子结构)为15,那么在第二次遍历到挑选2个a而不挑选b的时候通过记录我们就能知道体积为11的最大价值为15,这就是局部的最优子结构
  生怕讲绕了,我又画了个图描述上文再拓展案例的情景,没有写明价值和体积是觉得写完图会比较乱,如果想知道价值、体积可以参照上图再看看
最优子结构

无后效性

无后效性是指当前子问题的状态只和之前的状态有关,而与之后的状态无关,举个例子:当我们计算完背包体积为6装入的最大价值20后,无论之后怎么计算都无法改变体积为6装入的最大价值20的事实,如果可以改变则说明该问题无法使用DP进行解决

子问题重叠

重叠子结构

  当我们计算背包体积为23,已经装入体积12(装入一个2个a或者1个b)的物品后,剩下的体积11因为我们在装入2个a不装b的时候已经计算过了,那么自然而然,我们会想使用一种数据结构对结构进行存储局部最优解使之装入1个b不装入a的时候无需重复计算体积11的最大价值,且该重叠子问题是当前子问题的最优解,这就是重叠子问题引申出来的记忆化搜索,通过记忆每个子问题的最优解从而解决DFS重复遍历问题,这就是引申出来了记忆化搜索
  所以我经常跟讲当穷举到某个点,对于该点后面的最优状态是未知且确定的(未进行遍历所以未知,但是最优是肯定能通过DFS知道的),我们只需记录该点后面的状态即可解决DFS重复计算的问题,举个例子:女朋友生气了你不知道怎么哄她,但是你知道她哄的所有方式且所有方式中肯定有一个有用,那么你可以通过一个一个去试最终把女朋友给哄好,这个情景中哄好的方式是未知的,但是通过暴力枚举哄好的方式是肯定可以哄好的的(狗头)

记忆化搜索

  记忆化搜索本质是在DFS基础上进行改进的,我们只需要通过某种数据结构进行记录得到最优局部解即可(注意:最优解不一指的是极大值或者极小值解,下面示例可充分说明该点),再遇到相同的子问题时就无需再次遍历了,这也是DFS剪枝的一种
  接下来我们看个例题,在看例题之前作者想补充几句:动态规划解决的不止是最优问题,还可以解决特定值问题,下面属于动态规划中解决特定值问题的一类题目

用题目举个例子

力扣:不同路径
不同路径

思路

我第一反应是使用DFS进行遍历,但是一般DFS的题目的数据范围为30以内,这是一个靠谱的判断,因为DFS是指数级别的,所以一般数据都会控制在比较小的范围内,这也可以作为是否可以使用DFS的一个判断依据,因此此题不能使用暴力DFS进行解决,因此我们可以使用记忆化搜索进行解决(ps:有读者肯定迫不及待了,请再耐心一点 ~,就快讲到DP了)

普通DFS

思路

当前坐标(i,j)的不同路径数量会等于DFS向右和向下的路径之和,但是暴力DFS会超时

代码
class Solution {
    public int uniquePaths(int m, int n) {
        return DFS(m,n,0,0);
    }

    int DFS(int m, int n, int i, int j) {
        // 首先进行边界判断
        if (i >= m || j >= n) {
            return 0;
        } else if (i == m - 1 && j == n - 1) {  //当到达右下角时说明到达终点,此时路径只要1条
            return 1;
        }
        return DFS(m, n, i + 1, j) + DFS(m, n, i, j + 1);
    }
}

超时

记忆化DFS

思路

使用记忆化搜索,其实最重要的是考虑重复计算(也就是重复子结构),我们需要设计某种数据结构进行存储,使之进行聪明穷举,我们会想当从某个点(i,j)进行往下遍历后的路径数目是肯定的,下一次当再次遍历到该点是就无需再进行重复计算了,因此我们可以使用一个二维数组进行记录,横坐标为i,纵坐标为j,记录该点的路径数目

代码
class Solution {
    int[][] cache;

    public int uniquePaths(int m, int n) {
        cache = new int[m + 1][n + 1];
        for (int i = 0; i <= m; i++) {
            Arrays.fill(cache[i], -1);
        }
        // 进行一个初始化,表明到达右下角终点的路径数量为1
        cache[m - 1][n - 1] = 1;
        return DFS(m, n, 0, 0);
    }

    int DFS(int m, int n, int i, int j) {
        // 首先进行边界判断
        if (i >= m || j >= n) {
            cache[i][j] = 0;
            return cache[i][j];
        } else if (cache[i][j] != -1) {
            return cache[i][j];
        }

        // 第一种写法
        if (cache[i + 1][j] == -1) {
            cache[i + 1][j] = DFS(m, n, i + 1, j);
        }
        if (cache[i][j + 1] == -1) {
            cache[i][j + 1] = DFS(m, n, i, j + 1);
        }
        cache[i][j] = cache[i + 1][j] + cache[i][j + 1];
        return cache[i][j];

    }
}

记忆化搜索

细节

记忆化细节
可能有同学会有第二种写法,但是,第二种写法是不完全的记忆化搜索,相对第一种来说效率会比较低,因为对于第一种来说在每次递归前都会进行判断,而第二种直接进行DFS,因为DFS是无法确定某个点的访问时机和访问次数的,因此如果先访问了坐标(i+1,j+1)顺势访问了(i+1,j),那么此时(i+1,j)的不同路径数量是已经记录在cache数组中,那么在我们遍历到坐标(i,j)时再向右遍历的话就无需对坐标(i+1,j)进行再次DFS了,所以谨记在进行记忆化时一定要使用第一种效率才高

基于记忆化搜索的DP

如何利用记忆化搜索写出DP

  实际上在进行记忆化搜索的时候我们已经写出了一部分的DP了,可能有同学内心:什么!什么!你管这叫DP的一部分?稍安勿躁,通过下面几个步骤讲使用记忆化搜索改写成DP

  1. 其实记忆化搜索中的记录局部最优解的数据结构就是我们苦苦寻找的DP的备忘录,至于为什么这个后文再进行说明,我们要明确DP问题解法的3部曲:
    1.1 状态定义:明确备忘录的记录的元素的含义,如上文路径问题中dp[i][j]代表坐标(i,j)到终点的不同路径数量
    1.2 状态转移方程:即我们说的dp递推方程,这一点其实在我们进行递归的时候就已经可以知晓了,也就是说可以通过递归找到转移方程,如上文记忆化搜索中路径问题中dp[i][j]等于向右dp[i+1][j]和向下dp[i][j+1]的和,即是dp[i][j] = dp[i+1][j] + dp[i][j+1]
    1.3 初始化:几乎所有的dp都需要初始化,因为只有初始化才能进行状态的叠加

  2. 那么此时我们已经知道了递推方程及备忘录,那么就只剩下初始化了,动态规划和记忆化搜索最大的不同是动态规划是一个自下而上的过程,而记忆化搜索是一个自上而下的过程,要明确它是一个逆推的过程,在进行初始化时我们只需要根据题目进行初始化就行了,如路径问题中最右和最下的路径数目肯定为1,所以我们对其进行初始化即可

  3. 代码实现

class Solution {
    public int uniquePaths(int m, int n) {
        int[][] dp = new int[m][n];
        // 初始化
        for (int i = m - 1; i >= 0; i--) {
            dp[i][n - 1] = 1;
        }
        for (int j = n - 1; j >= 0; j--) {
            dp[m - 1][j] = 1;
        }
        for (int i = m - 2; i >= 0; i--) {
            for (int j = n - 2; j >= 0; j--) {
                dp[i][j] = dp[i + 1][j] + dp[i][j + 1];
            }
        }
        return dp[0][0];
    }
}

记忆化搜索和DP的异同

  1. 记忆化搜索是从上而下的过程,而DP是从下而上的,可以理解为从结构倒推进行求解,所以其本质上都是一样的,只是求解的顺序不同,这也就是解释了为什么记忆化搜索所使用的记录最优解的数据结构是备忘录了
  2. 记忆化搜索本质是DFS剪枝,对于每个状态的访问时机和访问次数是不确定,但是DP的状态叠加是一个又一个状态进行从下而上的枚举的,因此它是确定的访问时机和访问次数,能大大降低时间复杂度和空间复杂度,因为DP没有了记忆化搜索不断的自我调用,而这种自我调用是非常非常消耗栈空间和系统资源的,而DP一般是一个函数内的循环,不涉及更多的函数之间的切换,效率会更高
  3. 记忆化搜索和DP本质都是通过记录局部最优解的状态来降低时间复杂度,所以二者的时间复杂度是相同滴 ~

状态枚举

  1. 为什么说DP是一个自下而上的过程,其实DP的本质是以不变应万变,以确定的状态应对下一个不确定的状态,或者说上层状态需要什么我们就给什么,这个其实解答了为什么DP需要各个状态枚举,我们再通过题目进行体会:
  2. 在路径问题中我们进行DFS的过程中为了知晓坐标(i,j)的不同路径数目所以我们不得已通过DFS遍历(i,j+1)和(i+1,j),此时我们会想那我去求不就完了吗?是的,这种思路是从上到下的;
  3. 但是反过来,如果我们先知道了(i,j+1)和(i+1,j)的不同路径数目,那么我是可以去应对未来所需要的各个状态,如(i+1,j+1),(i,j)都可以从(i,j+1)和(i+1,j)中获取一部分需要的路径,不是需要了再去求解,而是先求解所有未来需要的需求,等待未来需要的话直接就可以调用
  4. 我们再通过一道力扣进行体会,此处贴上链接及题目:

力扣:目标和
目标和

记忆化思路

  1. 我第一想法是DFS,这道题的数据在20之内,是可以进行DFS,但是在DFS中我看了一个熟悉的背影 —— 重复子结构,设想一下在前面3个数有2种方案(-1+3-2)和(+1-3+2)都是为0,那么如果我要求target = 0的所有表达式子数目,那么问题转化为求后面(n - 3)个数相加减的不同表达式数目,所以我们可以尝试使用记忆化搜索再到DP的思路进行试一试能不能解决该问题
  2. 记忆化搜索:我们会想设置一个数据结构存放局部重复子结构的最优解,那么我们会想设置一个2维数组,横坐标从0 - (n - 1),纵坐标从0-target,那么就可以进行记录了,这里可能有同学会想为什么需要枚举纵坐标从0-target,因为在DFS过程中需要记录的是截止当前nums[i]前面加减和为temp,因为temp会随着坐标0到i的加减和发生变化,这部分是不确定的,而我们能知道的只是它肯定在0-target之间,所以需要纵坐标枚举从0 - target

记忆化代码

class Solution8 {
    int[][] cache;

    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        // 计算整个数组的所有元素之和
        for (int i = 0; i < nums.length; i++) {
            sum += nums[i];
        }
        // 如果目标值的绝对值比sum大说明不可能有组合可以组成target
        if (target > Math.abs(sum)) {
            return 0;
        }
         /*定义缓存寄存器,这里nums.length需要加1,因为考虑到角标越界,
         因为遍历的时候可能会超出nums长度最大值,
         因为假设i为nums.length - 1,那么以及到了nums的最后一位了,但是还是需要判断接下来的下一位是否符合条件*/
        cache = new int[nums.length + 1][2 * sum + 1];
        for (int i = 0; i <= nums.length; i++) {
            Arrays.fill(cache[i], -1);
        }
        return DFS(nums, target, sum, 0, 0);
    }

    int DFS(int[] nums, int target, int sum, int i, int temp) {
        // 如果寄存器中有缓存则无需遍历
        if (cache[i][temp + sum] != -1) {
            return cache[i][temp + sum];
        }
        // 如果超出nums的长度,则已经到达了最后一位的下一位,此时如果temp刚好等于target说明可以组成一个方案数
        if (i >= nums.length) {
            if (target == temp) {
                cache[i][temp + sum] = 1;
            } else {
                cache[i][temp + sum] = 0;
            }
            return cache[i][temp + sum];
        }


        // 深度记忆化搜索:要进行DFS前先进行判断
        if (cache[i + 1][temp - nums[i] + sum] == -1) {
            cache[i + 1][temp - nums[i] + sum] = DFS(nums, target, sum, i + 1, temp - nums[i]);
        }
        if (cache[i + 1][temp + nums[i] + sum] == -1) {
            cache[i + 1][temp + nums[i] + sum] = DFS(nums, target, sum, i + 1, temp + nums[i]);
        }
        cache[i][temp + sum] = cache[i + 1][temp - nums[i] + sum] + cache[i + 1][temp + nums[i] + sum];
        return cache[i][temp + sum];

    }
}

dp思路

从dp的思路解释状态枚举

还记得上文说的dp是以不变应万变,上层状态要什么下层状态就给要什么的原则吗?我们按照dp这种逆推的思路再来想一下:

  1. 假设现在记忆化搜索遍历到某个点i且当前的加减和为temp,这个temp是根据下标0到i前面的正负号不断发生改变的,那么此时DFS是得通过遍历才能知晓后面下标(i+1)到(n-1)的点是否能满足加减和数目等于target - temp;
  2. 如果按照以不变应万变的逆推思路,既然不确定从0到下标i的加减和temp(因为每一个数都可以进行加减),那么对于截止到下标i来说其实temp是无法确定是多大,我们能知道的只是temp小于sum
  3. 但是反过来想一想,使用DP自下而上的思维:如果我们能通过枚举从下标(i+1)从0到sum的各个状态下的方案数,那么对于下标i来说虽然temp是不确定,但是每当遍历到 i 时我们可以知道(target - temp)在下标(i + 1, n - 1)的具体方案数,那么则可以直接根据递推方程得到下标 i 的方案数,通过这种思路我们也可以知道为什么需要枚举从0到target各个状态
  4. 接下来画个图让大家再理解上面的话
    为什么需要枚举

记忆化搜索改写dp

  1. 接着,如何将记忆化搜索改成DP:我们前面已经知道了备忘录,根据DP写法我们需要知道状态定义、状态转移方程及初始化,而状态转移方程我们可以从递归里去找
  2. 我们可以明确状态定义为从坐标i开始到结尾所有加减和temp 1与temp值是否为target的方案数,记忆化搜索的状态转移方程为dp[i][j] = dp[i + 1][j - nums[i]] + dp[i + 1][i + nums[i]],右边等式的i+1如果放在dp中就是i - 1,因为记忆化搜索为自上而下,从上状态逐层递增,而dp为自下而上,从下状态逐层递增,这只是一个相对层数问题
  3. 我们再解决一下角标问题就ok了,这里target 为 -1000 到 1000,所以需要将数组进行右移

dp代码

class Solution9 {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        int len = nums.length;
        for (int i = 0; i < len; i++) {
            sum += nums[i];
        }
        
        // 如果数组总和小于target,那么就不可能有target的方案数
        if (Math.abs(target) > sum) {
            return 0;
        }
        int[][] dp = new int[nums.length][2 * sum + 1];
        // 初始化,注意需要进行连续加,因为可能出现num[0]为0,此时加0和减0方案数为2
        dp[0][nums[0] + sum] += 1;
        dp[0][-nums[0] + sum] += 1;
        for (int i = 1; i < len; i++) {
            for (int j = -sum; j <= sum; j++) {
                // 进行递推,当j - num[i] 小于sum的话是不可能有某个方案数使得结果小于sum的,因为数组总和最小为-sum
                if (j - nums[i] >= -sum) {
                    dp[i][j + sum] += dp[i - 1][j - nums[i] + sum];
                }
                // 与上相同
                if (j + nums[i] <= sum) {
                    dp[i][j + sum] += dp[i - 1][j + nums[i] + sum];
                }
            }
        }
        return dp[len - 1][sum + target];
    }
}

如何看出题目考察的是动态规划

  1. 动态规划的本质是聪明的递归,那么它前提可以是一个DFS,因此我们可以根据DFS+数据量或者重复子结构(超过30就尽量不考虑DFS啦 ~ )来确定这道题是否可以用DP,有时候看到数据量需要10^7+6其实也可以考虑DP,因为数据过于庞大一般DFS不适用,然后再顺着DFS->记忆化搜索->DP
  2. 动态规划很重要的一点是它来源于枚举(其实也是DFS),那么如果遇到枚举之类的问题,则可以考虑DP;其实DP类的题目可能也可以用其他解法解决,因此DP类题目特别灵活,因此需要逐步分析才能看得出是DP问题
  3. 动态规划可以是求解背包问题的最值问题,也可以是求解特定值问题,因此应该在熟悉各个dp模型的基础在遇到具体问题的时候进行对dp模型的进行套用或微调
  4. 此篇文章是在三叶姐的dp文章启发下所写的,在此表示感谢,也可以公众号搜索<宫水三叶>进行更加深入的dp学习
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值