【算法系列(二)】:动态规划

目录

一、基本思想

二、解题思路

三、算法应用

300.最长上升子序列 

674.最长连续递增序列

5. 最长回文子串

516. 最长回文子序列

72. 编辑距离

198. 打家劫舍

213. 打家劫舍 II


一、基本思想

动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。

动态规划常常适用于有重叠子问题最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法

动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题,例如斐波那契数列,如果运用递归的方式来求解会重复计算很多相同的子问题,利用动态规划的思想可以减少计算量。

通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。

动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离等等。求解动态规划的核心问题是穷举因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值。

二、解题思路

动态规划就这么简单,就是穷举就完事了?我看到的动态规划问题都很难啊!

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

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

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

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

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

下面通过斐波那契数列问题和凑零钱问题来详解动态规划的基本原理。前者主要是让你明白什么是重叠子问题(斐波那契数列严格来说不是动态规划问题),后者主要举集中于如何列出状态转移方程。

方法一:暴力递归

这也是大家比较容易想到的方法吧。我们可以直接根据斐波那契数列数学表达式进行递归,具体实现如下:

int fib(int N){
    if(N==1||N==2) return 1;
    return fib(N-1)+fib(N-2);
}

这样写代码虽然简洁易懂,但是十分低效,低效在哪里?如我们要求f(n),那么我们必须先求f(n-1)和f(n-2),依次类推,最后遇到 f(1) 或者 f(2) 的时候,结果已知,就能直接返回结果了。这个计算过程可以看作一个递归树,计算的个数相当于递归树结点的个数,也就是说,对于求解f(n),我们的时间复杂度为 O(2^n)。

观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。

这就是动态规划问题的第一个性质:重叠子问题。下面,我们想办法解决这个问题。

方法二:带数组的递归

明确了问题,其实就已经把问题解决了一半。即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。

int helper(vector<int> nums,int N){
    if(N==1||N==2) return 1;

    if (nums[N] != 0) return nums[N];//已经算过了
    nums[N]=helper(nums,N-1)+helper(nums,N-2);

    return nums[N];
}

int fib1(int N){
    if(N<1) return 0;
    vector<int> nums;
    return helper(nums,N);
}

递归算法的时间复杂度怎么算?子问题个数乘以解决一个子问题需要的时间。

子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是 f(1)f(2)f(3) ... f(20),数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。所以,本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击。

方法三:动态规划算法

int fib2(int N){
    if(N==1||N==2) return 1;
    vector<int> nums(N,0);
    nums[1]=1,nums[2]=1;
    for(int i=3;i<=N;i++){
        nums[i]=nums[i-1]+nums[i-2];
    }

    return nums[N];
}

这里我们使用状态数组来存储每个子问题的最优解,并将将第1、2初始化为1,应为根据斐波那契数列,f(1)=f(2)=1。而如何进行“自底向上”的计算呢?

这里,引出「状态转移方程」这个名词,实际上就是描述问题结构的数学形式:f(n)=f(n-1)+f(n-2)

为啥叫「状态转移方程」?为了听起来高端。你把 f(n) 想做一个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来,这就叫状态转移,仅此而已。

你会发现,上面的几种解法中的所有操作,例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及带数组的递归和状态数组的初始化操作,都是围绕这个方程式的不同表现形式。可见列出「状态转移方程」的重要性,它是解决问题的核心。很容易发现,其实状态转移方程直接代表着暴力解法。

动态规划模板步骤:

  • 确定动态规划状态

  • 写出状态转移方程(画出状态转移表)

  • 考虑初始化条件

  • 考虑输出状态

  • 考虑对时间,空间复杂度的优化(Bonus)

三、算法应用

300.最长上升子序列 

  • 题目描述

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

输入: [10,9,2,5,3,7,101,18]
输出: 4 
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

说明:

