动态规划Ⅲ:数组区间

动态规划题目类型 & 做题思路总览动态规划解题套路 & 题型总结 & 思路讲解

三、数组区间

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] <= 0dp[i+1] = nums[i+1],当 dp[i] > 0dp[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 casedp[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

参考:官方题解


参考:动态规划 LeetCode 题解

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不吃饭就会放大招

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值