详细总结【动态规划】的解题规律

动态规划

准备工作

首先,我们需要确定适用动态规划这种算法的题目特征,毕竟笔试题不会好心地在旁边标上动态规划的标签。

维基百科:动态规划在查找有很多重叠子问题的情况的最优解时有效……只能应用于有最优子结构的问题。听起来非常云里雾里(专业总结都是这么抽象)。我们先来理解一下最优子结构。最优子结构是局部最优解能决定全局最优解。也就是说,动态规划其实和分治方法非常类似,都是通过组合子问题的解来求解原问题,但是分治方法划分的子问题互不相交,而动态规划的子问题则互相重叠。为了减少复杂度,保证每个子问题只求解一次,我们利用**历史记录(备忘录)**来避免重复计算,其实就是所谓的dp数组(一维/二维/甚至有三维,不过一般会压缩到二维)

下面就正式进入求解三部曲:

  • 确定dp数组的含义:好的开始就是成功的一半!本人曾经多少次因为找不出dp数组的含义而默默流泪,举步维艰……其实数组的含义一般都不会太复杂,一般都直接和要求解的问题挂钩,背包问题就是最好的示例。
  • 找出状态转移方程:实战部分!需要我们深入分析每种可能的情况。
  • 确定初始状态:请不要忽略这一步!不正确的边界条件常常是一些奇怪bug的罪魁祸首,对细心和耐心程度要求很高!可以用极端的测试数据自己在纸上演练一遍。这里通常要注意dp数组是从0开始还是从1开始。

在求解出正确答案的前提下,我们还常常进行dp数组的压缩。这部分将在后面具体展开。

基本:一维

