Task 02:动态规划
动态规划
- 适用条件
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所消耗的时间远远少于朴素解法。 - 动态规划主要思想
若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题,例如斐波那契数列,如果运用递归的方式来求解会重复计算很多相同的子问题,利用动态
规划的思想可以减少计算量。
动态规划法仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量,一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。 - 动态规划模版步骤:
- 确定动态规划状态
- 写出状态转移方程(画出状态转移表)
- 考虑初始化条件
- 考虑输出状态
- 考虑对时间,空间复杂度的优化(Bonus)
例题:leetcode 300. 最长上升子序列
题目描述
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
说明:
可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 O(n2) 。
进阶:
你能将算法的时间复杂度降低到 O(n log n) 吗?
解题思路
第一步:确定动态规划状态
- 是否存在状态转移?
- 该题目可以直接用一个一维数组 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)
第三步:考虑初始条件
这是决定整个程序能否跑通的重要步骤,当我们确定好状态转移方程,我们就需要考虑一下边界值,边界值考虑主要又分为三个地方:
- dp数组整体的初始值
- dp数组(二维)i=0和j=0的地方
- dp存放状态的长度,是整个数组的长度还是数组长度加一,这点需要特别注意。对于本问题,子序列最少也是自己,所以长度为1,这样我们就可以方便的把所有的 dp 初始化为1,再考虑长 度问题,由于 dp[i] 代表的是 nums[i] 的最长子序列长度,所以并不需要加一。
第四步:考虑输出状态
主要有以下三种形式,对于具体问题,我们一定要想清楚到底dp数组里存储的是哪些值,最后我们需要的是数组中的哪些值:
- 返回dp数组中最后一个值作为输出,一般对应二维dp问题。
- 返回dp数组中最大的那个数字,一般对应记录最大值问题。
- 返回保存的最大值,一般是 Maxval=max(Maxval,dp[i]) 这样的形式。
Python代码
class Solution:
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) #确定输出状态
练习题合集
leetcode 674、5、516、72、198、213
leetcode 674. 最长连续递增序列
题目描述
给定一个未经排序的整数数组,找到最长且连续的的递增序列,并返回该序列的长度。
示例 1:
输入: [1,3,5,4,7]
输出: 3
解释: 最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为5和7在原数组里被4隔开。
示例 2:
输入: [2,2,2,2,2]
输出: 1
解释: 最长连续递增序列是 [2], 长度为1。
注意:数组长度不会超过10000。
分析
这道题与例题不同的是要求子序列连续,所以一旦升序条件被打破,dp[i]就要从1开始计算。
Python代码
class Solution:
def findLengthOfLCIS(self, nums: List[int]) -> int:
if not nums:return 0 #判断边界条件
dp=[1]*len(nums) #初始化dp数组状态
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) #确定输出状态
leetcode 5. 最长回文子串
题目描述
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: “babad”
输出: “bab”
注意: “aba” 也是一个有效答案。
示例 2:
输入: “cbbd”
输出: “bb”
Python代码
class Solution:
def longestPalindrome(self, s: str) -> str:
length=len(s)
if length<2: #判断边界条件
return s
dp=[[False for _ in range(length)]for _ in range(length)]
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]
leetcode 516. 最长回文子序列
题目描述
给定一个字符串s,找到其中最长的回文子序列。可以假设s的最大长度为1000。
示例 1:
输入: “bbbab”
输出:4
Python代码
class Solution:
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] #返回右上角位置的状态就是最长
leetcode 72. 编辑距离
题目描述
**给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 **
示例 1:
你可以对一个单词进行如下三种操作:
插入一个字符 删除一个字符 替换一个字符 示例 1:
输入:word1 = “horse”, word2 = “ros”
输出:3
解释:
horse -> rorse (将 ‘h’ 替换为 ‘r’)
rorse -> rose (删除 ‘r’)
rose -> ros (删除 ‘e’)
Python代码
class Solution:
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] #返回最终状态就是所求最小的编辑距离
leetcode 198 . 打家劫舍
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
提示:
0 <= nums.length <= 100
0 <= nums[i] <= 400
Python代码
class Solution:
def rob(self, nums: List[int]) -> int:
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]
leetcode 213 . 打家劫舍 II
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例 1:
输入: [2,3,2]
输出: 3
解释: 你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
示例 2:
输入: [1,2,3,1]
输出: 4
解释: 你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
Python代码
class Solution:
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]))