力扣hot100 | 动态规划2 | 139. 单词拆分、300. 最长递增子序列、152. 乘积最大子数组、416. 分割等和子集、32. 最长有效括号
139. 单词拆分
力扣题目链接
给你一个字符串 s
和一个字符串列表 wordDict
作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s
则返回 true
。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
示例 1:
输入: s = “leetcode”, wordDict = [“leet”, “code”]
输出: true
解释: 返回 true 因为 “leetcode” 可以由 “leet” 和 “code” 拼接成。
示例 2:
输入: s = “applepenapple”, wordDict = [“apple”, “pen”]
输出: true
解释: 返回 true 因为 “applepenapple” 可以由 “apple” “pen” “apple” 拼接成。
注意,你可以重复使用字典中的单词。
示例 3:
输入: s = “catsandog”, wordDict = [“cats”, “dog”, “sand”, “and”, “cat”]
输出: false
递归
参考自灵茶山艾府。
class Solution:def wordBreak(self, s: str, wordDict: List[str]) -> bool:max_len = max(map(len, wordDict)) # 用于限制下面 j 的循环次数words = set(wordDict) # 便于快速判断 s[j:i] in words@cache # 缓存装饰器,避免重复计算 dfs 的结果(记忆化)def dfs(i: int) -> bool: # dfs(i) 的意义: s[:i]是否能被拆分if i == 0: # 成功拆分!return Truefor j in range(i - 1, max(i - max_len - 1, -1), -1): # max(i-max_len-1,-1)直接写-1也能过if s[j:i] in words and dfs(j):return Truereturn Falsereturn dfs(len(s))
- 时间复杂度 O(mL+nL2mL + nL^2mL+nL2),其中
m
是wordDict
的长度,L
是wordDict
中字符串的最长长度,n
是s
的长度。创建哈希集合需要O(mL)
的时间。由于每个状态只会计算一次,动态规划的时间复杂度=状态个数×单个状态的计算时间动态规划的时间复杂度 = 状态个数 \times 单个状态的计算时间动态规划的时间复杂度=状态个数×单个状态的计算时间。本题状态个数等于 O(n)O(n)O(n),单个状态的计算时间为 O(L2)O(L^2)O(L2)(注意判断子串是否在哈希集合中需要 O(L)O(L)O(L) 的时间),所以记忆化搜索的时间复杂度为 O(nL2nL^2nL2)。 - 空间复杂度 O(mL+nmL + nmL+n)。哈希集合需要 O(mL)O(mL)O(mL) 的空间。记忆化搜索需要 O(n)O(n)O(n) 的空间。
递推
class Solution:def wordBreak(self, s: str, wordDict: List[str]) -> bool:max_len = max(map(len, wordDict)) # 用于限制下面 j 的循环次数words = set(wordDict) # 便于快速判断 s[j:i] in wordsn = len(s)f = [True] + [False] * nfor i in range(1, n + 1):for j in range(i - 1, max(i - max_len - 1, -1), -1):if f[j] and s[j:i] in words:f[i] = Truebreakreturn f[n]
- 复杂度都不变。
300. 最长递增子序列
力扣题目链接
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
一、递推
参考自Krahets
class Solution:def lengthOfLIS(self, nums: List[int]) -> int:if not nums: return 0n = len(nums)dp = [1] * n # dp[i]定义:以nums[i]结尾的最长上升子序列长度for i in range(n):for j in range(i):if nums[i] > nums[j]: # 对每个符合的j都更新dp[i]的最大值dp[i] = max(dp[i], dp[j] + 1) return max(dp) # 全局最长不一定是以nums[-1]结尾的哦(不能写dp[n-1])
- 时间复杂度 O(n^2)
- 空间复杂度 O(n)
二、递推 + 二分查找优化
# Dynamic programming + Dichotomy.
class Solution:def lengthOfLIS(self, nums: [int]) -> int:tails, res = [0] * len(nums), 0for num in nums:i, j = 0, reswhile i < j:m = (i + j) // 2if tails[m] < num: i = m + 1 # 如果要求非严格递增,将此行 '<' 改为 '<=' 即可。else: j = mtails[i] = numif j == res: res += 1return res
- 时间复杂度 O(n · log n)
- 空间复杂度 O(n)
152. 乘积最大子数组
力扣题目链接
给你一个整数数组 nums
,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。
示例 1:
输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:
输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
误区
本题是 53. 最大子数组和 的乘法版本,但不能套用代码,如下是错误的!
class Solution:def maxProduct(self, nums: List[int]) -> int:cur_pr = max_pr = nums[0]for num in nums[1:]:cur_pr = max(num, num * cur_pr)max_pr = max(cur_pr, max_pr)return max_pr
因为会遇到nums = [-2,3,-4]
这种“负负得正”的情况,正确结果是24
,但套用传统Kadane’s算法(如上)会导致-2
被提前丢弃,返回错误答案3
。—— 所以要调整思路【多维护一个变量】以应对“负负得正”的情况!
写法一:Modified Kadane’s
此方法参考自灵茶山艾府。
- 【思路】需要在遍历
nums
的同时,维护两个信息:- fmax[i]f_{max}[i]fmax[i]:右端点下标为 i 的子数组的最大乘积。
- fmin[i]f_{min}[i]fmin[i]:右端点下标为 i 的子数组的最小乘积。
- 遍历时,对每个元素
x = num[i]
,进行三种情况分类讨论但不用“人为分析“,直接取三种情况的max()
和min()
就能同时维护最大最小值。
其实就是比
53. 最大子数组和
的情况多维护了一个fmin[i]f_{min}[i]fmin[i](fmax[i]f_{max}[i]fmax[i]就是以上错误写法中的cur_pr
的数组形式。)
- 【问】fmin[i]f_{min}[i]fmin[i]为什么要加到
max(情况1, 情况2, 情况3)
中? - 【答】代表了 “负负得正” 情况,
f_min[i-1]
为负数时说明它是 “绝对值最大的负数”,而f_max[i-1]
表示 “绝对值最大的正数”。
- 所以面对
nums = [-2,3,-4]
,遍历到-4
时先前被错误抛弃的-2
被保留在f_min
中以待“转正”,转正后有可能比“最大正数”和“单独成段”的值更大。
class Solution:def maxProduct(self, nums: List[int]) -> int:n = len(nums)f_max = [0] * nf_min = [0] * nf_max[0] = f_min[0] = nums[0] # 初始化:以 nums[0] 为右端点的子数组乘积只能是 nums[0]for i in range(1, n):x = nums[i]# 把 x 加到右端点为 i-1 的(乘积最大/最小)子数组后面,# 或者单独组成一个子数组,只有 x 一个元素f_max[i] = max(f_max[i - 1] * x, f_min[i - 1] * x, x) # 分三类而不是两类,fmin考虑“负负得正”情况f_min[i] = min(f_max[i - 1] * x, f_min[i - 1] * x, x) # 同理,三类return max(f_max)
- 时间复杂度 O(n)
- 空间复杂度 O(n)
写法二:空间优化
此方法参考自灵茶山艾府
由于计算 f_max [i]
和 f_min [i]
只会用到 f_max [i−1]
和 f_min [i−1]
,不会用到更早的状态,所以可以用两个变量 f_max
和 f_min
滚动计算。
- 【具体优化有两点】
-
- 状态转移方程简化为(不用存储数组了):
fmax=max(fmax⋅x,fmin⋅x,x)fmin=min(fmax⋅x,fmin⋅x,x)f_{max} = max(f_{max}\cdot x,~f_{min}\cdot x,~ x)\\f_{min} = min(f_{max}\cdot x,~f_{min}\cdot x,~ x)fmax=max(fmax⋅x, fmin⋅x, x)fmin=min(fmax⋅x, fmin⋅x, x)
- 状态转移方程简化为(不用存储数组了):
-
- 可以初始化
f_max = f_min = 1
因为 1 乘以 nums[0 ]等于 nums[0],这样就可以从下标 0 开始遍历。
- 可以初始化
-
- 【注意一点】
f_max
和f_min
要 同时更新,不能写成两行! 若是写成如下两行,则更新f_min
时用的就是下一轮的f_max
了。
f_max = max(f_max * x, f_min * x, x)
f_min = min(f_max * x, f_min * x, x)
class Solution:def maxProduct(self, nums: List[int]) -> int:ans = -inf # 注意答案可能是负数!f_max = f_min = 1 # 初始化为1,for x in nums: # 则可以从i=0开始遍历f_max, f_min = max(f_max * x, f_min * x, x), \min(f_max * x, f_min * x, x)ans = max(ans, f_max)return ans
- 时间复杂度 O(n)
- 空间复杂度 O(1)
416. 分割等和子集
力扣题目链接
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。
“恰好装满”0-1背包
参考自灵茶山艾府,后续优化【翻译成递推、空间优化、二进制方法】请见原出处。
from functools import cache
class Solution:def canPartition(self, nums: List[int]) -> bool:@cachedef dfs(i, c):# 含义:nums[:i+1]中任选某几件,能否 刚好组成和为c的子序列if i < 0: return c == 0 # 边界时看容量c是否已经被装满(减为0)# 考虑nums[i]选或不选if c < nums[i]: # 只能不选return dfs(i-1, c)# 选 # 不选return dfs(i-1, c-nums[i]) or dfs(i-1, c)s = sum(nums) # 首先sum必须为偶数,不然整数数组凑不出小数的half值return s % 2 == 0 and dfs(len(nums) - 1, s // 2)
- 时间复杂度 O(ns),其中 n 是 nums 的长度,s 是 nums 的元素和(的一半)。由于每个状态只会计算一次,动态规划的时间复杂度=状态个数×单个状态的计算时间动态规划的时间复杂度 = 状态个数 × 单个状态的计算时间动态规划的时间复杂度=状态个数×单个状态的计算时间。本题状态个数等于 O(ns),单个状态的计算时间为 O(1),所以动态规划的时间复杂度为 O(ns)。
- 空间复杂度 O(ns),保存多少状态,就需要多少空间。
32. 最长有效括号
力扣题目链接
给你一个只包含 ‘(’ 和 ‘)’ 的字符串,找出最长有效(格式正确且连续)括号 子串 的长度。
左右括号匹配,即每个左括号都有对应的右括号将其闭合的字符串是格式正确的,比如 “(()())”。
示例 1:
输入:s = “(()”
输出:2
解释:最长有效括号子串是 “()”
示例 2:
输入:s = “)()())”
输出:4
解释:最长有效括号子串是 “()()”
示例 3:
输入:s = “”
输出:0
以下题解参考自powcai
法一、栈方法【括号匹配 + 排序+最长连续子数组】
对于这种括号匹配问题,一般都是使用栈
我们先找到所有可以匹配的索引号,然后找出最长连续数列!
- 例如:
s = )(()())
,我们用栈可以找到,- 位置
2
和位置3
匹配, - 位置
4
和位置5
匹配, - 位置
1
和位置6
匹配,
- 位置
这个数组为:2,3,4,5,1,6
这是通过栈找到的,我们按递增排序:1,2,3,4,5,6
找出该数组的最长连续数列的长度就是最长有效括号长度!
所以时间复杂度来自排序:O(nlogn)。
class Solution:def longestValidParentheses(self, s: str) -> int:if not s: return 0# 将匹配成功的下标放入res(用一个栈st辅助,括号匹配思想)res, st = [], []for i in range(len(s)):if s[i] == '(':st.append(i)if st and s[i] == ')':res.append(st.pop())res.append(i)res.sort()# 双指针遍历res,维护最长连续子数组长度maxLmaxL = 0n = len(res)l = r = 0while l < n:r = l # 开始,固定l遍历r,找到最大连续区间while r < n - 1 and res[r + 1] == res[r] + 1:r += 1maxL = max(maxL, r - l + 1)l = r + 1 # 收缩左边界至 本轮考虑过的区间 的右边(新开始一轮)return maxL
- 时间复杂度 O(n log n),复杂度主要来自排序的复杂度O(n log n)。
- 空间复杂度 O(n)
时间优化的栈方法【最推荐】
省略排序的过程,直接在弹栈时候进行操作。
class Solution:def longestValidParentheses(self, s: str) -> int:if not s: return 0maxL = 0st = [-1] # 初始哨兵,作为第一个参照点for i in range(len(s)):if s[i] == '(':st.append(i) # 压栈,等待匹配else: # 若遇右括号st.pop() # 先弹出(可能是匹配的'(',也可能是哨兵)if not st: # 若栈空了,说明当前')'无法匹配st.append(i) # 让它成为新的参照点(原始方法中的“左指针 l-1”)else: # 若匹配成功maxL = max(maxL, i - st[-1]) # 则更新最大长度return maxL
- 时间复杂度 O(n),只遍历一次
- 空间复杂度 O(n),栈最坏情况存储所有索引
法二、dp
我们用 dp[i]
表示以 i
结尾的最长有效括号;
当 s[i]
为 (
,dp[i]
必然等于 0,因为不可能组成有效的括号;
那么 s[i]
为 )
2.1 当 s[i-1]
为 (
,那么 dp[i] = dp[i-2] + 2
;
2.2 当 s[i-1]
为 )
并且 s[i-dp[i-1] - 1]
为 (
,那么 dp[i] = dp[i-1] + 2 + dp[i-dp[i-1]-2]
;
class Solution:def longestValidParentheses(self, s: str) -> int:n = len(s)if n == 0: return 0dp = [0] * nres = 0for i in range(n):if i>0 and s[i] == ")":if s[i - 1] == "(":dp[i] = dp[i - 2] + 2elif s[i - 1] == ")" 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]if dp[i] > res:res = dp[i]return res
- 时间复杂度 O(n)
- 空间复杂度 O(n)