可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 O(n2) 。
  • 解题思路:
  • 第一步:确定动态规划状态
    • 是否存在状态转移?

    • 什么样的状态比较好转移,找到对求解问题最方便的状态转移?

      想清楚到底是直接用需要求的,比如长度作为dp保存的变量还是用某个判断问题的状态比如是否是回文子串来作为方便求解的状态

      该题目可以直接用一个一维数组dp来存储转移状态,dp[i]可以定义为以nums[i]这个数结尾的最长递增子序列的长度。举个实际例子,比如在nums[10,9,2,5,3,7,101,18]中,dp[0]表示数字10的最长递增子序列长度,那就是本身,所以为1,对于dp[5]对应的数字7来说的最长递增子序列是[2,5,7](或者[2,3,7])所以dp[5]=3

  • 第二步:写出一个好的状态转移方程
    • 使用数学归纳法思维,写出准确的状态方程 比如还是用刚刚那个nums数组,我们思考一下是如何得到dp[5]=3:既然是递增的子序列,我们只要找到nums[5] (也就是7)前面那些结尾比7小的子序列,然后把7接到最后,就可以形成一个新的递增的子序列,也就是这个新的子序列也就是在找到的前面那些数后面加上7,相当长度加1。当然可能会找到很多不同的子序列,比如刚刚在上面列举的,但是只需要找到长度最长的作为dp[5]的值就行。总结来说就是比较当前dp[i]的长度和dp[i]对应产生新的子序列长度,我们用j来表示所有比i小的组数中的索引,可以用如下代码公式表示
for i in range(len(nums)):
    for j in range(i):
    	if nums[i]>nums[j]:
    		dp[i]=max(dp[i],dp[j]+1)

Tips: 在实际问题中,如果不能很快得出这个递推公式,可以先尝试一步一步把前面几步写出来,如果还是不行很可能就是 dp 数组的定义不够恰当,需要回到第一步重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组。

  • 第三步:考虑初始条件 ​这是决定整个程序能否跑通的重要步骤,当我们确定好状态转移方程,我们就需要考虑一下边界值,边界值考虑主要又分为三个地方:
    • dp数组整体的初始值
    • dp数组(二维)i=0和j=0的地方
    • dp存放状态的长度,是整个数组的长度还是数组长度加一,这点需要特别注意。对于本问题,子序列最少也是自己,所以长度为1,这样我们就可以方便的把所有的dp初始化为1,再考虑长度问题,由于dp[i]代表的是nums[i]​的最长子序列长度,所以并不需要加一。 所以用代码表示就是​dp=[1]*len(nums)​

Tips:还有一点需要注意,找到一个方便的状态转移会使问题变得非常简单。举个例子,对于Leetcode120.三角形最小路径和问题,大多数人刚开始想到的应该是自顶向下的定义状态转移的思路,也就是从最上面的数开始定义状态转移,但是这题优化的解法则是通过定义由下到上的状态转移方程会大大简化问题,同样的对于Leetcode53.最大子序和也是采用从下往上遍历,保证每个子问题都是已经算好的。这个具体我们在题目中会讲到。

  • 第四步:考虑输出状态 主要有以下三种形式,对于具体问题,我们一定要想清楚到底dp数组里存储的是哪些值,最后我们需要的是数组中的哪些值:
    • 返回dp数组中最后一个值作为输出,一般对应二维dp问题。

    • 返回dp数组中最大的那个数字,一般对应记录最大值问题。

    • 返回保存的最大值,一般是Maxval=max(Maxval,dp[i])这样的形式。

Tips:这个公式必须是在满足递增的条件下,也就是nums[i]>nums[j]​的时候才能成立,并不是nums[i]​前面所有数字都满足这个条件的,理解好这个条件就很容易懂接下来在输出时候应该是​max(dp)​而不是​dp[-1]​,原因就是dp数组由于计算递增的子序列长度,所以dp数组里中间可能有值会是比最后遍历的数值大的情况,每次遍历nums[j]所对应的位置都是比nums[i]小的那个数。举个例子,比如nums=[1,3,6,7,9,4,10,5,6],而最后dp=[1,2,3,4,5,3,6,4,5]。 总结一下,最后的结果应该返回dp数组中值最大的数。

  • 第五步:考虑对时间,空间复杂度的优化(Bonus)

切入点: 我们看到,之前方法遍历dp列表需要O(N),计算每个dp[i]需要O(N)的时间,所以总复杂度是O(N^2)

