目录
零、前言
回溯法解决的问题都可以抽象为树形结构(N叉树),回溯法解决的问题都是在集合中递归查找子集,集合的大小构成了树的宽度,递归的深度构成树的深度。【参考:代码随想录】
回溯法模板:
- 回溯函数模板返回值以及参数
void backtracking(参数)
- 回溯函数终止条件
if (终止条件) {
存放结果;
return;
}
- 回溯搜索的遍历过程
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
回溯算法能解决如下问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,解数独等等
无论是排列、组合还是子集问题,简单说就是从序列 nums
中以给定规则取若干元素,主要有以下几种变体:
- 形式一:元素无重不可复选,即
nums
中的元素都是唯一的,每个元素最多只能被使用一次,这也是最基本的形式。
解决:利用used数组进行去重。
- 形式二:元素可重不可复选,即
nums
中的元素可以存在重复,每个元素最多只能被使用一次。
解决:核心在于排序和剪枝。层内剪枝:
nums[i] == nums[i-1]
时跳过;排列问题遇到重复元素还要保证相对顺序,参见《全排列Ⅱ》,增加了 && !used[i - 1] 这一判定条件。
- 形式三:元素无重可复选,即
nums
中的元素都是唯一的,每个元素可以被使用若干次。
解决:将BackTracking(nums, i+1)改为BackTracking(nums, i),设置合适的 base case 以结束算法。
利用回溯方法搜索所有结果的框架:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
如果不需要得到所有结果,只寻找一个满足要求的解,可以把回溯函数返回值设置为bool。(见“解数独”问题)。
一、组合
77. 组合
vector<vector<int>> res;
vector<int> path;
vector<vector<int>> combine(int n, int k) {
BackTracking(n, k, 1);
return res;
}
void BackTracking(int n, int k, int start) {
// 递归终止条件
if (path.size() == k) {
res.push_back(path);
return;
}
// 递归逻辑
for (int i = start; i < n; i++) {
path.push_back(i);
BackTracking(n, k, i + 1);
// 回溯
path.pop_back();
}
}
本题即使采用暴力搜索,也很难完成,因此需要用到回溯法。递归的过程如下图所示(来源:代码随想录):
递归可以进行枝剪,本题递归中的for循环代码可以进行如下修改:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++)
216. 组合总和 III
vector<vector<int>> res;
vector<int> path;
int sum = 0;
vector<vector<int>> combinationSum3(int k, int n) {
BackTracking(k, n, 1);
return res;
}
void BackTracking(int k, int n, int start) {
if (path.size() == k) {
if (sum == n) {
res.push_back(path);
}
return;
}
for (int i = start; i <= 9; i++) {
if (i >= n) return;
path.push_back(i);
sum += i;
BackTracking(k, n, i + 1);
sum -= i;
path.pop_back();
}
}
本题与上一题类似,不再赘述。
17. 电话号码的字母组合
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
vector<string> res;
string path;
vector<string> letterCombinations(string digits) {
if (digits.empty()) return res;
BackTracking(digits, 0);
return res;
}
void BackTracking(string& digits, int digitIndex) {
if (path.size() == digits.size()) {
res.push_back(path);
return;
}
int digit = digits[digitIndex] - '0';
for (int i = 0; i < letterMap[digit].size(); i++) {
path.push_back(letterMap[digit][i]);
BackTracking(digits, digitIndex + 1);
path.pop_back();
}
}
首先,本题采用string数组存储数字和字符串间的映射关系。构造递归时,要想清楚以下过程(来源:代码随想录):
这里面一定要理清楚两条线:for循环的横向遍历和递归的纵向遍历。
- for循环的横向遍历:遍历的是单个按键上的字符串,因此要写为:
for (int i = 0; i < letterMap[digit].size(); i++)
- 递归的纵向遍历:遍历的是按键数字,改变对应的for循环循环集合,因此要写作:
BackTracking(digits, digitIndex + 1);
理清楚这两条线,回溯算法写起来就很简单,否则会一头雾水。理清楚这两条线的关键在于搞清楚组合方式,不要重复或遗漏,在此基础上建立起上图中的树模型。
39. 组合总和
vector<vector<int>> res;
vector<int> path;
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
int sum = 0;
sort(candidates.begin(), candidates.end());
BackTracking(candidates, sum, target, 0);
return res;
}
void BackTracking(vector<int>& candidates, int& sum, int target, int start) {
if (sum >= target) {
if (sum == target) res.push_back(path);
return;
}
for (int i = start; i < candidates.size(); i++) {
sum += candidates[i];
path.push_back(candidates[i]);
BackTracking(candidates, sum, target, i);
path.pop_back();
sum -= candidates[i];
}
}
首先,应明确下图中的树所展现出的搜索逻辑(来源:代码随想录):
取2时能在[2,3,5]中取值,取3时能在[3,5]中取值。这蕴含两个信息:
- 仍需要startIndex,某一个元素递归完后,意味着含有该元素的结果全都被查找完毕了。后续递归时不需要再囊括此元素;
- 由于递归时该元素仍能重复被使用,所以递归函数为BackTracking(candidates, sum, target, i),其中参数非i+1。
本题的另一技巧是提前对candidates数组排序,从而结合递归函数中的判断,提前终止递归。
- 如果是一个集合来求组合的话,就需要startIndex;
- 如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex。
40. 组合总和 II[*]
vector<vector<int>> res;
vector<int> path;
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
int sum = 0;
sort(candidates.begin(), candidates.end());
BackTracking(candidates, sum, target, 0);
return res;
}
void BackTracking(vector<int>& candidates, int& sum, int target, int start) {
if (sum >= target) {
if (sum == target) res.push_back(path);
return;
}
for (int i = start; i < candidates.size(); i++) {
if (i > start && candidates[i] == candidates[i - 1]) continue;
sum += candidates[i];
path.push_back(candidates[i]);
BackTracking(candidates, sum, target, i + 1);
path.pop_back();
sum -= candidates[i];
}
}
本题和上一题不同之处在于需要处理重复的结果,仍要明确一个基本点:树结构单层循环时,第一个元素递归完毕,证明含有第一个元素的结果已经全部找齐了!所以下一个元素和前一个元素相同时,下一个元素应当被舍去(candidates数组提前经过排序)。即应在单层循环内进行去重。
可以利用candidates数组对单层循环进行去重,尤其注意判断条件:
if (i > start && candidates[i] == candidates[i - 1]) continue;
为什么这行代码只对单层元素去重,而没有对深度递归进行去重呢?因为每次深度递归时,start的值都会改变,i>start的条件不会触发;而单层循环时start为定值。
二、分割
131. 分割回文串
vector<vector<string>> res;
vector<string> path;
vector<vector<string>> partition(string s) {
BackTracking(s, 0);
return res;
}
void BackTracking(const string& s, int start) {
if (start >= s.size()) {
res.push_back(path);
return;
}
for (int i = start; i < s.size(); i++) {
// 是回文串,path记录子串
if (isPalindrome(s, start, i)) {
path.push_back(s.substr(start, i - start + 1));
}
// 若不是回文串,无需深度递归,直接返回即可
else continue;
BackTracking(s, i + 1);
path.pop_back();
}
}
bool isPalindrome(const string& s, int start, int end) {
for (int i = start, j = end; i < j; i++, j--) {
if (s[i] != s[j]) return false;
}
return true;
}
本题抽象树结构如下图所示(来源:代码随想录):
93. 复原 IP 地址[*]
vector<string> res;
vector<string> restoreIpAddresses(string s) {
BackTracking(s, 0, 0);
return res;
}
void BackTracking(string& s, int start, int pointNums) {
// pointNums记录已分隔段数,即逗点个数
if (pointNums == 3) {
// 判断最后一段是否合法
if (isValid(s, start, s.size() - 1)) {
res.push_back(s);
}
return;
}
for (int i = start; i < s.size(); i++) {
if (isValid(s, start, i)) {
s.insert(s.begin() + i + 1, '.');
pointNums++;
// 插入逗点之后下一个子串的起始位置为i+2
BackTracking(s, i + 2, pointNums);
pointNums--;
s.erase(s.begin() + i + 1);
}
// 不符合后序递归都不必进行
else break;
}
}
// 判断子字符串是否有效
bool isValid(const string& s, int start, int end) {
if (start > end) {
return false;
}
if (s[start] == '0' && start != end) { // 0开头的数字不合法
return false;
}
int num = 0;
for (int i = start; i <= end; i++) {
if (s[i] > '9' || s[i] < '0') { // 遇到非数字字符不合法
return false;
}
num = num * 10 + (s[i] - '0');
if (num > 255) { // 如果大于255了不合法
return false;
}
}
return true;
}
该解法参考了上一题分割的思路,同时直接在源字符串s上进行操作。抽象树结构如下图所示(来源:代码随想录):
分清层序遍历和深度递归的目的:
- 当某一段满足要求,即可向下一层进行深度递归(本题最多递归三层)
- 利用最后的递归返回条件,判断第四段是否符合要求。符合时收录结果,不符合时回溯;
- 该段不符合要求时,该层后续循环都不必进行(不符合的条件包括大于255、存在非法字符、某段0以开头,这些问题即使进行层序遍历也无法消除,因此没有继续迭代的意义了)。
三、子集
78. 子集
vector<vector<int>> res;
vector<int> path;
vector<vector<int>> subsets(vector<int>& nums) {
BackTracking(nums, 0);
return res;
}
void BackTracking(const vector<int>& nums, int start) {
res.push_back(path);
if (start >= nums.size()) return;
for (int i = start; i < nums.size(); i++) {
path.push_back(nums[i]);
BackTracking(nums, i + 1);
path.pop_back();
}
}
子集问题需要搜集抽象树结构的所有结点,而非所有叶子结点。因此要注意代码中更行res的位置。本题抽象树结构如下图所示(来源:代码随想录):
90. 子集 II
vector<vector<int>> res;
vector<int> path;
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
sort(nums.begin(), nums.end());
BackTracking(nums, 0);
return res;
}
void BackTracking(const vector<int>& nums, int start) {
res.push_back(path);
if (start >= nums.size()) return;
for (int i = start; i < nums.size(); i++) {
if (i > start && nums[i] == nums[i - 1]) continue;
path.push_back(nums[i]);
BackTracking(nums, i + 1);
path.pop_back();
}
}
本题与40.组合总和Ⅱ相似,需要进行去重。这里需要秉持着:一个元素递归完毕后,含有该元素的集合都找全了,因此层间迭代时再遇到相同元素就要跳过。因此去重位置是层间去重,需要放在for循环的内部。
491. 递增子序列
vector<vector<int>> res;
vector<int> path;
vector<vector<int>> findSubsequences(vector<int>& nums) {
BackTracking(nums, 0);
return res;
}
void BackTracking(const vector<int>& nums, int start) {
// 收入的是所有结点,而非叶子结点
if (path.size() >= 2) {
res.push_back(path);
}
// 记录层内重复元素
unordered_map<int, int> record;
// 递归返回条件
if (start >= nums.size()) return;
for (int i = start; i < nums.size(); i++) {
if ((!path.empty() && nums[i] < path.back()) || record[nums[i]] == 1) continue;
record[nums[i]] = 1;
path.push_back(nums[i]);
BackTracking(nums, i + 1);
path.pop_back();
}
}
本题的抽象树结构如下图所示(来源:代码随想录):
本题难点在于层间去重,相比于40. 组合总和 II,本题的去重不能只判断两个相邻元素,因为序列不是有序的,某个元素后面可能会再次出现。因此采用一个额外的无序map记录元素是否已经使用过。每层都具有一个record数组,层间迭代时会被更新和检查;递归时下一层会新创建一个record数组。
尤其注意递归或迭代的条件:
if ((!path.empty() && nums[i] < path.back()) || record[nums[i]] == 1) continue;
前半部分是判断递归时是否满足递增子序列的条件,后半部分是判断层间是否重复。
698. 划分为k个相等的子集[*]
int target = 0;
vector<int> bucket;
bool canPartitionKSubsets(vector<int>& nums, int k) {
// 长度小于k
if (nums.size() < k) return false;
int sum = accumulate(nums.begin(), nums.end(), 0);
// 和不是k的整数倍
if (sum % k != 0) return false;
else target = sum / k;
// 大的在前,减少回溯次数
sort(nums.begin(), nums.end(), greater<int>());
// 收集路径,记录了单个集合
bucket.resize(k);
return BackTracking(nums, 0);
}
bool BackTracking(vector<int>& nums, int start) {
// 树结构每一条路径到底返回true;最终每一条路径记录了一个子数组
if (start == nums.size()) return true;
for (int i = 0; i < bucket.size(); i++) {
// 大于目标值,跳过进行下一次迭代
if (nums[start] + bucket[i] > target) continue;
// 去除【同层】重复元素(数组已排序)
if (i > 0 && bucket[i] == bucket[i - 1]) continue;
bucket[i] += nums[start];
if (BackTracking(nums, start + 1)) return true;
bucket[i] -= nums[start];
}
return false;
}
本题注意收集路径的方式、递归停止条件【类似全排列,执行到底才能返回true】和枝剪去重方法。
四、排列
46. 全排列
vector<vector<int>> res;
vector<int> path;
unordered_map<int, int> record;
vector<vector<int>> permute(vector<int>& nums) {
BackTacking(nums);
return res;
}
void BackTacking(const vector<int>& nums) {
if (path.size() == nums.size()) {
res.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
if (record[nums[i]] == 1) continue;
path.push_back(nums[i]);
record[nums[i]] = 1;
BackTacking(nums);
record[nums[i]] = 0;
path.pop_back();
}
}
本题的抽象树结构如下图所示(来源:代码随想录):
排列问题需要一个全局的record数组,标记已经使用过的元素。
47. 全排列 II
vector<vector<int>> res;
vector<int> path;
vector<vector<int>> permuteUnique(vector<int>& nums) {
// 用于递归枚举
vector<bool> used(nums.size(), false);
sort(nums.begin(), nums.end());
BackTacking(nums, used);
return res;
}
void BackTacking(const vector<int>& nums, vector<bool>& used) {
if (path.size() == nums.size()) {
res.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
// 层内去重
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) continue;
if (used[i] == false) {
path.push_back(nums[i]);
used[i] = true;
BackTacking(nums, used);
used[i] = false;
path.pop_back();
}
}
}
本题的抽象树结构如下图所示(来源:代码随想录):
本题的关键在于迭代层内去重和递归层外去重。
- 递归层外去重是为了不重不漏进行枚举,如上题46.全排列所示,利用全局used数组就可以完成;
- 迭代层内去重可以仿照491.递增子序列的方式完成,即每一树层都创建一个record数组进行记录。但这样效率较低(用时12ms)。不同于491.递增子序列,本题中的数组可以预先排序,因此可以直接对比nums[i]和nums[i-1]实现去重;
- 为了不使层内去重和层外去重冲突,判断逻辑如下:
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) continue;
if (used[i] == false) {
//递归及回溯逻辑
}
- 层内迭代时,num[i-1]的逻辑已经执行完毕,因此used[i-1]经回溯已经被置为false,满足相邻两元素相等且上一个元素的used标记为否就可跳过该层内循环;
- 层外递归时,上一个重复元素used标记会被置为true且还未被回溯,因此可以不重复地进行枚举。
这样的层内去重逻辑效率更高。
332. 重新安排行程[*]
// unordered_map<出发机场, map<到达机场, 航班次数>>
unordered_map<string, map<string, int>> records;
vector<string> findItinerary(vector<vector<string>>& tickets) {
vector<string> res;
// 对每个机场,初始化其可以到达的目的机场及机票数量
for (auto ticket : tickets) {
// target的第二个元素为map,无重复值,且已按照字母顺序排序
records[ticket[0]][ticket[1]]++;
}
res.push_back("JFK");
BackTracking(tickets.size(), res);
return res;
}
// 只需要找到一个行程,返回值类型为bool
bool BackTracking(const int ticketNum, vector<string>& res) {
// 总机场数量为票数+1
if (res.size() == ticketNum + 1) {
return true;
}
// 以记录的最后一个元素(即上一个到达机场)作为出发机场,进行遍历
for (pair<const string, int>& target : records[res[res.size() - 1]]) {
if (target.second > 0) {
res.push_back(target.first);
target.second--;
if (BackTracking(ticketNum, res)) return true;
res.pop_back();
target.second++;
}
}
return false;
}
本题难度较高,回溯法的抽象树结构如下图所示(来源:代码随想录):
本题的关键在于选用恰当的容器存放机票信息,要求做到目的地机场按照字母顺序排序。这里选用
// unordered_map<出发机场, map<到达机场, 航班次数>>
unordered_map<string, map<string, int>> tickets;
来记录机票的映射关系。
另一个需要注意的点是,由于目的机场已经被排序,所以一旦res数组找齐了,res就是正确的结果。因此只需要找到一个目标结果就可以立即返回,从而定义回溯函数的返回值类型为bool。
22. 括号生成
class Solution {
public:
vector<string> res;
string path;
vector<string> generateParenthesis(int n) {
BackTracking(n, n);
return res;
}
// right和left分别是剩余的")"和"("数量
// 规则1:剩下的右括号数应≥左括号数,即right>left
// 规则2:剩余左右括号数量应≥0
void BackTracking(int left, int right) {
// 违反规则1,不合法
if (left > right) return;
// 违反规则2,不合法
if (left < 0 || right < 0) return;
// 递归返回条件:满足要求
if (left == 0 && right == 0) {
res.push_back(path);
return;
}
// 尝试添加一个左括号
path.push_back('(');
BackTracking(left - 1, right);
path.pop_back();
// 尝试添加一个右括号
path.push_back(')');
BackTracking(left, right - 1);
path.pop_back();
}
};
本题需要注意括号生成的规则:「合法」括号组合的左括号数量一定等于右括号数量;子串 p[0..i]
中左括号的数量都大于或等于右括号的数量。
五、棋盘问题
51. N 皇后[*]
vector<vector<string>> res;
vector<vector<string>> solveNQueens(int n) {
// 初始化空棋盘
vector<string> chessboard(n, string(n, '.'));
BackTracking(n, 0, chessboard);
return res;
}
// chessboard可以视为二维数组,即一个棋盘
void BackTracking(const int n, int row, vector<string>& chessboard) {
// 最后一次操作时row = n - 1
if (row == n) {
res.push_back(chessboard);
return;
}
for (int col = 0; col < n; col++) {
if (IsValid(row, col, chessboard, n)) {
chessboard[row][col] = 'Q'; // 放置皇后
BackTracking(n, row + 1, chessboard);
chessboard[row][col] = '.'; // 回溯,撤销皇后
}
}
}
// 验证当前棋盘是否合法
bool IsValid(int row, int col, vector<string>& chessboard, const int n) {
// 检查列
for (int i = 0; i < row; i++) {
if (chessboard[i][col] == 'Q') {
return false;
}
}
// 检查135度角是否有皇后
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
// 检查45度角是否有皇后
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
return true;
}
本题和93. 复原 IP 地址非常类似,定义了一个IsVaild函数用于检验当前棋盘是否合法。依据这个判据,再进行回溯操作就很简单了。本题对应的抽象树结构如下图所示(来源:代码随想录):
层间循环搜索行,深度递归处理列。记得要初始化棋盘,从而回溯时,原位置重新设为“.”即可。
37. 解数独
void solveSudoku(vector<vector<char>>& board) {
BackTracking(board);
}
bool BackTracking(vector<vector<char>>& board) {
// 遍历下一行
for (int row = 0; row < board.size(); row++) {
// 在单行中遍历列
for (int col = 0; col < board[0].size(); col++) {
if (board[row][col] != '.') continue;
for (char c = '1'; c <= '9'; c++) {
if (IsValid(row, col, c, board)) {
board[row][col] = c;
if (BackTracking(board)) return true;
board[row][col] = '.';
}
}
// 9个数都不成立时
return false;
}
}
return true;
}
bool IsValid(int row, int col, char val, vector<vector<char>>& board) {
// 判断行中是否重复
for (int i = 0; i < board.size(); i++) {
if (board[row][i] == val) {
return false;
}
}
// 判断列中是否重复
for (int j = 0; j < board[0].size(); j++) {
if (board[j][col] == val) {
return false;
}
}
// 判断所在方格中是否重复
int startRow = (row / 3) * 3;
int startCol = (col / 3) * 3;
for (int i = startRow; i < startRow + 3; i++) {
for (int j = startCol; j < startCol + 3; j++) {
if (board[i][j] == val) {
return false;
}
}
}
return true;
}
本题对应的抽象树结构如下图所示(来源:代码随想录):
N皇后问题每一行每一列只放一个皇后,只需要一层for循环遍历; 本题中棋盘的每一个位置都要放一个数字,并检查数字是否合法,因此需要进行二维递归,即两个for循环分别遍历行和列。因为单行中前一个数字确定后,递归不是进入下一行,而是进入该行的下一个数字。
一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性
另外,本题不需要递归终止条件,因为解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。
回溯函数返回值类型为bool,这与上题类似。因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径。