跳到主要内容回溯算法与动态规划核心知识点及 Java 实现总结 | 极客日志Javajava算法
回溯算法与动态规划核心知识点及 Java 实现总结
综述由AI生成总结了回溯算法与动态规划的核心知识点及 Java 实现。回溯部分涵盖组合、排列、子集、切割、棋盘五大场景,强调 startIndex 与 used 数组的区别、剪枝优化及去重技巧。动态规划部分详解线性 DP、背包问题(01/完全/分组)、子序列/子串 DP、区间 DP、树形 DP 及状态压缩 DP,重点讲解状态定义、转移方程、初始条件、遍历顺序及空间优化。文章提供通用代码模板与高频 LeetCode 例题,适合后端面试准备。
星星泡饭19 浏览 回溯算法部分
回溯算法是后端面试中等题核心考察点(占比 60%+),本质是'深度优先搜索(DFS)+ 状态回退'的暴力搜索优化,核心解决'多阶段决策'类问题(组合、排列、子集、切割、棋盘)。
核心知识点
✅ 核心定义与本质
回溯算法 = 递归(深度优先遍历) + 状态回退(撤销选择),本质是'暴力枚举所有可能的决策路径':
- 对每一个决策阶段,先'选择'一个选项 → 递归探索后续路径 → 递归返回后'撤销选择' → 尝试下一个选项;
- 与纯暴力枚举的区别:通过'撤销选择'复用同一容器存储路径,节省空间,且能通过剪枝提前排除无效路径(优化时间)。
✅ 核心三要素(回溯的'灵魂')
| 要素 | 含义 | Java 实现方式 |
|---|
| 路径 | 已做出的选择(如组合问题中已选的数字、N 皇后中已放皇后的位置) | List<Integer> path、StringBuilder |
| 选择列表 | 当前阶段可选择的选项(如组合问题中未选的数字、棋盘问题中可放皇后的列) | 数组/集合、boolean[] used(去重) |
| 结束条件 | 到达决策树底部,需记录结果(如组合长度达标、棋盘遍历完成) | 路径长度==目标长度、索引越界等 |
✅ 时间&空间复杂度(二刷必懂,应对面试官追问)
- 时间复杂度:本质是暴力枚举,通常为指数级/阶乘级:
- 组合/子集问题:O(2^n)(每个元素选或不选);
- 排列问题:O(n!)(n 个元素的全排列);
- 剪枝能减少实际执行时间,但理论复杂度不变。
- 空间复杂度:O(n)(递归栈深度 + 路径存储容器,n 为决策阶段数)。
✅ Java 实现核心要点(避坑基础)
- 容器回溯的核心:Java 中 List/StringBuilder 是引用传递,递归中添加元素后,必须在递归返回后'撤销选择'(如
path.remove(path.size()-1)),否则会污染其他路径;
- 去重方式:
- 排列问题:
boolean[] used(标记元素是否已选,避免重复选同一元素);
- 有重复元素的组合/排列:先排序 +
used 数组(跳过同一层重复元素);
- 剪枝时机:在'选择'前判断(如组合总和超过目标值时直接 return),而非'选择后',最大化减少无效递归;
- 结果集存储:需新建容器存储路径(如
res.add(new ArrayList<>(path))),而非直接 add(path)(引用传递会导致结果集被后续修改)。
✅ 回溯与递归/DFS 的关系
- 递归是回溯的实现载体:回溯必须通过递归实现深度优先遍历;
- DFS 是回溯的遍历方式:回溯的核心是'选 - 试 - 撤',DFS 是遍历决策树的手段;
- 区别:纯 DFS 仅遍历所有节点,回溯多了'选择 - 撤销'的状态管理。
技巧方法
回溯 90% 的高频题可归为 5 类场景,每类场景都有'通用模板 + 专属剪枝/去重技巧',二刷需逐个固化。
✅ 技巧一:组合问题(★★★★★ 占比 30%+,核心中的核心)
核心定位
考察'无顺序的元素选择'(如选 [1,2] 和 [2,1] 视为同一组合),后端面试中等题高频(如组合总和、电话号码的字母组合)。
核心原理
- 组合的核心:不重复选(同一元素仅选一次) + 不考虑顺序;
- 控制顺序的关键:通过'起始索引'(startIndex)限制选择列表,避免重复组合(如选 1 后,后续仅从 2 开始选,而非从 0 重新选)。
适用场景
- 无重复元素组合(LeetCode 77)、有重复元素组合(LeetCode 40);
- 组合总和(LeetCode 39、40、216)、电话号码的字母组合(LeetCode 17)。
核心思路(四步法,必背)
- 参数定义:递归函数需包含「路径 path、结果集 res、起始索引 startIndex、目标值/其他约束」;
- 结束条件:路径长度达标(如组合长度 k)或路径和达标(如组合总和=target),将 path 拷贝加入 res;
- 遍历选择列表:从 startIndex 开始遍历(避免重复组合):
- 剪枝:提前排除无效选择(如组合总和超过 target,直接 break);
- 选择:将当前元素加入 path;
- 递归:传入 startIndex+1(同一元素仅选一次),探索后续路径;
- 撤销选择:从 path 中移除当前元素(回溯);
- 去重(有重复元素时):先排序数组 + 遍历中跳过同一层重复元素(
i>startIndex && nums[i]==nums[i-1] && !used[i-1])。
高频经典题
- LeetCode 77. 组合(基础模板题,无重复元素,二刷速过);
- LeetCode 39. 组合总和(无重复元素,元素可重复选,核心改 startIndex 为 i);
- LeetCode 40. 组合总和 II(有重复元素,需排序+used 去重);
- LeetCode 17. 电话号码的字母组合(多维度选择,无 startIndex,用索引遍历数字)。
避坑点
- 用 startIndex 控制顺序,而非 used 数组:组合无需 used(排列才需要),startIndex 是避免重复组合的核心(如选 1 后仅从 2 开始选);
- 元素可重复选时,递归 startIndex 传 i 而非 i+1:如组合总和中,数字可重复选,需从当前 i 继续,而非 i+1;
- 有重复元素时未排序:排序是去重的前提(同一层重复元素相邻,便于跳过);
- 结果集直接 add(path):Java 中 path 是引用,需
res.add(new ArrayList<>(path)),否则结果集所有元素指向同一 path;
- 剪枝时机错误:如组合总和中,需在遍历前判断
sum + nums[i] > target 则 break(而非 continue),因为数组已排序,后续元素更大。
✅ 技巧二:排列问题(★★★★ 占比 20%+,去重是核心)
核心定位
考察'有顺序的元素选择'(如 [1,2] 和 [2,1] 视为不同排列),后端面试中常与'去重'结合考察(有重复元素的排列)。
核心原理
- 排列的核心:元素可全选 + 考虑顺序;
- 去重的关键:
- 已选元素不能重复使用:
boolean[] used 数组标记元素是否已选,避免同一路径重复选;
- 相同结果的去重:有重复元素时,需排序 + 跳过同一层重复且未选的元素。
适用场景
- 无重复元素排列(LeetCode 46)、有重复元素排列(LeetCode 47);
- 字符串的全排列(含重复字符)。
核心思路(四步法)
- 参数定义:递归函数包含「路径 path、结果集 res、原数组 nums、used 数组」;
- 结束条件:path 长度==nums.length,将 path 拷贝加入 res;
- 遍历选择列表:从 0 开始遍历(排列需考虑所有未选元素):
- 剪枝/去重:
- 已选元素跳过(
used[i]==true);
- 有重复元素时,跳过同一层重复元素(
i>0 && nums[i]==nums[i-1] && !used[i-1]);
- 注意:之所以有!used[i-1] 是因为如果 used[i - 1] 为 true 的话,那么说明这个元素已经被其他层级使用过了,并不是当前层级的。并且使用 nums[i]==nums[i-1] 进行去重,需要对数组进行排序才能行之有效!
- 选择:标记
used[i]=true,将 nums[i] 加入 path;
- 递归:探索后续路径;
- 撤销选择:标记
used[i]=false,从 path 移除 nums[i];
- 收尾:无额外处理,递归结束后 res 即为所有排列。
高频经典题
- LeetCode 46. 全排列(无重复元素,基础模板题);
- LeetCode 47. 全排列 II(有重复元素,排序+used 去重核心)。
避坑点
- 排列用 startIndex 而非 used:排列需从 0 遍历所有元素,startIndex 是组合的专属,排列必须用 used 标记已选元素;
- 有重复元素时,去重条件错误:需
!used[i-1](同一层重复)而非 used[i-1](同一路径重复),前者跳过同一层重复,后者跳过同一路径重复(会漏解);
- 未排序直接去重:重复元素不相邻时,无法通过
nums[i]==nums[i-1] 判断,必须先排序;
- 递归参数传递 used 数组时拷贝:Java 中数组是引用传递,无需拷贝(回溯时修改 used[i] 即可),拷贝会增加时间复杂度。
✅ 易错点:组合与排序的解题区别
- 组合:
startIndex 控顺序,选过的元素后面不再选,结果是集合;
- 排列:
used 数组控已选,选过的元素同一路径不再选,结果是序列。
其实可以再抽象一层,因为很多题目不会直接告诉你是组合还是排序:顺序层面:考虑顺序 used,不考虑顺序用 offset 有重复元素的去重:排序是前提,考虑顺序那么去重的时候除了与前面一个元素相同,还要是当前层的元素
| 维度 | 组合问题 | 排列问题 |
|---|
| 核心本质 | 从 n 个元素中选 k 个元素,不考虑顺序 | 从 n 个元素中选 k 个元素,考虑顺序 |
| 结果特征 | [1,2] 和 [2,1] 视为同一个组合,只保留一个 | [1,2] 和 [2,1] 视为两个不同排列,都需要记录 |
| 核心目标 | 找出所有满足条件的'元素集合'(无顺序) | 找出所有满足条件的'元素序列'(有顺序) |
| 举例 | 从 [1,2,3] 选 2 个元素:[[1,2],[1,3],[2,3]] | 从 [1,2,3] 选 2 个元素:[[1,2],[2,1],[1,3],[3,1],[2,3],[3,2]] |
✔ 解题关键方法差异(回溯核心逻辑)
这是二者最核心的区别,直接决定代码的参数设计和遍历逻辑。
| 解题关键 | 组合问题 | 排列问题 |
|---|
| 控制顺序的核心手段 | startIndex 起始索引:限制下一层递归只能从当前元素的下一个位置开始选,避免重复组合 | used[] 标记数组:标记元素是否被选中,确保同一元素不会在同一路径中重复出现,同时允许从任意未选元素开始选 |
| 递归参数 | 必须传入 startIndex,无需 used 数组 | 必须传入 used 数组,无需 startIndex |
| 遍历范围 | for (int i = startIndex; i < n; i++):从 startIndex 开始,保证顺序 | for (int i = 0; i < n; i++):从 0 开始遍历所有元素,通过 used 过滤已选元素 |
| 回溯的核心目的 | 用 startIndex 规避顺序重复,无需去重标记 | 用 used 标记元素的选中状态,递归后恢复状态(used[i] = false) |
✔ 有重复元素时的去重逻辑差异
当原数组包含重复元素时(如 [1,1,2]),二者的去重策略都需要先排序,但去重条件不同,核心是区分'同一层重复'和'同一路径重复'。
理解:重复元素的去重都是针对同一层的元素。组合问题中,因为存在偏移量,所以 offset 右边的元素都是没有被选取的(保证了顺序性),所以去重的时候只需要保证在当层中与前面一个元素不一样就行。而在排序问题中,不讲究顺序,通过 used 控制每一个元素只取一遍, 而在当层理论上是可以拿到所有元素的,而实际上只有未被使用过的才是实际上当前层的元素(也就是 used[i] 为 false),所以我们在去重的时候,除了保证与前一个元素要一样之外,还要确保前一个元素并没有被使用。
| 去重步骤 | 组合问题(如组合总和 II) | 排列问题(如全排列 II) |
|---|
| 前置操作 | 必须先对数组排序(Arrays.sort(nums)),让重复元素相邻 | 必须先对数组排序(Arrays.sort(nums)),让重复元素相邻 |
| 去重条件 | i > startIndex && nums[i] == nums[i-1] | i > 0 && nums[i] == nums[i-1] && !used[i-1] |
| 条件含义 | - i > startIndex:保证是同一层的重复元素(而非同一路径的下一个元素) - 跳过同一层重复元素,避免生成重复组合 | - !used[i-1]:保证是同一层的重复元素(若 used[i-1]=true 则是同一路径的上一个元素) - 跳过同一层重复元素,避免生成重复排列 |
| 核心原理 | 同一层中,相同元素只选第一个,后续的跳过 | 同一层中,相同元素若前一个未被选,则当前元素跳过(避免重复) |
- 组合问题模板(无重复元素,如 LeetCode 77. 组合)
class Combine {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
dfs(n, k, 1);
return res;
}
private void dfs(int n, int k, int startIndex) {
if (path.size() == k) {
res.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i <= n; i++) {
path.add(i);
dfs(n, k, i + 1);
path.remove(path.size() - 1);
}
}
}
- 排列问题模板(无重复元素,如 LeetCode 46. 全排列)
class Permute {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
boolean[] used;
public List<List<Integer>> permute(int[] nums) {
used = new boolean[nums.length];
dfs(nums);
return res;
}
private void dfs(int[] nums) {
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue;
used[i] = true;
path.add(nums[i]);
dfs(nums);
path.remove(path.size() - 1);
used[i] = false;
}
}
}
✅ 技巧三:子集问题(★★★★ 占比 15%+,组合的变种)
核心定位
子集是'所有可能的组合(包括空集)',核心是'遍历过程中每一步都记录结果',而非仅在结束条件记录,后端场景中对应'权限子集、配置子集'。
核心原理
- 子集的核心:每个元素有'选'或'不选'两种选择,遍历决策树的所有节点(而非仅叶子节点);
- 与组合的区别:组合仅记录叶子节点(路径长度达标),子集记录所有节点(每一步的路径都是一个子集)。
适用场景
- 无重复元素子集(LeetCode 78)、有重复元素子集(LeetCode 90);
- 子集求和(如子集的和为目标值)。
核心思路(四步法)
- 参数定义:递归函数包含「路径 path、结果集 res、起始索引 startIndex、原数组 nums」;
- 结果记录:进入递归后,先将当前 path 拷贝加入 res(每一步都是一个子集);
- 遍历选择列表:从 startIndex 开始遍历(避免重复子集):
- 去重(有重复元素时):跳过同一层重复元素(
i>startIndex && nums[i]==nums[i-1]);
- 选择:将 nums[i] 加入 path;
- 递归:传入 startIndex+1,探索后续路径;
- 撤销选择:从 path 移除 nums[i];
- 结束条件:startIndex >= nums.length(递归自然终止,无需额外判断)。
高频经典题
- LeetCode 78. 子集(无重复元素,基础模板题);
- LeetCode 90. 子集 II(有重复元素,排序 + 跳过同一层重复)。
避坑点
- 结果记录时机错误:子集需在递归开头记录(每一步都是子集),而非结束条件(否则仅记录全量元素);
- 用 used 数组去重:子集无需 used,仅需 startIndex+ 排序去重(组合的去重逻辑);
- 空集未记录:递归开头记录 path,空集会被自然记录(初始 path 为空,第一次递归就 add);
- 有重复元素时未排序:导致同一层重复元素无法被跳过,子集重复。
✅ 技巧四:切割问题(★★★ 占比 10%+,组合的变形)
核心定位
将字符串/数组'切割'为若干子串/子数组,满足特定条件(如回文、分割后子串符合规则),本质是'组合问题的字符串版'。
核心原理
- ※切割的核心:用 startIndex 标记切割起点,每次选择'切割终点'(i),子串为 [startIndex, i];
- 与组合的对应:切割终点 i = 组合中的'选择项',子串 [startIndex,i] = 组合中的'选中元素'。
适用场景
- 分割回文串(LeetCode 131)、复原 IP 地址(LeetCode 93)。
核心思路(四步法)
- 参数定义:递归函数包含「原字符串 s、路径 path(存储子串)、结果集 res、起始索引 startIndex」;
- 结束条件:startIndex >= s.length(),将 path 拷贝加入 res;
- 遍历选择列表:从 startIndex 开始遍历(切割起点),i 为切割终点:
- 剪枝:判断子串 [s[startIndex…i]] 是否符合条件(如回文、IP 段合法),不符合则跳过;
- 选择:将子串加入 path;
- 递归:传入 i+1(下一次切割起点为 i+1);
- 撤销选择:从 path 移除子串;
- 收尾:无额外处理。
高频经典题
- LeetCode 131. 分割回文串(核心模板题,回文判断 + 切割);
- LeetCode 93. 复原 IP 地址(IP 段合法性判断 + 切割次数限制)。
避坑点
- 子串索引错误:Java 中
substring(start, end) 是左闭右开,需 substring(startIndex, i+1) 才能取到 [startIndex,i] 的子串;
- 回文判断效率低:可提前用动态规划预处理回文子串(二刷进阶优化,基础版用双指针即可);
- IP 地址切割未限制次数:IP 地址最多切割 3 次(4 个段),需在递归中加层数限制(如 path.size()<4),否则会生成无效 IP;
- IP 段合法性判断不全:需满足① 0<=数值<=255 ② 不能以 0 开头(除非段是"0")。
✅ 技巧五:棋盘问题(★★★ 占比 15%+,剪枝是关键)
核心定位
在二维棋盘上做'放置决策'(如 N 皇后、数独),核心考察'多维度剪枝'(行、列、对角线),后端场景中对应'布局规划、约束满足'。
核心原理
- 棋盘问题的核心:按行/列遍历,每一步选择'合法位置'放置元素,通过剪枝排除攻击位置;
- N 皇后:每行放一个皇后,需判断列、正对角线(行 - 列=常数)、反对角线(行 + 列=常数)是否已有皇后;
- 数独:每个空格填 1-9,需判断行、列、3x3 小宫格是否已有该数字。
适用场景
- N 皇后问题(LeetCode 51、52)、解数独(LeetCode 37)。
核心思路(以 N 皇后为例,四步法)
- 参数定义:递归函数包含「棋盘大小 n、路径 path(存储每行皇后的列索引)、结果集 res、当前行 row」;
- 结束条件:row == n,将 path 转换为棋盘格式加入 res;
- 遍历选择列表:遍历当前行的每一列 col:
- 剪枝:判断 (col, 正对角线,反对角线) 是否已有皇后,有则跳过;
- 选择:将 col 加入 path,标记该列/对角线为已用;
- 递归:处理下一行(row+1);
- 撤销选择:从 path 移除 col,取消列/对角线标记;
- 收尾:无额外处理。
高频经典题
- LeetCode 51. N 皇后(核心模板题,多维度剪枝);
- LeetCode 37. 解数独(进阶,填充 + 验证,需回溯 + 递归返回值)。
避坑点
- 对角线判断错误:正对角线(row - col)可能为负,需加偏移量(如 n)转为正数;反对角线(row + col)无需偏移;
- 棋盘转换错误:N 皇后中 path 存储的是'每行皇后的列索引',需转换为
....Q.. 格式,注意列的位置;
- 数独未提前遍历所有空格:解数独需先收集所有空格坐标,再逐个填充,避免重复遍历;
- 剪枝不彻底:N 皇后中,每行仅需判断已放皇后的行(无需判断所有行),减少判断次数。
通用代码模板
✔ 模板 1:回溯通用模板(所有场景的基础)
class BacktrackTemplate {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> backtrack(int[] nums, int target) {
Arrays.sort(nums);
dfs(nums, target, 0, 0);
return res;
}
private void dfs(int[] nums, int target, int startIndex, int sum) {
if (sum == target) {
res.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i < nums.length; i++) {
if (sum + nums[i] > target) {
break;
}
if (i > startIndex && nums[i] == nums[i - 1]) {
continue;
}
path.add(nums[i]);
sum += nums[i];
dfs(nums, target, i + 1, sum);
sum -= nums[i];
path.remove(path.size() - 1);
}
}
}
✔ 模板 2:组合问题(LeetCode 77. 组合)
class Combine {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
dfs(n, k, 1);
return res;
}
private void dfs(int n, int k, int startIndex) {
if (path.size() == k) {
res.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) {
path.add(i);
dfs(n, k, i + 1);
path.remove(path.size() - 1);
}
}
}
✔ 模板 3:排列问题(LeetCode 47. 全排列 II,有重复元素)
class PermuteUnique {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
boolean[] used;
public List<List<Integer>> permuteUnique(int[] nums) {
Arrays.sort(nums);
used = new boolean[nums.length];
dfs(nums);
return res;
}
private void dfs(int[] nums) {
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue;
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;
used[i] = true;
path.add(nums[i]);
dfs(nums);
path.remove(path.size() - 1);
used[i] = false;
}
}
}
✔ 模板 4:子集问题(LeetCode 90. 子集 II,有重复元素)
class SubsetsWithDup {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums);
dfs(nums, 0);
return res;
}
private void dfs(int[] nums, int startIndex) {
res.add(new ArrayList<>(path));
for (int i = startIndex; i < nums.length; i++) {
if (i > startIndex && nums[i] == nums[i - 1]) continue;
path.add(nums[i]);
dfs(nums, i + 1);
path.remove(path.size() - 1);
}
}
}
✔ 模板 5:切割问题(LeetCode 131. 分割回文串)
class Partition {
List<List<String>> res = new ArrayList<>();
List<String> path = new ArrayList<>();
public List<List<String>> partition(String s) {
dfs(s, 0);
return res;
}
private void dfs(String s, int startIndex) {
if (startIndex >= s.length()) {
res.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i < s.length(); i++) {
if (!isPalindrome(s, startIndex, i)) continue;
path.add(s.substring(startIndex, i + 1));
dfs(s, i + 1);
path.remove(path.size() - 1);
}
}
private boolean isPalindrome(String s, int l, int r) {
while (l < r) {
if (s.charAt(l) != s.charAt(r)) return false;
l++;
r--;
}
return true;
}
}
✔ 模板 6:棋盘问题(LeetCode 51. N 皇后)
class SolveNQueens {
List<List<String>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int n;
public List<List<String>> solveNQueens(int n) {
this.n = n;
dfs(0);
return res;
}
private void dfs(int row) {
if (row == n) {
res.add(convertPathToBoard());
return;
}
for (int col = 0; col < n; col++) {
if (!isValid(row, col)) continue;
path.add(col);
dfs(row + 1);
path.remove(path.size() - 1);
}
}
private boolean isValid(int row, int col) {
for (int i = 0; i < path.size(); i++) {
int c = path.get(i);
if (c == col) return false;
if (row - col == i - c) return false;
if (row + col == i + c) return false;
}
return true;
}
private List<String> convertPathToBoard() {
List<String> board = new ArrayList<>();
for (int col : path) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(i == col ? 'Q' : '.');
}
board.add(sb.toString());
}
return board;
}
}
总结
- 通用模板是根基:记住'选 - 试 - 撤'三步,所有场景都是模板的变形;
- 场景差异是关键:
- 组合:startIndex 控顺序,无 used;
- 排列:used 控已选,无 startIndex;
- 子集:每步记录结果,startIndex 控重复;
- 切割:子串判断+startIndex;
- 棋盘:多维度剪枝 + 行列遍历;
- 剪枝是优化核心:提前排除无效路径,减少递归次数;
- Java 特性要注意:引用传递、容器回溯、字符串索引,规避空指针和结果错误。
动态规划部分
本质是'将复杂问题拆解为重叠子问题,通过记录子问题的解(状态)避免重复计算'。
✅✅✅所以动态规划的解题重点就在于找到子问题,存储子问题的答案,再去推到其他子问题的答案
核心知识点
✅ 核心定义与本质
动态规划 = 状态定义 + 状态转移 + 初始条件,核心思想是:
- 「重叠子问题」:大问题可拆分为多个子问题,且子问题会被重复计算(如斐波那契数列中 f(5) 需计算 f(4) 和 f(3),f(4) 又需计算 f(3));
- 「最优子结构」:大问题的最优解由子问题的最优解组成(如最短路径中,到终点的最短路径 = 到前驱节点的最短路径 + 前驱到终点的距离);
- 「无后效性」:某阶段的状态仅由前序状态决定,与后续决策无关(如背包问题中,dp[i][j] 仅由 dp[i-1][j] 或 dp[i-1][j-w[i]] 决定)。
✅ 动态规划核心五要素(DP 的'灵魂',缺一不可)
| 要素 | 含义 | Java 实现方式 |
|---|
| 状态定义 | 定义 dp 数组/变量的含义(最核心、最易出错),回答'dp[i][j] 代表什么?' | int[] dp(一维)、int[][] dp(二维) |
| 状态转移方程 | 子问题之间的递推关系,回答'如何由 dp[i-1][j] 推导出 dp[i][j]?' | 条件判断、数学运算(+/-/max/min) |
| 初始条件 | 最小子问题的解(递归的终止条件),回答'dp[0]/dp[0][0] 的值是多少?' | 数组初始化(dp[0]=0、dp[0][j]=∞等) |
| 遍历顺序 | 按什么顺序计算 dp 数组(需满足'计算 dp[i][j] 时,依赖的状态已计算完成') | 单层循环(一维)、双层循环(二维,注意 i/j 顺序) |
| 空间优化 | 压缩 dp 数组维度(如二维→一维),减少空间复杂度(后端面试高频追问) | 滚动数组、逆序遍历、变量替代数组 |
✅ 动态规划适用场景(快速判断是否用 DP)
- 求'最值'(最长/最短/最大/最小)、'方案数'(有多少种方式)、'可行性'(是否能达到目标);
- 问题可拆分为重叠子问题,且满足最优子结构、无后效性;
- 暴力解法(回溯/递归)超时(时间复杂度 O(2ⁿ)/O(n!)),需通过记录子问题解优化。
✅ DP vs 贪心 vs 回溯(二刷必懂区别,应对面试官追问)
| 算法 | 核心思想 | 适用场景 | 时间复杂度 | 空间复杂度 |
|---|
| 动态规划 | 记录子问题解,递推求解 | 有重叠子问题、最优子结构 | O(n²)/O(nm) | O(n)/O(nm)(可优化) |
| 贪心 | 每一步选局部最优 | 贪心选择性质(局部最优→全局最优) | O(n)/O(nlogn) | O(1)/O(n) |
| 回溯 | 暴力枚举所有路径 | 方案数少、需枚举所有可能 | O(2ⁿ)/O(n!) | O(n)(递归栈) |
- 找零钱(无面值限制):贪心(选最大面值)仅在特定面值(如人民币)有效,DP 能解决所有情况;
- 最长递增子序列:DP(O(n²))/贪心 + 二分(O(nlogn)),回溯会超时。
✅ 时间&空间复杂度(二刷必懂,应对追问)
- 时间复杂度:等于 dp 数组的大小 × 每个状态的计算时间(通常 O(1)):
- 一维 DP(如 LIS):O(n²);
- 二维 DP(如 LCS):O(nm);
- 背包问题(二维):O(nm),优化后一维:O(m)。
- 空间复杂度:
- 未优化:O(n)/O(nm);
- 优化后:O(1)/O(m)(如背包问题滚动数组)。
✅ Java 实现 DP 的核心要点(避坑基础)
- 数组初始化:
- 求'最大值':初始化为
Integer.MIN_VALUE(需注意溢出);
- 求'最小值':初始化为
Integer.MAX_VALUE;
- 求'方案数':初始化为 0(边界条件设为 1,如 dp[0]=1);
- 边界处理:避免数组越界(如 dp[i-1] 需保证 i≥1);
- 空间优化:
- 二维→一维:需注意遍历顺序(01 背包逆序,完全背包正序);
- 一维→变量:仅依赖前 1-2 个状态时(如斐波那契数列);
- 大数处理:方案数可能溢出,需按题目要求取模(如
dp[i] %= 1000000007);
- 浮点数精度:避免用 float/double 存储状态,优先用 int/long(如路径概率可转分数)。
技巧方法
DP 90% 的高频题可归为 6 类场景,每类场景都有'状态定义模板 + 转移方程 + 遍历顺序',二刷需逐个固化。
✅ 技巧一:线性 DP(★★★★★ 占比 30%+,基础中的基础)
核心定位
状态仅与'前一个/前几个'状态相关,dp 数组为一维,是 DP 入门和后端面试最基础的考点(如斐波那契、爬楼梯、打家劫舍)。
核心原理
- 状态定义:
dp[i] 代表'前 i 个元素/第 i 个位置达到的目标(最值/方案数)';
- 转移方程:
dp[i] = f(dp[i-1], dp[i-2], ...)(仅依赖前序有限个状态);
- 遍历顺序:从左到右(依赖前序状态)。
适用场景
- 递推类:斐波那契数列、爬楼梯;
- 最值类:打家劫舍、最大子数组和、买卖股票的最佳时机;
- 计数类:不同路径、整数拆分。
核心思路(四步法)
- 状态定义:明确
dp[i] 的含义(如 dp[i]=前 i 间房子能偷的最大金额);
- 转移方程:分析第 i 个位置的选择(选/不选),推导递推关系:
- 例(打家劫舍):
dp[i] = max(dp[i-1](不偷第 i 间), dp[i-2]+nums[i](偷第 i 间));
- 初始条件:
- 例(爬楼梯):
dp[0]=1(0 级台阶 1 种方式)、dp[1]=1(1 级台阶 1 种方式);
- 遍历顺序:从 2 到 n(依赖 dp[0]、dp[1]),最后返回
dp[n]。
高频经典题
- LeetCode 70. 爬楼梯(计数类线性 DP,基础模板);
- LeetCode 198. 打家劫舍(最值类线性 DP,选/不选思路);
- LeetCode 53. 最大子数组和(Kadane 算法,线性 DP 优化);
- LeetCode 121. 买卖股票的最佳时机(单次交易,线性 DP)。
避坑点
- 状态定义模糊:如爬楼梯中
dp[i] 定义为'第 i 级台阶的方式数',而非'前 i 级',导致初始条件错误;
- 初始条件遗漏:如打家劫舍中
dp[0]=0、dp[1]=nums[0],遗漏 dp[0] 会导致 i=2 时越界;
- 空间优化未做:如斐波那契数列用一维数组而非两个变量(优化后空间 O(1));
- 遍历顺序错误:线性 DP 必须从左到右,反向遍历会导致依赖的状态未计算。
✅ 技巧二:背包问题(★★★★★ 占比 25%+,后端高频)
背包问题是 DP 的'经典模型',后端场景中对应'资源分配、成本优化'(如有限预算选最大价值的商品),核心分 4 类,二刷需掌握前 3 类。
核心定位
状态与'物品数 + 背包容量'相关,dp 数组为二维(可优化为一维),核心是'选/不选第 i 个物品'的决策。
核心原理(通用)
- 状态定义:
dp[i][j] 代表'前 i 个物品,背包容量为 j 时的最大价值/方案数/可行性';
- 转移方程:
- 不选第 i 个物品:
dp[i][j] = dp[i-1][j];
- 选第 i 个物品(需 j≥w[i]):
dp[i][j] = dp[i-1][j-w[i]] + v[i];
- 综合一下递归公式:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
- 遍历顺序:先物品后容量(二维),优化后一维需注意顺序(01 逆序,完全正序)。
做题方法:写递推式可以写二维的方便理解,实际的代码可以用一维或者二维的的,也很方便转换。
子场景 1:01 背包(每个物品仅选 1 次,核心)
适用场景
LeetCode 416. 分割等和子集(可行性)、LeetCode 494. 目标和(计数)、LeetCode 322. 零钱兑换(最值,特殊 01)。
核心思路
- 状态定义:
dp[i][j] = 前 i 个物品,容量 j 时的最大价值;
- 转移方程:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])(j≥w[i]);
- 初始条件:
dp[0][j] = 0(无物品时价值为 0);
空间优化:二维→一维,容量逆序遍历(避免重复选同一物品):
int[] dp = new int[bagSize + 1];
for (int i = 0; i < n; i++) {
for (int j = bagSize; j >= w[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
避坑点
- 一维优化时正序遍历容量:导致同一物品被多次选择(变成完全背包);
- 可行性问题初始条件错误:如分割等和子集,
dp[0] = true(容量 0 可达),其余为 false;
- 计数问题未初始化
dp[0] = 1:如目标和,dp[0] = 1(和为 0 的方案数 1)。
子场景 2:完全背包(每个物品可选无限次)
适用场景
LeetCode 518. 零钱兑换 II(计数)、LeetCode 322. 零钱兑换(最值)、LeetCode 70. 爬楼梯(特殊完全背包)。
核心思路
- 状态定义:同 01 背包;
- 转移方程:
dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]] + v[i])(选第 i 个物品后,仍可选第 i 个);
int[] dp = new int[bagSize + 1];
for (int i = 0; i < n; i++) {
for (int j = w[i]; j <= bagSize; j++) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
避坑点
- 混淆 01 和完全背包的遍历顺序:01 逆序、完全正序,记反会导致结果错误;
- 零钱兑换(最值)初始条件错误:
dp[0] = 0,其余为 Integer.MAX_VALUE,且计算时需判断 dp[j-w[i]] 是否为无穷大;
- 计数问题未取模:如零钱兑换 II,方案数可能溢出,需
dp[j] %= MOD。
子场景 3:分组背包(物品分组成组,每组仅选 1 个)
适用场景
LeetCode 1155. 掷骰子的 N 种方法(分组 + 计数)、自定义分组选品问题。
核心思路
- 状态定义:
dp[j] = 容量 j 时的最大价值;
- 遍历顺序:先容量(逆序)→ 再分组 → 再组内物品(保证每组仅选 1 个);
- 转移方程:
dp[j] = max(dp[j], dp[j-w[k]] + v[k])(k 为组内物品)。
✅ 技巧三:子序列/子串 DP(★★★★ 占比 15%+,中等题核心)
核心定位
状态与两个字符串/数组的'前 i 个、前 j 个'元素相关,dp 数组为二维,是后端面试中等题高频考点(如最长公共子序列、编辑距离)。
核心原理
- 子序列:不要求连续(如'abc'的子序列'ac');
- 子串:要求连续(如'abc'的子串'ab');
- 状态定义:
dp[i][j] 代表'第一个字符串前 i 个、第二个字符串前 j 个的目标值(最长长度/编辑次数)';
- 转移方程:
- 匹配:
dp[i][j] = dp[i-1][j-1] + 1;
- 不匹配:
dp[i][j] = max/min(dp[i-1][j], dp[i][j-1])。【当两个字符串的最后一个字符不同时,这个位置的两个字符不可能同时出现在 xxx 中,所以各取一个进行尝试】
dp[i][j] 数组常见的两种定义方式:以 i 结尾和以 j 结尾的字符串字符串的前 i 个和前 j 个字符串的 [i,j] 区间(属于区间 dp 后面的章节会提到)
适用场景
- 子序列:最长公共子序列(LCS)、最长递增子序列(LIS)、最长回文子序列;
- 子串:最长公共子串、最长回文子串;
- 编辑类:编辑距离、两个字符串的删除操作。
核心思路(以 LCS 为例)
- 状态定义:
dp[i][j] = 字符串 s1 前 i 个、s2 前 j 个的最长公共子序列长度;
- 转移方程:
- 若
s1[i-1] == s2[j-1](字符匹配):dp[i][j] = dp[i-1][j-1] + 1;
- 若不匹配:
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
- 初始条件:
dp[0][j] = 0、dp[i][0] = 0(空字符串的 LCS 长度为 0);
- 遍历顺序:先 i 后 j(或先 j 后 i),从 1 到 len(s1)/len(s2)。
为了方便初始化,一般会采用 int[][] dp = new int[word1.length() + 1][word2.length() + 1]。其实我们可以理解为添加了字符串的长度为 0 的情况,也就是多了一个占位,所以 dp[i][j] 实际上表示的是字符串 a 的前 i - 1 个字符串 b 的前 j - 1 个(或者以此结尾)
class Solution {
public int minDistance(String word1, String word2) {
int[][] dp = new int[word1.length() + 1][word2.length() + 1];
for (int i = 1; i <= word1.length(); i++) dp[i][0] = i;
for (int j = 1; j <= word2.length(); j++) dp[0][j] = j;
for (int i = 1; i <= word1.length(); i++) {
for (int j = 1; j <= word2.length(); j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1])) + 1;
}
}
return dp[word1.length()][word2.length()];
}
}
高频经典题
- LeetCode 1143. 最长公共子序列(LCS,基础模板);
- LeetCode 300. 最长递增子序列(LIS,一维 DP/贪心 + 二分);
- LeetCode 72. 编辑距离(最值类子序列 DP,后端文本处理场景);
- LeetCode 516. 最长回文子序列(子序列 DP,对称状态)。
避坑点
- 字符串索引偏移:dp[i][j] 对应 s1[i-1]、s2[j-1],易误写为 s1[i]、s2[j] 导致越界;
- LIS 的 O(n²) 优化:二刷需掌握贪心 + 二分的 O(nlogn) 解法(应对面试官追问);
- 回文子序列初始条件:
dp[i][i] = 1(单个字符是回文),而非 0;
- 编辑距离转移方程遗漏:需考虑插入、删除、替换三种操作,避免漏项。
✅ 技巧四:区间 DP(★★★ 占比 10%+,进阶)
核心定位
状态与'区间 [i,j]'相关,dp 数组为二维,核心是'从小区间扩展到大全区间',后端场景中对应'区间合并、字符串分割'。
核心原理
- 状态定义:
dp[i][j] = 区间 [i,j] 内的最优解(最值/方案数);
- 转移方程:
dp[i][j] = f(dp[i][k], dp[k+1][j])(k 为区间分割点,i≤k<j);
- 遍历顺序:先遍历区间长度(从 2 到 n),再遍历区间起点 i,终点 j=i+len-1(保证小区间先计算)。
适用场景
- 区间最值:石子合并、戳气球;
- 回文类:最长回文子串(DP 解法)、分割回文串 II;
- 字符串类:最长回文子序列(也可归为区间 DP)。
核心思路(以最长回文子串为例)
- 状态定义:
dp[i][j] 表示字符串 [i,j] 是否是最长回文子串;
- 转移方程:
dp[i][j] = s[i] == s[j] && dp[i + 1][j - 1];
- 初始条件:dp 数组的斜角表示单个字符,所以一定是回文的,全部初始化为 true
- 遍历顺序:从下往上遍历,因为 dp[i][j] 依赖下面一行的数据
class Solution {
public String longestPalindrome(String s) {
boolean[][] dp = new boolean[s.length()][s.length()];
for (int i = 0; i < s.length(); i++) dp[i][i] = true;
int max = 1;
int offest = 0;
for (int i = s.length() - 1; i >= 0; i--) {
for (int j = i + 1; j < s.length(); j++) {
if (s.charAt(i) != s.charAt(j)) dp[i][j] = false;
else {
if (j - i == 1) dp[i][j] = true;
else dp[i][j] = dp[i + 1][j - 1];
if (dp[i][j] && j - i + 1 > max) {
offest = i;
max = j - i + 1;
}
}
}
}
return s.substring(offest, offest + max);
}
}
核心思路(以石子合并为例)
- 状态定义:
dp[i][j] = 合并区间 [i,j] 的石子的最小代价;
- 转移方程:
dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum[i][j])(sum[i][j] 为区间 [i,j] 石子和);
- 初始条件:
dp[i][i] = 0(单个石子无需合并);
for (int len = 2; len <= n; len++) {
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
dp[i][j] = Integer.MAX_VALUE;
for (int k = i; k < j; k++) {
dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k + 1][j] + sum[i][j]);
}
}
}
高频经典题
- LeetCode 312. 戳气球(区间 DP Hard 级经典);
- LeetCode 132. 分割回文串 II(区间 DP+ 贪心);
- LeetCode 5. 最长回文子串(区间 DP 解法)。
避坑点
- 遍历顺序错误:先遍历起点再遍历长度,导致大全区间依赖的小区间未计算;
- 区间和未预处理:每次计算 sum[i][j] 用循环,时间复杂度升至 O(n³),需提前用前缀和数组;
- 初始条件错误:如戳气球中
dp[i][j] 初始化为 0,导致最小值计算错误。
✅ 技巧五:树形 DP(★★★ 占比 10%+,后端场景关联)
核心定位
状态定义在树上,通过后序遍历(自底向上)计算子树的状态,后端场景中对应'树形结构的最值/方案数'(如公司人员分配、树的直径)。
核心原理
- 状态定义:
dp[node][0/1] 代表'以 node 为根的子树,node 选/不选时的最优解';
- 转移方程:通过后序遍历,合并左右子树的状态;
- 遍历顺序:后序遍历(先计算左右子树,再计算根节点)。
适用场景
- 树的最值:打家劫舍 III(树形 01 背包)、二叉树的最大路径和;
- 树形决策:员工的重要性、树的直径。
核心思路(以打家劫舍 III 为例)
- 状态定义:
dp[node][0]:不偷 node 时,子树的最大金额;
dp[node][1]:偷 node 时,子树的最大金额;
- 转移方程:
dp[node][0] = max(dp[left][0], dp[left][1]) + max(dp[right][0], dp[right][1]);
dp[node][1] = node.val + dp[left][0] + dp[right][0];
- 初始条件:空节点
dp[null][0] = dp[null][1] = 0;
- 遍历顺序:后序遍历递归计算。
高频经典题
- LeetCode 337. 打家劫舍 III(树形 DP 核心模板);
- LeetCode 124. 二叉树中的最大路径和(树形 DP 进阶);
- LeetCode 543. 二叉树的直径(树形 DP 简单版)。
避坑点
- 递归终止条件遗漏:未处理空节点,导致空指针异常;
- 状态转移遗漏子树:如打家劫舍 III 中,未合并左右子树的状态;
- 重复计算子树状态:未用记忆化递归,导致时间复杂度升至 O(2ⁿ)。
✅ 技巧六:状态压缩 DP(★★ 占比 10%+,优化核心)
核心定位
将多维状态(如二维)压缩为一维,或用二进制数表示状态(如集合),后端场景中对应'有限状态的优化'(如旅行商问题、状态压缩背包)。
核心原理
- 二进制状态:用整数的二进制位表示'元素是否被选中'(如
mask=5(101)代表第 0、2 个元素被选中);
- 转移方程:
dp[mask] = f(dp[mask ^ (1<<i)])(mask 为当前状态,i 为选中的元素);
- 遍历顺序:遍历所有 mask(从 0 到 2ⁿ-1)。
适用场景
- 集合类:旅行商问题(TSP)、子集状态压缩;
- 多维压缩:二维 DP→一维(如背包问题)、三维 DP→二维。
核心思路(以 TSP 为例)
- 状态定义:
dp[mask][i] = 访问过 mask 表示的城市,最后在城市 i 的最小路径;
- 转移方程:
dp[mask][i] = min(dp[mask ^ (1<<i)][j] + dist[j][i])(j 为前一个城市);
- 初始条件:
dp[1<<i][i] = 0(仅访问城市 i,路径为 0);
- 遍历顺序:遍历 mask(从 1 到 2ⁿ-1),再遍历 i,最后遍历 j。
高频经典题
- LeetCode 698. 划分为 k 个相等的子集(状态压缩 + 回溯);
- LeetCode 943. 最短超级串(状态压缩 DP);
- LeetCode 1986. 完成任务的最少工作时间段(状态压缩 DP)。
避坑点
- 状态数爆炸:n>20 时,2ⁿ>1e6,状态压缩不可行(需其他优化);
- 二进制位操作错误:如
1<<i 易误写为 1<<(i-1),导致状态表示错误;
- 初始条件未初始化:如 TSP 中
dp[mask][i] 初始化为无穷大,未设置起点状态。
通用代码模板
✔ 模板 1:DP 通用模板(所有场景的基础)
class DPGeneralTemplate {
public int dpTemplate(int[] nums1, int[] nums2) {
int n = nums1.length;
int m = nums2.length;
int[][] dp = new int[n + 1][m + 1];
for (int i = 0; i <= n; i++) {
dp[i][0] = 0;
}
for (int j = 0; j <= m; j++) {
dp[0][j] = 0;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (nums1[i - 1] == nums2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[n][m];
}
}
✔ 模板 2:01 背包(基础 + 空间优化,LeetCode 416. 分割等和子集)
class ZeroOneKnapsack {
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;
for (int num : nums) {
for (int j = target; j >= num; j--) {
dp[j] = dp[j] || dp[j - num];
}
}
return dp[target];
}
}
✔ 模板 3:完全背包(基础 + 空间优化,LeetCode 518. 零钱兑换 II)
class CompleteKnapsack {
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1;
for (int coin : coins) {
for (int j = coin; j <= amount; j++) {
dp[j] += dp[j - coin];
}
}
return dp[amount];
}
}
✔ 模板 4:最长递增子序列(LIS,LeetCode 300)
class LIS {
public int lengthOfLIS(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
int[] dp = new int[n];
Arrays.fill(dp, 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:最长公共子序列(LCS,LeetCode 1143)
class LCS {
public int longestCommonSubsequence(String text1, String text2) {
int n = text1.length();
int m = text2.length();
int[][] dp = new int[n + 1][m + 1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[n][m];
}
}
✔ 模板 6:树形 DP(打家劫舍 III,LeetCode 337)
class TreeDP {
public int rob(TreeNode root) {
int[] res = dfs(root);
return Math.max(res[0], res[1]);
}
private int[] dfs(TreeNode node) {
if (node == null) return new int[]{0, 0};
int[] left = dfs(node.left);
int[] right = dfs(node.right);
int notRob = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
int rob = node.val + left[0] + right[0];
return new int[]{notRob, rob};
}
static class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
}
总结
- 五要素是根基:状态定义→转移方程→初始条件→遍历顺序→空间优化,按此步骤解题,思路不混乱;
- 场景模板化:
- 线性 DP:一维数组,前序依赖;
- 背包问题:二维→一维,注意遍历顺序;
- 子序列 DP:二维数组,字符匹配转移;
- 区间 DP:先长度后起点,分割点遍历;
- 树形 DP:后序遍历,子树状态合并;
- 优化是亮点:空间优化(滚动数组)、时间优化(贪心 + 二分)是超越普通候选人的关键;
- Java 特性要注意:数组初始化、边界处理、大数取模,规避空指针和溢出。
- 状态定义模糊:写代码前先明确'dp[i][j] 代表什么',避免边写边改;
- 转移方程遗漏条件:如背包问题中未判断'j≥w[i]',导致数组越界;
- 遍历顺序错误:
- 01 背包一维优化:容量逆序(否则重复选);
- 完全背包一维优化:容量正序;
- 区间 DP:先遍历长度再遍历起点;
- 初始条件错误:
- 最值问题初始化为 0(应初始化为 MAX/MIN);
- 计数问题未初始化 dp[0]=1;
- 空间优化时覆盖问题:如二维 DP→一维时,未保留依赖的前序状态;
- 大数溢出:方案数问题未取模,导致 int/long 溢出;
- 忽略题目约束:如'子序列'vs'子串',状态定义错误导致思路全错。
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online