前面遍历dp列表的时间复杂度肯定无法降低了,但是我们看后面在每轮遍历[0,i]dp[i]​元素的时间复杂度可以考虑设计状态定义,使得整个dp为一个排序列表,这样我们自然想到了可以利用二分法来把时间复杂度降到了O(NlogN)。如果大家感兴趣的话详细的解题步骤可以看好心人写的二分方法+动态规划详解

  • C++算法实现
int findNumberOfLIS(vector<int>& nums) {
    vector<int> dp(nums.size(),1);

    for(int i=0;i<nums.size();++i){
        for(int j=0;j<i;++j){
            if(nums[i]>nums[j]){
                dp[i]=max(dp[i],dp[j]+1);
            }
        }
    }

    int max=*max_element(dp.begin(),dp.end());

    return max;
}

模板总结:

        for i in range(len(nums)):
            for j in range(i):
                    dp[i]=最值(dp[i],dp[j]+...)

对于子序列问题,很多也都是用这个模板来进行解题,比如Leetcode53.最大子序和。此外,其他情况的子序列问题可能需要二维的dp数组来记录状态,比如:Leetcode5. 最长回文子串(下面会讲到) 、 Leetcode1143. 最长公共子序列 (当涉及到两个字符串/数组时) 如果你觉得刚刚那题有点难的话,不如我们从简单一点的题目开始理解一下这类子序列问题。接下来所有题目我们都按照那五个步骤考虑

674.最长连续递增序列

  • 题目描述

给定一个未经排序的整数数组,找到最长且连续的的递增序列。

示例 1:
输入: [1,3,5,4,7]
输出: 3
解释: 最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为5和7在原数组里被4隔开。 
  • 解题思路

这道题是不是一眼看过去和上题非常的像,没错了,这个题目最大的不同就是连续两个字,这样就让这个问题简单很多了,因为如果要求连续的话,那么就不需要和上题一样遍历两遍数组,只需要比较前后的值是不是符合递增的关系。

  • 第一步:确定动态规划状态 对于这个问题,我们的状态dp[i]也是以nums[i]这个数结尾的最长递增子序列的长度

  • 第二步:写出状态转移方程 这个问题,我们需要分两种情况考虑,第一种情况是如果遍历到的数nums[i]后面一个数不是比他大或者前一个数不是比他小,也就是所谓的不是连续的递增,那么这个数列最长连续递增序列就是他本身,也就是长度为1。 第二种情况就是如果满足有递增序列,就意味着当前状态只和前一个状态有关,dp[i]只需要在前一个状态基础上加一就能得到当前最长连续递增序列的长度。总结起来,状态的转移方程可以写成 dp[i]=dp[i-1]+1

  • 第三步:考虑初始化条件 和上面最长子序列相似,这个题目的初始化状态就是一个一维的全为1的数组。

  • 第四步:考虑输出状态 与上题相似,这个问题输出条件也是求dp数组中最大的数。

  • 第五步:考虑是否可以优化 这个题目只需要一次遍历就能求出连续的序列,所以在时间上已经没有可以优化的余地了,空间上来看的话也是一维数组,并没有优化余地。

  • C++算法实现
int findLengthOfLCIS(vector<int>& nums) {
    vector<int> dp(nums.size(),1);
    
    for(int i=1;i<nums.size();++i){
        if(nums[i]>=nums[i-1]){
            dp[i]=dp[i-1]+1;
        }
    }
    
    return *max_element(dp.begin(),dp.end());
}

总结: 通过这个题目和例题的比较,我们需要理清子序列和子数组(连续序列)的差别,前者明显比后者要复杂一点,因为前者是不连续的序列,后者是连续的序列,从复杂度来看也很清楚能看到即使穷举子序列也比穷举子数组要复杂很多。 

5. 最长回文子串

  • 题目描述

给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

示例 1:

输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。

示例 2:

