Leetcode刷题专题打卡 —— 动态规划

动态规划

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

主要思想

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

动态规划法仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量,

一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。

动态规划模板步骤

  • 确定动态规划状态
  • 写出状态转移方程(画出状态转移表)
  • 考虑初始化条件
  • 考虑输出状态
  • 考虑对时间,空间复杂度的优化(Bonus)

例题详解

接下来,我们对每个步骤进行详细的讲解,并给出不同题目中考虑的不同方式,争取让大家吃透动态规划的套路。 我们以最经典的动态规划题目——Leetcode 300.最长上升子序列 为例子。

题目描述

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

示例:

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

说明:

1
2
可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 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小的组数中的索引,可以用如下代码公式表示

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

    这里额外总结几种Python常用的初始化方法:

    • 对于产生一个全为1,长度为n的数组:

      1
      2
      1. dp=[1 for _ in range(n)]
      2. dp=[1]*n
    • 对于产生一个全为0,长度为m,宽度为n的二维矩阵:

      1
      2
      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])这样的形式。

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    def lengthOfLIS(self, nums: List[int]) -> int:
    if not nums:return 0 #判断边界条件
    dp=[1]*len(nums) #初始化dp数组状态
    for i in range(len(nums)):
    for j in range(i):
    if nums[i]>nums[j]: #根据题目所求得到状态转移方程
    dp[i]=max(dp[i],dp[j]+1)
    return max(dp) #确定输出状态

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

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

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

模板总结:

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

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

算法应用

Leetcode 674.最长连续递增序列

题目描述

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

1
2
3
4
5
示例 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
2
3
4
5
6
7
8
9
10
def findLengthOfLCIS(self, nums: List[int]) -> int:
if not nums:return 0 #判断边界条件
dp=[1]*len(nums) #初始化dp数组状态
#注意需要得到前一个数,所以从1开始遍历,否则会超出范围
for i in range(1,len(nums)):
if nums[i]>nums[i-1]:#根据题目所求得到状态转移方程
dp[i]=dp[i-1]+1
else:
dp[i]=1
return max(dp) #确定输出状态

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

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

Leetcode5. 最长回文子串

题目描述

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

1
2
3
4
5
示例 1:

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

解题思路

  • 第一步:确定动态规划状态 与上面两题不同的是,这个题目必须用二维的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。

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

      1
      2
      3
      4
      5
      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是否是回文子串,这样一来就告诉我们子串的起始位置和结束位置,但是由于我们需要找到最长的子串,所以我们优化一下可以只记录起始位置和当前长度(当然你要是喜欢记录终止位置和当前长度也是没问题的)

    1
    2
    3
    4
    5
    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 算法

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    def longestPalindrome(self, s: str) -> str:
    length=len(s)
    if length<2: #判断边界条件
    return s
    dp=[[False for _ in range(length)]for _ in range(length)] #定义dp状态矩阵
    #定义初试状态,这步其实可以省略
    # for i in range(length):
    # dp[i][i]=True

    max_len=1
    start=0 #后续记录回文串初试位置
    for j in range(1,length):
    for i in range(j):
    #矩阵中逐个遍历
    if s[i]==s[j]:
    if j-i<3:
    dp[i][j]=True
    else:
    dp[i][j]=dp[i+1][j-1]
    if dp[i][j]: #记录位置,返回有效答案
    cur_len=j-i+1
    if cur_len>max_len:
    max_len=cur_len
    start=i
    return s[start:start+max_len]

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

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

Leetcode516. 最长回文子序列

题目描述

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

1
2
3
4
5
示例 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
    2
    3
    4
    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]的状态转移直接画成一张二维表格,我们所要做的也就是往这张表中填充所有的状态,进而得到我们想要的结果。如下图:

img

我们用字符串为”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记录会发生变化的左下边信息。所以状态转移方程就变成了:

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    def longestPalindromeSubseq(self, s: str) -> int:
    n=len(s)
    dp=[[0]*n for _ in range(n)] #定义动态规划状态转移矩阵
    for i in range(n): # 初始化对角线,单个字符子序列就是1
    dp[i][i]=1
    for i in range(n,-1,-1): #从右下角开始往上遍历
    for j in range(i+1,n):
    if s[i]==s[j]: #当两个字符相等时,直接子字符串加2
    dp[i][j]= dp[i+1][j-1]+2
    else: #不相等时,取某边最长的字符
    dp[i][j]=max(dp[i][j-1],dp[i+1][j])
    return dp[0][-1] #返回右上角位置的状态就是最长

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

模板总结:

1
2
3
4
5
6
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]=最值(...)

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

Leetcode72. 编辑距离