练手题:53(经典) 70 213 198 413(可以直接用数学知识解) 650(有坑,解法很巧妙)

  • 浅浅总结一下以上涉及到的dp数组的含义

    • Q:求在不触发机关的情况下最多可以抢劫这n个房子的多少钱

      dp[i]:抢劫到第 i 个房子时,可以抢劫的最大数量(return dp[n]

    • Q:求给定数组中连续且等差的子数组一共有多少个

      dp[i]:以第i个数组元素结尾的子数组数目(return accumulate(dp.begin(), dp.end(), 0))

    • Q:给定整数数组 nums ,请找出一个具有最大和的连续子数组(最少包含一个元素),返回其最大和

      dp[i]:dp[i]表示以i结尾的连续数组的最大值,最后返回其中的最大值

    我们发现,dp数组的含义大多和求解问题直接挂钩,当然后面会根据问题的不同需要部分变通

下面讲解一些个人认为比较有代表性的题:

  • 213打家劫舍 II:作为70题的plus版本,该题增加了一个限制:这个地方所有的房屋都 围成一圈 。这意味着第一个房屋和最后一个房屋是紧挨着的。本菜狗一开始一直试图通过改变dp数组的含义或者状态转移方程来解题,后面发现正确的思路应该是分治:分为不偷第一间和不偷最后一间的情况。**注意是不偷不是偷!**如果是偷的话就需要分三种情况了。想通后就豁然开朗了。

    class Solution {
    public:
    //不要想着一次性解决,将情况分为两种,最后取max就好
        int rob(vector& nums) {
            if(nums.size()==1) return nums[0];
            //不偷第一间!分情况不是分成偷第一间和偷最后一间,这样会出来三种情况
            //动态规划中也是可以结合分治算法的
            int ppre=0,pre=0,cur1,cur2;
            for(int i=1;i
  • 343整数拆分:给定正整数 n 将其拆分为 k正整数 的和( k >= 2 )并使这些整数的乘积最大化。

    特点:本身不难,但是有很多小细节,以及转移方程不一定是根据dp的历史结果

    int integerBreak(int n) {
        vector dp(n+1,1);
        for(int i=2;i<=n;i++)
        for(int j=1;j
  • 650:最初记事本上只有一个 ‘A’ ,可以通过Copy All(复制当前全部)和Paste(粘贴上一次复制的字符),求最少的操作次数使记事本上输出恰好 n 个 ‘A’ 。

    审题:是拷贝当前!的全部,粘贴也只能是上一次!复制的字符。不同于以往通过加减实现的动态规划,这里需要乘除法来计算位置,因为粘贴操作是倍数增加的。

    dp[i]:延展到长度 i 的最少操作次数。对于每个位置 j,如果 j 可以被 i 整除,那么长度 i 就可以由长度 j 操作得到,其操作次数等价于把一个长度为 1 的 A 延展到长度为 i/j。因此递推公式是 dp[i] = dp[j] + dp[i/j]

    注意,官方给的题解状态转移方程是min(dp[i/j]+j-1,dp[j]+i/j-1),然后将所有的因子全部循环一遍。其实并不需要。我们可以将一个j长度或 i/j长度的数看作一个整体,然后执行dp[i/j]或dp[j]次操作,从而保证正确性

    int minSteps(int n) {
        vector dp(n + 1);
        int h = sqrt(n);
        for (int i = 2; i <= n; ++i) {
             dp[i] = i;
             for (int j = 2; j <= h; ++j) {
                  if (i % j == 0) {
                      dp[i] = dp[j] + dp[i/j];
                      break;
                  }
             }
        }
        return dp[n];
    }
    

进阶:二维

练手题:

64(easy) 542(有多种解法) 221(状态转移方程是关键) 279 91(本身不难,但很考验耐心和细心)

139(just so so,我自己根据给定单词的尾字母建了个unorder_map,略微优化了一番) 300

646(此类题常常根据下一个元素排序,注意返回值)

下面讲解一些个人认为比较有代表性的题:

  • 542:01 Matrix:给定一个由 0 和 1 组成的二维矩阵,求每个位置到最近的 0 的距离。

    特点:涉及四个方向的最近搜索。最直观的想法显然是广度优先搜索,但是复杂度会达到O(m2n2)。我们考虑将四个方向拆成两次,每次两个方向:从左上到右下进行一次动态搜索,再从右下到左上进行一次动态搜索

    vector> updateMatrix(vector>& matrix) {
    	if (matrix.empty()) return {};
    	int n = matrix.size(), m = matrix[0].size();
    	vector> dp(n, vector(m, INT_MAX - 1));
    	for (int i = 0; i < n; ++i) {
    		for (int j = 0; j < m; ++j) {
    			if (matrix[i][j] == 0)
    				dp[i][j] = 0;
    			else {
    				if (j > 0)
    					dp[i][j] = min(dp[i][j], dp[i][j-1] + 1);
    				if (i > 0) 
    					dp[i][j] = min(dp[i][j], dp[i-1][j] + 1);
    			}
    		}
    	}
    	for (int i = n - 1; i >= 0; --i) {
    		for (int j = m - 1; j >= 0; --j) {
    			if (matrix[i][j] != 0) {
    				if (j < m - 1)
    					dp[i][j] = min(dp[i][j], dp[i][j+1] + 1);
    				if (i < n - 1) 
    					dp[i][j] = min(dp[i][j], dp[i+1][j] + 1);
    			}
    		}
    	return dp;
    }
    
  • 646最长数对链

    这道题的思路说实话并不是第一次见了,将数组根据后一个元素的值进行升序排列(借用STL的sort算法),然后简单dp一下即可。注意返回值不一定是最后一个元素。

    int findLongestChain(vector>& pairs) {
        //先根据pairs的第二个数字进行升序排列
        sort(pairs.begin(),pairs.end(),[](vector &a,vectorb){
             if(a[1]==b[1])
                return a[0] dp(len,1);
        for(int i=1;i=0;--j){
                if(pairs[j][1]

字符串类型

练手题:1143(超重点!!) 583 72(HARD!) 10(转移方程很复杂)

下面进行具体讲解

  • 1143:求给定字符串的最长公共子序列

    这是一个非常明显的动态规划问题。以测试数据text1=”abcde“,text2=”bde“为例,在有两个字符串/数组的问题中,我们常见的模拟策略是固定一个字符串text1,将text2的字符串从头开始依次尝试和整个text1匹配并填充dp数组,即先将”b“与text1全部匹配,填满dp[1],然后根据dp[1]与”bd“将dp[2]填充,循序渐进。总时间复杂度是O(mn)。边界情况直接当作一个字符串为空,按照常规思路去填充即可。

    动态规划看起来是就某个中间状态进行分析,但是实际填充的时候也是从无到有,从最小的子问题开始逐步解决的,所以分析初始状态对求解是非常有帮助的。

    int longestCommonSubsequence(string text1, string text2) {
        int len1=text1.length();
        int len2=text2.length();
        vector > dp(len1+1,vector(len2+1,0));
        for(int i=1;i<=len1;i++){
            for(int j=1;j<=len2;j++){
                 if(text1[i-1]==text2[j-1])//注意要-1
                     dp[i][j]=dp[i-1][j-1]+1;//状态转移方程
                 else
                     dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
             }
         }
         return dp[len1][len2];
    }
    
  • 72编辑距离:求将 word1 转换成 word2 所使用的最少操作数 。可执行操作:插入/删除/替换一个字符

    坑:**插入一次和删除一次是可以合并成一个替换的!**想通这一步就很简单了。

    dp[i] [j]:将第一个位置1到i的字符串转换成第二个位置1到j的字符串最多需要几步编辑。当第 i 位和第 j 位对应的字符相同时,dp[i] [j] 等于dp[i-1] [j-1];当二者对应的字符不同时,修改的消耗是 dp[i-1] [j-1]+1,插入 i 位置/删除 j 位置 的消耗是 dp[i] [j-1] + 1,插入 j 位置/删除 i 位置的消耗是 dp[i-1] [j] + 1。

    此外,初始状况的初始化也非常重要!我们可以直接从原始意义出发进行处理。

    int minDistance(string word1, string word2) {
        int len1=word1.length();
        int len2=word2.length();
        vector > dp(len1+1,vector(len2+1,0));
        for(int i=0;i<=len1;i++)
        for(int j=0;j<=len2;j++){
            if(i==0)
               dp[i][j]=j;
            else if(j==0)
               dp[i][j]=i;
            else{
               dp[i][j]=min(
                        dp[i-1][j-1] + ((word1[i-1] == word2[j-1])? 0: 1),
                        min(dp[i-1][j] + 1, dp[i][j-1] + 1));
               //583题改行改成
               //dp[i+1][j+1]=min(dp[i+1][j],dp[i][j+1])+1;
            }
        }
        return dp[len1][len2];
    }
    
  • 583:把72题操作中的替换去掉。只需要浅浅修改一下状态转移方程即可。代码见上。

  • 10正则表达式匹配:给定字符串 s 和字符规律 p,要求实现一个支持 ‘.’ 和 ‘*’ 的正则表达式匹配。注意,‘.’ 匹配任意单个字符,’ *’ 匹配零个或多个前面的那一个元素

    难点:其实构造出dp数组并不难,关键是数组的初始化状态转移方程。当遇到*时,我们需要非常小心,不同的可能情况应该是前面的一次也没匹配过(dp[i] [j-2])或前面的已经匹配过一次且当前对应元素可匹配,即将 *和它前面的元素看作一个整体,一荣俱荣一损俱损。

    画图!动态规划在想不清楚时可以通过一个实例来画图,辅助理清思路。

    bool isMatch(string s, string p) {
         int len1=s.length();
         int len2=p.length();
         vector> dp(len1+1,vector(len2+1,false));
         //初始化
         dp[0][0]=true;
         for(int j=1;j<=len2;j++){
             if(p[j-1]=='*')
                dp[0][j]=dp[0][j-2];
        }
            
        //开始dp,可以压缩,从左到右
        for(int i=1;i<=len1;++i)//注意:是从1开始,而p/s是从1开始
        for(int j=1;j<=len2;++j)
        {
            if(p[j-1]=='*')//这个方程不好推,一个是0次,一个是一次以上
               dp[i][j]=(dp[i][j-2]) | (dp[i-1][j] && (p[j-2]=='.' | p[j-2]==s[i-1]));
            else 
               dp[i][j]=dp[i-1][j-1] && (p[j-1]=='.' | p[j-1]==s[i-1]);
        }
        return dp[len1][len2];
    }
    

背包问题

NP完全问题:N个物品和容量为W的背包,每个物品都有自己的体积 w 和价值 v,求哪些物品可使背包所装物品总价值最大。如果限定每种物品只能选择 0 个或 1 个,则问题称为 0-1 背包问题;如果不限定每种物品的数量,则问题称为无界背包问题或完全背包问题

  • 0-1背包

    • dp[i] [j] :前 i 件物品在体积不超过 j 的情况下能达到的最大价值
    • 遍历到第 i 件物品时(当前背包总容量为 j )如果我们不将物品 i 放入背包,则 dp[i] [j] = dp[i-1] [j],即前 i 个物品的最大价值等于只取前 i-1 个物品时的最大价值;如果将物品 i 放入背包,假设第 i 件物品体积为 w,价值为 v,那么我们得到 dp[i] [j] = dp[i-1] [j-w] + v。我们只需在遍历过程中对这两种情况取最大值即可,总时间复杂度和空间复杂度都为 O(NW)。
    int knapsack(vector weights, vector values, int N, int W) {
    	vector> dp(N + 1, vector(W + 1, 0));
    	for (int i = 1; i <= N; ++i) {
    		int w = weights[i-1], v = values[i-1];
    		for (int j = 1; j <= W; ++j) {
    			if (j >= w)
    				dp[i][j] = max(dp[i-1][j], dp[i-1][j-w] + v);
    			else
    				dp[i][j] = dp[i-1][j];
    		}
    	}
    	return dp[N][W];
    }
    
    • 进一步压缩:去掉dp第一个维度,在考虑物品 i 时变成 dp[j] = max(dp[j], dp[j-w] + v)。这里要注意是逆向遍历!这样才能保证调用的是上一行物品 i-1 时 dp[j-w] 的值,若正向则该值在遍历到 j 之前已被污染
    int knapsack(vector weights, vector values, int N, int W) {
    	vector dp(W + 1, 0);
    	for (int i = 1; i <= N; ++i) {
    		int w = weights[i-1], v = values[i-1];
    		for (int j = W; j >= w; --j)//这里只需要到w之前即可
    			dp[j] = max(dp[j], dp[j-w] + v);
    	return dp[W];
    }
    
  • 完全背包

    • 一个物品可以拿很多次,因此我们采用覆盖的方式。前面dp[i] [j] = dp[i-1] [j-w] + v保证了在拿第i个物品之前绝对没有拿过第i个,那么这里就改成dp[i] [j] = dp[i] [j-w] + v,隐含意义是当前物品可以无限取
    • dp[i] [j] = max(dp[i-1] [j], dp[i] [j-w] + v),差别仅仅是把状态转移方程中的第二个 i-1 变成了 i。
    int knapsack(vector weights, vector values, int N, int W) {
    	vector> dp(N + 1, vector(W + 1, 0));
    	for (int i = 1; i <= N; ++i) {
    		int w = weights[i-1], v = values[i-1];
    		for (int j = 1; j <= W; ++j) {
    			if (j >= w)
    				dp[i][j] = max(dp[i-1][j], dp[i][j-w] + v);//区别
    			else
    				dp[i][j] = dp[i-1][j];
    		}
    	}
    	return dp[N][W];
    }
    
    • 同样可以压缩,但是这里必须正向遍历(我们就是要被污染!)口诀:0-1 背包对物品的迭代放在外层,里层的体积或价值逆向遍历;完全背包对物品的迭代放在里层,外层的体积或价值正向遍历。
    int knapsack(vector weights, vector values, int N, int W) {
    	vector dp(W + 1, 0);
    	for (int i = 1; i <= N; ++i) {
    		int w = weights[i-1], v = values[i-1];
    		for (int j = w; j <=W; ++j)//区别
    			dp[j] = max(dp[j], dp[j-w] + v);
    	return dp[W];
    }
    

练手题:

416(关键在于如何变更成背包问题)474(三维压缩成二维的典例)323(请非常注意初始化的边界问题!)

494(怎么把看似诡异的问题转换成0-1背包问题)

  • 416:给定一个正整数数组,求是否可以把这个数组分成和相等的两部分。

    • 这道题可以换一种表述:给定一个只包含正整数的非空数组nums[0],判断是否可以从数组中选出一些数字,使得这些数字的和等于(传统背包问题是小于)整个数组的元素和的一半。

    • dp[i] [j]: 从数组的 [0,i]下标范围内选取若干个正整数(可以是 0 个),是否存在一种选取方案使得被选取的正整数的和等于 j。初始时dp 中的全部元素都是false。

    • 边界情况:不选取任何正整数和只有一个正整数。这道题要非常关注dp数组的初始化!

    class Solution {
    public:
        bool canPartition(vector& nums) {
            int maxNum=INT_MIN;
            int sum=0;
            for(auto& num:nums){
                sum+=num;
                maxNum=max(num,maxNum);
            }
            //判断一些基本条件
            if(sum%2 || maxNum>sum/2)
                return false;
            
            //正式开始动态规划,目标dp[n-1][target]
            int target=sum/2; 
            int n=nums.size();
    
            vector > dp(n,vector(target+1,false));
            //初始化,当target=0时全部true
            for(int i=0;i
  • 474:Ones and Zeros:给定 m 个数字 0 和 n 个数字 1,以及一些由 0-1 构成的字符串,求利用这些数字最多可以构 成多少个给定的字符串,字符串只可以构成一次。

    三维压缩成二维的典例,思路总体不难。

    int findMaxForm(vector& strs, int m, int n) {
        vector> dp(m+1,vector(n+1,0));
        for(auto& str:strs){
            int x=count(str.begin(),str.end(),'0');
            int y=count(str.begin(),str.end(),'1');
            for(int i=m;i>=0;i--)
            for(int j=n;j>=0;j--){
                if(x<=i && y<=j)
                    dp[i][j]=max(dp[i][j],dp[i-x][j-y]+1);
            }
        }
        return dp[m][n];
    }
    
  • 323:Coin Change:给定一些硬币的面额,求最少可以用多少颗硬币组成给定的金额。

    dp 数组应该初始化为 amount + 1, 而不是 -1 或INT_MAX:状态转移方程中有求最小值的操作,如果初始化成 -1 则会导致结果始终为-1,而INT_MAX会导致+1操作溢出。

    int coinChange(vector& coins, int amount) {
        int len=coins.size();
        vector dp(amount+1,amount+1);//用INT_MAX会溢出
    
        dp[0]=0;//初始化,保证放第一个正确,或者初始化
        for(auto& coin:coins)
        	for(int i=0;i<=amount;i++){
                if(i>=coin)
                    dp[i]=min(dp[i],dp[i-coin]+1);//取模运算同样会导致出问题
            }
        return dp[amount]==amount+1? -1 : dp[amount];
    }
    
  • 494目标和:给定一个整数数组 nums 和一个整数 target 。向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个表达式 :例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式 “+2-1” 。返回可以通过上述方法构造的、运算结果等于 target 的不同表达式的数目

    **先来看看我华丽丽的错误解法吧(也是用了动态规划)。**定义dp[i] [j]:前i个数字能构造出的目标和为target的表达式数目。则dp[i+1] [j] =dp[i] [j-nums[i]] + dp[i] [j+nums[i]] ,并将dp[0] [0]初始化为1,其余为0。在计算过程中加一个防御,防止数组访问过界即可。

    乍一看是不是完全没有没有问题!但是!别忘了j+nums[i] 在超过target以后是可以通过减法回退到target的(这就有了下面的1.0改进版本),同样的,j-nums[i] 在小于0之后也是有可能通过加法前进到target的。于是我们发现,dp数组需要根据测试数据整体平移,大大扩大需要测试的子问题target范围……悲伤.jpg

    int findTargetSumWays(vector& nums, int target) {
        int len=nums.size();
        //右侧边界不能只设定成target,而应该是全部的和
        //同理,当求和小于0时是不是也得扩展……要么把数组整体平移,要么寻找其他方法
        //将整个问题转换,看成非常明显的0-1背包
        int sum=accumulate(nums.begin(),nums.end(),0);
        int col=max(sum,target);//最开始是target
        vector > dp(len+1,vector(col+1,0));
        dp[0][0]=1;
    
        for(int i=0;i=nums[i])? dp[i][j-nums[i]] : 0;
                dp[i+1][j] += (j+nums[i]<=col)? dp[i][j+nums[i]] : 0;
            }
        }
        return dp[len][target];
    }
    

    下面让我们忘掉我愚蠢的解法,回到正解。我们知道整个数组的sum值和目标的target值是固定的,于是假设加了负号的值之和为negative,则有sum-2*negative=target,即negative=(sum-target)/2。**此时原问题就转换成了求数组中那些数字求和等于negative。**这不就是0-1背包吗!简简单单AC了……

    int findTargetSumWays(vector& nums, int target) {
        int sum = 0;
        for (int& num : nums)
            sum += num;
        int diff = sum - target;
        if (diff < 0 || diff % 2 != 0)
           return 0;
        int n = nums.size(), neg = diff / 2;
        vector> dp(n + 1, vector(neg + 1));
        dp[0][0] = 1;
        for (int i = 1; i <= n; i++) {
             int num = nums[i - 1];
             for (int j = 0; j <= neg; j++) {
                 dp[i][j] = dp[i - 1][j];
                 if (j >= num) 
                    dp[i][j] += dp[i - 1][j - num];
             }
         }
        return dp[n][neg];
    }
    

另外动态规划还有一类经典题【股票问题】,详见同专栏下的博文《吃透“买卖股票”问题》

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