输入: "cbbd"
输出: "bb"
  • 解题思路
  • 第一步:确定动态规划状态 与上面两题不同的是,这个题目必须用二维的dp数组来记录状态,主要原因就是子串有回文的限制。用两个指针来记录子串的位置可以很好的实现子串的回文要求,又因为最后结果需要返回的是子串,这里不同于之前题目的用dp保存长度,我们必须找到具体哪个部分符合回文子串的要求。这里插一句,其实也有求回文子串长度的题目Leetcode516. 最长回文子序列,如果有兴趣可以看一下。这里我们定义dp[i][j]表示子串s从i到j是否为回文子串。

  • 第二步:写出状态转移方程 首先我们需要知道符合回文的条件:

    • 字符串首尾两个字符必须相等,否则肯定不是回文。

    • 当字符串首尾两个字符相等时:如果子串是回文,整体就是回文,这里就有了动态规划的思想,出现了子问题;相反,如果子串不是回文,那么整体肯定不是。 对于字符串s,s[i,j]的子串是s[i+1,j-1],如果子串只有本身或者空串,那肯定是回文子串了,所以我们讨论的状态转移方程不是对于j-1-(i+1)+1<2的情况(整理得j-i<3),当s[i]s[j]相等并且j-i<3时,我们可以直接得出dp[i][j]是True。

综上所述,可以得到状态转移方程

if s[i]==s[j]:
	if j-i<3:
		dp[i][j]=True
	else:
		dp[i][j]=dp[i+1][j-1]
  • 第三步:考虑初始化条件 我们需要建立一个二维的初始状态是False的来保存状态的数组来表示dp,又因为考虑只有一个字符的时候肯定是回文串,所以dp表格的对角线dp[i][i]肯定是True。
  • 第四步:考虑输出状态 这里dp表示的是从ij是否是回文子串,这样一来就告诉我们子串的起始位置和结束位置,但是由于我们需要找到最长的子串,所以我们优化一下可以只记录起始位置和当前长度(当然你要是喜欢记录终止位置和当前长度也是没问题的)
if dp[i][j]: #只要dp[i][j]成立就表示是回文子串,然后我们记录位置,返回有效答案
    cur_len=j-i+1
    if cur_len>max_len:
    	max_len=cur_len
    	start=i
  • 第五步:考虑对时间,空间复杂度的优化 对于这个问题,时间和空间都可以进一步优化,对于空间方面的优化:这里采用一种叫中心扩散的方法来进行,而对于时间方面的优化,则是用了Manacher‘s Algorithm(马拉车算法)来进行优化。具体的实现可以参考动态规划、Manacher 算法
  • C++算法实现
string longestPalindrome(string s) {
    if(s.size()<2){
        return s;
    }

    vector<vector<int>> dp(s.size(),vector<int>(s.size(),0));
    //初始化
    for(int i=0;i<s.size();++i){
        dp[i][i]=1;
    }

    int max_len=1;
    int start=0;
    for(int i=1;i<s.size();++i){
        for(int j=0;j<i;++j){
            if(s[i]==s[j]){
                if(i-j<3){
                    dp[j][i]=1;
                }else{
                    dp[j][i]=dp[j+1][i-1];
                }
            }
            if(dp[j][i]){
                int cur_len=i-j+1;
                if(cur_len>max_len){
                    max_len=cur_len;
                    start=j;
                }
            }

        }
    }

    return string(s.begin()+start,s.begin()+start+max_len);
}

总结:这个是一个二维dp的经典题目,需要注意的就是定义dp数组的状态是什么,这里不用长度作为dp值而用是否是回文子串这个状态来存储也是一个比较巧妙的方法,使得题目变得容易理解。

看了这么多套路相信你也对动态规划有点感觉了,这里再介绍一个求长度的子序列问题。

516. 最长回文子序列

  • 题目描述

给定一个字符串s,找到其中最长的回文子序列。可以假设s的最大长度为1000。

示例 1:
输入:
"bbbab"
输出:
4
  • 解题思路

