4道LeetCode一起学烂回溯算法
前言
回溯算法本身是一种穷举所有可能的暴力解法,只是它可以在穷举的过程通过剪枝操作避免一些不合理的情况,并且在回溯的过程中尽可能的保留已有的结果。回溯算法解决的是所有可能组合的问题。
- 回溯函数框架
- 结束条件
1.1. 满足结束条件,则将当前选择组合存入全局变量中(结果添加)
1.2. 结束本次递归(返回操作) - 遍历所有可选择情况(可选选择)
2.1. 判断当前所做的选择是否合法(剪枝操作)
2.2. 将当前选择加入路径(路径添加)
2.3. 递归回溯函数做下个元素的选择(下一节点)
2.4. 一种路径选择完成,逐步移除最后一个元素,空出位置放其它选择(路径回溯)
1. 全排列
- 问题
/**
* 全排列 46
* 给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
*
* 示例 1:
* 输入:nums = [1,2,3]
* 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
*
* 示例 2:
* 输入:nums = [0,1]
* 输出:[[0,1],[1,0]]
*
* 示例 3:
* 输入:nums = [1]
* 输出:[[1]]
*
*/
- 思路
以nums = [1,2,3]为例,我可以把它转换为这样一颗三叉树,并且值保留了符合条件的路径。
- 回溯函数
1. 结束条件:组合个数达到nums.length
2. 可选选择:遍历nums的所有元素,每个元素都是可能的选择
2.1 剪枝操作:如果路径中已经包含本次选择的元素,则继续尝试其它选择
2.2 路径添加:将当前选择的元素加入路劲
2.3 递归选择下一层节点
2.4 路径回溯
- 实现
private static List<List<Integer>> res;
public static List<List<Integer>> permute_0(int[] nums) {
res=new ArrayList<>();//全局变量存储所有组合
List<Integer> permutation=new ArrayList<>();
backtrack(nums,permutation);
return res;
}
/**
* 将nums的当前组合permutation存储到res中。
* @param nums
* @param permutation
*/
private static void backtrack(int[] nums,List<Integer> permutation)
{
//1. 结束条件:如果permutation的长度为nums数组的长度,代表一个组合完成,将当前组合加入res,并回溯
if (permutation.size()==nums.length)
{
res.add(new ArrayList<>(permutation));
return;
}
//2.可选选择: 遍历nums,获取当前值num
for (int num : nums) {
//2.1 剪枝操作:如果 permutation 中包含curr,则结束当前组合情况
if (permutation.contains(num)) continue;
//2.2 路径添加:num加入permutation
permutation.add(num);
//2.3 选择下一节点:递归下一层结点
backtrack(nums,permutation);
//2.4 路径回溯:回溯重置permutation的当前值
permutation.remove(permutation.size()-1);
}
}
2. N皇后
- 问题
/**
* N皇后 51
*
* n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。(即皇后的上下左右,左上,左下,右上,右下都不能出现其它皇后)
* 给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
* 每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
*
* 示例1:
* 输入:n = 4
* 输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
* 解释:如上图所示,4 皇后问题存在两个不同的解法。
*
* 示例2:
* 输入:n = 1
* 输出:[["Q"]]
*
*/
- 思路
每一行只能放一个皇后,每一行的每一列都可能放皇后,那么可以回溯穷举所有可能。下图以4皇后为例
- 回溯函数框架
1. 结束条件:如果一种组合road完成,则添加到结果res中
2. 做选择:循环选择每一个列作为当前行n皇后可能的位置
3. 剪枝操作:判断在当前位置放置皇后,是否与已有的皇后起冲突
4. 递归做下一行n皇后的位置
5. 回溯操作:为当前节点其它选择留出位置
- 实现
/**
* 回溯算法
*
* 思路:
* 1. 每一行只能放一个皇后,每一行的每一列都可能放皇后,那么可以回溯穷举所有可能
*
* @param n
* @return
*/
public static List<List<String>> solveNQueens(int n) {
res=new ArrayList<>();
List<String> road=new ArrayList<>();
backtrack(n,road);
return res;
}
//全局变量存储所有可能组合
private static List<List<String>> res;
/**
* 尝试每一行可以放皇后的所有可能列,并将结果存入road
* @param n
* @param road
*/
private static void backtrack(int n,List<String> road)
{
//1. 如果road.size()=n,说明已经找到一种放置n皇后的方案,将其存入res,并结束本次递归
if (road.size()==n)
{
res.add(new ArrayList<>(road));
return;
}
//2. 可选选择:遍历当前行所有可能放置皇后的位置
for (int i = 0; i < n; i++) {
//2.1 剪枝操作:如果皇后位置冲突,则探索其它可能
int row = road.size();
if(isConflict(road,row, i,n)) continue;
//2.2 路径加入:将当前行放置n皇后的位置加入路径
StringBuilder str= new StringBuilder();
for (int j = 0; j < n; j++) {
if (i==j)
str.append("Q");
else str.append(".");
}
road.add(str.toString());
//2.3 递归选择下一行:递归探索下一行可能放置的位置
backtrack(n,road);
//2.4 路径回溯
road.remove(road.size()-1);
}
}
/**
* 检查将皇后放在(rowIndex,columnIndex)的位置是否会与其它皇后冲突
*
* 因为我们是从上往下一行一行添加皇后,此时选择的皇后位置的下边,左边,右边,左下,右下一定没有皇后,
* 我们只需要检查上,左上,右上即可
* @param road
* @param rowIndex
* @param columnIndex
* @param n 皇后个数,即矩阵大小
* @return
*/
private static boolean isConflict(List<String> road,int rowIndex, int columnIndex,int n) {
int rowNum = road.size();
//1. 上
for (int i = 0; i < rowNum; i++) {
if (road.get(i).charAt(columnIndex)=='Q')
return true;
}
//2. 左上
for (int i=rowIndex-1,j=columnIndex-1; i>=0 && j>=0; i--,j--)
{
if (road.get(i).charAt(j)=='Q')
return true;
}
//3. 右上
for (int i=rowIndex-1,j=columnIndex+1; i>=0 && j<n; i--,j++)
{
if (road.get(i).charAt(j)=='Q')
return true;
}
return false;
}
3. 单词搜索
- 问题
/**
* 单词搜索 79
*
* 给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。
* 如果 word 存在于网格中,返回 true ;否则,返回 false 。
*
* 单词必须按照字母顺序,通过相邻的单元格内的字母构成,
* 其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。
* 同一个单元格内的字母不允许被重复使用。
*
* 示例1:
* 输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
* 输出:true
*
* 提示:
* m == board.length
* n = board[i].length
* 1 <= m, n <= 6
* 1 <= word.length <= 15
* board 和 word 仅由大小写英文字母组成
*
*/
- 递归实现
/**
* 回溯法
*
* 思路:
* 1. 如果当前字母为word的第一个单词,则若以该位置的相邻位置可以找到剩下的所有单词,则返回true,否则返回false。
* 2. 而该位置可以找到剩下的所有单词吗?这又是和1一样的问题,所以是递归过程
* 3. 在递归中我们进行剪枝操作,就可以实现回溯。
*
* 时间复杂度:一个非常宽松的上界为 O(M*N*3^L),其中 M, N为网格的长度与宽度,L为字符串 word的长度。每次调用回溯函数时间复杂度为O(3^L)
* 空间复杂度:O(M*N),visited是O(M*N) ,回调栈的深度最大为O(min(L,M*N));
* @param board
* @param word
* @return
*/
public static boolean exist(char[][] board, String word) {
int m = board.length;
int n = board[0].length;
char[] chars = word.toCharArray();
boolean[][] visited=new boolean[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
//所有可能开头的位置
if (backtrace(i,j,0,board,chars,visited))
return true;
}
}
return false;
}
/**
* [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]
* "ABCCED"
* 表示从i,j的位置开始,能否从board中获得word中从第k个字母(从0开始计)到最后一个字母。visited保证字母不重复使用
* @param i
* @param j
* @param k
* @param board
* @param word
* @param visited
* @return
*/
private static boolean backtrace(int i,int j,int k,char[][] board,char[] word,boolean[][] visited)
{
//1. 如果当前位置不等于word[k],则返回false,否则如果k是word的最后一个位置说明获得了完成单词,返回true;
if (board[i][j]!=word[k])
return false;
else if (k==word.length-1)
return true;
//2. 标记当前位置已经访问过
visited[i][j]=true;
//3. 剪枝操作:判断从i,j的相邻位置开始,是否能找到word中从k+1到word.length的所有字母,如果有一种情况存在,则返回true,否则返回false
//3.1 上
if (i-1>=0 && !visited[i - 1][j])
{
boolean b = backtrace(i - 1, j, k + 1, board, word, visited);
if (b)
return true;
}
//3.2 下
if (i+1<board.length && !visited[i + 1][j])
{
boolean b = backtrace(i + 1, j, k + 1, board, word, visited);
if (b)
return true;
}
//3.3 左
if (j-1>=0 && !visited[i][j-1])
{
boolean b = backtrace(i, j-1, k + 1, board, word, visited);
if (b)
return true;
}
//3.4 右
if (j+1<board[0].length && !visited[i][j+1])
{
boolean b = backtrace(i, j+1, k + 1, board, word, visited);
if (b)
return true;
}
//4. 回溯
visited[i][j]=false;
return false;
}
- 标准回溯框架 实现
static boolean res=false;
/**
* 标准回溯法
* @param board
* @param word
* @return
*/
public static boolean exist_0(char[][] board, String word) {
int m = board.length;
int n = board[0].length;
char[] chars = word.toCharArray();
StringBuilder str_word = new StringBuilder();
boolean[][] visited=new boolean[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (board[i][j]==chars[0])
{
str_word.append(chars[0]);
visited[i][j]=true;
backtrace_0(i,j,board,chars,str_word,visited);
if (res) return true;
else{
visited[i][j]=false;
str_word.deleteCharAt(0);
}
}
}
}
return false;
}
/**
* 标准回溯算法写法
* @param i
* @param j
* @param board
* @param word
* @param str_word
* @param visited
*/
private static void backtrace_0(int i,int j,char[][] board,char[] word,StringBuilder str_word,boolean[][] visited)
{
//1. 如果str_word的长度等于word的长度,则表明所有匹配的字符查找完毕,结束递归
if (str_word.length()==word.length)
{
res=true;
return;
}
//待匹配字符索引
int k = str_word.length();
//2. 获取下一个字母的所有可选情况(即上下左右四种情况)
List<match> selects = new ArrayList<>();
//上下左右
if (i>0 && !visited[i-1][j])
{
selects.add(new match(board[i-1][j],new Integer[]{i-1,j}));
}
if (i<board.length-1 && !visited[i+1][j])
{
selects.add(new match(board[i+1][j],new Integer[]{i+1,j}));
}
if (j>0 && !visited[i][j-1])
{
selects.add(new match(board[i][j-1],new Integer[]{i,j-1}));
}
if (j<board[0].length-1 && !visited[i][j+1])
{
selects.add(new match(board[i][j+1],new Integer[]{i,j+1}));
}
//3. 遍历所有可选选择
for (match select_ : selects) {
char select=select_.getChr();
//3.1 如果字母不匹配,则尝试其它选择
if (select!=word[k]) continue;
//3.2 将选择的字母加入str_word
str_word.append(select);
//3.3 visited标记当前位置已经匹配过
Integer m = select_.getIndex()[0];
Integer n = select_.getIndex()[1];
visited[m][n]=true;
//3.4 做下一选择
backtrace_0(m,n,board,word,str_word,visited);
//3.5 回溯
visited[m][n]=false;
str_word.deleteCharAt(str_word.length()-1);
}
}
private static class match
{
public Character getChr() {
return chr;
}
public void setChr(Character chr) {
this.chr = chr;
}
public Integer[] getIndex() {
return index;
}
public void setIndex(Integer[] index) {
this.index = index;
}
public match(Character chr,Integer[] index) {
this.chr = chr;
this.index = index;
}
private Character chr;
private Integer[] index;
}
4. 子集
- 问题
/**
* 子集 78
*
* 给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
* 解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
*
* 示例 1:
* 输入:nums = [1,2,3]
* 输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
*
* 示例 2:
* 输入:nums = [0]
* 输出:[[],[0]]
*
*/
- 思路
1. 对于一个包含m个元素的数组,从中选择包含n个数字的不重复组合,每一个数字都m种选择,回溯选择即可。
2. 为了保证 不能 包含重复的子集,每次开始选择元素索引必须不能包含该元素之前的任何元素,
对于数组[1,2,3,4],当前选择元素是2,那么下一个元素一定不能选择1,2,只能选择后面的元素即3,4,
这样可以避免重复子集。
- 实现
/**
* 回溯法
*
* 设nums中有n个元素,即有n个位置待填充,而nums中每个数字对应于每个位置有两种选择,即要么填充,要么不填充,则最多有2^n个子集。
* 如: nums=[1,2,3]
* 待填充的有3个位置:[][][],而nums的数字对于待填充的位置只有两种选择,要么填充,要么不填充,因此有2^3=6个子集
*
* 时间复杂度:O(n*2^n),子集长度有n种情况,每种长度子集的个数最多为2^n
* 空间复杂度:O(n*2^n),
* @param nums
* @return
*/
public static List<List<Integer>> subsets_0(int[] nums) {
res=new ArrayList<>();
//Arrays.sort(nums);
for (int i = 0; i <= nums.length; i++) {
backtrace(nums,new ArrayList<>(),i,0);
}
return res;
}
/**
* 回溯获取nums中指定元素个数n的所有子集,并存入res。
* curr_index用于避免重复子集
* @param nums
* @param subset
* @param n
*/
public static void backtrace(int[] nums,List<Integer> subset,int n,int curr_index)
{
//1. 如果子集subset中的元素个数达到n,则加入res
if (subset.size()==n)
{
res.add(new ArrayList<>(subset));
return;
}
//2. 探索nums中当前索引到最后一个索引的情况,当前索引依次递增,是为了避免重复子集
for (int i = curr_index; i < nums.length; i++) {
int num = nums[i];
if (subset.contains(num))
continue;
subset.add(num);
backtrace(nums,subset,n,++curr_index);
subset.remove(subset.size()-1);
}
}