动态规划Ⅲ:数组区间
动态规划Ⅲ:数组区间
动态规划题目类型 & 做题思路总览:动态规划解题套路 & 题型总结 & 思路讲解
文章目录
-
三、数组区间
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 -> 9:不满足,对应的 dp[4] = 0 -> 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)<br> **空间复杂度**: 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 分别是两个数组的长度<br> **空间复杂度**: 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 的字符串一遍<br> **空间复杂度**: 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 是分成的非空的连续子数组的个数。<br> **空间复杂度**: 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)<br> **空间复杂度**: 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
参考:官方题解
- 明确状态:本题唯一的变量就是 “等差数列的个数”,所以 “个数” 就是要找的状态- DP 数组:创建一个和原数组相同大小的 DP 数组,每个位置上的元素值 dp[i] 表示在 A[0] 到 A[i] 区间上,等差数列的个数- 明确选择:从左向右遍历原数列,每添加进来一个新的数字,也许可以和前面构成等差数列,也许不能,具体就要判断