这个问题和上面的例题也非常相似,直接套用动态规划套路也可以很快解决出来:

  • 第一步:确定动态规划状态 这里求的是最长子串的长度,所以我们可以直接定义一个二维的dp[i][j]来表示字符串第i个字符到第j个字符的长度,子问题也就是每个子回文字符串的长度。

  • 第二步:写出状态转移方程 我们先来具体分析一下整个题目状态转移的规律。对于d[i][j],我们根据上题的分析依然可以看出, 当s[i]s[j]相等时,s[i+1...j-1]这个字符串加上2就是最长回文子序列; 当s[i]s[j]不相等时,就说明可能只有其中一个出现在s[i,j]的最长回文子序列中,我们只需要取s[i-1,j-1]加上s[i]或者s[j]的数值中较大的; 综上所述,状态转移方程也就可以写成:

    if s[i]==s[j]:
         dp[i][j]= dp[i+1][j-1]+2
    else:
    	 dp[i][j]=max(dp[i][j-1],dp[i+1][j])

    但是问题来了,具体我们应该怎么求每个状态的值呢?这里介绍一种利用状态转移表法写出状态转移方程,我们通过把dp[i][j]的状态转移直接画成一张二维表格,我们所要做的也就是往这张表中填充所有的状态,进而得到我们想要的结果。如下图:

 我们用字符串为"cbbd"作为输入来举例子,每次遍历就是求出右上角那些红色的值,通过上面的图我们会发现,按照一般的习惯都会先计算第一行的数值,但是当我们计算dp[0,2]的时候,我们会需要dp[1,2],按照这个逻辑,我们就可以很容易发现遍历从下往上遍历会很方便计算。

  • 第三步:考虑初始化条件 很明显看出来的当只有一个字符的时候,最长回文子序列就是1,所以可以得到dp[i][j]=1(i=j) 接下来我们来看看 当i>j时,不符合题目要求,不存在子序列,所以直接初始化为0。 当i<j时,每次计算表中对应的值就会根据前一个状态的值来计算。

  • 第四步:考虑输出状态

    我们想要求最长子序列的时候,我们可以直接看出来dp[0][-1]是最大的值,直接返回这个值就是最后的答案。

  • 第五步:考虑对时间,空间复杂度的优化 对于这个题目,同样可以考虑空间复杂度的优化,因为我们在计算dp[i][j]的时候,只用到左边和下边。如果改为用一维数组存储,那么左边和下边的信息也需要存在数组里,所以我们可以考虑在每次变化前用临时变量tmp记录会发生变化的左下边信息。所以状态转移方程就变成了:

if s[i] == s[j]:
    tmp, dp[j] = dp[j], tmp + 2
else:
    dp[j] =max(dp[j],dp[j-1])

 如果想看详细的,可以参考:空间压缩优化解法

  •  C++算法实现
int longestPalindromeSubseq(string s) {
    int len=s.size();
    vector<vector<int>> dp(len,vector<int>(len,0));

    for(int i=0;i<len;++i) dp[i][i]=1;

    for(int i=len-1;i>=0;--i){
        for(int j=i+1;j<len;j++){
            if(s[i]==s[j]){
                dp[i][j]=dp[i+1][j-1]+2;
            }else{
                dp[i][j]=max(dp[i+1][j],dp[i][j-1]);
            }
        }
    }

    return dp[0][len-1];
}

总结:对于二维的数组的动态规划,采用了画状态转移表的方法来得到输出的状态,这种方法更加直观能看出状态转移的具体过程,同时也不容易出错。当然具体选择哪种方法则需要根据具体题目来确定,如果状态转移方程比较复杂的利用这种方法就能简化很多。

模板总结:

        for i in range(len(nums)):
            for j in range(n):
            	if s[i]==s[j]:
                    dp[i][j]=dp[i][j]+...
                else:
                	dp[i][j]=最值(...)

当然,动态规划除了解决子序列问题,也可以用来解决其他实际的问题,接下来我们来看一道动态规划的高频面试题,也是实际开发中很常用的。

