目录
动态规划简介
动态规划(Dynamic Programming),是求解决策过程最优化的数学方法,后来沿用到了编程领域。
动态规划的基本思想与分治法类似,也是将待求解的问题分解为若干个子问题,按顺序求解子问题,前一个子问题的解为后一个子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各个子问题,最后一个子问题就是初始问题的解。
由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个数组中。
与分治法最大的区别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子问题的求解是建立在上一个子问题的解的基础上,进行进一步的求解)。
动态规划的三大解题步骤
动态规划,无非就是利用历史记录,来避免我们的重复计算。而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者二维数组来保存。
- 定义状态的含义,上面说了,我们会用一个数组来保存历史记录,而这个记录也称为状态,假设用一维数组 dp 来保存状态。这个时候有一个非常重要的点,就是规定你这个状态的含义,例如你的 dp[i] 是代表什么意思?
- 建立状态转移方程,动态规划有一点类似于归纳法,当我们要计算 dp[n] 时,是可以利用 dp[n-1],dp[n-2].....dp[1],来推出 dp[n],也就是可以利用历史记录来推出新的元素值,所以我们要找出状态之间的关系式,例如 dp[n] = dp[n-1] + dp[n-2],这个就是它们的状态转移方程了。
- 找出初始值。虽然我们知道了状态转移方程,例如 dp[n] = dp[n-1] + dp[n-2],我们可以通过 dp[n-1] 和 dp[n-2] 来计算 dp[n],但是,我们还得知道初始值,例如一直推下去的话,会由 dp[3] = dp[2] + dp[1]。而 dp[2] 和 dp[1] 是不能再分解的,所以我们必须要能够直接获得 dp[2] 和 dp[1] 的值,而这就是所谓的初始值。
什么样的题能用动态规划解决?
如果一个问题满足以下两点,那么它就能用动态规划解决。
- 问题的答案依赖于问题的规模,也就是问题的所有答案构成了一个数列。例如:一只兔有 4 条腿,两只 8 条腿,...,N 只 4N 条腿,其中问题的答案是 4N,问题的规模是 N,显然问题的答案是依赖于问题的规模的。因此,问题在所有规模下的答案可以构成一个数列
。
- 大规模问题的答案可以由小规模问题的答案递推得到,也就是
的值可以由
中的值求得。例如:在上面的例子中,
的值可以由
求得:
。
动态规划的经典题目
5. 最长回文子串
难度 中等
题目:给定一个字符串 s
,找到 s
中最长的回文子串。你可以假设 s
的最大长度为 1000。
思路:
- 前提:对于一个子串而言,如果它是回文串,并且长度大于 2,那么将它首尾的两个字母去除之后,它仍然是个回文串。
- 状态:dp[i][j] 表示子串 s[i..j] 是否为回文子串。
- 状态转移方程:dp[i][j] = (s[i] == s[j]) and dp[i+1][j-1]
- 边界条件:(j - 1) - (i + 1) + 1 < 2
- 初始化:dp[i][j] = True, i == j
- 最终的答案即为所有 dp[i][j] = True 中 j - i + 1 (即子串长度)的最大值所对应的子串。
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
# 字符串长度小于2时,必为回文串,可以直接返回
if n < 2:
return s
res = '' # 用于保存结果
dp = [[False] * n for _ in range(n)] # 状态转移矩阵
# i和j分别为指向子串首尾字母的指针,且i < j
for j in range(n):
for i in range(j+1):
if i == j:
# 当i == j时,子串为一个字符,单字符必为回文
dp[i][j] = True
elif s[i] != s[j]:
# 当首尾字符不相同时,子串必非回文串
dp[i][j] = False
else:
# 当首尾字符相同时,分两种情况:
# 1.字符串长度小于2时,必为回文串;
# 2.字符串长度不小于2时,则看去除首尾字符串后的子串是否属于回文串;
dp[i][j] = True if (j-1) - (i+1) + 1 < 2 else dp[i+1][j-1]
# 当前子串为回文串,且该子串的长度大于历史回文串长度时,更新最长回文串
if dp[i][j] and j - i + 1 > len(res):
res = s[i: j+1]
return res
复杂度分析:
- 时间复杂度:
,其中 N 是字符串的长度。动态规划的状态总数为
,对于每个状态,我们需要转移的时间为
。
- 空间复杂度:
,即存储动态规划状态需要的空间。
72. 编辑距离
难度 困难
题目:给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
思路:
- 编辑的含义:
- 若 word1[i] == word2[j],那么 word1[i] 转换到 word2[j] 的操作数就为 0,只需要比较 word1[0..i-1] 和 word2[0..j-1]
- 若 word1[i] != word2[j],那么将有三种情况:
- word1 执行插入操作,那么 word1[i+1] 转换到 word2[j] 的操作数就为 0,只需要比较 word1[0..i] 和 word2[0..j-1] 的结果。
- word1 执行删除操作,那么只需要比较 word1[0..i-1] 和 word2[0..j] 的结果。
- word1 执行替换操作,那么 word1[i] 转换到 word2[j] 的操作数就为0,只需要比较 word1[0..i-1] 和 word2[0..j-1] 的结果。
- word1 执行插入操作,那么 word1[i+1] 转换到 word2[j] 的操作数就为 0,只需要比较 word1[0..i] 和 word2[0..j-1] 的结果。
- 若 word1[i] == word2[j],那么 word1[i] 转换到 word2[j] 的操作数就为 0,只需要比较 word1[0..i-1] 和 word2[0..j-1]
- 状态:dp[i][j] 表示 word1[0..i] 与 word2[0..j] 的编辑距离。
- 状态转移方程:
- 当 word1[i] == word2[j] 时,dp[i][j] = dp[i-1][j-1]
- 当 word1[i] != word2[j] 时,dp[i][j] = min(dp[i][j-1], dp[i-1][j], dp[i-1][j-1]) + 1,其中 +1 表示当前操作的操作数。
- 边界条件:一个空串和一个非空串的编辑距离为 dp[i][0] = i 和 dp[0][j] = j,dp[i][0] 相当于对 word1 执行 i 次删除操作,dp[0][j] 相当于对 word1执行 j 次插入操作。
- 初始化:
- dp[i][0] = i,i = 0, 1, ..., len(word1)
- dp[0][j] = j,j = 0, 1, ..., len(word2)
- 最终的答案即为:dp[len(word1)][len(word2)]
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m = len(word1)
n = len(word2)
dp = [[0] * (n+1) for _ in range(m+1)] # 状态转移矩阵
# 根据状态转移方程,填充状态转移矩阵
for i in range(m+1):
for j in range(n+1):
if i == 0:
# 边界状态初始化:dp[0][j]相当于对word1执行j次插入操作
dp[i][j] = j
elif j == 0:
# 边界状态初始化:dp[i][0]相当于对word1执行i次删除操作
dp[i][j] = i
else:
if word1[i-1] == word2[j-1]:
# 如果word1的第i个字符和word2的第j个字符原本就相同,那么我们实际上不需要任何操作。
dp[i][j] = dp[i-1][j-1]
else:
# dp[i][j-1]表示对word1进行插入操作前需要的编辑距离
# dp[i-1][j]表示对word1进行删除操作前需要的编辑距离
# dp[i-1][j-1]表示对word1进行替换操作前需要的编辑距离
# '+1'表示当前操作使编辑距离+1
dp[i][j] = 1 + min(dp[i][j-1], dp[i-1][j], dp[i-1][j-1])
return dp[m][n]
复杂度分析:
- 时间复杂度:
,其中 M 为
word1
的长度,N 为word2
的长度。 - 空间复杂度:
,我们需要大小为
的 dp 数组来记录状态值。
198. 打家劫舍
难度 简单
题目:你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
思路:
- 前提:
- 两间相邻房屋,不能同时偷窃,只能偷窃其中的一间房屋。
- 若只有一间房屋,则偷窃该房屋,可以偷窃到最高总金额。
- 若只有两间房屋,则选择其中金额较高的房屋进行偷窃,可以偷窃到最高总金额。
- 若房屋数量大于两间,则对第 i(i>2) 间房屋:
- 偷第 i 间房屋,那就不能偷第 i-1 间,偷窃到最高总金额为偷取前 i-2 间房屋的最高总金额加第 i 间房的金额。
- 不偷第 i 间房屋,那偷窃到最高总金额为偷取偷取前 i-1 间房屋的最高总金额。
- 状态:dp[i] 表示前 i 间房屋能偷窃到的最高总金额。
- 状态转移方程:dp[i] = max(dp[i-2] + nums[i], dp[i-1])
- 初始化:dp[0] = nums[0], dp[1] = max(nums[0], nums[1])
- 最终的答案即为 dp[-1]。
class Solution:
def rob(self, nums: List[int]) -> int:
"""动态规划"""
n = len(nums)
if n == 0: # 房间数为0时,最大收益为0
return 0
elif n == 1: # 房间数为1时,最大收益为第一间房的金额
return nums[0]
# 状态初始化
dp = [0] * n # 状态转移向量
dp[0] = nums[0] # 只有一间房时,偷窃该房
dp[1] = max(nums[0], nums[1]) # 当有两间房时,选择金额最高的房进行偷窃
# 根据状态转移方程,填充状态转移向量
for i in range(2, n):
# dp[i-2] + nums[i]表示偷取第i间房的收益
# dp[i-1]表示不偷第i间房的收益
# dp[i]为两者中的最大收益
dp[i] = max(dp[i-2] + nums[i], dp[i-1])
return dp[-1]
复杂度分析:
- 时间复杂度:
,其中 N 是数组长度。只需要对数组遍历一次。
- 空间复杂度:
,我们需要大小为
的 dp 数组来记录状态值。
使用滚动数组优化空间复杂度的方案:
class Solution:
def rob(self, nums: List[int]) -> int:
"""动态规划 + 滚动数组"""
n = len(nums)
if n == 0: # 房间数为0时,最大收益为0
return 0
elif n == 1: # 房间数为1时,最大收益为第一间房的金额
return nums[0]
# 状态初始化
state1 = nums[0] # 只有一间房时,偷窃该房
state2 = max(nums[0], nums[1]) # 当有两间房时,选择金额最高的房进行偷窃
for i in range(2, n):
# 由于偷窃第i间房的收益只与第i-2和第i-1间房的收益相关,所以只需记录前两间房的收益即可
state1, state2 = state2, max(state1 + nums[i], state2)
return state2
复杂度分析:
- 时间复杂度:
,其中 N 是数组长度。只需要对数组遍历一次。
- 空间复杂度:
,使用滚动数组,可以只存储前两间房屋的最高总金额,而不需要存储整个数组的结果,因此空间复杂度是
。
213. 打家劫舍 II
难度 中等
题目:你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
思路:
- 环状排列意味着第一个房子和最后一个房子中只能选择一个偷窃,因此可以把此环状排列房间问题约化为两个单排排列房间子问题:
- 在不偷窃第一个房子的情况下(即 nums[1:]),最大金额是 p1 ;
- 在不偷窃最后一个房子的情况下(即 nums[:n−1]),最大金额是 p2 。
- 综合偷窃最大金额: 为以上两种情况的较大值,即 max(p1, p2) 。
class Solution:
def rob(self, nums: List[int]) -> int:
m = len(nums)
if m == 0:
return 0
elif m == 1:
return nums[0]
def inner(sub_nums):
"""详见'打家劫舍'的解题方案"""
n = len(sub_nums)
if n == 0: # 房间数为0时,最大收益为0
return 0
elif n == 1: # 房间数为1时,最大收益为第一间房的金额
return sub_nums[0]
state1 = sub_nums[0] # 只有一间房时,偷窃该房
state2 = max(sub_nums[0], sub_nums[1]) # 当有两间房时,选择金额最高的房进行偷窃
for i in range(2, n):
# 由于偷窃第i间房的收益只与第i-2和第i-1间房的收益相关,所以只需记录前两间房的收益即可
state1, state2 = state2, max(state1 + sub_nums[i], state2)
return state2
# 将环状房间的问题分解为两个单列房间的问题来解决
return max(inner(nums[1:]), inner(nums[:-1]))
复杂度分析:
- 时间复杂度:
,两次遍历
nums
需要线性时间; - 空间复杂度:
,使用滚动数组,可以只存储前两间房屋的最高总金额,而不需要存储整个数组的结果,因此空间复杂度是
。
516. 最长回文子序列
难度 中等
题目:给定一个字符串 s
,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s
的最大长度为 1000
。
思路:
- 前提:
- 若头尾字符相同,那么最长回文子序列就包括这两个字符,剩下字符在去掉这两个字符的子串中。
- 否则,最长回文子序列的字符要么在包含头而不包含尾的子串中,要么在不包含头而包含尾的子串中,即 s[i] 、 s[j] 至少有一个不在回文子序列中。
- 状态:dp[i][j] 表示子串 s[i..j] 最长的回文序列长度。
- 状态转移方程:
- dp[i][j] = (s[i] == s[j]) and dp[i+1][j-1] + 2
- dp[i][j] = (s[i] != s[j]) and max(dp[i+1][j], dp[i][j-1])
- 初始化:dp[i][j] = 1, i == j
- 最终的答案即为 dp[0][len(s) - 1]。
class Solution:
def longestPalindromeSubseq(self, s: str) -> int:
n = len(s)
# 字符串长度小于2时,必为回文序列,可以直接返回其长度
if n < 2:
return n
dp = [[0] * n for _ in range(n)] # 状态转移矩阵
# i和j分别为指向子序列首尾字母的指针,且i < j
for i in range(n-1, -1, -1):
for j in range(i, n):
if i == j:
# 当i == j时,子序列为一个字符,单字符必为回文序列
dp[i][j] = 1
elif s[i] == s[j]:
# 当首尾字符相同时,最长回文子序列长度为dp[i+1][j-1]加上当前的首尾两字符长度
dp[i][j] = dp[i+1][j-1] + 2
else:
# 当首尾字符不相同时,最长回文子序列长度取dp[i+1][j]和dp[i][j-1]中的最大值
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
# 返回最长回文子序列长度
return dp[0][n-1]
复杂度分析:
- 时间复杂度:
,其中 N 是字符串的长度。动态规划的状态总数为
,对于每个状态,我们需要转移的时间为
。
- 空间复杂度:
,即存储动态规划状态需要的空间。
674. 最长连续递增序列
难度 简单
题目:给定一个未经排序的整数数组,找到最长且连续的的递增序列,并返回该序列的长度。
思路:
- 前提:若数组长度为 0 或 1 时,最长且连续的递增序列长度也为 0 或 1;当数组中后一位大于前一位,长度 +1。
- 状态:dp[i] 表示以 nums[i] 结尾的连续递增序列长度。
- 状态转移方程:dp[i] = dp[i-1] + 1 if nums[i] > nums[i-1] else 1
- 初始化:dp[0] = 1
- 最终的答案即为 dp 中的最大值。
class Solution:
def findLengthOfLCIS(self, nums: List[int]) -> int:
"""动态规划"""
n = len(nums)
if n < 2: # 数组长度为0或1时,最长且连续的递增序列长度也为0或1
return n
dp = [0] * n # 状态转移向量
dp[0] = 1 # 状态初始化
for i in range(1, n):
# 当数组中后一位大于前一位,则长度+1
# 否则,长度为1
dp[i] = dp[i-1] + 1 if nums[i] > nums[i-1] else 1
# 取dp中最大的长度为最长递增序列长度
return max(dp)
复杂度分析:
- 时间复杂度:
,其中 N 是 nums 的长度。我们通过 nums 执行一个循环。
- 空间复杂度:
,其中 N 是 nums 的长度。我们通过 dp 来存储状态。
思路:
- 由于 dp[i] 的取值仅与 dp[i-1] 有关,所以只需记录 dp[i] 的前一状态即可;
- 需要用一个新变量来保存历史状态中的最长递增序列长度
class Solution:
def findLengthOfLCIS(self, nums: List[int]) -> int:
"""动态规划(优化)"""
n = len(nums)
if n < 2: # 数组长度为0或1时,最长且连续的递增序列长度也为0或1
return n
state = 1 # 记录当前状态时的递增序列长度
res = 1 # 记录最长递增序列长度
for i in range(1, n):
# 当数组中后一位大于前一位,则长度+1
# 否则,长度为1
state = state + 1 if nums[i] > nums[i-1] else 1
res = max(state, res)
return res
复杂度分析:
- 时间复杂度:
,其中 N 是 nums 的长度。我们通过 nums 执行一个循环。
- 空间复杂度:
,state 和 res 使用了常数级空间。