动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策过程最优化的过程。20世纪50年代初,美国数学家贝尔曼(R.Bellman)等人在研究多阶段决策过程的优化问题时,提出了著名的最优化原理,从而创立了动态规划。动态规划的应用极其广泛,包括工程技术、经济、工业生产、军事以及自动化控制等领域,并在背包问题、生产经营问题、资金管理问题、资源分配问题、最短路径问题和复杂系统可靠性问题等中取得了显著的效果
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
1.最优子结构
最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
2.无后效性
无后效性。即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
3.子问题重叠
子问题重叠性质。子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。
• 确定动态规划状态
• 写出状态转移方程(画出状态转移表)
• 考虑初始化条件
• 考虑输出状态
• 考虑对时间,空间复杂度的优化(Bonus)
题目描述:给定一个未经排序的整数数组,找到最长且连续的的递增序列。
示例 1:
输入: [1,3,5,4,7]
输出: 3
解释: 最长连续递增序列是 [1,3,5], 长度为3。
解题思路:
• 第一步:确定动态规划状态 对于这个问题,我们的状态dp[i]也是以nums[i]这个数结尾的最长递增子序列的长度
• 第二步:写出状态转移方程 这个问题,我们需要分两种情况考虑,第一种情况是如果遍历到的数nums[i]后面一个数不是比他大或者前一个数不是比他小,也就是所谓的不是连续的递增,那么这个数列最长连续递增序列就是他本身,也就是长度为1。 第二种情况就是如果满足有递增序列,就意味着当前状态只和前一个状态有关,dp[i]只需要在前一个状态基础上加一就能得到当前最长连续递增序列的长度。总结起来,状态的转移方程可以写成 dp[i]=dp[i-1]+1
• 第三步:考虑初始化条件 和上面最长子序列相似,这个题目的初始化状态就是一个一维的全为1的数组。
• 第四步:考虑输出状态 与上题相似,这个问题输出条件也是求dp数组中最大的数。
• 第五步:考虑是否可以优化 这个题目只需要一次遍历就能求出连续的序列,所以在时间上已经没有可以优化的余地了,空间上来看的话也是一维数组,并没有优化余地。
代码:
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) #确定输出状态
题目描述:给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: "babad"
输出: "bab"
注意: “aba” 也是一个有效答案。
解题思路:
• 第一步:确定动态规划状态 与上面两题不同的是,这个题目必须用二维的dp数组来记录状态,主要原因就是子串有回文的限制。用两个指针来记录子串的位置可以很好的实现子串的回文要求,又因为最后结果需要返回的是子串,这里不同于之前题目的用dp保存长度,我们必须找到具体哪个部分符合回文子串的要求。这里我们定义dp[i][j]表示子串s从i到j是否为回文子串。
• 第二步:写出状态转移方程 首先我们需要知道符合回文的条件:
o 字符串首尾两个字符必须相等,否则肯定不是回文。
o 当字符串首尾两个字符相等时:如果子串是回文,整体就是回文,这里就有了动态规划的思想,出现了子问题;相反,如果子串不是回文,那么整体肯定不是。 对于字符串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。
• 第三步:考虑初始化条件 我们需要建立一个二维的初始状态是False的来保存状态的数组来表示dp,又因为考虑只有一个字符的时候肯定是回文串,所以dp表格的对角线dp[i][i]肯定是True。
• 第四步:考虑输出状态 这里dp表示的是从i到j是否是回文子串,这样一来就告诉我们子串的起始位置和结束位置,但是由于我们需要找到最长的子串,所以我们优化一下可以只记录起始位置和当前长度(当然你要是喜欢记录终止位置和当前长度也是没问题的)
• 第五步:考虑对时间,空间复杂度的优化 对于这个问题,时间和空间都可以进一步优化,对于空间方面的优化:这里采用一种叫中心扩散的方法来进行,而对于时间方面的优化,则是用了Manacher‘s Algorithm(马拉车算法)来进行优化。具体的实现可以参考动态规划、Manacher 算法。
代码:
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]
题目描述:给定一个字符串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,所以可以得到dp[i][j]=1(i=j) 接下来我们来看看 当i>j时,不符合题目要求,不存在子序列,所以直接初始化为0。 当i• 第四步:考虑输出状态
我们想要求最长子序列的时候,我们可以直接看出来dp[0][-1]是最大的值,直接返回这个值就是最后的答案。
• 第五步:考虑对时间,空间复杂度的优化 对于这个题目,同样可以考虑空间复杂度的优化,因为我们在计算dp[i][j]的时候,只用到左边和下边。如果改为用一维数组存储,那么左边和下边的信息也需要存在数组里,所以我们可以考虑在每次变化前用临时变量tmp记录会发生变化的左下边信息。
代码:
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] #返回右上角位置的状态就是最长
题目描述:给定两个单词 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的解法,对于时间方面应该并没有可以优化的方法。
对第二步的补充:对word1的插入操作也就等价于对word2的一个删除操作,而对word1的删除操作也等价于对word2的一个插入操作,因此也就解释了:
对于插入操作,当我们在word1中插入一个和word2一样的字符,那么word2就被匹配了,所以可以直接表示为dp[i][j-1]+1
对于删除操作,直接表示为dp[i-1][j]+1
代码:
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] #返回最终状态就是所求最小的编辑距离
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
题目描述:给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例 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)。
代码:
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]
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
题目描述:给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例 1:
输入: [2,3,2]
输出: 3
解释: 你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
解题思路:
• 第一步:确定动态规划状态
直接定义题目所求的偷窃的最高金额,所以dp[i]表示偷窃第i号房子能得到的最高金额。
• 第二步:写出状态转移方程
和上个题目类似,这个题目不一样的是现在所有房屋都围成一个圈,相比于上个问题又增加了一个限制,这样一来第一个房子和最后一个房子只能选择其中一个偷窃了。所有我们把这个问题拆分成两个问题:
o 偷窃了第一个房子,此时对应的是nums[1:],得到最大的金额value是v1。
o 偷窃了最后一个房子,此时对应的是nums:n-1,得到的最大金额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)。
代码:
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]))