04.03 回溯算法 一种能避免不必要搜索的穷举式的搜索算法。采用试错的思想,在搜索尝试过程中寻找问题的解,当探索到某一步时,发现原先的选择并不满足求解条件,或者还需要满足更多求解条件时,就退回一步(回溯)重新选择,这种走不通就退回再走的技术称为「回溯法」,而满足回溯条件的某个状态的点称为「回溯点」。
明确所有选择:画出搜索过程的决策树,根据决策树来确定搜索路径。
明确终止条件:推敲出递归的终止条件,以及递归终止时的要执行的处理方法。
将决策树和终止条件翻译成代码:
定义回溯函数(明确函数意义、传入参数、返回结果等)。
书写回溯函数主体(给出约束条件、选择元素、递归搜索、撤销选择部分)。
明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。
分析
明确所有选择:根据数组中每个位置上的元素选与不选两种选择,画出决策树。
明确终止条件:当遍历到决策树的叶子节点时,就终止了。即当前路径搜索到末尾时,递归终止。
将决策树和终止条件翻译成代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class Solution {public : vector<vector<int >> subsets (vector<int >& nums) { vector<vector<int >> res; vector<int > path; helper (nums, 0 , path, res); return res; } void helper (vector<int >& nums, int index, vector<int >& path,vector<vector<int >>& res) { if (index == nums.size ()){ res.push_back (path); return ; } helper (nums, index+1 , path, res); path.push_back (nums[index]); helper (nums, index+1 , path, res); path.pop_back (); } };
之前在LCR079 中做过这道题,当时分析的时间复杂度是$O(2^n)$,但是官方题解中给出的时间复杂度是$O(n \cdot{2^n})$,查了一下剑指offer书中的不够准确。每个递归分支都会生成一个子集,总共有$2^n$个子集,对于每个子集生成过程最多需要$O(n)$的时间。
时间复杂度:$O(n \cdot 2^n)$
空间复杂度:leetcode官方题解中在分析空间复杂度时,一般不考虑返回的结果所占用的空间,但是chatgpt说需要考虑结果所占用的空间。代码随想录 给出的解释是
$O(n)$,递归深度为n,所以系统栈所用空间为$O(n)$,每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为$O(n)$。
分析
明确所有选择:根据棋盘中当前行的所有列位置上是否选择放置皇后,画出决策树。
明确终止条件:当遍历到决策树的叶子节点时,就终止了。也就是在最后一行放置完皇后时,递归终止。
将决策树和终止条件翻译成代码:
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 class Solution {public : vector<vector<string>> res; vector<vector<string>> solveNQueens (int n) { vector<string> chessBoard (n, string(n, '.' )) ; backtrack (chessBoard, 0 ); return res; } void backtrack (vector<string>& chessBoard, int row) { if (row == chessBoard.size ()){ res.push_back (chessBoard); return ; } for (int col = 0 ; col < chessBoard.size (); col++){ if (isValid (chessBoard, row, col)){ chessBoard[row][col] = 'Q' ; backtrack (chessBoard, row + 1 ); chessBoard[row][col] = '.' ; } } } bool isValid (vector<string>& chessBoard, int row, int col) { int n = chessBoard.size (); for (int i = 0 ; i < row; i++){ if (chessBoard[i][col] == 'Q' ){ return false ; } } for (int i = row-1 , j = col-1 ; i >=0 && j >= 0 ; i--, j--){ if (chessBoard[i][j] == 'Q' ){ return false ; } } for (int i = row-1 , j = col+1 ; i >= 0 && j < n; i--, j++){ if (chessBoard[i][j] == 'Q' ){ return false ; } } return true ; } };
剩下的练习题目应该是在剑指offer里做过的,但是有点忘记了,再重新做一遍。
分析
明确所有选择:全排列中每个位置上的元素都可以从剩余可选元素中选出,对此画出决策树。
明确终止条件:当遍历到决策树的叶子节点时,就终止了。即当前路径搜索到末尾时,递归终止。
将决策树和终止条件翻译成代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Solution {public : vector<vector<int >> permute (vector<int >& nums) { vector<vector<int >> res; helper (nums, 0 , res); return res; } void helper (vector<int >& nums, int index, vector<vector<int >>& res) { if (index == nums.size ()){ res.push_back (nums); return ; } for (int i = index; i < nums.size (); i++){ swap (nums[i], nums[index]); helper (nums, index+1 , res); swap (nums[i], nums[index]); } } };
分析 和上一题的区别是,nums中可能包含重复的元素,但返回得结果不能有两个一样的排列。用unordered_set保存已经访问过的数字,后面如果再遇到,可以直接不处理。
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 class Solution {public : vector<vector<int >> permuteUnique (vector<int >& nums) { vector<vector<int >> res; helper (nums, 0 , res); return res; } void helper (vector<int >& nums, int index, vector<vector<int >>& res) { if (index == nums.size ()){ res.push_back (nums); return ; } unordered_set<int > set; for (int i = index; i < nums.size (); i++){ if (set.find (nums[i]) == set.end ()){ set.insert (nums[i]); swap (nums[i], nums[index]); helper (nums, index+1 , res); swap (nums[i], nums[index]); } } } };
这两道题的复杂度分析和之前做的又不一样。各个资料的分析都不太一样,但是都有一定的道理……以后再回来看看。
分析
明确所有选择:$2 \times n$ 的括号组合中的每个位置,都可以从 ( 或者 ) 中选出。并且,只有在 symbol < n 的时候,才能选择 (,在 symbol > 0 的时候,才能选择 )。
明确终止条件:当遍历到决策树的叶子节点时,就终止了。即当前路径搜索到末尾时,递归终止。
传入参数:左括号剩余数,右括号剩余数,当前字符串,结果。递归结束条件:左括号右括号剩余数等于0。条件选择:left>0选择左括号;left < right选择右括号,因为当left==right时,不能再选择右括号。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class Solution {public : vector<string> generateParenthesis (int n) { string s; vector<string> res; helper (n, n, s, res); return res; } void helper (int left, int right, string s, vector<string>& res) { if (left == 0 && right == 0 ){ res.push_back (s); return ; } if (left > 0 ){ helper (left-1 , right, s + "(" , res); } if (left < right){ helper (left, right-1 , s + ")" , res); } } };
时间复杂度:$\frac{4^n}{\sqrt{n}}$
空间复杂度:O(n)
分析 用哈希表保存每个数字键位对应的所有可能的字母,然后进行回溯操作。
回溯过程中,维护一个字符串 combination,表示当前的字母排列组合。初始字符串为空,每次取电话号码的一位数字,从哈希表中取出该数字所对应的所有字母,并将其中一个插入到 combination 后面,然后继续处理下一个数字,知道处理完所有数字,得到一个完整的字母排列。开始进行回退操作,遍历其余的字母排列。
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 {private : unordered_map<char , string> hash = {{'2' ,"abc" }, {'3' , "def" }, {'4' ,"ghi" }, {'5' ,"jkl" }, {'6' ,"mno" }, {'7' ,"pqrs" }, {'8' ,"tuv" }, {'9' ,"wxyz" }};public : vector<string> letterCombinations (string digits) { if (digits.length () == 0 ){ return {}; } string combination; vector<string> res; backtrack (digits, 0 , combination, res); return res; } void backtrack (string digits, int index, string& combination, vector<string>& res) { if (index == digits.length ()){ res.push_back (combination); return ; } char digit = digits[index]; for (int i = 0 ; i < hash[digit].length (); i++){ char add = hash[digit][i]; combination += add; backtrack (digits, index+1 , combination, res); combination = combination.substr (0 , combination.length ()-1 ); } } };
分析 递归结束条件:当前和等于target。
递归选择条件:选择当前元素/不选择当前元素。前提是选择后和不能大于target。
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 class Solution {public : vector<vector<int >> combinationSum (vector<int >& candidates, int target) { vector<int > path; vector<vector<int >> res; backtrack (candidates, target, 0 , 0 , path, res); return res; } void backtrack (vector<int >& candidates, int target, int index, int sum, vector<int >& path, vector<vector<int >>& res) { if (sum == target){ res.push_back (path); return ; } if (index == candidates.size ()){ return ; } if (sum + candidates[index] <= target){ path.push_back (candidates[index]); backtrack (candidates, target, index, sum + candidates[index], path, res); path.pop_back (); } backtrack (candidates, target, index+1 , sum, path, res); } };
时间复杂度:$O(2^n \times n)$
空间复杂度:$O(target)$,递归函数需要用到栈空间,栈空间取决于递归深度。
分析 为了方便跳过后面所有值相同的数字,可以将集合中的所有数字排序,把相同的数字放在一起,这样方便比较数字。当决定跳过某个值的数字时,可以按顺序扫描后面的数字,直到找到不同的值为止。当决定跳过数字nums[i]时可以调用函数getNext找到与该数字不同的下一个数字。
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 class Solution {public : vector<vector<int >> combinationSum2 (vector<int >& candidates, int target) { sort (candidates.begin (), candidates.end ()); vector<int > path; vector<vector<int >> res; backtrack (candidates, target, 0 , 0 , path, res); return res; } void backtrack (vector<int >& candidates, int target, int index, int sum, vector<int >& path, vector<vector<int >>& res) { if (sum == target){ res.push_back (path); return ; } if (index == candidates.size ()){ return ; } backtrack (candidates, target, getNext (candidates, index), sum, path, res); if (sum + candidates[index] <= target){ path.push_back (candidates[index]); backtrack (candidates, target, index+1 , sum+candidates[index], path, res); path.pop_back (); } } int getNext (vector<int >& candidates, int index) { int next = index + 1 ; while (next < candidates.size () && candidates[next] == candidates[index]){ next++; } return next; } };
时间复杂度:$O(n \times 2 ^ n)$
空间复杂度:$O(n)$
分析 其中可能包含重复元素,可以考虑先进行排序,然后跳过该位置上相同的元素。
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 class Solution {public : vector<vector<int >> subsetsWithDup (vector<int >& nums) { sort (nums.begin (), nums.end ()); vector<int > path; vector<vector<int >> res; backtrack (nums, 0 , path, res); return res; } void backtrack (vector<int >& nums, int index, vector<int >& path, vector<vector<int >>& res) { if (index == nums.size ()){ res.push_back (path); return ; } backtrack (nums, getNext (nums, index), path, res); path.push_back (nums[index]); backtrack (nums, index + 1 , path, res); path.pop_back (); } int getNext (vector<int >& nums, int index) { int next = index + 1 ; while (next < nums.size () && nums[next] == nums[index]){ next++; } return next; } };
时间复杂度:$O(n \times 2 ^ n)$
空间复杂度:O(n)
分析 设函数backtrack(i, j, index)
表示从board[i][j]
出发,能否搜索到字母word[index]
,及其后续的子串。
三种情况:
board[i][j] == word[index] && index == word.length()-1
,递归结束,返回true。
board[i][j] == word[index] && index < word.length()-1
,向每个方向递归搜索,如果有返回true的,返回true,否则返回false。
board[i][j] != word[index]
,返回false。
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 class Solution {public : bool exist (vector<vector<char >>& board, string word) { int m = board.size (); int n = board[0 ].size (); vector<vector<bool >> visited (m, vector <bool >(n, false )); for (int i = 0 ; i < m; i++) { for (int j = 0 ; j < n; j++) { if (backtrack (board, word, i, j, 0 , visited)) { return true ; } } } return false ; } bool backtrack (vector<vector<char >>& board, string& word, int i, int j, int index, vector<vector<bool >>& visited) { if (index == word.length ()) return true ; if (i < 0 || i >= board.size () || j < 0 || j >= board[0 ].size () || visited[i][j] || board[i][j] != word[index]) { return false ; } visited[i][j] = true ; bool result = backtrack (board, word, i+1 , j, index+1 , visited) || backtrack (board, word, i-1 , j, index+1 , visited) || backtrack (board, word, i, j+1 , index+1 , visited) || backtrack (board, word, i, j-1 , index+1 , visited); visited[i][j] = false ; return result; } };
时间复杂度:最坏情况下,我们需要遍历整个板子的每个位置作为起点。对于每个起点,我们可能需要探索所有可能的路径。假设板子的大小为 m × n,单词长度为 k。对于每个起点,在最坏情况下,我们可能需要探索 4^k 条路径(因为每一步有4个方向可以选择,总共k步)。因此,总的时间复杂度为$O(m n 4^k)$。
空间复杂度:我们使用了一个 visited 数组,大小与板子相同,即 O(m n)。递归调用栈的深度最大为单词的长度 k。因此,总的空间复杂度为 O(mn + k)。
虽然理论上的最坏时间复杂度是 O(m n 4^k),但在实际情况中,由于我们有提前终止的条件(如字符不匹配就返回),实际运行时间通常会好很多。
这个问题是NP完全问题,没有已知的多项式时间解法。当输入规模变大时,最坏情况下的时间复杂度会急剧增加。
在实际应用中,可以考虑一些优化策略,比如先检查板子中是否包含单词中的所有字符,如果不包含,可以直接返回false,避免不必要的搜索。
分析 对于每一行、每一列、每一个数字,都需要一重 for 循环来遍历,这样就是三重 for 循环。
对于第i行、第j列的元素来说,如果当前位置为空位,则尝试将第k个数字置于此处,并检验数独的有效性。如果有效,则继续遍历下一个空位,直到遍历完所有空位,得到可行方案或者遍历失败时结束。
遍历完下一个空位之后再将此位置进行回退,置为.
。
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 class Solution {public : void solveSudoku (vector<vector<char >>& board) { backtrack (board); } bool backtrack (vector<vector<char >>& board) { for (int i = 0 ; i < board.size (); i++){ for (int j = 0 ; j <board.size (); j++){ if (board[i][j] != '.' ){ continue ; } for (int k = 1 ; k <= 9 ; k++){ if (isValid (board, i, j, k)){ board[i][j] = k + '0' ; if (backtrack (board)){ return true ; } board[i][j] = '.' ; } } return false ; } } return true ; } bool isValid (vector<vector<char >>& board, int i, int j, int k) { for (int col = 0 ; col < board.size (); col++){ if (board[i][col]-'0' == k){ return false ; } } for (int row = 0 ; row < board.size (); row++){ if (board[row][j]-'0' == k){ return false ; } } int r = i / 3 * 3 ; int c = j / 3 * 3 ; for (int row = r; row < r + 3 ; row++){ for (int col = c; col < c + 3 ; col++){ if (board[row][col]-'0' == k){ return false ; } } } return true ; } };
时间复杂度:$O(9^m)$,$m$ 为棋盘中.
的数量。
空间复杂度:$O(9^2)$
分析
递归结束条件:当前索引已到达最后一个字符。
选择条件:每个字母要么翻转,要么不翻转。
补充ASCII码值:
数字0~9:48 ~ 57
大写A~Z:65 ~ 90
小写a~z:97 ~ 122
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 class Solution {public : vector<string> letterCasePermutation (string s) { string cur = s; vector<string> res; backtrack (0 , cur, res); return res; } void backtrack (int index, string& cur, vector<string>& res) { if (index == cur.length ()){ res.push_back (cur); return ; } int type = checkType (cur[index]); backtrack (index+1 , cur, res); if (type == 1 ){ cur[index] += 32 ; backtrack (index+1 , cur, res); cur[index] -= 32 ; } else if (type == 2 ){ cur[index] -= 32 ; backtrack (index+1 , cur, res); cur[index] += 32 ; } } int checkType (char ch) { if (ch >= 48 && ch <= 57 ){ return 0 ; } if (ch >= 65 && ch <= 90 ){ return 1 ; } return 2 ; } };
时间复杂度:$O(n \times 2 ^ n)$
空间复杂度:$O(n \times 2 ^ n)$
上面的题以后慢慢刷……
补充 卡特兰数