回溯算法是一种枚举算法,主要是在搜索过程尝试寻找问题的解,如果发现条件不满足、不匹配,那么就回溯“返回”,尝试别的路径。
许多复杂的、规模大的问题都可以尝试使用回溯法解决。
通常,在搜索过程中,我们使用深度搜索,并在搜索之前对条件标记,搜索结束后的回溯阶段取消标记。
整个搜索过程可以抽象成一棵树,树的分支对应每种搜索路径,例如求解数组 [1,2,3,4] 的全排列,可以抽象为:
1
/ | \
2 3 4
/\ /\ /\
3 4 2 4 2 3
/ / ......
4 3 ......
一个通用的回溯算法模板如下:
void backTracking(args) {
if (终止条件) {
收集结果
}
// 处理集合
for (求解的集合) {
处理结点,标记结点
backTracking()
回溯,将标记结点撤销
}
}
257. 二叉树的所有路径 - 简单
给你一个二叉树的根节点 root ,按 任意顺序 ,返回所有从根节点到叶子节点的路径。
叶子节点 是指没有子节点的节点。
示例:
输入:root = [1,2,3,null,5]
输出:["1->2->5","1->3"]
题解:
需要枚举所有的情况,可以考虑使用回溯法:
使用链表来存储每次遍历的结点,即“路径”。
从根结点出发,如果当前结点不是“叶子结点”,搜索其子节点,并将当前结点加到“路径”中,搜索结束后,将路径中的最后一个结点移除
如果当前结点是叶子结点,将当前结点添加到“路径”中,并将路径添加到结果集中
代码:
class Solution {
public List<String> binaryTreePaths(TreeNode root) {
List<String> res = new ArrayList<>();
Deque<Integer> path = new LinkedList<>();
dfs(root, path, res);
return res;
}
public void dfs(TreeNode root, Deque<Integer> path, List<String> res) {
if (root == null) {
return;
}
// 如果是叶子结点,将路径添加到结果集中
if (root.left == null && root.right == null) {
StringBuilder sb = new StringBuilder();
for (Integer node : path) {
sb.append(node).append("->");
}
sb.append(root.val);
res.add(sb.toString());
}
// 将当前结点添加到路径中,遍历下一个结点
path.addLast(root.val);
// 遍历所有路径
dfs(root.left, path, res);
dfs(root.right, path, res);
// 路径搜索结束,将结点从路径中移除
path.removeLast();
}
}
46. 全排列 - 中等
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
题解:
对所有情况进行枚举,可以使用回溯法。
遍历数组:
如果排列结果已经满了,添加到结果集中
如果当前元素已经使用过,那么遍历下一个元素;如果当前元素没有使用过,添加到排列结果中,并标记元素已经使用过
搜索下一个元素
搜索结束,取消元素的标记
代码:
class Solution {
public List<List<Integer>> permute(int[] nums) {
// 结果集
List<List<Integer>> res = new ArrayList<>();
// 排列结果
List<Integer> array = new LinkedList<>();
// 标记元素是否使用过
boolean[] flag = new boolean[nums.length];
backTracking(nums, res, array, flag);
return res;
}
private void backTracking(int[] nums, List<List<Integer>> res, List<Integer> array, boolean[] flag) {
// 排列结果满了,添加到结果集中
if (array.size() == nums.length) {
res.add(new ArrayList<>(array));
return;
}
for (int i = 0; i < nums.length; i++) {
// 如果当前元素没有使用过,添加到排列结果中
if (!flag[i]) {
flag[i] = true;
array.add(nums[i]);
backTracking(nums, res, array, flag);
array.remove(array.size() - 1);
flag[i] = false;
}
}
}
}
47. 全排列 II - 中等
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
示例:
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
题解:
使用一个列表存放排列结果,如果列表满了,将排列结果添加到结果集中。
遍历数组,如果当前元素已经标记过,即已经加入到排列结果中,遍历下一个元素;或者,当前元素与之前的元素相等,但是之前的元素没有标记,即之前的元素已经考虑过了,遍历下一个元素。
如果不满足上面的条件,那么将当前元素标记,并添加到排列结果中,搜索下一个排列元素。
代码:
class Solution {
public List<List<Integer>> permuteUnique(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> perm = new ArrayList<>();
boolean[] flag = new boolean[nums.length];
Arrays.sort(nums);
backTracking(nums, 0, flag, res, perm);
return res;
}
private void backTracking(int[] nums, int idx,boolean[] flag, List<List<Integer>> res, List<Integer> perm) {
if (idx >= nums.length) {
res.add(new ArrayList<>(perm));
return;
}
for (int i = 0; i < nums.length; i++) {
// 当前元素使用过,不加入排列。
// 或者当前元素与上一个相等并且上一个元素没有使用过,不加入排列,会导致重复,因为上一个元素已经在之前的路径考虑过了
if (flag[i] || (i > 0 && nums[i-1] == nums[i] && !flag[i-1])) {
continue;
}
flag[i] = true;
perm.add(nums[i]);
backTracking(nums, idx+1, flag, res, perm);
perm.remove(idx);
flag[i] = false;
}
}
}
77. 组合 - 中等
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
示例:
输入:n = 4, k = 2
输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]
题解:
枚举所有组合,使用回溯算法:
- 使用一个列表保存组合结果,如果组合结果中元素个数等于 k,将组合结果添加到结果集中
- 遍历数组,将当前元素添加到组合结果中,搜索下一个元素的组合结果,搜索结束后,从组合结果中移除当前元素
可以进一步优化,如果剩余的元素个数已经小于 k 了,那么是没有足够的元素个数构成 k 个数的组合的,因此结束搜索节省时间。
代码:
class Solution {
public List<List<Integer>> combine(int n, int k) {
// 结果集
List<List<Integer>> res = new ArrayList<>();
// 组合结果
LinkedList<Integer> nums = new LinkedList<>();
backTracking(n, k, 1, res, nums);
return res;
}
private void backTracking(int n, int k, int i, List<List<Integer>> res, LinkedList<Integer> nums) {
// 组合结果满了,将组合结果添加到结果集中
if (nums.size() == k) {
res.add(new ArrayList<>(nums));
return;
}
for (int j = i; j <= n; j++) {
// 剪枝操作,如果剩余的数字数量小于 k,那么没必要搜索
if (nums.size() + (n - j + 1) < k) {
break;
}
nums.addLast(j);
backTracking(n, k, j + 1, res, nums);
nums.removeLast();
}
}
}
79. 单词搜索 - 中等
给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
示例:
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
输出:true
题解:
遍历整个表格,需要枚举每个格子所能组成的单词,使用回溯算法。
具体思路为:
- 遍历表格,如果从当前格子出发可以组成单词
word,那么返回true,否则继续遍历表格;- 判断当前格子可否组成单词
word,过程如下:- 当前格子的字符与当前指针
p指向word的字符相同,那么有可能可以组成单词,标记当前格子搜索过,移动指针p,搜索其他方向的字符,搜索结束后取消标记- 如果指针
p能够遍历完word,那么表格中的字母可以组成单词word,结束搜索,返回true- 如果当前格子的字符与指针
p指向的字符不相同,进行回溯。
代码:
class Solution {
private static final int[] direction = {-1, 0, 1, 0, -1};
public boolean exist(char[][] board, String word) {
int m = board.length, n = board[0].length;
// 标记某个单元格的字符是否已经使用过
boolean[][] flag = new boolean[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (backTracking(board, word, 0, i, j, flag)) {
return true;
}
}
}
return false;
}
private boolean backTracking(char[][] board, String word, int i, int x, int y, boolean[][] flag) {
// 如果单词已经遍历完,即网格中的字母可以构成 word,返回 结果
if (i >= word.length()) {
return true;
}
int m = board.length, n = board[0].length;
if (x < 0 || x >= m || y < 0 || y >= n) {
return false;
}
if (board[x][y] != word.charAt(i) || flag[x][y]) {
return false;
}
// 搜索当前格子的四个方向,如果是 word 中的字符,标记,进行搜索,搜索结束后,取消标记
flag[x][y] = true;
boolean found = false;
for (int j = 0; j < 4; j++) {
int newX = x + direction[j], newY = y + direction[j + 1];
found = found || backTracking(board, word, i + 1, newX, newY, flag);
if (found) {
return true;
}
}
flag[x][y] = false;
return false;
}
}
51. N 皇后 - 困难
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
示例:
输入:n = 4 输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
题解:
N 皇后问题,在放置棋子时,需要考虑当前棋盘上某一列上、某一行、斜对角线上是否有棋子,如果有棋子,就不放置。同样是需要枚举出所有可能的情况,因此使用回溯法。
使用哈希思想来快速判断某个位置上是否有棋子,声明三个布尔数组来标记:
col[],标记某一列是否有放置棋子diagonal1[],标记棋盘左上角指向右下角方向的斜线上是否有放置棋子diagonal2[],标记棋盘右上角指向左下角方向的斜线上是否有放置棋子关于对角线上的棋子如何判断:
假设
board是一个4*4二维数组,那么board[1][1]和board[2][0]在同一斜线(右上角指向左下角方向)上,同理,board[2][1]和board[3][0]也在同一斜线上,可以看到1+1 = 2+0,2+1 = 3+0,因此,右上角指向左下角方向的斜线可以表示为i+j。同理,另一方向的斜线也可以表示为i-j。从第一行第一列开始遍历棋盘,如果能够放置棋子,标记列、斜线上已经放置了棋子,随后枚举下一行,枚举结束后取消标记。
如果成功遍历完整个棋盘,那么将棋盘结果添加到结果集中,然后回溯。
代码:
class Solution {
// col 标记棋盘上某一列是否有放置棋子
boolean[] col;
// diagonal1 标记棋盘上对角线 \ 上是否有放置棋子
boolean[] diagonal1;
// diagonal2 标记棋盘上对角线 / 上是否有放置棋子
boolean[] diagonal2;
// 结果集
List<List<String>> res;
// 表示棋盘
char[][] board;
public List<List<String>> solveNQueens(int n) {
// 初始化
col = new boolean[n];
diagonal1 = new boolean[3 * n];
diagonal2 = new boolean[3 * n];
res = new ArrayList<>();
board = new char[n][n];
for (int i = 0; i < n; i++) {
Arrays.fill(board[i], '.');
}
backTracking(board, 0, n);
return res;
}
private void backTracking(char[][] board, int row, int n) {
// 如果棋盘遍历完毕,那么将结果添加到结果集中
if (row >= n) {
List<String> temp = new ArrayList<>();
for (int i = 0; i < n; i++) {
temp.add(new String(board[i]));
}
res.add(temp);
return;
}
for (int j = 0; j < n; j++) {
// 检查当前行当前列,是否有棋子,斜对角线上是否有棋子
// 如果有棋子,遍历下一列,没有棋子,标记并搜索下一行,搜索结束后,取消标记
// 因为 row-j 有可能出现负数的,所以加上 n 防止下标越界
if (col[j] || diagonal1[n + (row - j)] || diagonal2[n + (row + j)]) {
continue;
}
col[j] = diagonal1[n + (row - j)] = diagonal2[n + (row + j)] = true;
board[row][j] = 'Q';
backTracking(board, row + 1, n);
board[row][j] = '.';
col[j] = diagonal1[n + (row - j)] = diagonal2[n + (row + j)] = false;
}
}
}
37. 解数独 - 困难
编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
- 数字 1-9 在每一行只能出现一次。
- 数字 1-9 在每一列只能出现一次。
- 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 '.' 表示。
示例:
输入:board = [["5","3",".",".","7",".",".",".","."],
["6",".",".","1","9","5",".",".","."],
[".","9","8",".",".",".",".","6","."],
["8",".",".",".","6",".",".",".","3"],
["4",".",".","8",".","3",".",".","1"],
["7",".",".",".","2",".",".",".","6"],
[".","6",".",".",".",".","2","8","."],
[".",".",".","4","1","9",".",".","5"],
[".",".",".",".","8",".",".","7","9"]]
输出:[["5","3","4","6","7","8","9","1","2"],
["6","7","2","1","9","5","3","4","8"],
["1","9","8","3","4","2","5","6","7"],
["8","5","9","7","6","1","4","2","3"],
["4","2","6","8","5","3","7","9","1"],
["7","1","3","9","2","4","8","5","6"],
["9","6","1","5","3","7","2","8","4"],
["2","8","7","4","1","9","6","3","5"],
["3","4","5","2","8","6","1","7","9"]]
解释:输入的数独如上图所示,唯一有效的解决方案如下所示:
题解:
枚举所有可能出现的情况,使用回溯法。
为了快速判断某一列、某一行、某一宫格内是否出现过某个数字,使用哈希思想,利用布尔数组快速定位某个数字是否出现:
- 二维数组
col,标志某一列是否出现过某个数字,例如:col[1][2]表示数字1出现第一列- 二维数组
row,标志某一行是否出现过某个数字,例如:row[1][2]表示数字1出现在第一行- 三维数组
boxes标志某一宫格内是否出现过某个数字,例如:boxes[0][0][2]表示数字1出现在第一个宫格内。
- 我们使用数独表格的下标来定位宫格,
board数组的一维下标i和二维下标j的范围都为0-9,i/3和j/3的范围都为0,3,可以对应 9 个宫格遍历
board数组,如果是空格,将其坐标缓存,如果是数字,标记到三个哈希数组上。随后进行枚举:
- 如果缓存中已经没有待填空格,说明题目已经解决,否则
- 从缓存中取出一个待填空格的坐标
- 枚举这个空格所能填入的各种数字,标记,随后枚举下一个空格
- 枚举结束后,如果题目已经解决,停止枚举,否则
- 取消标记,如果该空格所有数字都不能填入,将空格重新加入缓存,返回上一个空格继续枚举
代码:
class Solution {
// 哈希标记数组
private boolean[][] col = new boolean[9][9];
private boolean[][] row = new boolean[9][9];
private boolean[][][] boxes = new boolean[3][3][9];
// 缓存待填空格队列
private Deque<int[]> queue = new LinkedList<>();
public void solveSudoku(char[][] board) {
// 确定需要填入数字的表格,标记已经填入数字的格子
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
char c = board[i][j];
if (c == '.') {
queue.offer(new int[]{i, j});
} else {
int num = c - '0';
col[j][num-1] = row[i][num-1] = boxes[i/3][j/3][num-1] = true;
}
}
}
//回溯
backTrack(board);
}
private boolean backTrack(char[][] board) {
// 所有格子都已经填充完毕
if (queue.isEmpty()) {
return true;
}
// 取到待填入空格的坐标
int[] pos = queue.pollFirst();
int x = pos[0], y = pos[1];
boolean flag = false;
// 在该空格上,遍历所有可以填入的数字的情况,然后深度搜索,搜索结束后,取消标记
for (int num = 1; num <= 9; num++) {
int i = num - 1;
if (!col[y][i] && !row[x][i] && !boxes[x/3][y/3][i]) {
col[y][i] = row[x][i] = boxes[x/3][y/3][i] = true;
board[x][y] = (char) ('0'+num);
flag = backTrack(board);
if (flag) {
return true;
}
board[x][y] = '.';
col[y][i] = row[x][i] = boxes[x/3][y/3][i] = false;
}
}
// 该空格无法填入数字,回溯,重新加入队列
queue.addFirst(pos);
return false;
}
}