欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。

目录
引言
让我们从“思考模式”的层面出发,去理解更为复杂和抽象的问题。
- 深入理解递归的本质,学会如何写出优雅的递归函数。
- 掌握回溯 (Backtracking) 这一强大的搜索模式,解决排列、组合、子集等经典问题。
- 最终,挑战算法面试中的“珠穆朗玛峰”——动态规划 (Dynamic Programming),学会用 DP 思维解决最优化问题。(见下P)
1 回溯 (Backtracking)
回溯算法本质上就是一种特殊的深度优先搜索(DFS)。
回溯的口诀:一条路走到黑,发现不对就撤退。
这是一种通过“试错”来寻找所有可能解的暴力搜索算法,但它比纯粹的暴力聪明,因为它懂得“剪枝”,即发现某条路肯定不对后,就不会再继续往下走了。
几乎所有的回溯问题,都可以套用一个非常经典的三段式代码结构,也就是“选择 -> 递归 -> 撤销选择”。
或者说是“可选列表 -> 当前路径 -> 结束条件”。
// result 用来存放最终结果
// path 用来记录当前已经走过的路径
function backtrack(可选列表, 当前路径) {
if (满足结束条件) {
将当前路径加入结果;
return;
}
for (在可选列表里做选择) {
// 1. 做选择 (Choose)
将当前选择加入路径;
// 2. 递归 (Explore)
// 进入下一层决策树
backtrack(新的可选列表, 当前路径);
// 3. 撤销选择 (Un-choose)
// 这是回溯的精髓!
// 将刚才的选择从路径中移除,以便尝试其他选择
将当前选择从路径中移除;
}
}
回溯本身的思路并不难理解,难的是代码实现。
路径怎么表示?可选列表怎么维护?结束条件是什么?
下面我们用示例“全排列”进行讲解。
1.1 LeetCode 46. 全排列
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
class Solution {
/**
排列问题,采用回溯
采用“可选列表->递归->撤销选择”三步走
1、可选列表怎么维护
在这道题中是还没有被选中的数字,采用一个boolean[] used进行标识
2、当前路径怎么表示
使用List<Integer表示路径>
3、结束条件是什么
所有数字都被选择时结束
*/
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> resultList = new ArrayList<>();
int length = nums.length;
if(length == 0){
return resultList;
}
boolean[] used = new boolean[length];
List<Integer> path = new ArrayList<>();
backtrack(nums,used,resultList,path);
return resultList;
}
/**
回调,DFS递归
*/
private void backtrack(int[] nums,boolean[] used,List<List<Integer>> resultList,List<Integer> path){
// 4.结束条件
if(path.size() == nums.length){
resultList.add(new ArrayList<>(path));
return;
}
// 循环遍历数组,开始选择
for(int i = 0; i < nums.length ; i++){
// 3.已经选择过了
if(used[i]){
continue;
}
// 1.选择可选列表
used[i] = true;
path.add(nums[i]);
// 2.递归,选择不同路径
backtrack(nums,used,resultList,path);
// 5.撤销选择
used[i] = false;
// 6.移除最后一个,较new Integer(nums[i])省内存
path.remove(path.size() -1);
}
}
}
1.2 LeetCode 77. 组合
回溯算法能解决的问题非常多,除了“全排列”,还有“组合”、“子集”、“棋盘问题(N皇后)”等等。它们的核心思想都是一样的,只是在“剪枝”和“结束条件”上略有不同。
比如77的组合问题。
在“组合”问题里,[1, 2]和 [2, 1]被认为是同一种组合。
我们需要对“选择 -> 递归 -> 撤销选择”进行调整,在选择时,强制要求从小到大选择来避免重复。
只需要对回溯方法做一点点改动,首先,我们不需要used数组了,因为从小到大往后选,避免了重复使用。
比如:
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> resultList = new ArrayList<>();
if(n<k){
return resultList;
}
List<Integer> path = new ArrayList<>();
backTrack(n,k,resultList,path);
return resultList;
}
//回溯
public void backTrack(int n, int k,List<List<Integer>> resultList,List<Integer> path){
// 终止条件
if(path.size() == k){
resultList.add(new ArrayList(path));
return;
}
// 循环取数
for(int i =1 ;i<= n;i++){
// 强制从小到大
if(path.size() != 0 && i <= path.get(path.size() -1)){
continue;
}
path.add(i);
// 递归
backTrack(n,k,resultList,path);
// 撤销操作
path.remove(path.size() -1);
}
}
}
同时,我们需要一个 startIndex ,告诉递归从那里开始循环以避免无效循环的浪费。
更近一步,如果 n=20, k=10,我们当前的 path 里已经有8个数字了,我们还需要 10 - 8 = 2 个数字。
此时,如果 for 循环的 i 已经走到了 19,那么我们最多还能选择 19 和 20 这两个数。
如果 i 走到了 20,我们最多只能再选 20 这一个数,已经凑不够我们需要的2个数了。所以,i=20 的这个分支就可以直接剪掉。
即,缩小可选列表。
path 还需要 k - path.size() 个数。
从 i 到 n,我们还剩下 n - i + 1 个数可选。
如果 n - i + 1 < k - path.size(),说明剩下的数已经不够凑了,可以直接 break 循环。
优化后代码如下:
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> resultList = new ArrayList<>();
if(n<k){
return resultList;
}
List<Integer> path = new ArrayList<>();
backTrack(1,n,k,resultList,path);
return resultList;
}
//回溯
public void backTrack(int startIndex,int n, int k,List<List<Integer>> resultList,List<Integer> path){
// 终止条件
if(path.size() == k){
resultList.add(new ArrayList(path));
return;
}
// 循环取数 (n - (k - path.size()) + 1) 是 i 的一个理论上限
for(int i = startIndex ;i<= n - (k -path.size()) + 1;i++){
// 强制从小到大
path.add(i);
// 递归
backTrack(i + 1,n,k,resultList,path);
// 撤销操作
path.remove(path.size() -1);
}
}
}
2 分治
与回溯这种递归寻找所有可能性的算法不同,分治是一种收敛的思维模型。
可以不断缩小问题规模来高效寻找唯一解。
口诀:分而治之,合而为一。 分、治、合。
它将一个难以直接解决的大问题,分割成两个或多个规模较小的、与原问题形式相同的子问题,然后递归地去解决这些子问题,最后将子问题的解合并,得到原问题的解。
比如:
- 归并排序 (Merge Sort): 将数组从中间一分为二,分别对左右两半进行排序(递归),然后将两个有序的子数组合并成一个大的有序数组。
- 快速排序 (Quick Sort): 选取一个基准值,将数组分成“小于基准”和“大于基准”的两部分,然后对这两部分递归地进行排序。
- 二分查找 (Binary Search): 这是一种最简单、也最高频的分治应用。
需要注意二分查找和P1的双指针的区别,左右指针是一步步收缩,而二分查找是跳跃,它每一次都直接跳到当前搜索范围的正中间去进行判断,然后扔掉一半的范围。
2.1 LeetCode704. 二分查找
二分查找有着标准的模板,最不容易出错的“闭区间”写法。
- 搜索区间是
[left, right],两端都包含。 - right 初始化为 nums.length - 1。
- 循环条件是 while (left <= right)。
class Solution {
/**
最经典的二分查找,找有序数组中的目标值
*/
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length -1;
// 循环条件 left <= right
while(left <= right){
//中点计算
int mid = left + (right-left)/2;
if(nums[mid] == target){
return mid;
}
if(nums[mid] < target){
left = mid +1;
}else{
right = mid -1;
}
}
return -1;
}
}
另外78. 子集也是很经典的回溯题。
209

被折叠的 条评论
为什么被折叠?



