vlambda博客
学习文章列表

一文彻底理解动态规划套路


动态规划

动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题,例如斐波那契数列,如果运用递归的方式来求解会重复计算很多相同的子问题,利用动态规划的思想可以减少计算量。通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。同时动态规划在各种AI算法中也是非常常见,比如自然语言处理中的编辑距离,这个算法是计算文本相似度的一种基本距离计算方式,编辑距离越小说明文本越相似,反之则差异越大。例如在拼写纠错中,就是通过计算比较接近的编辑距离来进行纠错。再比如机器学习中非常有代表性的维特比算法,这个算法主要来寻找最有可能产生观测事件序列的维特比路径——隐含状态序列,特别是在马尔可夫信息源上下文和隐马尔可夫模型中。该算法在分词,词性标注,命名实体识别等方面有很重要的运用。

动态规划模板步骤:

  1. 确定动态规划状态

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

  3. 考虑初始化条件

  4. 考虑输出状态

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

例题详解

接下来,我们对每个步骤进行详细的讲解,并给出不同题目中考虑的不同方式,争取让大家吃透动态规划的套路。我们以最经典的动态规划题目——Leetcode 300.最长上升子序列 为例子第一步:拿到一个题目我们首先要确定这个问题是否可以用动态规划来解决,关键就是看这个问题

  • 是否存在状态转移

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

对于第一个问题可能大家都很好确定,平时遇到问题也基本能确定这个题目是否可以用动态规划来解决,但是对于第二个问题,有时候需要想清楚到底是直接用需要求的比如长度作为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$。

总结一下,这步主要做的事情就是定义dp这个数组。

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

 
   
   
 
  1. for i in range(len(nums)):

  2. for j in range(i):

  3. if nums[i]>nums[j]:

  4. 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.最大子序和也是采用从下往上遍历,保证每个子问题都是已经算好的。这个具体我们在题目中会讲到。

这里额外总结几种Python常用的初始化方法:对于产生一个全为1,长度为n的数组:

  1. dp=[1 for _ in range(n)]

  2. dp=[1]*n

对于产生一个全为0,长度为m,宽度为n的二维矩阵:

  1. dp=[[0 for _ in range(n)] for _ in range(m)]

  2. dp=[[0]*n for _ in range(m)]

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

  • 返回dp数组中最后一个值作为输出,一般对应二维dp问题。

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

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

这里有一个要注意的,这个公式必须是在满足递增的条件下,也就是$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数组中值最大的数。

最后加上考虑数组是否为空的判断条件,下面是该问题完整的代码:

 
   
   
 
  1. def lengthOfLIS(self, nums: List[int]) -> int:

  2. if not nums:return 0 #判断边界条件

  3. dp=[1]*len(nums) #初始化dp数组状态

  4. for i in range(len(nums)):

  5. for j in range(i):

  6. if nums[i]>nums[j]: #根据题目所求得到状态转移方程

  7. dp[i]=max(dp[i],dp[j]+1)

  8. return max(dp) #确定输出状态

到了这里,我们发现我们已经把这道题目做出来了,但是别忘了还有最后一步;

第五步:当我们完成前面四个步骤的时候,对于动态规划还有一个很重要的问题就是优化问题,基本上大多数动态规划的问题都是可以优化的,对于本问题,空间上优化几乎不太可能了(但是很多二维的dp问题都可以考虑在空间上优化,比如把dp数组降维)另外一个方面就是时间问题上的优化了。这个问题恰好可以进行优化。切入点:我们看到,之前方法遍历dp列表需要$O(N)$,计算每个$dp[i]$需要$O(N)$的时间,所以总复杂度是$O(N^2)$

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

模板总结:

 
   
   
 
  1. for i in range(len(nums)):

  2. for j in range(i):

  3. dp[i]=最值(dp[i],dp[j]+...)

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

算法应用

Leetcode 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数组中最大的数。

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

综上所述,可以很容易得到最后的代码:

 
   
   
 
  1. def findLengthOfLCIS(self, nums: List[int]) -> int:

  2. if not nums:return 0 #判断边界条件

  3. dp=[1]*len(nums) #初始化dp数组状态

  4. #注意需要得到前一个数,所以从1开始遍历,否则会超出范围

  5. for i in range(1,len(nums)):

  6. if nums[i]>nums[i-1]:#根据题目所求得到状态转移方程

  7. dp[i]=dp[i-1]+1

  8. else:

  9. dp[i]=1

  10. return max(dp) #确定输出状态

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

