递归回溯这类题目的代码往往会包含一个递归函数和递归函数内部的一个循环语句。
比如N皇后问题,它每次递归时,就进入到下一行做一些逻辑处理。而在每一行时,都会循环遍历每一列,判断本行该列的元素是否满足条件。又比如电话号码的字母组合问题,每递归一次就进入到下一个数字,然后遍历该数字对应的字母列表。其他问题也类似,但会有一定的变形。
既然都有递归和循环结构,并且递归和循环都沿着这两个结构进行,那为何不提出递归线和循环线两个概念呢?
递归线可以认为是递归遍历的线性结构,比如N皇后题目中待遍历的N行、电话号码字母组合题目中待遍历的N个数字、单词搜索题目中待遍历的网格、子集题目中待遍历的数字数组。而递归点则是递归遍历时的具体位置,比如N皇后中具体的某一行,电话号码字母组合中具体某一个数字。
循环线则是某个递归点需要遍历的几种可能选择。比如N皇后题目中某一行需要遍历的N列、电话号码字母组合题目中某一个数字对应的需要遍历的字母集合、单词搜索题目中某个网格需要遍历的左右上下4个邻居格子。而循环点则是循环遍历时的具体位置,比如N皇后问题中某个具体的行的具体列。
**递归线和循环线相互相成。 某个递归点的在循环时选定的循环点往往就是下一个递归点。 ** 对于单词搜索题目,选定的循环点就是下一次递归的递归点,而对于N皇后问题,循环点的选择就不影响下一次递归点。
回溯递归题目一般所求都是一个集合,需要在不断递归和循环时记录选择的递归点和循环点,在某个适当的时机将一路上记录的递归点和循环点放到一个集合中。一般来说,会用一个vector记录每次递归时选择的循环点(push_back)。在回溯的时候,清除当前循环点(pop_back)。遍历到下一个循环点,再用vector记录之。
显然,对于递归回溯题目,找到递归线和循环线基本上就可以认为问题解决了大半。这类问题的代码结构都是类似的,一个递归函数 + 一个for循环 + 位置记录vector。
下面通过一些题目训练掌握并巩固这个思路。
显式的递归循环线
电话号码的字母组合
leetcode原题https://leetcode-cn.com/problems/letter-combinations-of-a-phone-number/
- 题目描述
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
- 例子
输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
- 题解
数字"23"可以用于递归遍历,每次递归就访问下一个下标的数字,也就是数字字符串本身是递归线,而每个具体的数字则是递归点。每个数字都对应有几个字母,这些字母就是循环线。进入到某个递归点时,就循环遍历循环线上的字母。 于是:
- 递归线:
递归遍历字符串中的每个数字。
- 内部循环线
循环遍历递归点数字对应的字母数组。
- 递归和内部循环的结束条件
当所有的数字都递归遍历完成后,即可终止递归。循环则是每个数字的字母都遍历一次,遍历完成即可终止。
- 注意点:
每次递归是都需要记录当前循环选择的值。
- AC代码
class Solution {
public:
vector<string> letterCombinations(string digits) {
m_vec.clear();
if(digits.empty())
return m_vec;
std::string str;
dfs(digits, 0, str);
return m_vec;
}
void dfs(const std::string &digits, int index, std::string &str)
{
if(index == digits.size())
{
m_vec.push_back(str);
return ;
}
for(auto c : m_nums[digits[index]])//循环遍历每个数字对应的字母
{
str.push_back(c);//记录本次递归选择的循环值
dfs(digits, index+1, str);
str.pop_back();//释放本次递归选择的循环制
}
}
private:
std::vector<string> m_vec;
std::map<char, string> m_nums = { {'2', "abc"}, {'3', "def"}, {'4', "ghi"}, {'5', "jkl"}, {'6', "mno"},{'7', "pqrs"}, {'8', "tuv"}, {'9', "wxyz"} };
};
N皇后
leetcode原题https://leetcode-cn.com/problems/n-queens/
- 题目描述
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
- 例子
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
- 题解
N皇后问题中,递归线就是棋盘上的那N行,每个递归点的循环线就是该行的所有列。 由于N皇后问题中N个皇后不能相互冲突,因此在循环遍历到某一列时,需要检查能否选定该列。这也是稍微复杂的点,遍历时判断该点是否满足条件。
- 递归线:
递归遍历棋盘的每一行。
- 内部循环线
循环遍历递归行中的N列。
- 递归和内部循环的结束条件
当该行没有任何一列满足条件时终止递归;当递归到最后一行时终止递归。
- 注意点:
循环时需要判断循环点是否满足N皇后的不冲突条件。
- AC代码
class Solution {
public:
vector<vector<string>> solveNQueens(int n) {
m_locations.clear();
std::vector<int> locate;
dfs(locate, n);
std::vector<std::vector<std::string>> ret;
for(auto &vec : m_locations)
{
std::vector<std::string> vec_str(n, std::string(n, '.'));
for(int i = 0; i < vec.size(); ++i)
{
vec_str[i][vec[i]] = 'Q';
}
ret.push_back(std::move(vec_str));
}
return ret;
}
void dfs(std::vector<int> &locate, int n)
{
if(locate.size() == n)
{
m_locations.push_back(locate);
return ;
}
for(int i = 0; i < n; ++i)
{
if(isValid(locate, i))
{
locate.push_back(i);
dfs(locate, n);
locate.pop_back();
}
}
}
bool isValid(const std::vector<int> &locate, int index)
{
//存在同一列
if(std::find(locate.begin(), locate.end(), index) != locate.end())
return false;
//斜线 x轴之差等于y轴之差就在斜线上
int size = locate.size(); //这个size是index所在的y轴位置
for(int i = 0; i < size; ++i)
{
int x_diff = std::abs(index - locate[i]);
int y_diff = size - i;
if(x_diff == y_diff)
return false;
}
return true;
}
private:
std::vector<std::vector<int>> m_locations;
};
单词搜索
leetcode原题https://leetcode-cn.com/problems/word-search/
- 题目描述
给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
- 例子
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
输出:true
- 题解
递归线是每一个格子,而每个递归点的循环线则是该格子上下左右四个邻居。有了递归线和循环线,大体的代码架子就浮现了。 还有几个要注意的点。1.单词开始的地方不一定是左上角的格子,可能从任意一个格子开始;2. 每个格子需要只能走一次,因此需要记录每个格子是否已经走过;3. 边上的格子的下一个递归点不能跨过边界;3. 选择的循环点就是下一次递归时的递归点。
- 递归线:
递归遍历网格中的每个格子。
- 内部循环线
循环遍历递归点格子的上下左右四个邻居。循环时需要判断该循环点是否已经走过了。
- 递归和内部循环的结束条件
当某个格子不符合单词下一个字符时,终止递归。越出网格边界时终止递归。
- 注意点:
如前所述
- AC代码
class Solution {
public:
bool exist(vector<vector<char>>& board, string word) {
if(board.empty() || word.empty())
return false;
int row = board.size();
int col = board[0].size();
for(int i = 0; i < row; ++i)
{
for(int j = 0; j < col; ++j)
{
if(board[i][j] == word[0] && existCore(board, i, j , word, 0))
return true;
}
}
return false;
}
bool existCore(std::vector<std::vector<char>> &mat, int i, int j, const std::string &word, int index)
{
if(mat[i][j] != word[index])
return false;
else if(index+1 == word.size())
return true;
int row = mat.size();
int col = mat[0].size();
char record = '#';
//往左
if(j > 0)
{
std::swap(mat[i][j], record); //标志该格子已经走过
if(existCore(mat, i, j-1, word, index+1))
return true;
std::swap(mat[i][j], record); //复原该格子
}
//往右
if(j < col-1)
{
std::swap(mat[i][j], record);
if(existCore(mat, i, j+1, word, index+1))
return true;
std::swap(mat[i][j], record);
}
//往下
if(i < row-1 )
{
std::swap(mat[i][j], record);
if(existCore(mat, i+1, j, word, index+1))
return true;
std::swap(mat[i][j], record);
}
//往上
if(i > 0 )
{
std::swap(mat[i][j], record);
if(existCore(mat, i-1, j, word, index+1))
return true;
std::swap(mat[i][j], record);
}
return false;
}
};
隐式的循环线
数字组合
leetcode原题https://leetcode-cn.com/problems/combinations/
- 题目描述
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
- 例子
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
- 题解
相对于前面的题目,本题只有一条明显的可递归或者循环的线:1到n这n个数字。 按照前面题目的经验,题目给出的数组适宜作为递归线。那循环线是什么?看过这类题目的读者可能知晓的答案:"选择或者不选择当前的数字“ 这两种情况。比如说递归到数字3,此时既可以选择3,也可以不选择3。这两种情况就是循环线。当然只有两种情况时,代码上并不会写成循环的形式。但本质就是循环线。
- 递归线:
递归遍历1到n这数组中的每一个数字
- 内部循环线
循环遍历递归点数字的选择和不选择两种情况。
- 递归和内部循环的结束条件
当选择的数字达到了预设的k个时,就终止递归。
- 注意点:
注意剪枝,当剩余未递归的数字数量小于k时,没必要再递归了。因为即使全选了也凑不够k个数字。
- AC代码
class Solution {
public:
vector<vector<int>> combine(int n, int k) {
m_collections.clear();
std::vector<int> nums(n);
std::iota(nums.begin(), nums.end(), 1);
std::vector<int> vec;
dfs(nums, 0, vec, k);
return m_collections;
}
//vec存放已经选定的数字,k表示还差几个数字没有收集
void dfs(const std::vector<int> &nums, int index, std::vector<int> &vec, int k)
{
if(k == 0)
{
m_collections.push_back(vec);
return ;
}
//剪枝。还需k个数字,但目前能提供数字都不够k个
if( (k > nums.size()-index) || index == nums.size() )
{
return ;
}
//因为只有两种情况,所以没有必要写成循环的形式
dfs(nums, index+1, vec, k); //不选择这个数字
vec.push_back(nums[index]); //选择这个数字
dfs(nums, index+1, vec, k-1);
vec.pop_back();
}
private:
std::vector<std::vector<int>> m_collections;
};
子集
leetcode原题https://leetcode-cn.com/problems/subsets/
- 题目描述
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
- 例子
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
- 题解
这题目和前面的数字组合的类似的。唯一不同的是就是递归终止条件。 数字组合中,限制了不多不少只能选择k个数字。而本题则是没有这个限制。
- 递归线:
递归遍历数组中的每一个数字。
- 内部循环线
遍历递归点数字的选择和不选择两种情况。
- 递归和内部循环的结束条件
当递归完数组的最后一个元素时终止递归。
- 注意点:
无。
- AC代码
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
m_vec.clear();
std::vector<int> vec;
subSetsCore(nums, 0, vec);
return m_vec;
}
void subSetsCore(const std::vector<int> &nums, int startIndex, std::vector<int> &vec)
{
if(startIndex == nums.size())
{
m_vec.push_back(vec);
return ;
}
//因为只有两种情况,所以没有必要写成循环的形式
subSetsCore(nums, startIndex+1, vec);//不选择该数字
vec.push_back(nums[startIndex]);
subSetsCore(nums, startIndex+1, vec); //选择该数字
vec.pop_back();
}
private:
std::vector<std::vector<int>> m_vec;
};
全排列
leetcode原题https://leetcode-cn.com/problems/permutations/
- 题目描述
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
- 例子
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
- 题解
这题目的递归线比较明显,就是遍历数组的每个元素。循环线呢? 选和不选? 并不能满足题设。第一次碰到该题目百思不得其解。后来看到题解后,wocao 还能这样玩。 对于每个递归点的循环线是递归点的元素和后面的元素swap一次。通过这样不断交换位置,实现不同的排序。
- 递归线:
递归遍历对数组中的每一个数字
- 内部循环线
循环遍历递归点数字后面的元素,并与递归点的元素进行交换。
- 递归和内部循环的结束条件
当递归完数组的最后一个元素时终止递归
- 注意点:
无
- AC代码
class Solution {
public:
vector<vector<int>> permute(vector<int>& nums) {
m_ret.clear();
dfs(nums, 0);
return m_ret;
}
void dfs(std::vector<int> &nums, int startIndex)
{
if(startIndex == nums.size())
{
m_ret.push_back(nums);
return ;
}
for(int i = startIndex; i < nums.size(); ++i)
{
std::swap(nums[i], nums[startIndex]);
dfs(nums, startIndex+1);
std::swap(nums[i], nums[startIndex]);
}
}
private:
vector<vector<int>> m_ret;
};
隐式的递归和循环线
括号生成
leetcode原题https://leetcode-cn.com/problems/generate-parentheses/
- 题目描述
数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
有效括号组合需满足:左括号必须以正确的顺序闭合
- 例子
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]
- 题解
一开始会比较懵逼,递归线和循环线都没有头绪。没有条件也要创造条件上。之前的递归线是顺着一个数组、格子等一个结构递归的。 本题中并没有任何一个结构。那能不能顺着次数递归? 比如说左括号使用次数,每使用一个左括号就递归一次。这个思路是不错,但还是有一些细节要斟酌。比如例子中的一个有效括号组合((())),当递归完3个左括号后,后面是3个右括号是怎么生成的? 循环线?
能不能将括号的使用作为递归线? 循环线则是某递归点下,遍历["选择左括号", "选择右括号"]。也就是选择的循环点就是下一次递归的递归点。这个和前面的单词搜索题目类似。
另外,这题和N皇后题目类似,并不是循环线上的所有循环点都能选择。需要判断该选择得是合法的括号顺序。
- 递归线:
单纯递归,没有结构依赖。在循环线上选定一个合法的循环点之后就作为递归点进入下一次递归。
- 内部循环线
循环遍历["选择左括号", "选择右括号"]。选择时需要检查循环点是否为合法的括号顺序。
- 递归和内部循环的结束条件
当左右括号数都为n时,终止遍历。
- 注意点:
无
- AC代码
class Solution {
public:
vector<string> generateParenthesis(int n) {
m_vec.clear();
std::string str;
dfs(str, n, n);
return m_vec;
}
void dfs(std::string &str, int left_num, int right_num)
{
if(left_num == 0 && right_num == 0)
{
m_vec.push_back(str);
return ;
}
//可以继续放左括号
if(left_num > 0)
{
str.push_back('(');
dfs(str, left_num-1, right_num);
str.pop_back();
}
//当str中 左括号的数量大于右括号的数量,那么可以继续往str放一个右括号。
if(left_num < right_num) // < 号是因为是剩余数
{
str.push_back(')');
dfs(str, left_num, right_num-1);
str.pop_back();
}
//自然终止递归。
}
private:
std::vector<std::string> m_vec;
};
去重问题
组合总和II
leetcode原题https://leetcode-cn.com/problems/combination-sum-ii/
- 题目描述
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
注意:解集不能包含重复的组合
- 例子
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
- 题解
这题是前面题目“组合”的变种,也是递归遍历一个整数数组中选择出一些数字。只是本题的递归终止条件包含了题设:选出的数字总和等于target。
另外,本题还有一个需要注意的点是解集中不能包含重复的组合。
- 递归线:
递归遍历数组中的每一个数字。
- 内部循环线
循环遍历递归点数字的选择和不选择两种情况。
- 递归和内部循环的结束条件
当递归完数组的最后一个元素时终止递归;当选出的数字总和大于等于target时终止遍历;
- 注意点:
如前所述需要避免重复组合,具体方法参考代码注释
- AC代码
class Solution {
public:
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
m_ret.clear();
//排序是为了后面的去重
std::sort(candidates.begin(), candidates.end());
std::vector<int> path;
dfs(candidates, path, 0, target);
return m_ret;
}
void dfs(const std::vector<int> &candidates, std::vector<int> &path, int start_index, int target)
{
if(target == 0) //满足题设
{
m_ret.push_back(path);
return ;
}
//剪枝
if(target < 0 || start_index >= candidates.size())
return ;
//选择这个元素
path.push_back(candidates[start_index]);
dfs(candidates, path, start_index+1, target-candidates[start_index]);
path.pop_back();
//不选择start_index位置上的这个元素。不能简单不选择就可以了。考虑[1, 2, 2, 2, 3, 4]
//此时start_index为1。对于前面选择这个元素,那么就是结果集中有了【1, 2】. 如果在start_index为1
//的位置不选择2,转身在start_index等于2的位置就选择了2, 那么结果集中也是【1, 2】,此时就重复了
//因此,如果跳过了,那么就一直跳过这个值。
for(int v = candidates[start_index++]; start_index < candidates.size() && v == candidates[start_index]; )
++start_index;
dfs(candidates, path, start_index, target);
}
private:
std::vector<std::vector<int>> m_ret;
};
子集II
leetcode原题https://leetcode-cn.com/problems/subsets-ii/
涉及重复子集的问题
- 题目描述
给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
- 例子
输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]
- 题解
递归线和循环线都比较明确,递归线就是数组。循环线就是选和不选递归点数字这两种情况。要注意的是要避免重复的子集。这个避免的思路和前面题目的一致,跳过后面相同值的元素即可。
另外,本题还有一个需要注意的点是解集中不能包含重复的组合。
- 递归线:
递归遍历数组中的每一个数字。
- 内部循环线
循环遍历递归点数字的选择和不选择两种情况。
- 递归和内部循环的结束条件
当递归完数组的最后一个元素时终止递归;
- 注意点:
如前所述需要避免重复子集,具体方法参考代码注释
- AC代码
class Solution {
public:
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
m_ret.clear();
std::vector<int> vec;
std::sort(nums.begin(), nums.end());//排序是为了后面的去重
dfs(nums, vec, 0);
return m_ret;
}
void dfs(const std::vector<int> &nums, std::vector<int> &vec, int startIndex)
{
if(startIndex == nums.size())
{
m_ret.push_back(vec);
return ;
}
//选择该元素
vec.push_back(nums[startIndex]);
dfs(nums, vec, startIndex+1);
vec.pop_back();
//不选择该元素,那么需要跳过所有相同的元素
int val = nums[startIndex++];
while(startIndex < nums.size() && nums[startIndex] == val)
++startIndex;
dfs(nums, vec, startIndex);
}
private:
std::vector<std::vector<int>> m_ret;
};
全排列II
leetcode原题https://leetcode-cn.com/problems/permutations-ii/
- 题目描述
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
- 例子
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
- 题解
这题是前面全排列的变形题,难点在于需要会出现重复的数字并且需要去重。前面的全排列解法中,需要不同对两个位置的数字进行swap,因此即使在一开始做排序,后面经过递归和各种swap后,各种可能的排列都会有的(毕竟这题目是全排列)。此时不能简单用前面的去重方法了。新的去重方法参考代码中的注释。
- 递归线:
递归遍历对数组中的每一个数字
- 内部循环线
循环遍历递归点数字后面的元素,并与递归点的元素进行交换。
- 递归和内部循环的结束条件
当递归完数组的最后一个元素时终止递归
- 注意点:
需要去重,具体参考代码中的注释
- AC代码
class Solution {
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
m_ret.clear();
std::sort(nums.begin(), nums.end());
dfs(nums, 0);
return m_ret;
}
void dfs(std::vector<int> &nums, int startIndex)
{
if(startIndex == nums.size())
{
m_ret.push_back(nums);
return ;
}
auto it = nums.begin();
for(int i = startIndex; i < nums.size(); ++i)
{
//这里需要判断[startIndex, i)之间是否已经出现过了nums[i]。因为如果出现过,那么它之前已经和nums[startIndex]
//做过了swap。此时,还将nums[i]和nums[startIndex]做swap,那么就是它之前出现时和startIndex做swap得到的数组
//和i和startIndex做swap得到的数组是一致,也就是出现了重复.
//虽然前面有排序,但经过N次的递归,swap后,再已经面目全非,不能简单以nums[i]和nums[i-1]判断是否相同的字符
if(std::find(it+startIndex, it+i, nums[i]) == it+i) //[startIndex, i)之间没有和nums[i]值相同的元素
{
std::swap(nums[i], nums[startIndex]);
dfs(nums, startIndex+1);
std::swap(nums[i], nums[startIndex]);
}
}
}
private:
std::vector<std::vector<int>> m_ret;
};
第一时间接收最新文章,请关注同名微信公众号:代码的色彩