说到编辑距离(Edit distance),大家可能都比较熟悉。在自然语言处理(Natural Language Processing,又NLP)中,编辑距离是计算文本相似度的一种基本距离计算方式。简单来说,就是只能通过替换,删除,插入操作,将一串字符串变为另一串字符串的操作数。而求最小编辑距离的过程,就是一个典型的动态规划的过程。

如果计算两个字符串的编辑距离,我们可以看做是父问题,那他的子问题自然就是如何求更小字符串之间的编辑距离。那上面提到,动态规划不仅要将父问题拆分成子问题,更要记下子问题的解,以达到节省空间和时间的效果。

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

题目描述

1
2
3
4
5
6
7
8
9
10
11
12
13
你可以对一个单词进行如下三种操作:

插入一个字符
删除一个字符
替换一个字符
示例 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所执行的最少操作次数的值。

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

    关于这个问题的状态转移方程其实很难想到,这里提供的一个方向就是试着举个例子,然后通过例子的变化记录每一步变化得到的最少次数,来找到删除,插入,替换操作的状态转移方程具体应该怎么写。 我们采用从末尾开始遍历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的解法,对于时间方面应该并没有可以优化的方法。

总结起来代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def minDistance(self, word1, word2):
#m,n 表示两个字符串的长度
m=len(word1)
n=len(word2)
#构建二维数组来存储子问题
dp=[[0 for _ in range(n+1)] for _ in range(m+1)]
#考虑边界条件,第一行和第一列的条件
for i in range(n+1):
dp[0][i]=i #对于第一行,每次操作都是前一次操作基础上增加一个单位的操作
for j in range(m+1):
dp[j][0]=j #对于第一列也一样,所以应该是1,2,3,4,5...
for i in range(1,m+1): #对其他情况进行填充
for j in range(1,n+1):
if word1[i-1]==word2[j-1]: #当最后一个字符相等的时候,就不会产生任何操作代价,所以与dp[i-1][j-1]一样
dp[i][j]=dp[i-1][j-1]
else:
dp[i][j]=min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])+1 #分别对应删除,添加和替换操作
return dp[-1][-1] #返回最终状态就是所求最小的编辑距离

动态时间规整

说完NLP里的相似度计算,咱们再来看看语音识别里有没有类似的算法用到动态规划呢?答案是肯定的。这个算法就叫动态时间规整(Dynamic time warping),简称DTW,一听名字是不是感觉就很动态规划。DTW算法是传统语音识别中的重要方法,起初是用于孤立词的语音识别,判断两段语音是否为同一个单词。实际上,只要是时间序列都可以用来计算相似度,不局限于语音识别当中,例如股市的交易策略,手势识别等等场景都有应用。

在语音信号中,有一个很大的问题就是,信号长度并不相等,即使是同一个人说同一个单词,也会有语速上的差别。那这个时候基于欧几里得距离(Euclidean Distance)的方法就不奏效了,但是两个时间序列形状上又非常的相似,于是我们就希望可以通过某种对齐的方式来衡量这两个时间序列的相似性。我们用所有相似点之间的距离和来表示这种相似度,称之为规整路径距离。

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

Leetcode198. 打家劫舍

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

题目描述

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

示例 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
2
3
4
5
6
7
8
9
10
11
12
13
def rob(self, nums):

if(not nums): #特殊情况处理
return 0
if len(nums)==1:
return nums[0]
n=len(nums)
dp=[0]*n #初始化状态转移数组
dp[0]=nums[0] #第一个边界值处理
dp[1]=max(nums[0],nums[1])#第二个边界值处理
for i in range(2,n):
dp[i]=max(dp[i-2]+nums[i],dp[i-1]) #状态转移方程
return dp[-1]

Leetcode213. 打家劫舍 II

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

题目描述

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

示例 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def rob(self, nums: List[int]) -> int:
if not nums:
return 0
elif len(nums)<=2:
return max(nums)
def helper(nums):
if len(nums)<=2:
return max(nums)
dp=[0]*len(nums)
dp[0]=nums[0]
dp[1]=max(nums[0],nums[1])
for i in range(2,len(nums)):
dp[i]=max(dp[i-1],dp[i-2]+nums[i])
return dp[-1]
return max(helper(nums[1:]),helper(nums[:-1]))

写在最后

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

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

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

五分钟学算法的动态规划系列: 浅谈什么是动态规划以及相关的「股票」算法题 有了四步解题法模板,再也不害怕动态规划! (进阶版)有了四步解题法模板,再也不害怕动态规划!

主要参考的Leetcode 优秀题解: 动态规划设计方法&&纸牌游戏讲解二分解法 动态规划、Manacher 算法 编辑距离面试题详解 打家劫舍 II(动态规划,结构化思路,清晰题解)

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2020 chenk
  • 由 帅气的CK本尊 强力驱动
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信