72. 编辑距离

  • 题目描述
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
示例 1:
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
  • 解题思路
    • 第一步:确定动态规划状态 这个题目涉及到两个字符串,所以我们最先想到就是用两维数组来保存转移状态,定义dp[i][j]为字符串word1长度为i和字符串word2长度为j时,word1转化成word2所执行的最少操作次数的值。
    • 第二步:写出状态转移方程 关于这个问题的状态转移方程其实很难想到,这里提供的一个方向就是试着举个例子,然后通过例子的变化记录每一步变化得到的最少次数,来找到删除,插入,替换操作的状态转移方程具体应该怎么写。 我们采用从末尾开始遍历word1word2, 当word1[i]等于word2[j]时,说明两者完全一样,所以ij指针可以任何操作都不做,用状态转移式子表示就是dp[i][j]=dp[i-1][j-1],也就是前一个状态和当前状态是一样的。 当word1[i]word2[j]不相等时,就需要对三个操作进行递归了,这里就需要仔细思考状态转移方程的写法了。 对于插入操作,当我们在word1中插入一个和word2一样的字符,那么word2就被匹配了,所以可以直接表示为dp[i][j-1]+1 对于删除操作,直接表示为dp[i-1][j]+1 对于替换操作,直接表示为dp[i-1][j-1]+1 所以状态转移方程可以写成min(dp[i][j-1]+1,dp[i-1][j]+1,dp[i-1][j-1]+1)​

    • 第三步:考虑初始化条件 我们还是利用dp转移表法来找到状态转移的变化(读者可以自行画一张dp表,具体方法在求最长子序列中已经演示过了),这里我们用空字符串来额外加入到word1和word2中,这样的目的是方便记录每一步操作,例如如果其中一个是空字符串,那么另外一个字符至少的操作数都是1,就从1开始计数操作数,以后每一步都执行插入操作,也就是当i=0时,dp[0][j]=j,同理可得,如果另外一个是空字符串,则对当前字符串执行删除操作就可以了,也就是dp[i][0]=i​

    • 第四步:考虑输出状态 在转移表中我们可以看到,可以从左上角一直遍历到左下角的值,所以最终的编辑距离就是最后一个状态的值,对应的就是dp[-1][-1]​

    • 第五步:考虑对时间,空间复杂度的优化 和上题一样,这里由于dp[i][j]只和dp表中附近的三个状态(左边,右边和左上边)有关,所以同样可以进行压缩状态转移的空间存储,如果觉得有兴趣可以参考@Lyncien的解法,对于时间方面应该并没有可以优化的方法。

  •  C++算法实现
int minDistance(string word1, string word2) {
    int m=word1.size(),n=word2.size();
    vector<vector<int>> dp(m+1,vector<int>(n+1,0));
    for(int i=1;i<=m;++i){
        dp[i][0]=i;
    }
    for(int j=1;j<=n;++j){
        dp[0][j]=j;
    }

    for(int i=1;i<=m;++i){
        for(int j=1;j<=n;++j){
            if(word1[i]==word2[j]) dp[i][j]=dp[i-1][j-1];
            else{
                dp[i][j]=min({dp[i][j-1],dp[i-1][j],dp[i-1][j-1]});
            }
        }
    }

}

代码详解:

(1)word1[i]==word2[j]

if(word1[i]==word2[j]) 
    dp[i][j]=dp[i-1][j-1];
# 解释:
# 本来就相等,不需要任何操作
# word1[0..i] 和 word2[0..j] 的最小编辑距离等于
# word1[0..i-1] 和 word2[0..j-1] 的最小编辑距离
# 也就是说 dp(i, j) 等于 dp(i-1, j-1)

(2)word1[i] !=word2[j]

dp[i][j-1]+1   # 插入
# 解释:
# 我直接在 word1[i] 插入一个和 word2[j] 一样的字符
# 那么 word2[j] 就被匹配了,前移 j,继续跟 i 对比
# 别忘了操作数加一
dp[i - 1, j] + 1,    # 删除
# 解释:
# 我直接把 word1[i] 这个字符删掉
# 前移 i,继续跟 j 对比
# 操作数加一
dp[i - 1, j - 1] + 1 # 替换
# 解释:
# 我直接把 word1[i] 替换成 word2[j],这样它俩就匹配了
# 同时前移 i,j 继续对比
# 操作数加一

如果上面的题目看起来还是有点吃力的话,接下我们来来看轻松一点的题目,下面的题目和斐波那契数列求解类似,既可用迭代也可用动态规划做。

