动态规划的思想比较适用于有重叠子问题和最优子结构性质的问题。主要的思想就是:若要解一个给定问题,我们需要解其不同部分即子问题,再根据子问题的解以得出原问题的解。动态规划法仅仅解决每个子问题一次,一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。
*确定动态规划状态
*写出状态转移方程(画出状态转移表)
*考虑初始化条件
*考虑输出状态
*考虑对时间,空间复杂度的优化(Bonus)
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
是否存在状态转移?
什么样的状态比较好转移,找到对求解问题最方便的状态转移?
想清楚到底是直接用需要求的,比如长度作为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)
主要有以下三种形式,对于具体问题,我们一定要想清楚到底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数组中值最大的数。
最后加上考虑数组是否为空的判断条件,下面是该问题完整的代码:
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) #确定输出状态
切入点: 我们看到,之前方法遍历dp列表需要 O ( N ) O(N) O(N),计算每个dp[i]需要 O ( N ) O(N) O(N)的时间,所以总复杂度是 O ( N 2 ) O(N^2) O(N2)
前面遍历dp列表的时间复杂度肯定无法降低了,但是我们看后面在每轮遍历[0,i]的dp[i]元素的时间复杂度可以考虑设计状态定义,使得整个dp为一个排序列表,这样我们自然想到了可以利用二分法来把时间复杂度降到了 O ( N l o g N ) O(NlogN) O(NlogN)。这里由于篇幅原因,如果大家感兴趣的话详细的解题步骤可以看好心人写的二分方法+动态规划详解
模板总结:
for i in range(len(nums)):
for j in range(i):
dp[i]=最值(dp[i],dp[j]+...)
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。
示例 2:
输入: "cbbd"
输出: "bb"
对于一个子串而言,如果它是回文串,并且长度大于 2,那么将它首尾的两个字母去除之后,它的子串仍然是个回文串。首先,单个字符一定是回文子串,比如a。其次若回文串的长度为2或者为3,那么只要他左右两个边界处字符相等,那么他也是回文串(无需验证去掉边界后的子串)。对于字符串 ababa,如果我们已经知道 bab 是回文串,那么ababa 一定是回文串,这是因为它的首尾两个字母都是 a。
我们可以用动态规划的方法解决本题。我们用 P(i,j) 表示字符串s的第i到j个字母组成的串,下文表示成 s[i:j]是否为回文串
其他情况可能是:
1.s[i,j] 本身不是一个回文串;
2.i > j,此时 s[i, j] 本身不合法。
因此动态规划的状态转移方程就是:
也就是说,只有 s[i+1:j-1] 是回文串,并且 s的第 i 和 j 个字母相同时,s[i:j]才会是回文串。
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
dp = [[False] * n for _ in range(n)]
ans = ""
# 枚举子串的长度 l+1
for l in range(n):
# 枚举子串的起始位置 i,这样可以通过 j=i+l 得到子串的结束位置
for i in range(n):
#dp[i][j]表示字串s[i...j]是否为回文子串
j = i + l
if j >= len(s):
break
if l == 0:
#对角线上的字符串一定是回文,因此直接赋值为ture
dp[i][j] = True
elif l == 1:
#单个字符一定是回文,dp[i][j]表示字串s[ij]是回文(s[i] == s[j])
dp[i][j] = (s[i] == s[j])
else:
#状态转移方程:dp[i][j]在去掉了两边的边界值之后依然是回文
dp[i][j] = (dp[i + 1][j - 1] and s[i] == s[j])
#边界值
if dp[i][j] and l + 1 > len(ans):
ans = s[i:j+1]
return ans
Leetcode(力扣):https://leetcode-cn.com/problems/longest-palindromic-substring/
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
示例 1:
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
示例 2:
输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')
由于本题有两个字符串,所以可以用二维数组来保存转移状态,定义dp[i][j]为字符串word1长度为i和字符串word2长度为j时,word1转化成word2所执行的最少操作次数的值。
思路具体为:
1.当 word1[i] == word2[j]时,dp[i][j] = dp[i-1][j-1];
2.当 word1[i] != word2[j]时,dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1
其中,dp[i-1][j-1] 表示替换操作,dp[i-1][j] 表示删除操作,dp[i][j-1] 表示插入操作。
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
n1 = len(word1)
n2 = len(word2)
dp = [[0] * (n2 + 1) for _ in range(n1 + 1)]
# 第一行
for j in range(1, n2 + 1):
dp[0][j] = dp[0][j-1] + 1
# 第一列
for i in range(1, n1 + 1):
dp[i][0] = dp[i-1][0] + 1
for i in range(1, n1 + 1):
for j in range(1, n2 + 1):
if word1[i-1] == word2[j-1]:
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
return dp[-1][-1]
Leetcode(力扣):https://leetcode-cn.com/problems/edit-distance/
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 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 。
本题可以采用滑动窗口的方式思考。如果房屋数量大于两间,应该如何计算能够偷窃到的最高总金额呢?对于第 k(k>2)间房屋,有两个选项:
1.偷窃第 k 间房屋,那么就不能偷窃第 k-1 间房屋,偷窃总金额为前 k-2 间房屋的最高总金额与第 k 间房屋的金额之和。
2.不偷窃第 k 间房屋,偷窃总金额为前 k-1 间房屋的最高总金额。
在两个选项中选择偷窃总金额较大的选项,该选项对应的偷窃总金额即为前 k 间房屋能偷窃到的最高总金额。
用 dp[i] 表示前 i 间房屋能偷窃到的最高总金额,那么就有如下的状态转移方程:
那么对于最简单的情况:只有一间房子,那就只能偷这间,如果有两间房子,那就偷数额最大的一间
class Solution:
def rob(self, nums: list[int]) -> int:
if not nums:
return 0
size = len(nums)
if size == 1:
return nums[0]
dp = [0] * size
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, size):
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1])
return dp[size - 1]
Leetcode(力扣):https://leetcode-cn.com/problems/house-robber/
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例:
示例 1:
输入: [2,3,2]
输出: 3
解释: 你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
示例 2:
输入: [1,2,3,1]
输出: 4
解释: 你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
本题与上题的区别就是房子是环形排列的。
class Solution:
def rob(self, nums: [int]) -> int:
def my_rob(nums):
cur, pre = 0, 0
for num in nums:
cur, pre = max(pre + num, cur), cur
return cur
return max(my_rob(nums[:-1]),my_rob(nums[1:])) if len(nums) != 1 else nums[0]
Leetcode(力扣):https://leetcode-cn.com/problems/house-robber-ii/
给定一个字符串s,找到其中最长的回文子序列。可以假设s的最大长度为1000。
示例
示例 1:
输入:
"bbbab"
输出:
4
一个可能的最长回文子序列为 "bbbb"。
示例 2:
输入:
"cbbd"
输出:
2
一个可能的最长回文子序列为 "bb"。
本题与第一道最长回文子串非常相似。可以建一个二维数组dp[i][j],按照第一题的方式来解题。
class Solution:
def longestPalindromeSubseq(self, s: str) -> int:
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = 1
for j in range(i - 1, -1, -1):
if s[j] == s[i]:
dp[j][i] = 2 + dp[j + 1][i - 1]
else:
dp[j][i] = max(dp[j + 1][i], dp[j][i - 1])
return dp[0][n - 1]
Leetcode(力扣):https://leetcode-cn.com/problems/longest-palindromic-subsequence/
给定一个未经排序的整数数组,找到最长且连续的递增序列,并返回该序列的长度。
示例:
示例 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。
第一步:可以设一个数组dp[i]代表最长连续递增序列,对于这个问题,我们的状态dp[i]也是以nums[i]这个数结尾的最长递增子序列的长度。
第二步:写出状态转移方程:
def findLengthOfLCIS(self, nums: list[int]) -> int:
# 判断边界条件
if not nums: return 0
# 初始化dp数组状态
dp = [1] * len(nums)
# 注意需要得到前一个数,所以从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)
Leetcode(力扣):https://leetcode-cn.com/problems/longest-continuous-increasing-subsequence/