思想:在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
回溯算法就是在一个树形问题上做一次深度优先遍历,以达到搜索所有可能的解的效果。
执行深度优先遍历,从较深层的结点返回到较浅层结点的时候,需要做**「状态重置」,即「回到过去」、「恢复现场」**
一定不要忘记恢复现场
“回溯”算法总结(深度优先遍历 + 状态重置 + 剪枝)
常出现情况:
当题目中出现 “所有组合” 等类似字眼时,我们第一感觉就要想到用回溯。
五大算法之一回溯算法
1.有效结果(可能也有无效结果)
2.回溯范围及答案更新
3.剪枝条件
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
`
提示:这部分练习可以帮助我们熟悉「回溯算法」的一些概念和通用的解题思路。解题的步骤是:先画图,再编码。去思考可以剪枝的条件, 为什么有的时候用 used 数组,有的时候设置搜索起点 begin 变量,理解状态变量设计的想法。
全排列(中等)
全排列 II(中等):思考为什么造成了重复,如何在搜索之前就判断这一支会产生重复;
组合总和(中等)
组合总和 II(中等)
组合(中等)
子集(中等)
子集 II(中等):剪枝技巧同 47 题、39 题、40 题;
第 k 个排列(中等):利用了剪枝的思想,减去了大量枝叶,直接来到需要的叶子结点;
复原 IP 地址(中等)
提示:Flood 是「洪水」的意思,Flood Fill 直译是「泛洪填充」的意思,体现了洪水能够从一点开始,迅速填满当前位置附近的地势低的区域。类似的应用还有:PS 软件中的「点一下把这一片区域的颜色都替换掉」,扫雷游戏「点一下打开一大片没有雷的区域」。
下面这几个问题,思想不难,但是初学的时候代码很不容易写对,并且也很难调试。我们的建议是多写几遍,忘记了就再写一次,参考规范的编写实现(设置 visited 数组,设置方向数组,抽取私有方法),把代码写对。
提示:字符串的问题的特殊之处在于,字符串的拼接生成新对象,因此在这一类问题上没有显示「回溯」的过程,但是如果使用 StringBuilder 拼接字符串就另当别论。
在这里把它们单独作为一个题型,是希望朋友们能够注意到这个非常细节的地方。
括号生成(中等) :这道题广度优先遍历也很好写,可以通过这个问题理解一下为什么回溯算法都是深度优先遍历,并且都用递归来写。
回溯算法是早期简单的人工智能,有些教程把回溯叫做暴力搜索,但回溯没有那么暴力,回溯是有方向地搜索。「力扣」上有一些简单的游戏类问题,解决它们有一定的难度,大家可以尝试一下。
回溯算法思想如果在递归中使用其实就是深度优先遍历。
二叉树的 DFS 有两个要素:「访问相邻结点」和「判断 base case」
public void dfs(){
Stack<TreeNode> stack = new Stack<>();
stack.add(root);
while(!stack.isEmpty){
TreeNode node = stack.poll();
system.out.println(node.val);
if(node.right != null){
stack.add(node.right);
}
if(node.left != null){
stack.add(node.left);
}
}
}
public void dfs(){
if(root==null)
return;
system.out.println(root.val);
dfs(root.left);
dfs(root.right);
}
给定一个可包含重复数字的序列 nums
,按任意顺序 返回所有不重复的全排列。
1.有效结果
要添加的数组长度等于原数组长度
if(path.size() == nums.length) {
list.add(new ArrayList(path));
return;
}
2.回溯范围及答案更新
//执行遍历
view[i] = true;
path.add(nums[i]);
dfs(nums);
//恢复现场
path.removeLast();
view[i] = false;
3.剪枝条件
如果该数字已经使用过或者当前数字等于前面一个(排除重复排列)
用一个数组保存用过的数字位置
i>0 && nums[i]==nums[i-1] && view[i-1]==false
class Solution {
List<List<Integer>> list = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
boolean[] view;
public List<List<Integer>> permuteUnique(int[] nums) {
if(nums.length==0){
return list;
}
Arrays.sort(nums);
view = new boolean[nums.length];
dfs(nums);
return list;
}
public void dfs(int[] nums){
if(path.size() == nums.length) {
list.add(new ArrayList(path));
return;
}
for(int i = 0 ;i < nums.length ; i++){
if(i>0 && nums[i]==nums[i-1] && view[i-1]==false){
continue;
}
if(view[i] == false){
view[i] = true;
path.add(nums[i]);
dfs(nums);
path.removeLast();
view[i] = false;
}
}
}
}
这类问题是在一种「网格」结构中进行的。岛屿问题是这类网格 DFS 问题的典型代表。
「访问相邻结点」和「判断 base case」
「访问相邻结点」:该方块的上下左右四个区域
「判断 base case」:数组下标越界
通用代码:
void dfs(int[][] grid, int r, int c) {
// 判断 base case
// 如果坐标 (r, c) 超出了网格范围,直接返回
if (!inArea(grid, r, c)) {
return;
}
// 访问上、下、左、右四个相邻结点
dfs(grid, r - 1, c);
dfs(grid, r + 1, c);
dfs(grid, r, c - 1);
dfs(grid, r, c + 1);
}
// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length
&& 0 <= c && c < grid[0].length;
}
给你一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
限制条件:陆地不能重复读即1不能重复读
解决方法:读取1的时候将它改为2,添加判断避免重复读取
class Solution {
public int numIslands(char[][] grid) {
int sum = 0;
int m = grid.length;
int n = grid[0].length;
for(int i = 0;i<m;i++){
for(int j=0;j<n;j++){
if(grid[i][j]=='1'){
dfs(grid,i,j,m,n);
sum++;
}
}
}
return sum;
}
public void dfs(char[][] grid,int i,int j,int m,int n){
if(i<0 || j<0 || i>=m || j>=n || grid[i][j]!='1'){
return;
}
grid[i][j]='2';
dfs(grid,i+1,j,m,n);
dfs(grid,i-1,j,m,n);
dfs(grid,i,j+1,m,n);
dfs(grid,i,j-1,m,n);
}
}
给定一个只包括 ‘(’,’)’,’{’,’}’,’[’,’]’ 的字符串 s ,判断字符串是否有效。
有效字符串需满足:
1.左括号必须用相同类型的右括号闭合。
2.左括号必须以正确的顺序闭合
1.有效结果(可能也有无效结果)
左右括号都不剩余了,递归终止
res.add(curStr);
2.回溯范围及答案更新
如果**左括号还剩余**的话,可以拼接左括号
dfs(left - 1, right, curStr + "(");
如果右括号剩余多于左括号剩余的话,可以拼接右括号
`dfs(left, right - 1, curStr + ")");`
3.剪枝条件
左括号的数目一旦小于右括号的数目,以及,左括号的数目和右括号数目均小于n。
class Solution {
List<String> res = new ArrayList<>();
public List<String> generateParenthesis(int n) {
dfs(n, n, "");
return res;
}
private void dfs(int left, int right, String curStr) {
if (left == 0 && right == 0) { // 左右括号都不剩余了,递归终止
res.add(curStr);
return;
}
if (left > 0) { // 如果左括号还剩余的话,可以拼接左括号
dfs(left - 1, right, curStr + "(");
}
if (right > left) { // 如果右括号剩余多于左括号剩余的话,可以拼接右括号
dfs(left, right - 1, curStr + ")");
}
}
}
给你一个整数 n
,返回所有不同的 n 皇后问题 的解决方案。
使其中任意两个皇后都不同列、同行和在一条斜线上
1.有效结果(可能也有无效结果)
递归到棋盘最底层
if(n==row){
path = new ArrayList<>();
for(char[] c : view){
path.add(String.copyValueOf(c));
}
list.add(path);
return;
}
2.回溯范围及答案更新
递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。
每次都是要从新的一行的起始位置开始搜,所以都是从0开始。
for(int j = 0 ;j<n;j++){
if(isValid(view,row,j)){
//执行遍历
view[row][j]='Q';
dfs(view,n,row+1);
//恢复现场
view[row][j]='.';
}
}
3.剪枝条件
不能同行
不能同列
不能同斜线
public boolean isValid(char[][] view,int row,int col)
class Solution {
List<List<String>> list = new ArrayList<>();
List<String> path;
public List<List<String>> solveNQueens(int n) {
char[][] view = new char[n][n];
for(char[] c : view){
Arrays.fill(c,'.');
}
dfs(view,n,0);
return list;
}
public void dfs(char[][] view,int n , int row){
if(n==row){
path = new ArrayList<>();
for(char[] c : view){
path.add(String.copyValueOf(c));
}
list.add(path);
return;
}
for(int j = 0 ;j<n;j++){
if(isValid(view,row,j)){
view[row][j]='Q';
dfs(view,n,row+1);
view[row][j]='.';
}
}
}
public boolean isValid(char[][] view,int row,int col){
//判断同列
for(int i=0;i<row;i++){
if(view[i][col] == 'Q'){
return false;
}
}
//判断左上角
for(int i=row-1,j=col-1;i>=0&&j>=0;i--,j--){
if(view[i][j] == 'Q'){
return false;
}
}
//判断右上角
for(int i=row-1,j=col+1;i>=0&&j<view.length;i--,j++){
if(view[i][j] == 'Q'){
return false;
}
}
return true;
}
}