198. 打家劫舍

  • 题目描述
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4 。
  •  解题思路
    • 第一步:确定动态规划状态 直接定义题目所求的偷窃的最高金额,所以dp[i]表示偷窃第i号房子能得到的最高金额。
    • 第二步:写出状态转移方程 如果我们不考虑限制条件相邻两个房子不能抢,那么问题就很简单。想得到第i个房间偷窃到的最高金额的时候,我们会考虑子问题前i-1​间的最高金额dp[i-1],然后再加上当前房间的金额,所以最后可以表达为dp[i]=dp[i-1]+nums[i]。 需要注意的是,这里限制了相邻两个房子是不能抢的,接下来我们就要考虑两种情况。 如果抢了第i个房间,那么第i-1​肯定是不能抢的,这个时候需要再往前一间,用第i-2间的金额加上当前房间的金额,得到的状态转移方程是dp[i]=dp[i-2]+nums[i]。 如果没有抢第i​个房间,那么肯定抢了第i-1间的金额,所以直接有dp[i]=dp[i-1]。最后综合一下两种情况,就可以很快得到状态转移方程:dp[i]=max(dp[i-2]+nums[i],dp[i-1])​
    • 第三步:考虑初始化条件 初始化条件需要考虑第一个房子和第二个房子,之后的房子都可以按照规律直接求解,当我们只有一个房子的时候,自然只抢那间房子,当有两间房的时候,就抢金额较大的那间。综合起来就是dp[0]=nums[0],dp[1]=max(nums[0],nums[1])​
    • 第四步:考虑输出状态 直接返回状态转移数组的最后一个值就是所求的最大偷窃金额。
    • 第五步:考虑对时间,空间复杂度的优化 时间复杂度为O(N)不能再优化了,空间复杂度方面如果用动态规划是不能优化,但是如果用迭代的方法只存储临时变量来记录每一步计算结果,这样可以降到O(1)。
  • C++算法实现
int rob(vector<int>& nums) {
    vector<int> dp(nums.size(),0);
    dp[0]=nums[0];
    dp[1]=max(nums[0],nums[1]);
    
    for(int i=2;i<nums.size();++i){
        dp[i]=max(dp[i-2]+nums[i],dp[i-1]);
    }
    
    return dp[nums.size()-1];
}

213. 打家劫舍 II

  • 题目描述
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

输入: [2,3,2]
输出: 3
解释: 你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
  • 解题思路
    • 第一步:确定动态规划状态 直接定义题目所求的偷窃的最高金额,所以dp[i]表示偷窃第i号房子能得到的最高金额。
    • 第二步:写出状态转移方程
      • 偷窃了第一个房子,此时对应的是nums[1:],得到最大的金额value是v1偷窃了最后一个房子,此时对应的是nums[:n-1](其中n是所有房子的数量),得到的最大金额value是v2。 最后的结果就是取这两种情况的最大值,即max(v1,v2)
      • 和上个题目类似,这个题目不一样的是现在所有房屋都围成一个圈,相比于上个问题又增加了一个限制,这样一来第一个房子和最后一个房子只能选择其中一个偷窃了。所有我们把这个问题拆分成两个问题:每个子问题就和上题是一样的了,所以可以直接得到状态转移方程还是dp[i]=max(dp[i-2]+nums[i],dp[i-1])
    • 第三步:考虑初始化条件 初始化一个房子和两个房子的情况就是dp[0]=nums[0],dp[1]=max(nums[0],nums[1])

    • 第四步:考虑输出状态 直接返回状态转移数组的最后一个值就是所求的最大偷窃金额。

    • 第五步:考虑对时间,空间复杂度的优化 时间复杂度为O(N)不能再优化了,空间复杂度方面如果用动态规划是不能优化,但是如果用迭代的方法只存储临时变量来记录每一步计算结果,这样可以降到O(1)。

  • C++算法实现
int robRange(vector<int> &nums,int left,int right){
    int len=right-left;
    if(len==1) return nums[left];    
    if(len==2) return max(nums[left],nums[left+1]);
    
    vector<int> dp(len,0);
    dp[0]=nums[left];
    dp[1]=max(nums[left],nums[left+1]);
    
    for(int i=2;i<len;++i){
        dp[i]=max(dp[i-1],dp[i-2]+nums[left+i]);
    }
    
    return dp[len-1];
}

int rob(vector<int>& nums) {  
    int len=nums.size();
    if(len==0) return 0;
    if(len==1) return nums[0];    
    if(len==2) return max(nums[0],nums[1]);
    
    return max(robRange(nums,1,len),
              robRange(nums,0,len-1));
    
}

参考链接:

https://leetcode-cn.com/tag/dynamic-programming/

动态规划详解

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值