承接上面的话题,我们接下来继续来看一个子序列问题,这次是另外一种涉及二维状态的题目。

Leetcode5. 最长回文子串

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

示例 1:

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

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

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

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

  2. 当字符串首尾两个字符相等时:如果子串是回文,整体就是回文,这里就有了动态规划的思想,出现了子问题;相反,如果子串不是回文,那么整体肯定不是。对于字符串$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。

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

 
   
   
 
  1. if s[i]==s[j]:

  2. if j-i<3:

  3. dp[i][j]=True

  4. else:

  5. dp[i][j]=dp[i+1][j-1]

第三步:考虑初始化条件。我们需要建立一个二维的初始状态是False的来保存状态的数组来表示dp,又因为考虑只有一个字符的时候肯定是回文串,所以dp表格的对角线$dp[i][i]$肯定是True。

第四步:考虑输出状态 这里dp表示的是从$i$到$j$是否是回文子串,这样一来就告诉我们子串的起始位置和结束位置,但是由于我们需要找到最长的子串,所以我们优化一下可以只记录起始位置和当前长度(当然你要是喜欢记录终止位置和当前长度也是没问题的)

 
   
   
 
  1. if dp[i][j]: #只要dp[i][j]成立就表示是回文子串,然后我们记录位置,返回有效答案

  2. cur_len=j-i+1

  3. if cur_len>max_len:

  4. max_len=cur_len

  5. start=i

第五步:考虑对时间,空间复杂度的优化 对于这个问题,时间和空间都可以进一步优化,对于空间方面的优化:这里采用一种叫中心扩散的方法来进行,而对于时间方面的优化,则是用了Manacher‘s Algorithm(马拉车算法)来进行优化。具体的实现可以参考动态规划、Manacher 算法

这里给出比较容易理解的经典方法的代码:

 
   
   
 
  1. def longestPalindrome(self, s: str) -> str:

  2. length=len(s)

  3. if length<2: #判断边界条件

  4. return s

  5. dp=[[False for _ in range(length)]for _ in range(length)] #定义dp状态矩阵

  6. #定义初试状态,这步其实可以省略

  7. # for i in range(length):

  8. # dp[i][i]=True


  9. max_len=1

  10. start=0 #后续记录回文串初试位置

  11. for j in range(1,length):

  12. for i in range(j):

  13. #矩阵中逐个遍历

  14. if s[i]==s[j]:

  15. if j-i<3:

  16. dp[i][j]=True

  17. else:

  18. dp[i][j]=dp[i+1][j-1]

  19. if dp[i][j]: #记录位置,返回有效答案

  20. cur_len=j-i+1

  21. if cur_len>max_len:

  22. max_len=cur_len

  23. start=i

  24. return s[start:start+max_len]

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

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

Leetcode516. 最长回文子序列

给定一个字符串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]$的数值中较大的;综上所述,状态转移方程也就可以写成:

 
   
   
 
  1. if s[i]==s[j]:

  2. dp[i][j]= dp[i+1][j-1]+2

  3. else:

  4. 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

第四步:考虑输出状态

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

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

 
   
   
 
  1. if s[i] == s[j]:

  2. tmp, dp[j] = dp[j], tmp + 2

  3. else:

  4. dp[j] =max(dp[j],dp[j-1])

这里给出基本版的实现代码,如果需要优化后的可以看空间压缩优化解法

 
   
   
 
  1. def longestPalindromeSubseq(self, s: str) -> int:

  2. n=len(s)

  3. dp=[[0]*n for _ in range(n)] #定义动态规划状态转移矩阵

  4. for i in range(n): # 初始化对角线,单个字符子序列就是1

  5. dp[i][i]=1

  6. for i in range(n,-1,-1): #从右下角开始往上遍历

  7. for j in range(i+1,n):

  8. if s[i]==s[j]: #当两个字符相等时,直接子字符串加2

  9. dp[i][j]= dp[i+1][j-1]+2

  10. else: #不相等时,取某边最长的字符

  11. dp[i][j]=max(dp[i][j-1],dp[i+1][j])

  12. return dp[0][-1] #返回右上角位置的状态就是最长

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

模板总结:

 
   
   
 
  1. for i in range(len(nums)):

  2. for j in range(n):

  3. if s[i]==s[j]:

  4. dp[i][j]=dp[i][j]+...

  5. else:

  6. dp[i][j]=最值(...)

