目录
[二、题 1:最长递增子序列(LeetCode 300)](#二、题 1:最长递增子序列(LeetCode 300))
[1. 题目描述](#1. 题目描述)
[2. 核心理论](#2. 核心理论)
[3. 详细思路分析](#3. 详细思路分析)
[(4)优化思路(贪心 + 二分)的推导](#(4)优化思路(贪心 + 二分)的推导)
[4. 解法 1:一维 DP(O (n²),基础易理解)](#4. 解法 1:一维 DP(O (n²),基础易理解))
[5. 解法 2:贪心 + 二分(O (nlogn),高效优化)](#5. 解法 2:贪心 + 二分(O (nlogn),高效优化))
[6. 易错点](#6. 易错点)
[7. 过程展示(示例nums=[10,9,2,5,3,7])](#7. 过程展示(示例nums=[10,9,2,5,3,7]))
[三、题 2:乘积最大子数组(LeetCode 152)](#三、题 2:乘积最大子数组(LeetCode 152))
[1. 题目描述](#1. 题目描述)
[2. 核心理论](#2. 核心理论)
[3. 详细思路分析](#3. 详细思路分析)
[4. 解法 1:二维 DP(O (n²),基础易理解)](#4. 解法 1:二维 DP(O (n²),基础易理解))
[5. 解法 2:一维变量优化(O (n),高效)](#5. 解法 2:一维变量优化(O (n),高效))
[6. 易错点](#6. 易错点)
[7. 过程展示(示例nums=[-4,-3,-2])](#7. 过程展示(示例nums=[-4,-3,-2]))
[四、题 3:分割等和子集(LeetCode 416)](#四、题 3:分割等和子集(LeetCode 416))
[1. 题目描述](#1. 题目描述)
[2. 核心理论](#2. 核心理论)
[3. 详细思路分析](#3. 详细思路分析)
[4. 解法 1:二维 DP(O (n×target),基础)](#4. 解法 1:二维 DP(O (n×target),基础))
[5. 解法 2:一维 DP(倒序,O (target),优化)](#5. 解法 2:一维 DP(倒序,O (target),优化))
[6. 易错点](#6. 易错点)
[7. 过程展示(示例nums=[1,5,11,5],target=11)](#7. 过程展示(示例nums=[1,5,11,5],target=11))
[五、题 4:最长有效括号(LeetCode 32)](#五、题 4:最长有效括号(LeetCode 32))
[1. 题目描述](#1. 题目描述)
[2. 核心理论](#2. 核心理论)
[3. 详细思路分析](#3. 详细思路分析)
[(3)状态转移的逻辑(仅处理s[i] = ')'的情况)](#(3)状态转移的逻辑(仅处理s[i] = ')'的情况))
[情况 1:s[i-1] = '('(直接配对)](#情况 1:s[i-1] = '('(直接配对))
[情况 2:s[i-1] = ')'(嵌套配对)](#情况 2:s[i-1] = ')'(嵌套配对))
[4. 解法 1:动态规划(O (n),基础)](#4. 解法 1:动态规划(O (n),基础))
[5. 解法 2:栈解法(O (n),非 DP)](#5. 解法 2:栈解法(O (n),非 DP))
[6. 易错点](#6. 易错点)
[7. 过程展示(示例s=")()())")](#7. 过程展示(示例s=")()())"))
[1. 核心共性](#1. 核心共性)
[2. 核心差异](#2. 核心差异)
[3. 解题技巧](#3. 解题技巧)
一、题型总览
本次总结覆盖 4 道动态规划核心进阶题,涵盖「子序列 / 子数组 / 子串」三类场景,每道题均提供两种核心解法(基础 DP / 优化 DP / 非 DP 解法),并拆解易错点、难点及核心逻辑:
| 题目 | 核心场景 | 解法 1(基础) | 解法 2(优化 / 非 DP) | 核心难点 |
|---|---|---|---|---|
| 最长递增子序列 | 非连续子序列 | 二维 / 一维 DP(O (n²)) | 贪心 + 二分(O (nlogn)) | dp [i] 定义(以 i 结尾) |
| 乘积最大子数组 | 连续子数组 | 二维 DP(O (n²)) | 一维变量优化(O (n)) | 负数反转最大 / 最小乘积 |
| 分割等和子集 | 01 背包(可行性) | 二维 DP(O (n×target)) | 一维 DP(倒序,O (target)) | 倒序遍历避免重复选 |
| 最长有效括号 | 连续子串 | 动态规划(O (n)) | 栈解法(O (n)) | 括号匹配的嵌套 + 拼接逻辑 |
二、题 1:最长递增子序列(LeetCode 300)
1. 题目描述
给一个整数数组nums,求最长严格递增子序列的长度(子序列非连续)。示例:nums=[10,9,2,5,3,7,101,18] → 输出 4([2,3,7,101])。
2. 核心理论
状态定义核心:dp[i] =以nums[i]结尾的最长严格递增子序列长度(必须以 i 结尾,而非前 i 个的全局最长)。
3. 详细思路分析
(1)状态定义的推导
- 子序列的核心是 "非连续",要找最长递增子序列,首先要拆解问题规模:如果想知道 "以
nums[i]结尾的最长递增子序列长度",只需要看nums[i]前面所有 比它小的元素nums[j](j < i),取dp[j] + 1的最大值(因为nums[i]可以接在nums[j]的子序列后面);- 若直接定义 "前 i 个元素的最长递增子序列长度",会无法处理 "非连续" 的特性(比如前 i 个的最长子序列可能不包含
nums[i],无法推导后续状态),因此 "以 i 结尾" 是更合理的拆分方式。
(2)初始化的原因
- 每个元素自身可以构成一个长度为 1 的递增子序列(哪怕前面没有比它小的元素),因此
dp[i]初始值必须为 1,而非默认的 0(若初始为 0,会导致单个元素的子序列长度被错误计算)。
(3)状态转移的逻辑
- 遍历
i从 1 到 n-1(因为要对比前面的元素,0的情况已经初始化),对每个i,遍历j从 0 到 i-1:
- 若
nums[i] > nums[j]:说明nums[i]可以接在以nums[j]结尾的子序列后,此时dp[i]= max(dp[i],dp[j] + 1);- 若
nums[i] ≤ nums[j]:说明无法接在nums[j]后,dp[i]保持原值;- 最终需要维护一个全局
maxLen,因为最长子序列不一定以最后一个元素结尾(比如示例中最长子序列结尾是 101,而非 18)。
(4)优化思路(贪心 + 二分)的推导
- 基础 DP 的时间复杂度是 O (n²),瓶颈在于 "遍历 j 找所有比
nums[i]小的元素"; - 贪心思路:要让递增子序列尽可能长,需要让子序列的结尾元素尽可能小(比如
[2,5]比[2,7]更容易接后续元素),因此维护tails数组记录 "长度为 k+1 的递增子序列的最小结尾元素"; - 二分的作用:遍历
nums时,用二分快速找到nums[i]在tails中的位置,替换或追加,从而将时间复杂度降到 O (nlogn)。
4. 解法 1:一维 DP(O (n²),基础易理解)
状态定义
dp[i]:以nums[i]结尾的最长严格递增子序列长度。
初始化
dp[i] = 1(每个元素自身构成长度为 1 的子序列)。
状态转移
java
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
完整代码
java
class Solution {
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); // 初始化每个dp[i]=1
int maxLen = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
}
5. 解法 2:贪心 + 二分(O (nlogn),高效优化)
核心思路
维护tails数组:tails[i] = 长度为i+1的递增子序列的最小结尾元素(贪心选最小结尾,为后续留更大空间)。
完整代码
java
class Solution {
public int lengthOfLIS(int[] nums) {
int[] tails = new int[nums.length];
int len = 0; // tails的有效长度
for (int num : nums) {
// 二分找第一个≥num的位置
int left = 0, right = len;
while (left < right) {
int mid = left + (right - left) / 2;
if (tails[mid] < num) left = mid + 1;
else right = mid;
}
tails[left] = num;
if (left == len) len++; // 插入到末尾,长度+1
}
return len;
}
}
6. 易错点
dp[i]未初始化为 1(默认 0 导致结果错误);- 直接返回
dp[n-1](忽略 "最长子序列不一定以最后一个元素结尾"); - 贪心解法中二分边界错误(比如
right=len-1导致漏解)。
7. 过程展示(示例nums=[10,9,2,5,3,7])
| i | nums[i] | dp[i] | tails 数组(解法 2) |
|---|---|---|---|
| 0 | 10 | 1 | [10] |
| 1 | 9 | 1 | [9] |
| 2 | 2 | 1 | [2] |
| 3 | 5 | 2 | [2,5] |
| 4 | 3 | 2 | [2,3] |
| 5 | 7 | 3 | [2,3,7] |
三、题 2:乘积最大子数组(LeetCode 152)
1. 题目描述
给整数数组nums,求连续 子数组的最大乘积。示例:nums=[2,3,-2,4] → 输出 6([2,3]);nums=[-2,0,-1] → 输出 0。
2. 核心理论
乘积的特殊性:负数 × 负数 = 正数,需同时维护「以 i 结尾的最大乘积」和「最小乘积」(最小乘积可能是负数,乘下一个负数变最大)。
3. 详细思路分析
(1)状态定义的推导
- 子数组的核心是 "连续",因此状态定义需绑定 "以 i 结尾":
dpMax[i]= 以nums[i]结尾的最大乘积,dpMin[i]= 以nums[i]结尾的最小乘积;- 若只维护
dpMax,会漏掉 "负数 × 负数 = 正数" 的情况(比如nums=[-4,-3,-2],dpMax[1]=12,若没有dpMin,无法推导dpMax[2])。
(2)初始化的原因
- 连续子数组至少包含一个元素,因此
dpMax[0] = dpMin[0] = nums[0](第一个元素的最大 / 最小乘积都是自身);- 全局
result初始化为nums[0],因为至少有一个元素,最大乘积不会小于第一个元素。
(3)状态转移的逻辑
对每个i ≥ 1,以nums[i]结尾的乘积有 3 种可能:
- 只选
nums[i](前面的乘积为负,单独选更优); dpMax[i-1] × nums[i](前面的最大乘积 × 当前元素);dpMin[i-1] × nums[i](前面的最小乘积 × 当前元素,负数 × 负数可能变最大);因此:
dpMax[i] = max(nums[i], dpMax[i-1]×nums[i], dpMin[i-1]×nums[i]);dpMin[i] = min(nums[i], dpMax[i-1]×nums[i], dpMin[i-1]×nums[i])。
(4)优化思路(一维变量)的推导
- 基础二维 DP 需要 O (n) 空间存储
dpMax和dpMin,但观察到dpMax[i]/dpMin[i]仅依赖前一个状态dpMax[i-1]/dpMin[i-1],因此可用两个变量preMax/preMin替代数组,将空间复杂度降到 O (1); - 注意:更新
preMin时需要用到原始的preMax,因此需先临时保存preMax,避免覆盖。
4. 解法 1:二维 DP(O (n²),基础易理解)
状态定义
dpMax[i][j]:子数组nums[i...j]的最大乘积;dpMin[i][j]:子数组nums[i...j]的最小乘积。
完整代码
java
class Solution {
public int maxProduct(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int n = nums.length;
int[][] dpMax = new int[n][n];
int[][] dpMin = new int[n][n];
int max = nums[0];
// 初始化:子数组长度为1时,最大/最小都是自身
for (int i = 0; i < n; i++) {
dpMax[i][i] = nums[i];
dpMin[i][i] = nums[i];
max = Math.max(max, nums[i]);
}
// 遍历子数组长度(从2到n)
for (int len = 2; len <= n; len++) {
for (int i = 0; i + len <= n; i++) {
int j = i + len - 1;
int val1 = dpMax[i][j-1] * nums[j];
int val2 = dpMin[i][j-1] * nums[j];
dpMax[i][j] = Math.max(nums[j], Math.max(val1, val2));
dpMin[i][j] = Math.min(nums[j], Math.min(val1, val2));
max = Math.max(max, dpMax[i][j]);
}
}
return max;
}
}
5. 解法 2:一维变量优化(O (n),高效)
状态定义
preMax:以nums[i-1]结尾的最大乘积;preMin:以nums[i-1]结尾的最小乘积。
完整代码
java
class Solution {
public int maxProduct(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int preMax = nums[0], preMin = nums[0], result = nums[0];
for (int i = 1; i < nums.length; i++) {
int cur = nums[i];
// 临时保存preMax,避免更新后被覆盖
int tempMax = preMax;
preMax = Math.max(cur, Math.max(tempMax * cur, preMin * cur));
preMin = Math.min(cur, Math.min(tempMax * cur, preMin * cur));
result = Math.max(result, preMax);
}
return result;
}
}
6. 易错点
- 仅维护最大乘积(忽略负数反转的情况);
- 初始化用
Integer.MAX_VALUE(导致溢出); - 未临时保存
preMax(更新preMin时用了已修改的preMax)。
7. 过程展示(示例nums=[-4,-3,-2])
| i | nums[i] | preMax | preMin | result |
|---|---|---|---|---|
| 0 | -4 | -4 | -4 | -4 |
| 1 | -3 | 12 | -3 | 12 |
| 2 | -2 | 6 | -24 | 12 |
四、题 3:分割等和子集(LeetCode 416)
1. 题目描述
给正整数数组nums,判断能否分割为两个子集,使子集和相等。示例:nums=[1,5,11,5] → 输出 true;nums=[1,2,3,5] → 输出 false。
2. 核心理论
转化为 01 背包可行性问题:总和s为偶数时,判断能否选若干数凑出s/2(每个数只能选 1 次)。
3. 详细思路分析
(1)问题转化的推导
- 分割为两个和相等的子集 → 总和
s必须是偶数(否则无法平分),目标和target = s/2;- 问题等价于 "从数组中选若干数(每个数只能选 1 次),使和为 target"------ 这是典型的 01 背包 "可行性判断" 问题(区别于 "最大价值""方案数")。
(2)状态定义的推导
- 01 背包的核心是 "选 / 不选第 i 个物品",因此状态定义为
dp[i][j]:前i个数能否凑出和为j;- 维度拆解:
i表示 "前 i 个数"(物品维度),j表示 "目标和"(容量维度)。
(3)初始化的原因
dp[i][0] = true:前 i 个数凑和为 0,只需不选任何数,一定可行(背包容量为 0 时,空集永远满足);dp[0][j] = false(j > 0):没有数可选时,无法凑出大于 0 的和。
(4)状态转移的逻辑
对第i个数(nums[i-1]),分两种情况:
- 不选:
dp[i][j] = dp[i-1][j](前 i-1 个数能凑 j,前 i 个数也能); - 选:若
j ≥ nums[i-1],则dp[i][j] = dp[i-1][j - nums[i-1]](前 i-1 个数能凑j - nums[i-1],加上当前数就凑 j);最终dp[i][j] = 不选 || 选(只要有一个可行,结果就为 true)。
(5)一维优化的推导
- 二维 DP 的空间复杂度是 O (n×target),观察到
dp[i][j]仅依赖dp[i-1][...](上一行),因此可压缩为一维数组dp[j]; - 01 背包的关键:一维数组需倒序遍历容量(从 target 到 nums [i]),避免同一数被重复选(正序遍历会变成 "完全背包",数可重复选)。
4. 解法 1:二维 DP(O (n×target),基础)
状态定义
dp[i][j]:前i个数能否凑出和为j。
完整代码
java
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num : nums) sum += num;
if (sum % 2 != 0) return false;
int target = sum / 2;
int n = nums.length;
boolean[][] dp = new boolean[n + 1][target + 1];
// 初始化:前i个数凑0一定可行
for (int i = 0; i <= n; i++) dp[i][0] = true;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= target; j++) {
if (j >= nums[i-1]) {
dp[i][j] = dp[i-1][j] || dp[i-1][j - nums[i-1]];
} else {
dp[i][j] = dp[i-1][j];
}
}
}
return dp[n][target];
}
}
5. 解法 2:一维 DP(倒序,O (target),优化)
核心思路
倒序遍历容量,避免同一数被重复选(01 背包核心)。
完整代码
java
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num : nums) sum += num;
if (sum % 2 != 0) return false;
int target = sum / 2;
boolean[] dp = new boolean[target + 1];
dp[0] = true; // 凑0一定可行
for (int num : nums) {
// 倒序遍历:从target到num,避免重复选
for (int j = target; j >= num; j--) {
dp[j] = dp[j] || dp[j - num];
}
if (dp[target]) return true; // 提前剪枝
}
return dp[target];
}
}
6. 易错点
- 未判断总和奇偶性(直接 DP 导致无效计算);
- 一维 DP 正序遍历(变成完全背包,重复选数);
- 二维 DP 遗漏
j < nums[i-1]时的状态继承。
7. 过程展示(示例nums=[1,5,11,5],target=11)
| 遍历数字 | dp 数组(一维) |
|---|---|
| 初始 | [T,F,F,...F] |
| 1 | [T,T,F,...F] |
| 5 | [T,T,F,F,F,T,T,...F] |
| 11 | [T,T,F,F,F,T,T,...T] |
五、题 4:最长有效括号(LeetCode 32)
1. 题目描述
给只含'('和')'的字符串s,求最长有效括号子串的长度(子串连续)。示例:s=")()())" → 输出 4;s="(()" → 输出 2。
2. 核心理论
状态定义核心:dp[i] = 以s[i]结尾的最长有效括号长度(以 i 结尾 ,'('结尾时 dp [i]=0)。
3. 详细思路分析
(1)状态定义的推导
- 子串的核心是 "连续",因此状态需绑定 "以 i 结尾":只有知道以
i结尾的有效长度,才能推导以i+1结尾的长度;- 有效括号的特性:必须以
')'结尾(以'('结尾的子串永远无法闭合,因此dp[i]=0)。
(2)初始化的原因
dp数组初始值为 0:未处理时,所有位置的有效长度默认 0;- 全局
maxLen初始值为 0:初始时无有效子串。
(3)状态转移的逻辑(仅处理s[i] = ')'的情况)
情况 1:s[i-1] = '('(直接配对)
s[i]和s[i-1]组成"()",长度为 2;- 若
i ≥ 2,还需加上以i-2结尾的有效长度(比如"()()",i=3时需加上i=1的长度 2); - 公式:
dp[i] = (i ≥ 2 ? dp[i-2] : 0) + 2。
情况 2:s[i-1] = ')'(嵌套配对)
- 先找到以
i-1结尾的有效子串的 "前一个字符":pos = i - dp[i-1] - 1; - 若
pos ≥ 0且s[pos] = '(',说明s[i]能和s[pos]配对,长度为dp[i-1] + 2; - 若
pos ≥ 1,还需加上以pos-1结尾的有效长度(比如"()(())",i=5时需加上i=1的长度 2); - 公式:
dp[i] = dp[i-1] + 2 + (pos-1 ≥ 0 ? dp[pos-1] : 0)。
(4)栈解法的推导
- DP 解法依赖状态推导,栈解法更直观:用栈记录 "最后一个无效括号的下标";
- 遇到
'('压入下标,遇到')'弹出栈顶(匹配'(');- 若栈空,压入当前下标(更新无效下标);否则,有效长度 = 当前下标 - 栈顶下标。
4. 解法 1:动态规划(O (n),基础)
状态转移
- 情况 1:
s[i]=')'且s[i-1]='('→dp[i] = dp[i-2] + 2; - 情况 2:
s[i]=')'且s[i-1]=')'→ 若s[i-dp[i-1]-1]='(',则dp[i] = dp[i-1] + 2 + dp[i-dp[i-1]-2]。
完整代码
java
运行
java
class Solution {
public int longestValidParentheses(String s) {
int n = s.length();
if (n < 2) return 0;
int[] dp = new int[n];
int maxLen = 0;
for (int i = 1; i < n; i++) {
if (s.charAt(i) == ')') {
// 情况1:前一个是'('
if (s.charAt(i-1) == '(') {
dp[i] = (i >= 2 ? dp[i-2] : 0) + 2;
}
// 情况2:前一个是')',找更前面的'('
else if (i - dp[i-1] > 0 && s.charAt(i - dp[i-1] - 1) == '(') {
dp[i] = dp[i-1] + 2 + (i - dp[i-1] - 2 >= 0 ? dp[i - dp[i-1] - 2] : 0);
}
maxLen = Math.max(maxLen, dp[i]);
}
}
return maxLen;
}
}
5. 解法 2:栈解法(O (n),非 DP)
核心思路
用栈记录 "最后一个无效括号的下标",有效长度 = 当前下标 - 栈顶下标。
完整代码
java
class Solution {
public int longestValidParentheses(String s) {
Deque<Integer> stack = new LinkedList<>();
stack.push(-1); // 初始无效下标
int maxLen = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == '(') {
stack.push(i); // 压入'('的下标
} else {
stack.pop(); // 弹出栈顶(匹配'(')
if (stack.isEmpty()) {
stack.push(i); // 无匹配,更新无效下标
} else {
maxLen = Math.max(maxLen, i - stack.peek());
}
}
}
return maxLen;
}
}
6. 易错点
dp[i]定义错误(未关注 "以 i 结尾",误算全局最长);- 下标越界(未判断
i-dp[i-1]-1 >=0); - 处理
'('结尾的情况(实际无需处理,dp [i]=0)。
7. 过程展示(示例s=")()())")
| i | s[i] | dp[i] | maxLen |
|---|---|---|---|
| 0 | ')' | 0 | 0 |
| 1 | '(' | 0 | 0 |
| 2 | ')' | 2 | 2 |
| 3 | '(' | 0 | 2 |
| 4 | ')' | 4 | 4 |
| 5 | ')' | 0 | 4 |
六、整体总结
1. 核心共性
- 状态定义:多以 "以 i 结尾" 为核心(子序列 / 子数组 / 子串的 DP 通用技巧);
- 初始化:基准状态(如
dp[0]=1/dp[0]=true)是 DP 的基础; - 边界处理:下标越界、特殊值(如负数、空串)需优先判断。
2. 核心差异
| 场景 | 关键逻辑 |
|---|---|
| 非连续子序列 | 遍历前面所有元素,取最优解 |
| 连续子数组 / 串 | 仅依赖前一个 / 前若干个状态 |
| 背包问题 | 遍历顺序决定 "选 1 次 / 选多次" |
3. 解题技巧
- 先理解 "基础 DP 解法"(二维 / 暴力),再优化(一维 / 贪心 / 非 DP);
- 遇到 "乘积 / 括号" 等特殊约束,需补充辅助状态(如最小乘积、栈记录下标);
- 状态定义的核心是 "缩小问题规模"(以 i 结尾→只关注 i 的局部,推导全局)。
