文章目录
- 回溯算法部分
-
- 核心知识点
-
- [✅ 核心定义与本质](#✅ 核心定义与本质)
- [✅ 核心三要素(回溯的"灵魂")](#✅ 核心三要素(回溯的“灵魂”))
- [✅ 时间&空间复杂度(二刷必懂,应对面试官追问)](#✅ 时间&空间复杂度(二刷必懂,应对面试官追问))
- [✅ Java实现核心要点(避坑基础)](#✅ Java实现核心要点(避坑基础))
- [✅ 回溯与递归/DFS的关系](#✅ 回溯与递归/DFS的关系)
- 技巧方法
-
- [✅ 技巧一:组合问题(★★★★★ 占比30%+,核心中的核心)](#✅ 技巧一:组合问题(★★★★★ 占比30%+,核心中的核心))
- [✅ 技巧二:排列问题(★★★★ 占比20%+,去重是核心)](#✅ 技巧二:排列问题(★★★★ 占比20%+,去重是核心))
- ✅易错点:组合与排序的解题区别
- [✅ 技巧三:子集问题(★★★★ 占比15%+,组合的变种)](#✅ 技巧三:子集问题(★★★★ 占比15%+,组合的变种))
- [✅ 技巧四:切割问题(★★★ 占比10%+,组合的变形)](#✅ 技巧四:切割问题(★★★ 占比10%+,组合的变形))
- [✅ 技巧五:棋盘问题(★★★ 占比15%+,剪枝是关键)](#✅ 技巧五:棋盘问题(★★★ 占比15%+,剪枝是关键))
- 通用代码模板
-
- [✔ 模板1:回溯通用模板(所有场景的基础)](#✔ 模板1:回溯通用模板(所有场景的基础))
- [✔ 模板2:组合问题(LeetCode 77. 组合)](#✔ 模板2:组合问题(LeetCode 77. 组合))
- [✔ 模板3:排列问题(LeetCode 47. 全排列II,有重复元素)](#✔ 模板3:排列问题(LeetCode 47. 全排列II,有重复元素))
- [✔ 模板4:子集问题(LeetCode 90. 子集II,有重复元素)](#✔ 模板4:子集问题(LeetCode 90. 子集II,有重复元素))
- [✔ 模板5:切割问题(LeetCode 131. 分割回文串)](#✔ 模板5:切割问题(LeetCode 131. 分割回文串))
- [✔ 模板6:棋盘问题(LeetCode 51. N皇后)](#✔ 模板6:棋盘问题(LeetCode 51. N皇后))
- 总结
- 动态规划部分
-
- 核心知识点
-
- [✅ 核心定义与本质](#✅ 核心定义与本质)
- [✅ 动态规划核心五要素(DP的"灵魂",缺一不可)](#✅ 动态规划核心五要素(DP的“灵魂”,缺一不可))
- [✅ 动态规划适用场景(快速判断是否用DP)](#✅ 动态规划适用场景(快速判断是否用DP))
- [✅ DP vs 贪心 vs 回溯(二刷必懂区别,应对面试官追问)](#✅ DP vs 贪心 vs 回溯(二刷必懂区别,应对面试官追问))
- [✅ 时间&空间复杂度(二刷必懂,应对追问)](#✅ 时间&空间复杂度(二刷必懂,应对追问))
- [✅ Java实现DP的核心要点(避坑基础)](#✅ Java实现DP的核心要点(避坑基础))
- 技巧方法
-
- [✅ 技巧一:线性DP(★★★★★ 占比30%+,基础中的基础)](#✅ 技巧一:线性DP(★★★★★ 占比30%+,基础中的基础))
- [✅ 技巧二:背包问题(★★★★★ 占比25%+,后端高频)](#✅ 技巧二:背包问题(★★★★★ 占比25%+,后端高频))
- [✅ 技巧三:子序列/子串DP(★★★★ 占比15%+,中等题核心)](#✅ 技巧三:子序列/子串DP(★★★★ 占比15%+,中等题核心))
- [✅ 技巧四:区间DP(★★★ 占比10%+,进阶)](#✅ 技巧四:区间DP(★★★ 占比10%+,进阶))
- [✅ 技巧五:树形DP(★★★ 占比10%+,后端场景关联)](#✅ 技巧五:树形DP(★★★ 占比10%+,后端场景关联))
- [✅ 技巧六:状态压缩DP(★★ 占比10%+,优化核心)](#✅ 技巧六:状态压缩DP(★★ 占比10%+,优化核心))
- 通用代码模板
-
- [✔ 模板1:DP通用模板(所有场景的基础)](#✔ 模板1:DP通用模板(所有场景的基础))
- [✔ 模板2:01背包(基础+空间优化,LeetCode 416. 分割等和子集)](#✔ 模板2:01背包(基础+空间优化,LeetCode 416. 分割等和子集))
- [✔ 模板3:完全背包(基础+空间优化,LeetCode 518. 零钱兑换II)](#✔ 模板3:完全背包(基础+空间优化,LeetCode 518. 零钱兑换II))
- [✔ 模板4:最长递增子序列(LIS,LeetCode 300)](#✔ 模板4:最长递增子序列(LIS,LeetCode 300))
- [✔ 模板5:最长公共子序列(LCS,LeetCode 1143)](#✔ 模板5:最长公共子序列(LCS,LeetCode 1143))
- [✔ 模板6:树形DP(打家劫舍III,LeetCode 337)](#✔ 模板6:树形DP(打家劫舍III,LeetCode 337))
- 总结
回溯算法部分
回溯算法是后端面试中等题核心考察点(占比60%+),本质是"深度优先搜索(DFS)+ 状态回退"的暴力搜索优化,核心解决"多阶段决策"类问题(组合、排列、子集、切割、棋盘)。
核心知识点
✅ 核心定义与本质
回溯算法 = 递归(深度优先遍历) + 状态回退(撤销选择),本质是"暴力枚举所有可能的决策路径":
- 对每一个决策阶段,先"选择"一个选项 → 递归探索后续路径 → 递归返回后"撤销选择" → 尝试下一个选项;
- 与纯暴力枚举的区别:通过"撤销选择"复用同一容器存储路径,节省空间,且能通过剪枝提前排除无效路径(优化时间)。
✅ 核心三要素(回溯的"灵魂")
| 要素 | 含义 | Java实现方式 |
|---|---|---|
| 路径 | 已做出的选择(如组合问题中已选的数字、N皇后中已放皇后的位置) | List<Integer> path、StringBuilder |
| 选择列表 | 当前阶段可选择的选项(如组合问题中未选的数字、棋盘问题中可放皇后的列) | 数组/集合、boolean[] used(去重) |
| 结束条件 | 到达决策树底部,需记录结果(如组合长度达标、棋盘遍历完成) | 路径长度==目标长度、索引越界等 |
✅ 时间&空间复杂度(二刷必懂,应对面试官追问)
- 时间复杂度 :本质是暴力枚举,通常为指数级/阶乘级:
- 组合/子集问题: O ( 2 n ) O(2^n) O(2n)(每个元素选或不选);
- 排列问题: O ( n ! ) O(n!) O(n!)(n个元素的全排列);
- 剪枝能减少实际执行时间,但理论复杂度不变。
- 空间复杂度 : O ( n ) O(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,不考虑顺序用offest
- 有重复元素的去重:排序是前提,考虑顺序那么去重的时候除了与前面一个元素相同,还要是当前层的元素
✔ 核心定义与目标差异
| 维度 | 组合问题 | 排列问题 |
|---|---|---|
| 核心本质 | 从 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 则是同一路径的上一个元素) - 跳过同一层重复元素,避免生成重复排列 |
| 核心原理 | 同一层中,相同元素只选第一个,后续的跳过 | 同一层中,相同元素若前一个未被选,则当前元素跳过(避免重复) |
✔ Java代码模板对比
- 组合问题模板(无重复元素,如LeetCode 77. 组合)
java
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); // 起始索引从1开始(对应元素1~n)
return res;
}
// 核心参数:startIndex(控制下一层遍历起点)
private void dfs(int n, int k, int startIndex) {
// 结束条件:路径长度等于k
if (path.size() == k) {
res.add(new ArrayList<>(path));
return;
}
// 遍历范围:从startIndex开始,避免重复组合
for (int i = startIndex; i <= n; i++) {
path.add(i); // 选择
dfs(n, k, i + 1); // 下一层从i+1开始
path.remove(path.size() - 1); // 撤销选择(回溯)
}
}
}
- 排列问题模板(无重复元素,如LeetCode 46. 全排列)
java
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;
}
// 核心参数:used数组(标记已选元素),无startIndex
private void dfs(int[] nums) {
// 结束条件:路径长度等于数组长度
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
// 遍历范围:从0开始,遍历所有元素
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:回溯通用模板(所有场景的基础)
java
// 通用回溯模板(以组合问题为例)
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);
// 启动回溯:起始索引0,当前和0
dfs(nums, target, 0, 0);
return res;
}
// 递归函数:核心逻辑
private void dfs(int[] nums, int target, int startIndex, int sum) {
// 1. 结束条件(不同场景不同)
if (sum == target) {
// 必须新建List,避免引用传递
res.add(new ArrayList<>(path));
return;
}
// 2. 遍历选择列表(从startIndex开始,组合/子集;从0开始,排列)
for (int i = startIndex; i < nums.length; i++) {
// 3. 剪枝:提前排除无效路径(核心优化)
if (sum + nums[i] > target) {
break; // 数组已排序,后续更大,直接终止循环
}
// 去重:有重复元素时,跳过同一层重复元素
if (i > startIndex && nums[i] == nums[i-1]) {
continue;
}
// 4. 选择:将当前元素加入路径
path.add(nums[i]);
sum += nums[i];
// 5. 递归:探索下一层(组合:i+1;排列:0;子集:i+1;切割:i+1)
dfs(nums, target, i+1, sum);
// 6. 撤销选择:回溯,恢复状态
sum -= nums[i];
path.remove(path.size() - 1);
}
}
}
✔ 模板2:组合问题(LeetCode 77. 组合)
java
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) {
// 结束条件:路径长度==k
if (path.size() == k) {
res.add(new ArrayList<>(path));
return;
}
// 剪枝:剩余可选元素 < 需要的元素数,直接终止(优化)
// 需要的元素数:k - path.size();剩余可选:n - i + 1
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) {
path.add(i);
dfs(n, k, i+1); // 组合:startIndex+1,避免重复
path.remove(path.size()-1);
}
}
}
✔ 模板3:排列问题(LeetCode 47. 全排列II,有重复元素)
java
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;
}
// 排列:从0开始遍历所有元素
for (int i = 0; i < nums.length; i++) {
// 剪枝1:已选元素跳过
if (used[i]) continue;
// 剪枝2:同一层重复元素跳过(核心去重)
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
used[i] = true;
path.add(nums[i]);
dfs(nums); // 排列:无startIndex,传0
path.remove(path.size()-1);
used[i] = false;
}
}
}
✔ 模板4:子集问题(LeetCode 90. 子集II,有重复元素)
java
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));
// 结束条件:startIndex越界,自然终止
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. 分割回文串)
java
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;
// 选择:子串[startIndex, i](左闭右闭)
path.add(s.substring(startIndex, i+1));
dfs(s, i+1); // 下一次切割起点为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皇后)
java
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); // 从第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) {
// 遍历已放皇后的行(path.size()行)
for (int i = 0; i < path.size(); i++) {
int c = path.get(i);
// 列冲突
if (c == col) return false;
// 正对角线冲突(row - col == i - c)
if (row - col == i - c) return false;
// 反对角线冲突(row + col == i + c)
if (row + col == i + c) return false;
}
return true;
}
// 将path转换为棋盘格式(如["..Q..", "...Q.", ...])
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;
}
}
总结
回溯算法二刷的核心逻辑(Java版):
- 通用模板是根基:记住"选-试-撤"三步,所有场景都是模板的变形;
- 场景差异是关键 :
- 组合: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)
遇到以下问题,优先考虑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]);
- 不选第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); -
空间优化:二维→一维,容量逆序遍历(避免重复选同一物品):
java// 一维优化版 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个); -
空间优化:一维数组,容量正序遍历(允许重复选):
javaint[] 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个(或者以此结尾)
例如经典的编辑距离问题:
java
class Solution {
public int minDistance(String word1, String word2) {
//dp[i][j]表示从word1[0,i - 1]转化到word2[0,j - 1]所需要的最少操作数
//if(word1[i - 1] == word2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
//if(word1[i - 1] != word2[j - 1]) min(dp[i - 1][j - 1],dp[i - 1][j],dp[i][j - 1]) + 1
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]依赖下面一行的数据
java
class Solution {
public String longestPalindrome(String s) {
//dp[i][j]表示字符串[i,j]是否是最长回文子串
//dp[i][j] = s[i] == s[j] && dp[i + 1][j - 1];
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(单个石子无需合并); -
遍历顺序:
javafor (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通用模板(所有场景的基础)
java
// 通用DP模板(以二维DP为例)
class DPGeneralTemplate {
public int dpTemplate(int[] nums1, int[] nums2) {
int n = nums1.length;
int m = nums2.length;
// 1. 状态定义:dp[i][j] 代表nums1前i个、nums2前j个的目标值
int[][] dp = new int[n+1][m+1];
// 2. 初始条件
for (int i = 0; i <= n; i++) {
dp[i][0] = 0; // 示例初始值,根据场景调整
}
for (int j = 0; j <= m; j++) {
dp[0][j] = 0;
}
// 3. 遍历顺序:先i后j(根据依赖调整)
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
// 4. 状态转移方程(核心,根据场景调整)
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]);
}
}
}
// 5. 返回最终状态
return dp[n][m];
}
}
✔ 模板2:01背包(基础+空间优化,LeetCode 416. 分割等和子集)
java
class ZeroOneKnapsack {
// 题目:判断数组是否能分割为两个子集,和相等(01背包可行性)
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num : nums) sum += num;
if (sum % 2 != 0) return false; // 和为奇数,无法分割
int target = sum / 2;
// 空间优化:一维dp,dp[j] = 容量j是否可达
boolean[] dp = new boolean[target + 1];
dp[0] = true; // 初始条件:容量0可达
// 遍历物品(01背包:先物品后容量,容量逆序)
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)
java
class CompleteKnapsack {
// 题目:给定金额和无限数量的硬币,求组成金额的组合数
public int change(int amount, int[] coins) {
// 状态定义:dp[j] = 金额j的组合数
int[] dp = new int[amount + 1];
dp[0] = 1; // 初始条件:金额0的组合数为1
// 遍历物品(完全背包:先物品后容量,容量正序)
for (int coin : coins) {
for (int j = coin; j <= amount; j++) {
dp[j] += dp[j - coin]; // 状态转移:选当前硬币
// dp[j] %= 1000000007; // 大数取模(题目要求时加)
}
}
return dp[amount];
}
}
✔ 模板4:最长递增子序列(LIS,LeetCode 300)
java
class LIS {
// O(n²) DP解法 + O(nlogn) 贪心+二分优化
public int lengthOfLIS(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
// 1. O(n²) DP解法
int[] dp = new int[n];
Arrays.fill(dp, 1); // 初始条件:每个元素自身是长度为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]);
}
// 2. O(nlogn) 贪心+二分优化(二刷必掌握)
// List<Integer> tails = new ArrayList<>();
// for (int num : nums) {
// int idx = Collections.binarySearch(tails, num);
// if (idx < 0) idx = -idx - 1;
// if (idx == tails.size()) tails.add(num);
// else tails.set(idx, num);
// }
// return tails.size();
return maxLen;
}
}
✔ 模板5:最长公共子序列(LCS,LeetCode 1143)
java
class LCS {
public int longestCommonSubsequence(String text1, String text2) {
int n = text1.length();
int m = text2.length();
// 状态定义:dp[i][j] = text1前i个、text2前j个的LCS长度
int[][] dp = new int[n+1][m+1];
// 遍历顺序:先i后j
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)
java
class TreeDP {
// 题目:二叉树中偷节点,不能偷相邻节点,求最大金额
public int rob(TreeNode root) {
int[] res = dfs(root);
return Math.max(res[0], res[1]); // 0:不偷根,1:偷根
}
// 后序遍历,返回int[2]:[不偷当前节点的最大值,偷当前节点的最大值]
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; }
}
}
总结
动态规划二刷的核心逻辑(Java版):
- 五要素是根基:状态定义→转移方程→初始条件→遍历顺序→空间优化,按此步骤解题,思路不混乱;
- 场景模板化 :
- 线性DP:一维数组,前序依赖;
- 背包问题:二维→一维,注意遍历顺序;
- 子序列DP:二维数组,字符匹配转移;
- 区间DP:先长度后起点,分割点遍历;
- 树形DP:后序遍历,子树状态合并;
- 优化是亮点:空间优化(滚动数组)、时间优化(贪心+二分)是超越普通候选人的关键;
- Java特性要注意:数组初始化、边界处理、大数取模,规避空指针和溢出。
✅ 高频避坑点(二刷必记)
- 状态定义模糊:写代码前先明确"dp[i][j]代表什么",避免边写边改;
- 转移方程遗漏条件:如背包问题中未判断"j≥w[i]",导致数组越界;
- 遍历顺序错误 :
- 01背包一维优化:容量逆序(否则重复选);
- 完全背包一维优化:容量正序;
- 区间DP:先遍历长度再遍历起点;
- 初始条件错误 :
- 最值问题初始化为0(应初始化为MAX/MIN);
- 计数问题未初始化dp[0]=1;
- 空间优化时覆盖问题:如二维DP→一维时,未保留依赖的前序状态;
- 大数溢出:方案数问题未取模,导致int/long溢出;
- 忽略题目约束:如"子序列"vs"子串",状态定义错误导致思路全错。