回溯(8) --hot 100 in leetcode
全排列
算法概述
原题
本题要求为给出目标数组的所有排列可能。使用 n!回溯 。
- 时间复杂度为O(n×n!):需要固定的元素数量,以及它们的回溯
- 空间复杂度为O(n):栈
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| class Solution { public List<List<Integer>> permute(int[] nums) { List<List<Integer>> res = new ArrayList<List<Integer>>(); List<Integer> output = new ArrayList<Integer>(); for (int num : nums) { output.add(num); } int n = nums.length; backtrack(n, output, res, 0); return res; } public void backtrack(int n, List<Integer> output, List<List<Integer>> res, int first) { if (first == n) { res.add(new ArrayList<Integer>(output)); } for (int i = first; i < n; i++) { Collections.swap(output, first, i); backtrack(n, output, res, first + 1); Collections.swap(output, first, i); } } }
|
重要实例方法及属性(JAVA)
Collections.swap(List<?> list, int i, int j)
:将i和j位置上的元素交换
C++
注意
从时间复杂度上可以尝试理解,其实每一次就是 从左向右固定一个元素 ,而且这个过程用 自顶向下的递归实现 ,也就意味着,可以把 不同的栈深度看作对原数组扩大分割的结果 。
重要
子集
算法概述
原题
本题要求为返回给出数组的所有子集。使用 二分类的回溯 ,或者将直接把这2^n个自己映射到二进制上。
对于回溯:
- 时间复杂度为O(n*n^2):所有元素和它们的递归深度
- 空间复杂度为O(n):栈和临时数组最大都是n
对于迭代:
- 时间复杂度为O(n*n^2):
- 空间复杂度为O(n):临时数组
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| class Solution { List<Integer> t = new ArrayList<Integer>(); List<List<Integer>> ans = new ArrayList<List<Integer>>();
public List<List<Integer>> subsets(int[] nums) { dfs(0, nums); return ans; }
public void dfs(int cur, int[] nums) { if (cur == nums.length) { ans.add(new ArrayList<Integer>(t)); return; } t.add(nums[cur]); dfs(cur + 1, nums); t.remove(t.size() - 1); dfs(cur + 1, nums); } }
class Solution { public: vector<int> t; vector<vector<int>> ans;
vector<vector<int>> subsets(vector<int>& nums) { int n = nums.size(); for (int mask = 0; mask < (1 << n); ++mask) { t.clear(); for (int i = 0; i < n; ++i) { if (mask & (1 << i)) { t.push_back(nums[i]); } } ans.push_back(t); } return ans; } };
|
C++
重要实例方法及属性(C++)
vector.pop_back()
:删除最后一个元素
注意
位运算的算法太逆天了,以后再说吧。
这道题回溯的重点是将递归分成了两类,分别是 包含当前元素和不包含当前元素 。回溯法的核心思想依然是 分而治之 ,而拆解成包含与不包含也正好对应了子集问题中的 取和舍 ,同时也将原来的一维数组结构变成了 可用深度优先搜索处理的二叉树 ,这是一种经典的 回溯建模 ,需要熟练掌握。
电话号码的字母组合
算法概述
原题
本题要求为按照给出的2-9内的数字字符串返回可能的九键输入法组合。
- 时间复杂度为O(3^n):基本上一个数字对应长度为3的字符串
- 空间复杂度为O(n)
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| class Solution { public List<String> letterCombinations(String digits) { List<String> combinations = new ArrayList<String>(); if (digits.length() == 0) { return combinations; } Map<Character, String> phoneMap = new HashMap<Character, String>() {{ put('2', "abc"); put('3', "def"); put('4', "ghi"); put('5', "jkl"); put('6', "mno"); put('7', "pqrs"); put('8', "tuv"); put('9', "wxyz"); }}; backtrack(combinations, phoneMap, digits, 0, new StringBuffer()); return combinations; }
public void backtrack(List<String> combinations, Map<Character, String> phoneMap, String digits, int index, StringBuffer combination) { if (index == digits.length()) { combinations.add(combination.toString()); } else { char digit = digits.charAt(index); String letters = phoneMap.get(digit); int lettersCount = letters.length(); for (int i = 0; i < lettersCount; i++) { combination.append(letters.charAt(i)); backtrack(combinations, phoneMap, digits, index + 1, combination); combination.deleteCharAt(index); } } } }
|
重要实例方法及属性(JAVA)
StringBuffer
:支持多线程操作的可变字符串,相对的,String
虽然也是线程安全但不可变
C++
注意
这个题目的核心算法思想不复杂,就是一个简单的回溯结构,甚至只需要 向下遍历 就可以了。麻烦的是有很多映射,需要处理这些映射之间交互的问题。
梳理一下总体的结构:char digit = digits.charAt(index);
每个数字对应一个字符串,而组合问题发生在字符串的维度,所以如果将整个回溯的过程类比为一个多叉树,那么 每一层都代表一个数字 ,虽然实际上组合的是它们的映射,字符串。然后,在回溯真正工作的循环for (int i = 0; i < lettersCount; i++)
内,回溯的 外部递归是层的维度 ,也就是backtrack(combinations, phoneMap, digits, index + 1, combination);
,而 内部递归的主体仍然是遍历字符 ,也就是for (int i = 0; i < lettersCount; i++)
,其实递归类的问题完全可以 简化到最后一层 ,index
与数字长度相等的次数应该与lettersCount
相等。
要记得从 判断当前深度是否与最大深度相等 ,也就是if (index == digits.length())
开始。
组合总和
算法概述
原题
本题要求为从无重复数字的数组中找出和为目标值的组合,其中同一数字可以任取多次。还是 dfs ,和子集问题相同。
- 时间复杂度为O(S):所有可行长度之和,因为有剪枝,所以不用到2^n
- 空间复杂度为O(n)
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| class Solution { public List<List<Integer>> combinationSum(int[] candidates, int target) { List<List<Integer>> ans = new ArrayList<List<Integer>>(); List<Integer> combine = new ArrayList<Integer>(); dfs(candidates, target, ans, combine, 0); return ans; }
public void dfs(int[] candidates, int target, List<List<Integer>> ans, List<Integer> combine, int idx) { if (idx == candidates.length) { return; } if (target == 0) { ans.add(new ArrayList<Integer>(combine)); return; } dfs(candidates, target, ans, combine, idx + 1); if (target - candidates[idx] >= 0) { combine.add(candidates[idx]); dfs(candidates, target - candidates[idx], ans, combine, idx); combine.remove(combine.size() - 1); } } }
|
C++
注意
类似于子集问题,还是构造成二叉树的深度优先搜索。要注意的是结构和另外加入的 剪枝判断 。
还要注意的是,回溯的过程是 不断递归向下生成二叉树 ,并且 尝试完毕后在空间上撤回 。对于这道题, 并不需要遍历完所有节点 。所以 添加组合的判断与遍历到最大深度的判断不一致 ,是分成两块的,if (target == 0)
才是主要,还有不要忘记return;
。
对于这类加和问题,还可以使用剪枝if (target - candidates[idx] >= 0)
减少复杂度。
这里的二叉树的分支是依赖 重复不重复读取 判断。
括号生成
算法概述
原题
本题要求为按照要求的括号数量,给出所有可能且合理的括号组合。还是和前面几道题一个思路,关键是在 回溯的基础上剪枝如何剪枝和分类 。
- 时间复杂度为%$O\left(\frac{4^n}{\sqrt{n}}\right)$:卡特兰数(不懂)
- 空间复杂度为O(n):递归栈
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| class Solution { public List<String> generateParenthesis(int n) { List<String> ans=new ArrayList<String>(); dfs(ans,new StringBuffer(),n,0,0); return ans; }
private void dfs(List<String> ans,StringBuffer t,int n,int left,int right){ if(t.length()==n<<1){ ans.add(t.toString()); return; } if (left < n) { t.append('('); dfs(ans, t, n, left + 1, right); t.deleteCharAt(t.length() - 1); }
if (right < left) { t.append(')'); dfs(ans, t, n, left, right + 1); t.deleteCharAt(t.length() - 1); } } }
|
重要实例方法及属性(JAVA)
new StringBuffer()
可以换成new StringBuilder()
,后者只支持单线程,但是性能更好。
C++
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| class Solution { shared_ptr<vector<string>> cache[100] = {nullptr}; public: shared_ptr<vector<string>> generate(int n) { if (cache[n] != nullptr) return cache[n]; if (n == 0) { cache[0] = shared_ptr<vector<string>>(new vector<string>{""}); } else { auto result = shared_ptr<vector<string>>(new vector<string>); for (int i = 0; i != n; ++i) { auto lefts = generate(i); auto rights = generate(n - i - 1); for (const string& left : *lefts) for (const string& right : *rights) result -> push_back("(" + left + ")" + right); } cache[n] = result; } return cache[n]; } vector<string> generateParenthesis(int n) { return *generate(n); } };
|
重要实例方法及属性(C++)
shared_ptr<vector<string>> cache[100] = {nullptr};
:这里使用了线程安全的智能指针
result -> push_back()
:可以直接这样访问实例方法,无需解引用
注意
回溯问题到目前为止都是 在回溯的框架上实现不同的细节 。这道题中的C++解法更像广度优先搜索,虽然用的还是递归,但采用了分层思想,不纳入考虑。参考JAVA的解法,回溯的框架,也就是 改变深度以及撤销操作 与之前的题目相仿。不同点在于 分类和剪枝以及如何更新 。这道题中可以以天然的左括号和右括号搭建深度优先搜索的二叉树,所以剪枝为if (left < n)
和if (right < left)
,以及需要像t.append('(');
这样更新节点。
其实结构并不复杂,关键是要掌握 如何分离每个部分所属,属于底层回溯,还是当前背景 。
单词搜索
算法概述
原题
本题要求判断在给出的二维字符网格中是否有路径能够练成要搜索的单词。有很多优化的细节操作吧,但是我感觉这个就是很经典的 图论深度优先搜索+回溯 。
- 时间复杂度为O(n!):函数递归的栈空间
- 空间复杂度为O(n):几个集合
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| class Solution { public boolean exist(char[][] board, String word) { int h = board.length, w = board[0].length; boolean[][] visited = new boolean[h][w]; for (int i = 0; i < h; i++) { for (int j = 0; j < w; j++) { boolean flag = check(board, visited, i, j, word, 0); if (flag) { return true; } } } return false; }
public boolean check(char[][] board, boolean[][] visited, int i, int j, String s, int k) { if (board[i][j] != s.charAt(k)) { return false; } else if (k == s.length() - 1) { return true; } visited[i][j] = true; int[][] directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}; boolean result = false; for (int[] dir : directions) { int newi = i + dir[0], newj = j + dir[1]; if (newi >= 0 && newi < board.length && newj >= 0 && newj < board[0].length) { if (!visited[newi][newj]) { boolean flag = check(board, visited, newi, newj, s, k + 1); if (flag) { result = true; break; } } } } visited[i][j] = false; return result; } }
|
C++
注意
这道题和之前题目的最大区别就是 回溯的操作对象是图 ,而不是之前的字符串,或者自己拼的哈希表。 图dfs的特点就是有四个方向和使用标记数组 。回溯的操作基本还是那样。
分割回文串
算法概述
原题
本题要求为把一个字符串分成多个回文字符串(单个字符也算)。使用 动态切割版本的子集问题解法+一个回文判断 。
- 时间复杂度为$O(n \cdot 2^n)$:最多是这样的
- 空间复杂度为$O(n^2)$:所有可能的子串数
C++
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| class Solution { public: vector<vector<string>> partition(string s) { vector<vector<string>> result; vector<string> path; backtrack(s, 0, path, result); return result; }
private: void backtrack(const string& s, int start, vector<string>& path, vector<vector<string>>& result) { if (start == s.size()) { result.push_back(path); return; }
for (int end = start; end < s.size(); ++end) { if (isPalindrome(s, start, end)) { path.push_back(s.substr(start, end - start + 1)); backtrack(s, end + 1, path, result); path.pop_back(); } } }
bool isPalindrome(const string& s, int left, int right) { while (left < right) { if (s[left] != s[right]) return false; ++left; --right; } return true; } };
|
JAVA
注意
这里使用的还是求子集的方法,但是是 动态分割 ,也就是 起始点在递归中更新,并且更新用的是上一层递归的终点 ,所以是backtrack(s, end + 1, path, result);
,而终点从起点开始for (int end = start; end < s.size(); ++end)
。核心是能够 剪枝 。但如果是“加入或跳过”,这里做不了判断,就无法剪枝。
N皇后
算法概述
原题
本题要求为在nn的棋盘上找出有多少种放置互不攻击的n个皇后的方法。我一开始就想用类似bfs的回溯算法,也就是下方的解法,但又算错了空间复杂度,反而使用了更加复杂的纯dfs回溯。ε=(´ο`)))唉。这道题的核心还是 套一层外部循环以符合皇后的全局作用,用集合存不可访问的节点 ,以及底层毫无变化的 回溯 。
JAVA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| class Solution { public List<List<String>> solveNQueens(int n) { List<List<String>> solutions = new ArrayList<List<String>>(); int[] queens = new int[n]; Arrays.fill(queens, -1); Set<Integer> columns = new HashSet<Integer>(); Set<Integer> diagonals1 = new HashSet<Integer>(); Set<Integer> diagonals2 = new HashSet<Integer>(); backtrack(solutions, queens, n, 0, columns, diagonals1, diagonals2); return solutions; }
public void backtrack(List<List<String>> solutions, int[] queens, int n, int row, Set<Integer> columns, Set<Integer> diagonals1, Set<Integer> diagonals2) { if (row == n) { List<String> board = generateBoard(queens, n); solutions.add(board); } else { for (int i = 0; i < n; i++) { if (columns.contains(i)) { continue; } int diagonal1 = row - i; if (diagonals1.contains(diagonal1)) { continue; } int diagonal2 = row + i; if (diagonals2.contains(diagonal2)) { continue; } queens[row] = i; columns.add(i); diagonals1.add(diagonal1); diagonals2.add(diagonal2); backtrack(solutions, queens, n, row + 1, columns, diagonals1, diagonals2); queens[row] = -1; columns.remove(i); diagonals1.remove(diagonal1); diagonals2.remove(diagonal2); } } }
public List<String> generateBoard(int[] queens, int n) { List<String> board = new ArrayList<String>(); for (int i = 0; i < n; i++) { char[] row = new char[n]; Arrays.fill(row, '.'); row[queens[i]] = 'Q'; board.add(new String(row)); } return board; } }
|
C++
注意
完整的棋盘概念无需再递归的检查中出现,因为检查的功能完全由三个集合承担,而generateBoard(int[] queens, int n)
只需出现在最后加入答案的时候。
最核心的是:
1 2 3 4
| if (row == n) { List<String> board = generateBoard(queens, n); solutions.add(board); }
|
题目要求我们返回的确实是 一整个棋盘 ,但这并不影响我们以 棋盘上的每一行作为回溯单位 。因为我们要求得的是所有的棋盘可能,排列的单位是什么,我们回溯什么 ,所以generateBoard(queens, n)
一定要只看作回溯的某个阶段的整合,而不是回溯的目的或者回溯的一部分, 要专注于处理最基本的排列单位 。