文章目录
- 简介
- [139. 单词拆分](#139. 单词拆分)
- [300. 最长递增子序列](#300. 最长递增子序列)
- [152. 乘积最大子数组](#152. 乘积最大子数组)
- [416. 分割等和子集](#416. 分割等和子集)
- [32. 最长有效括号](#32. 最长有效括号)
- 个人学习总结
简介
本篇博客聚焦LeetCode热题100中的动态规划经典题目,涵盖单词拆分、最长递增子序列、乘积最大子数组、分割等和子集、最长有效括号五类问题。通过分析解题思想、状态转移逻辑及复杂度,深入理解动态规划在字符串处理、数组子序列、背包问题等场景的应用,掌握状态定义与转移的核心技巧。
139. 单词拆分
问题描述
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
示例:
java
示例 1:
输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以由 "leet" 和 "code" 拼接成。
示例 2:
输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。
注意,你可以重复使用字典中的单词。
标签提示: 字典树、记忆化搜索、哈希表、字符串、动态规划
解题思想
采用动态规划解决,定义状态 dp[i] 表示字符串 s 的前 i 个字符(即 s[0...i-1])是否可以被字典中的单词拆分。状态转移的核心逻辑是:若存在 j < i,使得前 j 个字符可拆分(dp[j] = true)且 s[j...i-1] 是字典中的单词,则前 i 个字符可拆分。状态转移方程为:
- dp[i]=dp[j] ∧ wordDict.contains(s[j...i−1])
其中 j 遍历 0 到 i-1,寻找满足条件的分割点。
解题步骤
- 数据预处理:将字典列表转换为 HashSet,以便快速判断子串是否存在于字典中。
- 状态初始化:创建长度为 s.length() + 1 的布尔数组 dp,初始化 dp[0] = true(空字符串视为可拆分)。
- 状态转移:外层循环遍历 i 从 1 到 s.length(),内层循环遍历 j 从 0 到 i-1,检查 dp[j] 是否为 true 且 s.substring(j, i) 是否在字典中。若满足,则 dp[i] = true 并跳出内层循环(无需继续检查更小的 j)。
- 结果返回:返回 dp[s.length()],表示整个字符串是否可拆分。
实现代码
java
class Solution {
// 状态转移方程:dp[i]=dp[j] && check(s[j..i−1])
public boolean wordBreak(String s, List<String> wordDict) {
// 利用HashSet存储单词表
Set<String> wordDictSet = new HashSet<>(wordDict);
// 初始化状态转移数组
boolean[] dp = new boolean[s.length() + 1];
dp[0] = true;
for(int i = 1; i < s.length() + 1; i ++){
// 判断能否有单词构成
for(int j = 0; j < i; j ++){
if(dp[j] && wordDictSet.contains(s.substring(j, i))){
dp[i] = true; // 能够由单词构成
break;
}
}
}
return dp[s.length()];
}
}
复杂度分析
- 时间复杂度: O ( n 2 ) O(n^2 ) O(n2)
外层循环执行 n 次(n 为字符串 s 的长度),内层循环对于每个 i 最多执行 i 次,总次数为 1 + 2 + ⋯ + n = n ( n + 1 ) 2 1+2+⋯+n = \frac{n(n+1)}{2} 1+2+⋯+n=2n(n+1),因此时间复杂度为 O ( n 2 ) O(n^2 ) O(n2)。 - 空间复杂度: O ( n ) O(n) O(n)
需要使用长度为 n+1 的数组存储中间状态。
300. 最长递增子序列
问题描述
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例:
java
示例 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
标签提示: 数组、二分查找、动态规划
解题思想
采用动态规划解决,定义状态 dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。对于每个元素 nums[i],需遍历其之前的所有元素 nums[j](j < i),若 nums[i] > nums[j],则 nums[i] 可接在 nums[j] 后面形成更长的递增子序列,此时 dp[i] 可更新为 dp[j] + 1。最终结果为所有 dp[i] 中的最大值。
解题步骤
- 状态初始化:创建长度为 n(n = nums.length)的数组 dp,初始化所有元素为 1(每个元素自身可构成长度为1的递增子序列)。
- 状态转移:遍历数组 nums,对于每个位置 i,再遍历 j 从 0 到 i-1:
若 nums[i] > nums[j],则更新 dp[i] = max(dp[i], dp[j] + 1)。 - 结果计算:遍历 dp 数组,取其中的最大值作为最终答案。
实现代码
java
class Solution {
// 贪心+二分查找
// 利用一个数组tails[i]来存储当前长度为i+1的递增子序列的最小尾部元素(这样就能更好的让子序列增长)
// 当num大于tails中所有元素,那么可以添加其尾部(长度加1);否则替换掉当前第一个大于num的元素
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1);
int ans = 0;
for(int i = 0; i < n; i ++){
for(int j = 0; j < i; j ++){
if(nums[i] > nums[j]){
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
ans = Math.max(ans, dp[i]);
}
return ans;
}
}
复杂度分析
- 时间复杂度: O ( n 2 ) O(n^2 ) O(n2)
外层循环执行 n 次,内层循环对于每个 i 最多执行 i 次,总次数为 1 + 2 + ⋯ + n = n ( n + 1 ) 2 1+2+⋯+n = \frac{n(n+1)}{2} 1+2+⋯+n=2n(n+1),因此时间复杂度为 O ( n 2 ) O(n^2 ) O(n2)。 - 空间复杂度: O ( n ) O(n) O(n)
需要使用长度为 n 的数组 dp 存储每个位置的最长递增子序列长度。
152. 乘积最大子数组
问题描述
给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续 子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。
请注意,一个只包含一个元素的数组的乘积是这个元素的值。
示例:
java
示例 1:
输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:
输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
标签提示: 数组、动态规划
解题思想
由于乘法运算中负数的影响(负数乘以负数会变为正数,可能成为新的最大乘积 ),需同时维护以当前元素结尾的最大乘积 和最小乘积。若仅记录最大值,当遇到负数时可能遗漏由最小负数转化而来的最大正数,因此需双状态转移。
解题步骤
- 状态定义:maxDp[i]:以 nums[i] 结尾的子数组的最大乘积;minDp[i]:以 nums[i] 结尾的子数组的最小乘积
- 状态初始化:maxDp[0] = minDp[0] = nums[0],初始化第一个元素的最大/最小乘积为自身。
- 状态转移:对于每个元素 nums[i](i ≥ 1),需考虑三种情况:
- nums[i] 自身作为子数组
- nums[i] 与前一个最大乘积相乘(可能为正数或负数)
- nums[i] 与前一个最小乘积相乘(负数乘负数可能转化为最大正数)
因此状态转移方程: - maxDp[i] = max(num, max(num * maxDp[i - 1], num * minDp[i - 1]));
- minDp[i] = min(num, min(num * minDp[i - 1], num * maxDp[i - 1]));
- 结果更新:遍历过程中维护全局最大值 ans = max(ans, maxDp[i]),最终返回 ans。
实现代码
java
class Solution {
// 最小的为自己本身,状态转移方程dp[i] = max(nums[i], nums[i] * dp[i - 1])
// 但是目前无法处理负元素(乘上一个负数,直接变最小积,但是后面再乘以一个负数又能变最大)
// 因此需要记录最大和最小(分别记录)
public int maxProduct(int[] nums) {
int n = nums.length;
int[] maxDp = new int[n];
int[] minDp = new int[n];
maxDp[0] = nums[0];
minDp[0] = nums[0];
int ans = nums[0];
for(int i = 1; i < n; i ++){
int num = nums[i];
int tmpMax = Math.max(num, Math.max(num * maxDp[i - 1], num * minDp[i - 1]));
int tmpMin = Math.min(num, Math.min(num * minDp[i - 1], num * maxDp[i - 1]));
maxDp[i] = tmpMax;
minDp[i] = tmpMin;
ans = Math.max(ans, maxDp[i]);
}
return ans;
}
}
复杂度分析
- 时间复杂度:O(n)
遍历数组一次,每次状态转移为常数时间操作,总时间复杂度为线性。 - 空间复杂度:O(n)
使用两个长度为 n 的数组 maxDp 和 minDp 存储中间状态。
416. 分割等和子集
问题描述
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例:
java
示例 1:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。
标签提示: 数组、动态规划
解题思想
将分割等和子集问题转化为子集和问题:若数组总和为sum,需判断是否存在子集和为sum/2(此时另一子集和也为sum/2,实现等和分割)。该问题等价于0/1背包问题,即从数组中选择若干元素,使其和为目标值target = sum/2。
解题步骤
- 总和判断:计算数组总和sum,若sum为奇数,直接返回false(无法分成两个等和子集)。
- 目标确定:令target = sum / 2,需判断是否存在子集和为target。
- 状态定义:用布尔数组dp[j]表示"能否用数组元素凑出和为j",初始化dp[0] = true(和为0时,不选元素即可满足)。
- 状态转移:遍历每个数字num,对每个j从target倒序遍历到num(倒序避免重复使用同一元素),更新状态:dp[j]=dp[j] ∣∣ dp[j−num](dp[j]表示不选num时能否凑出j,dp[j - num]表示选num时能否凑出j - num,两者取或)。
- 结果判断:最终返回dp[target],若为true则存在等和子集,否则不存在。
实现代码
java
class Solution {
// 分割等和,如果考虑两个子集怎么组合的话,会考虑因素有点多
// 可以转换一下为凑成总和一半的子集
// 这样就变成了0、1背包问题
public boolean canPartition(int[] nums) {
int sum = 0;
for(int num : nums){
sum += num;
}
if(sum % 2 == 1){
return false;
}
boolean[] dp = new boolean[sum/2 + 1];
dp[0] = true;
int target = sum / 2;
for(int num : nums){
for(int j = target; j >= num; j --){
dp[j] = dp[j] || dp[j - num];
}
if(dp[target]){
return true;
}
}
return dp[target];
}
}
复杂度分析
- 时间复杂度: O ( n × t a r g e t ) O(n×target) O(n×target)
外层循环遍历n个元素,内层循环遍历target(即sum/2)次,总操作次数为 n × t a r g e t n × target n×target。 - 空间复杂度: O ( t a r g e t ) O(target) O(target)
使用长度为target + 1的dp数组存储中间状态。
32. 最长有效括号
问题描述
给你一个只包含 '(' 和 ')' 的字符串,找出最长有效(格式正确且连续)括号 子串 的长度。
左右括号匹配,即每个左括号都有对应的右括号将其闭合的字符串是格式正确的,比如 "(()())"。
示例:
java
示例 1:
输入:s = "(()"
输出:2
解释:最长有效括号子串是 "()"
示例 2:
输入:s = ")()())"
输出:4
解释:最长有效括号子串是 "()()"
标签提示: 栈、字符串、动态规划
解题思想
采用动态规划,定义状态 dp[i] 表示以字符串第 i 个字符(索引从0开始)结尾的最长有效括号子串的长度。由于有效括号必须以 ')' 结尾,因此仅当 s[i] == ')' 时才可能更新 dp[i]。通过分情况讨论前一个字符的类型,推导状态转移方程。
解题步骤
- 状态定义:dp[i] 表示以 s[i] 结尾的最长有效括号子串的长度。
- 初始化:创建长度为 n(n = s.length())的数组 dp,初始值为 0(默认无有效子串)。
- 状态转移:遍历字符串,对每个位置 i(从1开始):
- 若 s[i] == ')' 且 s[i-1] == '('(直接匹配): d p [ i ] = ( i ≥ 2 ? d p [ i − 2 ] : 0 ) + 2 dp[i]=(i≥2?dp[i−2]:0)+2 dp[i]=(i≥2?dp[i−2]:0)+2
- 若 s[i] == ')' 且 s[i-1] == ')'(需检查前一个有效子串的前一个字符): 设 j = i - dp[i-1] - 1(前一个有效子串的前一个位置),若 j >= 0 且 s[j] == '(',则: d p [ i ] = d p [ i − 1 ] + 2 + ( j ≥ 1 ? d p [ j − 1 ] : 0 ) dp[i]=dp[i−1]+2+(j≥1?dp[j−1]:0) dp[i]=dp[i−1]+2+(j≥1?dp[j−1]:0)
- 结果提取:遍历 dp 数组,取最大值作为最终答案。
实现代码
java
class Solution {
// 动态规划解法(关键找准数组存什么信息,以及状态转移方程)
// dp[i]:存储当前i结尾的最长有效子串长度(不一定最长子串是以i结尾)
// 状态转移方程, 想要变长,首先当前位置得是')'
// 当i - 1 为'(', 那么匹配上了,dp[i] = dp[i - 2] + 2
// 当i - 1 为')',那么就需要去判断dp[i - 1]前面的字符(j),子串前面是否为'('了,是那么 dp[i] = dp[i - dp[i - 1] -2] + 2 + dp[i - 1];
public int longestValidParentheses(String s) {
int n = s.length();
if(n == 0){
return 0;
}
int[] dp = new int[n];
int ans = 0;
dp[0] = 0;
for(int i = 1; i < n; i ++){
if(s.charAt(i) == ')'){
if(s.charAt(i - 1) == '('){
dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
}else{
int j = i - dp[i - 1] - 1;
if(j >= 0 && s.charAt(j) == '('){
dp[i] = dp[i - 1] + 2;
if(j >= 1){
dp[i] += dp[j - 1];
}
}
}
}
ans = Math.max(ans, dp[i]);
}
return ans;
}
}
复杂度分析
- 时间复杂度:O(n)
遍历字符串一次,每个位置的状态转移为常数时间操作,总时间复杂度为线性。 - 空间复杂度:O(n)
使用长度为 n 的数组 dp 存储中间状态。
个人学习总结
- 动态规划核心:明确状态定义(如dp[i]表示以i结尾的最优解)和状态转移方程,是解决问题的关键。不同题目需灵活调整状态含义(如最长递增子序列的dp[i]、乘积最大子数组的双状态maxDp/minDp)。
- 问题转化思维:部分题目需通过转化简化(如分割等和子集转化为0/1背包问题),将复杂问题映射为已知模型,降低解题难度。
- 复杂度平衡:时间与空间复杂度需权衡(如最长有效括号的O(n)时间O(n)空间),优先选择高效算法(如贪心+二分查找优化最长递增子序列)。
- 边界与细节:注意特殊情况(如负数乘积、奇数总和、空字符串),确保状态转移覆盖所有场景,避免遗漏。
- 实践价值:动态规划是算法学习的核心技能,通过经典题目积累经验,提升解决实际问题的能力。