赞
踩
解决一个回溯问题,实际上就是一个决策树的遍历过程.
回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做⼀些操作,算法框架如下:
多叉树的遍历框架
图的遍历操作
回溯遍历框架
写 backtrack 函数时,需要维护⾛过的「路径」和当前可以做的「选择列表」,当触发「结束条件」时, 将「路径」记⼊结果集。
无论是排列、组合还是子集问题,简单说无非就是让你从序列nums中以给定规则取若干元素,主要有以下几种变体:
形式一、元素无重不可复选,即nums中的元素都是唯一的,每个元素最多只能被使用一次,这也是最基本的形式。(78,77,46)
以组合为例,如果输入nums = [2,3,6,7],和为 7 的组合应该只有[7]。
形式二、元素可重不可复选,即nums中的元素可以存在重复,每个元素最多只能被使用一次。(90,40,47)
以组合为例,如果输入nums = [2,5,2,1,2],和为 7 的组合应该有两种[2,2,2,1]和[5,2]。
形式三、元素无重可复选,即nums中的元素都是唯一的,每个元素可以被使用若干次。(39)
以组合为例,如果输入nums = [2,3,6,7],和为 7 的组合应该有两种[2,2,3]和[7]。
当然,也可以说有第四种形式,即元素可重可复选。但既然元素可复选,那又何必存在重复元素呢?元素去重之后就等同于形式三,所以这种情况不用考虑。
上面用组合问题举的例子,但排列、组合、子集问题都可以有这三种基本形式,所以共有 9 种变化。
除此之外,题目也可以再添加各种限制条件,比如让你求和为target且元素个数为k的组合,那这么一来又可以衍生出一堆变体,怪不得面试笔试中经常考到排列组合这种基本题型。
但无论形式怎么变化,其本质就是穷举所有解,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽。
注意:组合问题和子集问题其实是等价的
使用start参数控制树枝的生长避免产生重复的子集,用path记录根节点到每个节点的路径的值,同时在前序位置把每个节点的路径值收集起来,放入res中,完成回溯树的遍历就收集了所有子集:
class Solution { // 记录回溯算法的最终结果 List<List<Integer>> res = new LinkedList<>(); // 记录回溯算法的递归路径 LinkedList<Integer> path = new LinkedList<>(); // 主函数 public List<List<Integer>> subsets(int[] nums) { traverse(nums, 0); return res; } // 回溯算法核心函数,遍历子集问题的回溯树 private void traverse(int[] nums, int start) { // 前序位置,每个节点的值都是一个子集 res.add(new LinkedList<>(path)); // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 做选择 path.add(nums[i]); // 通过 start 参数控制树枝的遍历,避免产生重复的子集 traverse(nums, i + 1); // 撤销选择 path.removeLast(); } } }
最后,backtrack函数开头看似没有 base case,会不会进入无限递归?
其实不会的,当start == nums.length时,叶子节点的值会被装入res,但 for 循环不会执行,也就结束了递归。
class Solution { List<List<Integer>> res = new LinkedList<>(); LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> subsetsWithDup(int[] nums) { // 先排序,让相同的元素靠在一起 Arrays.sort(nums); traverse(nums, 0); return res; } private void traverse(int[] nums, int start) { // 前序位置,每个节点的值都是一个子集 res.add(new LinkedList<>(path)); for (int i = start; i < nums.length; i++) { // 剪枝逻辑,值相同的相邻树枝,只遍历第一条 if (i > start && nums[i] == nums[i - 1]) { continue; } path.add(nums[i]); traverse(nums, i + 1); path.removeLast(); } } }
这就是典型的回溯算法,k 限制了树的高度,n 限制了树的宽度,直接套我们以前讲过的回溯算法模板框架就行了。
还是以nums = [1,2,3]为例,刚才让你求所有子集,就是把所有节点的值都收集起来;现在你只需要把第 2 层(根节点视为第 0 层)的节点收集起来,就是大小为 2 的所有组合:
class Solution { // 记录回溯算法的最终结果 List<List<Integer>> res = new LinkedList<>(); // 记录回溯算法的递归路径 LinkedList<Integer> path = new LinkedList<>(); // 主函数 public List<List<Integer>> combine(int n, int k) { int[] nums = new int[n]; for (int i = 0; i < nums.length; i++) { nums[i] = i + 1; } traverse(nums, 0, k); return res; } // 回溯算法核心函数,遍历子集问题的回溯树 private void traverse(int[] nums, int start, int k) { // base case if (path.size() == k) { // 遍历到了第 k 层,收集当前节点的值 res.add(new LinkedList<>(path)); return; } // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 做选择 path.add(nums[i]); // 通过 start 参数控制树枝的遍历,避免产生重复的子集 traverse(nums, i + 1, k); // 撤销选择 path.removeLast(); } } }
排列问题每次通过 contains 方法来排除在 track 中已经选择过的数字;而组合问题通过传入一个 start 参数,来排除 start 索引之前的数字。
class Solution { List<List<Integer>> res = new LinkedList<>(); // 记录回溯的路径 LinkedList<Integer> path = new LinkedList<>(); // 记录 track 中的路径和 int sum; public List<List<Integer>> combinationSum(int[] candidates, int target) { traverse(candidates, 0, target); return res; } // 回溯算法主函数 private void traverse(int[] nums, int start, int target) { // base case,找到目标和,记录结果 if (sum == target) { res.add(new LinkedList<>(path)); return; } // base case,超过目标和,停止向下遍历 if (sum > target) { return; } // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 选择 nums[i] path.add(nums[i]); sum += nums[i]; // 递归遍历下一层回溯树 // 同一元素可重复使用,注意参数 traverse(nums, i, target); // 撤销选择 nums[i] sum -= nums[i]; path.removeLast(); } } }
class Solution { List<List<Integer>> res = new LinkedList<>(); // 记录回溯的路径 LinkedList<Integer> path = new LinkedList<>(); // 记录 path 中的元素之和 int sum; public List<List<Integer>> combinationSum2(int[] candidates, int target) { // 先排序,让相同的元素靠在一起 Arrays.sort(candidates); traverse(candidates, 0, target); return res; } // 回溯算法主函数 private void traverse(int[] nums, int start, int target) { // base case,达到目标和,找到符合条件的组合 if (sum == target) { res.add(new LinkedList<>(path)); return; } // base case,超过目标和,直接结束 if (sum > target) { return; } // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 剪枝逻辑,值相同的树枝,只遍历第一条 if (i > start && nums[i] == nums[i - 1]) { continue; } // 做选择 path.add(nums[i]); sum += nums[i]; // 递归遍历下一层回溯树 traverse(nums, i + 1, target); // 撤销选择 sum -= nums[i]; path.removeLast(); } } }
class Solution { List<List<Integer>> res = new LinkedList<>(); public List<List<Integer>> combinationSum3(int k, int n) { LinkedList<Integer> path = new LinkedList<>(); backtrace(path, n, k, 1, 0); return res; } private void backtrace(LinkedList<Integer> path, int n, int k, int start, int sum) { if (sum == n && path.size() == k) { res.add(new LinkedList<>(path)); return; } if (sum > n || path.size() >= k) return; for (int i = start; i <= 9; i++) { path.add(i); backtrace(path, n, k, i + 1, sum + i); path.removeLast(); } } }
used数组标记已经在路径上的元素避免重复选择,然后收集所有叶子节点上的值
class Solution { List<List<Integer>> res = new LinkedList<>(); // 记录回溯算法的递归路径 LinkedList<Integer> path = new LinkedList<>(); // path 中的元素会被标记为 true boolean[] visited; /* 主函数,输入一组不重复的数字,返回它们的全排列 */ public List<List<Integer>> permute(int[] nums) { visited = new boolean[nums.length]; traverse(nums); return res; } // 回溯算法核心函数 private void traverse(int[] nums) { // base case,到达叶子节点 if (path.size() == nums.length) { // 收集叶子节点上的值 res.add(new LinkedList<>(path)); return; } // 回溯算法标准框架 for (int i = 0; i < nums.length; i++) { // 已经存在 track 中的元素,不能重复选择 if (!visited[i]) { // 做选择 path.add(nums[i]); visited[i] = true; // 进入下一层回溯树 traverse(nums); // 取消选择 visited[i] = false; path.removeLast(); } } } }
标准全排列算法之所以出现重复,是因为把相同元素形成的排列序列视为不同的序列,但实际上它们应该是相同的;而如果固定相同元素形成的序列顺序,当然就避免了重复。
画一下决策树就明白了。
当出现重复元素时,比如输入nums = [1,2,2’,2’’],2’只有在2已经被使用的情况下才会被选择,2’'只有在2’已经被使用的情况下才会被选择,这就保证了相同元素在排列中的相对位置保证固定。
class Solution { List<List<Integer>> res = new LinkedList<>(); LinkedList<Integer> path = new LinkedList<>(); boolean[] visited; public List<List<Integer>> permuteUnique(int[] nums) { // 先排序,让相同的元素靠在一起 Arrays.sort(nums); visited = new boolean[nums.length]; traverse(nums); return res; } private void traverse(int[] nums) { if (path.size() == nums.length) { res.add(new LinkedList<>(path)); return; } for (int i = 0; i < nums.length; i++) { if (visited[i]) { continue; } // 新添加的剪枝逻辑,固定相同的元素在排列中的相对位置 if (i > 0 && nums[i] == nums[i - 1] && !visited[i - 1]) { continue; } path.add(nums[i]); visited[i] = true; traverse(nums); visited[i] = false; path.removeLast(); } } }
力扣上没有排列(元素无重可复选)类似的题目,不妨自己想一个
比如输入nums = [1,2,3],那么这种条件下的全排列共有 3^3 = 27 种:
[
[1,1,1],[1,1,2],[1,1,3],[1,2,1],[1,2,2],[1,2,3],[1,3,1],[1,3,2],[1,3,3],
[2,1,1],[2,1,2],[2,1,3],[2,2,1],[2,2,2],[2,2,3],[2,3,1],[2,3,2],[2,3,3],
[3,1,1],[3,1,2],[3,1,3],[3,2,1],[3,2,2],[3,2,3],[3,3,1],[3,3,2],[3,3,3]
]
标准的全排列算法利用used数组进行剪枝,避免重复使用同一个元素。如果允许重复使用元素的话,直接放飞自我,去除所有used数组的剪枝逻辑就行了。
List<List<Integer>> res = new LinkedList<>(); // 记录回溯的路径 LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> permuteRepeat(int[] nums) { traverse(nums); return res; } // 回溯算法主函数 private void traverse(int[] nums) { // base case,到达叶子节点 if (path.size() == nums.length) { // 收集叶子节点上的值 res.add(new LinkedList<>(path)); return; } // 回溯算法标准框架 for (int i = 0; i < nums.length; i++) { // 做选择 path.add(nums[i]); // 进入下一层回溯树 traverse(nums); // 取消选择 path.removeLast(); } }
只要从树的角度思考,这些问题看似复杂多变,实则改改 base case 就能解决
class Solution { String[] str = new String[]{"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"}; List<String> res = new LinkedList<>(); public List<String> letterCombinations(String digits) { if (digits.length() == 0) return new LinkedList<>(); StringBuffer path = new StringBuffer(); backtrace(digits, path, 0); return res; } private void backtrace(String digits, StringBuffer path, int index) { if (path.length() == digits.length()) { res.add(path.toString()); return; } for (int i = index; i < digits.length(); i++) {//子集 int k = digits.charAt(i) - '0'; for (int j = 0; j < str[k].length(); j++) {//排列 path.append(str[k].charAt(j)); backtrace(digits, path, i + 1); path.deleteCharAt(path.length() - 1); } } } }
class Solution { // 记录所有合法的括号组合 List<String> res = new LinkedList<>(); // 回溯过程中的路径 StringBuffer path = new StringBuffer(); public List<String> generateParenthesis(int n) { // 可用的左括号和右括号数量初始化为 n traverse(n, n); return res; } // 可用的左括号数量为 left 个,可用的右括号数量为 rgiht 个 private void traverse(int left, int right) { // 若左括号剩下的多,说明不合法 if (left > right) { return; } // 数量小于 0 肯定是不合法的 if (left < 0 || right < 0) { return; } // 当所有括号都恰好用完时,得到一个合法的括号组合 if (left == 0 && right == 0) { res.add(path.toString()); return; } // 尝试放一个左括号 path.append('(');// 选择 traverse(left - 1, right); path.deleteCharAt(path.length() - 1);// 撤消选择 // 尝试放一个右括号 path.append(')');// 选择 traverse(left, right - 1); path.deleteCharAt(path.length() - 1);// 撤消选择 } }
对于递归相关的算法,时间复杂度这样计算[递归次数]x[递归函数本身的时间复杂度]
时间复杂度=卡特兰数
class Solution { boolean[] visited; public boolean canPartitionKSubsets(int[] nums, int k) { visited = new boolean[nums.length]; // 排除⼀些基本情况 if (k > nums.length) return false; int sum = 0; for (int v : nums) sum += v; if (sum % k != 0) return false; int target = sum / k; // k 号桶初始什么都没装,从 nums[0] 开始做选择 return backtrack(k, 0, nums, 0, target); } boolean backtrack(int bucket, int sum, int[] nums, int start, int target) { // base case if (bucket == 0) { // 所有桶都被装满了,⽽且 nums ⼀定全部⽤完了 // 因为 target == sum / bucket return true; } if (sum == target) { // 装满了当前桶,递归穷举下⼀个桶的选择 // 让下⼀个桶从 nums[0] 开始选数字 return backtrack(bucket - 1, 0, nums, 0, target); } if (sum > target) { // 当前桶装不下 nums[i] return false; } // 从 start 开始向后探查有效的 nums[i] 装⼊当前桶 for (int i = start; i < nums.length; i++) { // 剪枝 if (visited[i]) { // nums[i] 已经被装⼊别的桶中 continue; } // 做选择,将 nums[i] 装⼊当前桶中 visited[i] = true; sum += nums[i]; // 递归穷举下⼀个数字是否装⼊当前桶 if (backtrack(bucket, sum, nums, i + 1, target)) { return true; } // 撤销选择 visited[i] = false; sum -= nums[i]; } // 穷举了所有数字,都⽆法装满当前桶 return false; } }
函数backtrack依然像个在决策树上游走的指针,每个节点就表示在board[row][col]上放置皇后,通过isValid函数可以将不符合条件的情况剪枝:
class Solution { List<List<String>> res = new LinkedList<>(); /* 输入棋盘边长 n,返回所有合法的放置 */ public List<List<String>> solveNQueens(int n) { char[][] board = new char[n][n]; // '.' 表示空,'Q' 表示皇后,初始化空棋盘。 for (int i = 0; i < n; i++) { Arrays.fill(board[i], '.'); } traverse(board, 0); return res; } // 路径:board 中小于 row 的那些行都已经成功放置了皇后 // 选择列表:第 row 行的所有列都是放置皇后的选择 // 结束条件:row 超过 board 的最后一行 private void traverse(char[][] board, int row) { // 触发结束条件 if (row == board.length) { List<String> temp = new LinkedList<>(); for (char[] chars : board) { temp.add(new String(chars)); } res.add(temp); return; } for (int i = 0; i < board.length; i++) { // 排除不合法选择 if (isValid(board, row, i)) { // 做选择 board[row][i] = 'Q'; // 进入下一行决策 traverse(board, row + 1); // 撤销选择 board[row][i] = '.'; } } } /* 是否可以在 board[row][col] 放置皇后? */ private boolean isValid(char[][] board, int row, int col) { for (int i = 0; i < board.length; i++) { // 检查列是否有皇后互相冲突 if (board[i][col] == 'Q') return false; // 检查行是否有皇后互相冲突 if (board[row][i] == 'Q') return false; } // 检查左上方是否有皇后互相冲突 for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) { if (board[i][j] == 'Q') return false; } // 检查右上方是否有皇后互相冲突 for (int i = row - 1, j = col + 1; i >= 0 && j < board.length; i--, j++) { if (board[i][j] == 'Q') return false; } return true; } }
public void solveSudoku(char[][] board) { traverse(board, 0, 0); } private boolean traverse(char[][] board, int row, int col) { if (row == 9) { // 找到一个可行解,触发 base case return true; } // 穷举到最后一列的话就换到下一行重新开始。 if (col == 9) { return traverse(board, row + 1, 0); } if (board[row][col] != '.') { // 如果有预设数字,不用我们穷举 return traverse(board, row, col + 1); } for (char ch = '1'; ch <= '9'; ch++) { // 如果遇到不合法的数字,就跳过 if (isValid(board, row, col, ch)) { board[row][col] = ch; // 如果找到一个可行解,立即结束 if (traverse(board, row, col + 1)) { return true; } ; board[row][col] = '.'; } } // 穷举完 1~9,依然没有找到可行解,此路不通 return false; } // 判断 board[r][c] 是否可以填入 n private boolean isValid(char[][] board, int row, int col, char ch) { for (int i = 0; i < board.length; i++) { // 判断行是否存在重复 if (board[row][i] == ch) return false; // 判断列是否存在重复 if (board[i][col] == ch) return false; // 判断 3 x 3 方框是否存在重复 if (board[(row / 3) * 3 + i / 3][(col / 3) * 3 + i % 3] == ch) return false; } return true; }
子集问题可以利用数学归纳思想,假设已知一个规模较小的问题的结果,思考如何推导出原问题的结果。也可以用回溯算法,要用 start 参数排除已选择的数字。
组合问题利用的是回溯思想,结果可以表示成树结构,我们只要套用回溯算法模板即可,关键点在于要用一个 start 排除已经选择过的数字。
排列问题是回溯思想,也可以表示成树结构套用算法模板,不同之处在于使用 contains 方法排除已经选择的数字.
写backtrack函数时,需要维护走过的「路径」和当前可以做的「选择列表」,当触发「结束条件」时,将「路径」记入结果集。
其实想想看,回溯算法和动态规划是不是有点像呢?我们在动态规划系列文章中多次强调,动态规划的三个需要明确的点就是「状态」「选择」和「base case」,是不是就对应着走过的「路径」,当前的「选择列表」和「结束条件」?
某种程度上说,动态规划的暴力求解阶段就是回溯算法。只是有的问题具有重叠子问题性质,可以用 dp table 或者备忘录优化,将递归树大幅剪枝,这就变成了动态规划。而今天的两个问题,都没有重叠子问题,也就是回溯算法问题了,复杂度非常高是不可避免的。
// ⼆叉树遍历框架
public void traverse(TreeNode root) {
traverse(root.left);
traverse(root.right);
}
// ⼆维矩阵遍历框架 public void dfs(int[][] grid, int i, int j, Boolean[][] visited) { int m = grid.length, n = grid[0].length; if (i < 0 || j < 0 || i >= m || j >= n) { // 超出索引边界 return; } if (visited[i][j] == true) { // 已遍历过 (i, j) return; } // 进⼊节点 (i, j) visited[i][j] = true; dfs(grid, i - 1, j, visited);// 上 dfs(grid, i + 1, j, visited); // 下 dfs(grid, i, j - 1, visited); // 左 dfs(grid, i, j + 1, visited); // 右 }
class Solution { // 主函数,计算岛屿数量 public int numIslands(char[][] grid) { int res = 0; // 遍历 grid for (int i = 0; i < grid.length; i++) { for (int j = 0; j < grid[i].length; j++) { if (grid[i][j] == '1') { res++; // 每发现⼀个岛屿,岛屿数量加⼀ } dfs(grid, i, j); // 然后使⽤ DFS 将岛屿淹了 } } return res; } // 从 (i, j) 开始,将与之相邻的陆地都变成海⽔ private void dfs(char[][] grid, int i, int j) { if (i < 0 || j < 0 || i >= grid.length || j >= grid[0].length) return; // 超出索引边界 if (grid[i][j] == '0') return; //已经是海⽔了 grid[i][j] = '0';//将 (i, j) 变成海⽔ //淹没上下左右的陆地 dfs(grid, i - 1, j); dfs(grid, i + 1, j); dfs(grid, i, j - 1); dfs(grid, i, j + 1); } }
class Solution { // 主函数,计算岛屿数量 public int closedIsland(int[][] grid) { int n = grid.length, m = grid[0].length; int res = 0; // 遍历 grid for (int j = 0; j < m; j++) { dfs(grid, 0, j); dfs(grid, n - 1, j); } for (int i = 0; i < n; i++) { dfs(grid, i, 0); dfs(grid, i, m - 1); } for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (grid[i][j] == 0) { res++; // 每发现⼀个岛屿,岛屿数量加⼀ } dfs(grid, i, j); // 然后使⽤ DFS 将岛屿淹了 } } return res; } // 从 (i, j) 开始,将与之相邻的陆地都变成海⽔ private void dfs(int[][] grid, int i, int j) { if (i < 0 || j < 0 || i >= grid.length || j >= grid[0].length) return; // 超出索引边界 if (grid[i][j] == 1) return; //已经是海⽔了 grid[i][j] = 1;//将 (i, j) 变成海⽔ //淹没上下左右的陆地 dfs(grid, i - 1, j); dfs(grid, i + 1, j); dfs(grid, i, j - 1); dfs(grid, i, j + 1); } }
class Solution { // 主函数,计算岛屿数量 public int numEnclaves(int[][] grid) { int n = grid.length, m = grid[0].length; int res = 0; // 遍历 grid for (int j = 0; j < m; j++) { dfs(grid, 0, j); dfs(grid, n - 1, j); } for (int i = 0; i < n; i++) { dfs(grid, i, 0); dfs(grid, i, m - 1); } for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (grid[i][j] == 1) { res++; // 每发现⼀个岛屿,岛屿数量加⼀ } } } return res; } // 从 (i, j) 开始,将与之相邻的陆地都变成海⽔ private void dfs(int[][] grid, int i, int j) { if (i < 0 || j < 0 || i >= grid.length || j >= grid[0].length) return; // 超出索引边界 if (grid[i][j] == 0) return; //已经是海⽔了 grid[i][j] = 0;//将 (i, j) 变成海⽔ //淹没上下左右的陆地 dfs(grid, i - 1, j); dfs(grid, i + 1, j); dfs(grid, i, j - 1); dfs(grid, i, j + 1); } }
public int countSubIslands(int[][] grid1, int[][] grid2) { int n = grid1.length, m = grid1[0].length; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (grid2[i][j] == 1 && grid1[i][j] == 0) { dfs(grid2, i, j); } } } int ret = 0; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (grid2[i][j] == 1) { ret++; //这个岛屿肯定不是⼦岛,淹掉 dfs(grid2, i, j); } } } return ret; } //从 (i, j) 开始,将与之相邻的陆地都变成海⽔ private void dfs(int[][] grid2, int i, int j) { int n = grid2.length, m = grid2[0].length; if (i < 0 || j < 0 || i >= n || j >= m) return; if (grid2[i][j] == 0) return; grid2[i][j] = 0; dfs(grid2, i - 1, j); dfs(grid2, i + 1, j); dfs(grid2, i, j - 1); dfs(grid2, i, j + 1); }
阿里面试题,在二维数组中搜索“alibaba”,当时不会做。。
用模板写出来,虽然增加了很多无关的操作,但是想通过这道题举一反三。
后期可能会优化一下。
时空复杂度:3^l * MN、MN
class Solution { List<LinkedList<Character>> res = new LinkedList<>();//存放结果 public boolean exist(char[][] board, String word) { boolean[][] visited = new boolean[board.length][board[0].length];//记录是否被访问过 LinkedList<Character> path = new LinkedList<>();//记录当前路径 //从不同起点出发 for (int i = 0; i < board.length; i++) { for (int j = 0; j < board[0].length; j++) { dfs(board, i, j, path, visited, word, 0);//dfs返回以i,j起点得到的满足题意的字符串 } } //res中存放多个满足题意的结果 if (res.size() == 0) return false; return true; } private void dfs(char[][] board, int i, int j, LinkedList<Character> path, boolean[][] visited, String word, int k) { int n = board.length, m = board[0].length; //row和col if (i < 0 || j < 0 || i >= n || j >= m) return; // 超出索引边界 if (visited[i][j] == true) return; //访问过返回 visited[i][j] = true; // 已遍历过 (i, j) path.add(board[i][j]); //将当前节点放到访问路径path中 if (path.get(k) != word.charAt(k)) {//节点不符合,提前剪枝 path.removeLast(); //撤销加入节点操作并将该节点设为false visited[i][j] = false; return; } if (k == word.length() - 1) { //满足题意返回,返回前撤销 res.add(new LinkedList<>(path)); path.removeLast(); visited[i][j] = false; return; } dfs(board, i - 1, j, path, visited, word, k + 1);//上 dfs(board, i + 1, j, path, visited, word, k + 1);//下 dfs(board, i, j - 1, path, visited, word, k + 1);//左 dfs(board, i, j + 1, path, visited, word, k + 1);//右 visited[i][j] = false; path.removeLast(); //撤销 } }
class Solution { List<String> res = new LinkedList<>(); boolean flag; public List<String> findWords(char[][] board, String[] words) { int n = board.length, m = board[0].length; StringBuffer path = new StringBuffer(); boolean[][] vistied = new boolean[n][m]; for (int k = 0; k < words.length; k++) { for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { flag = false; dfs(board, i, j, words[k], vistied, 1); if (flag){ res.add(words[k]); break; } } if (flag) break; } } HashSet<String> hashSet = new HashSet<>(); for (String re : res) { hashSet.add(re); } res.removeAll(res); for (String s : hashSet) { res.add(s); } return res; } private void dfs(char[][] board, int i, int j, String word, boolean[][] vistied, int k) { int n = board.length, m = board[0].length; //1、base case if (i < 0 || j < 0 || i >= n || j >= m) return;//1.1、越界返回 if (vistied[i][j]) return; //1.2、访问过返回 if (flag) return; vistied[i][j] = true; //访问过当前节点 //3、值不一样,剪枝,因为操作2,所以要进行撤销 if (board[i][j] != word.charAt(k - 1)) { vistied[i][j] = false; return; } if (k == word.length()) { //4、符合要求的节点荷属也满足要求,加入结果集中返回 vistied[i][j] = false; flag = true; return; } dfs(board, i - 1, j, word, vistied, k + 1); dfs(board, i + 1, j, word, vistied, k + 1); dfs(board, i, j - 1, word, vistied, k + 1); dfs(board, i, j + 1, word, vistied, k + 1); vistied[i][j] = false; } }
// 计算从起点 start 到终点 target 的最近距离 int BFS(Node start, Node target) { // 核⼼数据结构 Queue<Node> q; Set<Node> visited; // 避免⾛回头路 q.offer(start); // 将起点加⼊队列 visited.add(start); int step = 0; // 记录扩散的步数 while (q not empty){ int sz = q.size(); /* 将当前队列中的所有节点向四周扩散 */ for (int i = 0; i < sz; i++) { Node poll = q.poll(); /* 划重点:这⾥判断是否到达终点 */ if (poll is target)return step; /* 将 poll 的相邻节点加⼊队列 */ for (Node x : poll.adj()) { if (x not in visited){ q.offer(x); visited.add(x); } } } /* 划重点:更新步数在这⾥ */ step++; } }
class Solution { public int minDepth(TreeNode root) { return traverse(root); } private int traverse(TreeNode root) { if (root == null) return 0; Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); int depth = 1;// root 本身就是⼀层,depth 初始化为 1 while (!queue.isEmpty()) { int size = queue.size(); /* 将当前队列中的所有节点向四周扩散 */ for (int i = 0; i < size; i++) { TreeNode poll = queue.poll(); /* 判断是否到达终点 */ if (poll.left == null && poll.right == null) { return depth; } /* 将 cur 的相邻节点加⼊队列 */ if (poll.left != null) { queue.offer(poll.left); } if (poll.right != null) { queue.offer(poll.right); } } /* 这⾥增加步数 */ depth++; } return depth; } }
其中,DFS 算法和回溯算法可以说是师出同⻔,⼤同⼩异,图论算法基础 探讨过这个问题,区别仅仅在于根 节点是否被遍历到⽽已。
另外,你应该注意到了,这个 onPath 数组的操作很像 回溯算法核⼼套路 中做「做选择」和「撤销选择」, 区别在于位置:回溯算法的「做选择」和「撤销选择」在 for 循环⾥⾯,⽽对 onPath 数组的操作在 for 循环 外⾯。
在 for 循环⾥⾯和外⾯唯⼀的区别就是对根节点的处理。
⽐如下⾯两种多叉树的遍历:
void traverse(TreeNode root) { if (root == null) return; System.out.println("enter: " + root.val); for (TreeNode child : root.children) { traverse(child); } System.out.println("leave: " + root.val); } void traverse2(TreeNode root) { if (root == null) return; for (TreeNode child : root.children) { System.out.println("enter: " + child.val); traverse(child); System.out.println("leave: " + child.val); List<List<Integer>> res = new LinkedList<>(); } }
前者会正确打印所有节点的进⼊和离开信息,⽽后者唯独会少打印整棵树根节点的进⼊和离开信息。 为什么回溯算法框架会⽤后者?因为回溯算法关注的不是节点,⽽是树枝。
显然,对于这⾥「图」的遍历,我们应该把 onPath 的操作放到 for 循环外⾯,否则会漏掉记录起始点的遍
历。
说了这么多 onPath 数组,再说下 visited 数组,其⽬的很明显了,由于图可能含有环,visited 数组就 是防⽌递归重复遍历同⼀个节点进⼊死循环的。
当然,如果题⽬告诉你图中不含环,可以把 visited 数组都省掉,基本就是多叉树的遍历。
BFS 算法常⻅于求最值的场景,因为 BFS 的算法逻辑保证了算法第⼀次到达⽬标时的代价是最⼩的。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。