当然,动态规划除了解决子序列问题,也可以用来解决其他实际的问题,比如之前提到过的各种AI的经典算法,接下来我们来看一道动态规划的高频面试题,也是实际开发中很常用的。

Leetcode72. 编辑距离

给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

插入一个字符 删除一个字符 替换一个字符 示例 1:

输入: word1 = "horse", word2 = "ros" 输出: 3 解释: horse -> rorse (将 'h' 替换为 'r') rorse -> rose (删除 'r') rose -> ros (删除 'e')

第一步:确定动态规划状态

这个题目涉及到两个字符串,所以我们最先想到就是用两维数组来保存转移状态,定义$dp[i][j]$为字符串word1长度为$i$和字符串word2长度为$j$时,word1转化成word2所执行的最少操作次数的值。

第二步:写出状态转移方程

关于这个问题的状态转移方程其实很难想到,这里提供的一个方向就是试着举个例子,然后通过例子的变化记录每一步变化得到的最少次数,来找到删除,插入,替换操作的状态转移方程具体应该怎么写。我们采用从末尾开始遍历$word1$和$word2$, 当$word1[i]$等于$word2[j]$时,说明两者完全一样,所以$i$和$j$指针可以任何操作都不做,用状态转移式子表示就是$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的解法,对于时间方面应该并没有可以优化的方法。

总结起来代码如下:

 
   
   
 
  1. def minDistance(self, word1, word2):

  2. #m,n 表示两个字符串的长度

  3. m=len(word1)

  4. n=len(word2)

  5. #构建二维数组来存储子问题

  6. dp=[[0 for _ in range(n+1)] for _ in range(m+1)]

  7. #考虑边界条件,第一行和第一列的条件

  8. for i in range(n+1):

  9. dp[0][i]=i #对于第一行,每次操作都是前一次操作基础上增加一个单位的操作

  10. for j in range(m+1):

  11. dp[j][0]=j #对于第一列也一样,所以应该是1,2,3,4,5...

  12. for i in range(1,m+1): #对其他情况进行填充

  13. for j in range(1,n+1):

  14. if word1[i-1]==word2[j-1]: #当最后一个字符相等的时候,就不会产生任何操作代价,所以与dp[i-1][j-1]一样

  15. dp[i][j]=dp[i-1][j-1]

  16. else:

  17. dp[i][j]=min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])+1 #分别对应删除,添加和替换操作

  18. return dp[-1][-1] #返回最终状态就是所求最小的编辑距离

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

Leetcode198. 打家劫舍

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

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

示例 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)$。

这里给出动态规划版本的实现代码:

 
   
   
 
  1. def rob(self, nums):


  2. if(not nums): #特殊情况处理

  3. return 0

  4. if len(nums)==1:

  5. return nums[0]

  6. n=len(nums)

  7. dp=[0]*n #初始化状态转移数组

  8. dp[0]=nums[0] #第一个边界值处理

  9. dp[1]=max(nums[0],nums[1])#第二个边界值处理

  10. for i in range(2,n):

  11. dp[i]=max(dp[i-2]+nums[i],dp[i-1]) #状态转移方程

  12. return dp[-1]

Leetcode213. 打家劫舍 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)$。

最后的代码实现:

 
   
   
 
  1. def rob(self, nums: List[int]) -> int:

  2. if not nums:

  3. return 0

  4. elif len(nums)<=2:

  5. return max(nums)

  6. def helper(nums):

  7. if len(nums)<=2:

  8. return max(nums)

  9. dp=[0]*len(nums)

  10. dp[0]=nums[0]

  11. dp[1]=max(nums[0],nums[1])

  12. for i in range(2,len(nums)):

  13. dp[i]=max(dp[i-1],dp[i-2]+nums[i])

  14. return dp[-1]

  15. return max(helper(nums[1:]),helper(nums[:-1]))

写在最后:

动态规划是算法中比较难的类型,但是其实主要是掌握一种思维,有了这种思维,其实很难的问题都能一步一步解决好。最后再推荐一些比较优质的动态规划文章。

掌握动态规划,助你成为优秀的算法工程师

推荐MIT的动态规划练习资料,这份资料通过动态规划经典的问题让我们很清晰的了解到这个算法的魅力所在,对于新手入门动态规划是一个很不错的资料。Dynamic Programming Practice Problems

五分钟学算法的动态规划系列:

主要参考的Leetcode 优秀题解:动态规划设计方法&&纸牌游戏讲解二分解法

动态规划、Manacher 算法

编辑距离面试题详解

打家劫舍 II(动态规划,结构化思路,清晰题解)