动态规划题目类型 & 做题思路总览:动态规划解题套路 & 题型总结 & 思路讲解
三、数组区间
1. 数组区间和
数组区间和:给定一个整数数组 nums,求出数组从索引 i 到 j (i ≤ j) 范围内元素的总和,包含 i, j 两点。LeetCode 直达
如果直接用暴力循环,时间开销是很大的,用动态规划的思路,建立 dp 数组,数组每个位置上是 当前位置的状态,即 从数组开始到当前位置所有元素的和。对于给定区间 [i,j],将求和问题转换为 res = dp[j] - dp[i-1]
,在具体的代码实现中,为了免去对 i
是否等于 0 的判断,将 dp 的第一个位置初始化为 0。
class NumArray:
def __init__(self, nums: List[int]):
if not nums: return None
self.dp = [0]
n = len(nums)
for i in range(1, n+1):
# dp[i]为数组0~i-1位置上元素的和
self.dp.append(self.dp[i-1] + nums[i-1])
def sumRange(self, i: int, j: int) -> int:
return self.dp[j+1] - self.dp[i]
# Your NumArray object will be instantiated and called as such:
# obj = NumArray(nums)
# param_1 = obj.sumRange(i,j)
2. 等差数列划分
等差数列划分:如果一个数列至少有三个元素,并且任意两个相邻元素之差相同,则称该数列为等差数列。请返回数组 A 中所有为等差数组的子数组个数。LeetCode 直达
- 明确状态:本题唯一的变量就是 “等差数列的个数”,所以 “个数” 就是要找的状态
- DP 数组:创建一个和原数组相同大小的 DP 数组,每个位置上的元素值 dp[i] 表示在 A[0] 到 A[i] 区间上,等差数列的个数
- 明确选择:从左向右遍历原数列,每添加进来一个新的数字,也许可以和前面构成等差数列,也许不能,具体就要判断
A[i]-A[i-1] == A[i-1]-A[i-2]
- 状态间关系:如果当前数字能和前面的两个数构成等差数列,那么当前等差数列的个数 = 前一状态等差数列的个数 + 能够新增的等差数列个数,而能够新增的等差数列个数 = 当前有效等差数列的长度 - 2。所以有状态转移方程:
dp[i] = dp[i-1] + (m-2)
,其中m
存放了当前 “有效” 等差数列的长度 - 确定 base case:元素个数小于等于 2 时,构不成等差数列,dp 数组中对应的状态为 0
为什么我的状态转移方程是 dp[i] = dp[i-1] + (m-2)
,可以举一个具体的例子:
如果有数列 [1, 2, 3, 8, 9, 10, 11],那么初始化 DP 数组为 [0, 0, 0, 0, 0, 0, 0]。从 i = 2,即数列元素 3 开始向后遍历。如果能够和前两个数构成等差数列,且当前等差数列长度 m 为 0,则将 m 设为 3,同时 dp[2] = 0 + (3 - 2) = 1。如果 m 不等于 0,说明此时等差数列还是 “连续” 的,新增的这个元素只是扩大了等差数列的规模,m 仅仅自增 1 即可。
在 i = 3 时,数列元素为 8,不能和前面构成等差数列,也就是等差数列 “断了”,则将 m 重置到 0,且此时 dp[3] = dp[2],即等差数列个数不会变化。
以此类推,在 i = 6 时,数列元素为 11,此时 dp[5] = 2,添加进来的元素 11 能够和前面的 8,9,10 构成等差数列,不难看出其实新增加的个数就等于当前等差数列长度 - 2。
class Solution:
def numberOfArithmeticSlices(self, A: List[int]) -> int:
n, m = len(A), 0
if not A or n < 3: return 0
dp = [0 for _ in range(n)]
for i in range(2, n):
if A[i]-A[i-1] == A[i-1]-A[i-2]:
if m == 0: m = 3
else: m += 1
dp[i] = dp[i-1] + (m-2)
else:
m = 0
dp[i] = dp[i-1]
return dp[-1]
可以发现 dp[i] 仅和 dp[i-1] 有关,可以优化空间,省去 dp 数组:
class Solution:
def numberOfArithmeticSlices(self, A: List[int]) -> int:
n = len(A)
if not A or n < 3: return 0
m, cur = 0, 0
for i in range(2, n):
if A[i]-A[i-1] == A[i-1]-A[i-2]:
if m == 0: m = 3
else: m += 1
cur = cur + (m-2)
else:
m = 0
return cur
时间复杂度:O(n),遍历长度为 n 的数组
空间复杂度:O(1)
还有令一种角度,将 DP 数组中的 dp[i] 看作以 A[i] 做结尾的等差数列的个数,那么当 A[i]-A[i-1] == A[i-1]-A[i-2]
时,dp[i] = dp[i-1] + 1
。由于题目条件是等差数列不一定要以最后一个元素做结尾,所以最终的答案应该是 “累加和”。这种做法和第一种做法的区别在于对 dp 数组元素含义的定义。
举个例子:仍是数列 [1, 2, 3, 8, 9, 10, 11],dp 数组的前两个元素仍初始化为 0。
-> 3:满足 A[i]-A[i-1] == A[i-1]-A[i-2],对应的 dp[2] = 0 + 1 = 1
-> 8:不满足,对应的 dp[3] = 0
-> 9:不满足,对应的 dp[4] = 0
-> 10:满足,dp[5] = dp[4] + 1 = 0 + 1 = 1
-> 11:满足,dp[6] = dp[5] + 1 = 1 + 1 = 2
最终的答案是 dp 数组所有元素的累加和,即 1 + 1 + 2 = 4
class Solution:
def numberOfArithmeticSlices(self, A: List[int]) -> int:
n = len(A)
if not A or n < 3: return 0
cur, sum = 0, 0
for i in range(2, n):
if A[i]-A[i-1] == A[i-1]-A[i-2]:
cur += 1
sum += cur
else: cur = 0
return sum
时间复杂度:O(n),遍历长度为 n 的数组
空间复杂度:O(1)
还可以发现,每次满足条件 A[i]-A[i-1] == A[i-1]-A[i-2]
时,就进行 sum += 1
,其实这里可以优化一下,因为 sum 加的是从 1 到 k 的递增序列,可以直接用一个 count 来计数,当不满足等差条件时,再对 sum 自增 1 到 k 的和。
class Solution:
def numberOfArithmeticSlices(self, A: List[int]) -> int:
n = len(A)
if not A or n < 3: return 0
cur, sum = 0, 0
for i in range(2, n):
if A[i]-A[i-1] == A[i-1]-A[i-2]:
cur += 1
else:
sum += int((1+cur)*cur / 2)
cur = 0
sum += int((1+cur)*cur / 2)
return sum
ps:1 ~ k 的求和公式: ( 1 + k ) × k 2 \frac{(1 + k) \times k}{2} 2(1+k)×k
3. 子数组最大和
连续子数组的最大和:输入一个整型数组,数组里有正数也有负数。数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。LeetCode 直达
注意:子数组一定是连续的。
【动态规划解题思路】
- 状态列表
dp
:其中dp[i]
是第i
个位置的状态(在本题中就是以第i
个位置的元素nums[i]
作为结尾的连续子数组的最大和)。确定状态的要点是,每一点的状态都与前面所有的状态有关联(比如本题,要确定第i+1
个位置的状态dp[i+1]
就要参考第i
个位置的状态dp[i]
,而第i
个位置的状态又是从i-1
确定的,以此类推),且每一点的状态都包含了前面所有点的状态,从而能够仅从i-1
就确定出i
的状态。 - 状态转移方程:要明确状态列表中各个状态之间如何转换,建立关系式(关键!!!),通常这个关系式是一个分段表达式。在本题中,
dp[i+1]
的状态取决于dp[i]
,如果dp[i] < 0
,那加了会更小,不符合我们的目标,所以当dp[i] <= 0
时dp[i+1] = nums[i+1]
,当dp[i] > 0
时dp[i+1] = nums[i+1] + dp[i]
- 初始状态:
dp[0] = nums[0]
,要进行初始化 - 返回值:返回状态列表
dp
中的最大值,即全局最大值
时间复杂度:O(n),线性遍历长度为 n 的数组
空间复杂度:O(n),需要维护一个与原数组长度相同的状态列表
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
dp = [nums[0]] # 状态列表dp
n, m = len(nums), nums[0] # 存储最大值
for i in range(1, n):
cur = max(nums[i], nums[i]+dp[i-1]) # 计算当前位置状态
dp.append(cur)
if cur > m: m = cur
return m
再分析发现,位置 i
的状态仅和 i-1
的状态有关,所以实际和前面爬楼梯问题一样,没必要每个状态都存下来,只需要存当前位置的前一个位置状态就可以了。
时间复杂度:O(n),线性遍历长度为 n 的数组
空间复杂度:O(1),只维护一个变量 cur
用于指向前一个位置的状态
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
n, m = len(nums), nums[0] # 存储最大值
cur = nums[0] # 存储当前值
for i in range(1, n):
cur = max(nums[i], nums[i]+cur) # 计算当前位置状态
if cur > m: m = cur
return m
4. 单词拆分
单词拆分:给定一个非空字符串 s 和一个包含非空单词列表的字典 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。LeetCode 直达
【动态规划解题思路】
- 明确状态:该题目的变量是 “能否拆分”,即布尔值 True 或 False
- dp 数组:
dp[i]
表示以第i
个字符结尾的字符串能否被拆分为字典中出现过的单词 - 明确选择:在每个位置
i
,都可以选择其前0~i-1
个字符中任意一个位置作为起始点,与字典单词进行匹配,所以起码有一个二重循环 - 状态间关系:
dp[i]
为 True,当且仅当[0,j]
位置的字符串属于字典,且[j,i+1]
位置的字符串也属于字典(这里的区间都是左闭右开的) - 确定 base case:为了方便统一处理,将 dp 数组初始化为 n+1 大小,第 0 位初始化为 True
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
空间复杂度:
O
(
n
)
O(n)
O(n)
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
if not wordDict: return False
n = len(s)
dp = [False for i in range(n+1)]
dp[0] = True
for i in range(1, n+1):
for j in range(i):
dp[i] = dp[i] or dp[j] and s[j:i] in wordDict
# 或者:
# dp[i] = dp[j] and s[j:i] in wordDict
# if dp[i] == True: break
return dp[-1]
5. 最长重复子数组
最长重复子数组:给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。LeetCode直达
【动态规划解题思路】
- 明确状态:该题目的变量是唯一的,即“最长公共前缀的长度”,由于两个串的起始位置是可以任意匹配的,所以 dp 数组起码需要二维
- dp 数组:
dp[i][j]
表示以 B 数组第i
个数字做结尾,以 A 数组第j
个数字做结尾时,最大公共前缀的长度 - 状态间关系:在任意位置
dp[i][j]
,其最长公共前缀长度可以由dp[i-1][j-1]
推出,若A[j] == B[i]
,则dp[i][j] = dp[i-1][j-1] + 1
,否则dp[i][j] = 0
- 确定 base case:初始化 dp 矩阵第一行第一列,若相等则为 1,否则为 0
时间复杂度:
O
(
n
m
)
O(nm)
O(nm),n 和 m 分别是两个数组的长度
空间复杂度:
O
(
n
m
)
O(nm)
O(nm)
class Solution:
def findLength(self, A: List[int], B: List[int]) -> int:
n1, n2 = len(A), len(B)
dp = [[0]*n1 for _ in range(n2)]
m = 0
for i in range(n1):
if A[i] == B[0]:
dp[0][i] = 1
m = 1
else:
dp[0][i] = 0
for j in range(n2):
if B[j] == A[0]:
dp[j][0] = 1
m = 1
else:
dp[j][0] = 0
for i in range(1, n2):
for j in range(1, n1):
if A[j] == B[i]:
dp[i][j] = dp[i-1][j-1] + 1
m = max(m, dp[i][j])
else:
dp[i][j] = 0
return m
简洁版代码:官方题解
class Solution:
def findLength(self, A: List[int], B: List[int]) -> int:
n, m = len(A), len(B)
dp = [[0] * (m + 1) for _ in range(n + 1)]
ans = 0
for i in range(n - 1, -1, -1):
for j in range(m - 1, -1, -1):
dp[i][j] = dp[i + 1][j + 1] + 1 if A[i] == B[j] else 0
ans = max(ans, dp[i][j])
return ans
6. 最长有效括号
最长有效括号:给定一个只包含 ‘(’ 和 ‘)’ 的字符串,找出最长的包含有效括号的子串的长度。LeetCode直达
【动态规划解题思路】
- 明确状态:唯一的变量是有效子串的“长度”
- dp 数组:
dp[i]
表示以第i
个字符结尾的最长有效括号子串的长度 - 状态间关系:需要分 3 种情况来看。如果
s[i] == '('
,则dp[i] == 0
,因为不可能有以左括号结尾的有效子串。如果s[i] == ')'
,分为两种情况:若s[i-1] == '('
,则直接匹配成功,dp[i] = dp[i-2] + 2
,也就是将之前的最大有效子串长度再加上 2;若s[i-1] == ')'
,则继续向前寻找是否匹配(比如像'(())'
这种例子,最后一个)
应该与第一个(
匹配),此时判断s[i-dp[i-1]-1] == '('
是否成立,若成立则dp[i] = dp[i-1] + 2 + dp[i-dp[i-1]-2]
。 - 确定 base case:
dp[0]
初始化为 0,因为第一个字符无论是左右括号都不可能出现有效字符串。
时间复杂度:
O
(
n
)
O(n)
O(n),遍历长度为 n 的字符串一遍
空间复杂度:
O
(
n
)
O(n)
O(n),需要 dp 数组的额外存储空间
class Solution:
def longestValidParentheses(self, s: str) -> int:
n = len(s)
dp = [0 for _ in range(n)]
m = 0
for i in range(1, n):
if s[i] == ')' and i-1 >= 0 and s[i-1] == '(':
dp[i] = dp[i-2] + 2
elif s[i] == ')' and i-dp[i-1]-1 >=0 and s[i-dp[i-1]-1] == '(':
dp[i] = dp[i-1] + 2 + dp[i-dp[i-1]-2]
m = dp[i] if dp[i] > m else m
return m
7. 分割数组的最大值
分割数组的最大值:给定一个非负整数数组和一个整数 m,你需要将这个数组分成 m 个非空的连续子数组。设计一个算法使得这 m 个子数组各自和的最大值最小。LeetCode 直达
【动态规划解题思路】
- 明确状态:本题的变量包括 m 和连续子数组,对不同的组合值得到的数组的最大值都是不一样的。考虑用二维 dp 数组来保存状态。
- dp 数组:
dp[i][j]
表示数组前i
个数划分为j
个子数组所得到的连续子数组和的最大值的最小值。 - 状态间关系:枚举 k,前 k 个数被分割为 j-1 个子数组,第 k+1 到第 i 个数为第 j 个子数组。此时 j 个子数组中和的最大值等于 dp[k][j-1] 与 sub(k+1, i) 中的较大值,其中 sub(i, j) 表示数组 nums 中下标落在区间 [i, j] 内的数之和。 要使得子数组和的最大值最小,有状态转移方程:
f[i][j] = min{max(f[k][j-1], sub(k+1, i))}
- 确定 base case: dp[0][0] = 0
时间复杂度:
O
(
n
2
m
)
O(n^2m)
O(n2m),其中 n 是数组的长度,m 是分成的非空的连续子数组的个数。
空间复杂度:
O
(
n
m
)
O(nm)
O(nm)
class Solution:
def splitArray(self, nums: List[int], m: int) -> int:
n = len(nums)
f = [[10**18] * (m + 1) for _ in range(n + 1)]
sub = [0]
for elem in nums:
sub.append(sub[-1] + elem)
f[0][0] = 0
for i in range(1, n + 1):
for j in range(1, min(i, m) + 1):
for k in range(i):
f[i][j] = min(f[i][j], max(f[k][j - 1], sub[i] - sub[k]))
return f[n][m]
代码来源:官方题解
8. 回文子串
回文子串:给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。LeetCode 直达
【动态规划解题思路】
- 明确状态:对于字符串的任意起始位置
i
和结束位置j
都可以组成一个子字符串s[i][j]
,可以判断该字符串是否为回文串,是则dp[i][j]
为 True,否则为 False。 - dp 数组:使用二维 dp 数组,
dp[i][j]
表示以i
为起点,j
为重点的字符串s[i][j]
是否为回文串。 - 状态间关系:对于子串
s[i][j]
,若s[i+1][j-1]
为回文串,且s[i] == s[j]
,则s[i][j]
也是一个回文串,故有状态转移dp[i][j] = (dp[i+1][j-1] and s[i] == s[j])
。 - 确定 base case: 外层循环枚举所有可能的子串长度
length
,内层循环枚举所有可能的子串起点,当length
等于 0 时,表示子字符串只有一个字符,肯定是回文串;当length
等于 1 时,表示字符串有两个字符,只有当两个字符相同时才为回文串。
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
空间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
class Solution:
def countSubstrings(self, s: str) -> int:
n = len(s)
dp = [[False] * n for _ in range(n)]
for length in range(n):
for start in range(n):
end = start + length
if end > n-1:
break
if length == 0:
dp[start][end] = True
elif length == 1:
dp[start][end] = (s[start] == s[end])
else:
dp[start][end] = (dp[start+1][end-1] and s[start] == s[end])
# count for number
count = 0
for i in range(n):
for j in range(n):
if dp[i][j]:
count += 1
return count
最长回文子串:给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。LeetCode 直达
和上题做法一模一样…
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
res = ""
dp = [[False] * n for _ in range(n)]
for length in range(n):
for start in range(n):
end = start + length
if end > n-1:
break
if length == 0:
dp[start][end] = True
elif length == 1:
dp[start][end] = (s[start] == s[end])
else:
dp[start][end] = (dp[start+1][end-1] and s[start] == s[end])
if dp[start][end]:
res = s[start:end+1]
return res
参考:官方题解