本文参考:carl大佬–代码随想录的题解
个人算法小抄:
1.组合问题,无序性。如{2,4}和{4,2}是同一种结果。考虑index控制。
2.排序问题,有序性。如{1,2,3}和{3,2,1}是不一样的。考虑维护used数组。
3.分割问题,虚拟出一条分割线。很多时候直接在原数据上操作及撤回。
4.子集问题,当用三部曲模板时(其实就是迭代加递归)。搜集全部经过的节点。 当使用双递归时,收集根节点即可。
77.组合 (组合问题 控制for的下界的startindex)
给定两个整数 n 和 k,返回 1 … n 中所有可能的 k 个数的组合。
样例:
输入: n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
总结:
遇到这种,不能颠倒顺序的组合问题(如只能[1,2],不能[2,1])的时候,在递归中传入一个start,作为for横向选择时的脚标,避免走回头路。
坑点:
下一层递归是从i+1开始的,不然会重复选择相同的数字本身。(如[2,2])。 如果记不住,想想[1]的时候咋选[3],我这时候i指向2,下一层肯定是i+1才能是3呀。如果是start加一岂不是又选到2了。
剪枝:
存在一种情况如n = 4,k = 4的时候,如果第一层不选1,从2开始选,那么这条树枝全部都是没有意义的,因为后面的元素全选了都不够满足条件。
因此,可以约束i的上界条件:
1.当前已选择数量为 path.size();
2.需求元素的数量自然就是 k - path.size();
3.所以在Nums数组中,只有在某个脚标之前的选择才是有意义的-- nums.size() - (k - path.size()) + 1。至于为啥要加一,可以把path.size() = 0带进入一个具体的例子如n = 4 ,k = 3中。
class Solution {
public:
vector<vector<int>> ans;
vector<int> path;
vector<vector<int>> combine(int n, int k) {
vector<int> nums(n);
for (int i = 0; i < n; i++)
nums[i] = i + 1;
backtrack(nums, k, 0);
return ans;
}
void backtrack(vector<int>& nums,int k,int start)
{
if (path.size() == k)
{
ans.push_back(path);
return;
}
//已选path.size(),需求 k - path.size(),只有n - (需求)+1位置之前的有意义 (之后的元素全选都不够了,直接剪枝)
for (int i = start; i < nums.size() - (k - path.size()) + 1; ++i) //剪枝
{
int a = nums[i];
path.push_back(nums[i]);
backtrack(nums, k, i + 1);//注意下一层要从i+1Kaishi
path.pop_back();
}
}
};
46. 全排列 (排列问题 维护一个used数组)
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
这题可以用交换来改变排列的。撤销操作就是再交换一遍。 用一个level(level)参数来影响循环中的i的值,实现不同的交换对象。但排列问题有更通用的模板
class Solution {
public:
vector<vector<int>> ans;
vector<vector<int>> permute(vector<int>& nums) {
if (nums.empty())
return ans;
dfs(nums, 0);
return ans;
}
void dfs(vector<int>& nums, int level)
{
if (level == nums.size())
{
ans.push_back(nums);
return;
}
for (int i = level; i < nums.size(); ++i)
{
swap(nums[i], nums[level]);
dfs(nums, level+1);
swap(nums[i], nums[level]);
}
}
};
通用套路!排列问题
用一个bool的used数组来记录已访问元素。(这样就可以知道还剩哪些元素,用for遍历的时候跳过那些已经访问过的)。
注意 回溯撤销操作的时候,要把访问数组的操作也撤销。
class Solution {
public:
vector<vector<int>> ans;
vector<vector<int>> permute(vector<int>& nums) {
if (nums.empty())
return ans;
vector<bool> used(nums.size());
dfs(nums, used);
return ans;
}
vector<int> path;
void dfs(vector<int>& nums, vector<bool>& used)
{
if (path.size() == nums.size())
{
ans.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++)
{
if (used[i] == true) continue; //跳过那些已经访问过的
used[i] = true; //标记当前访问
path.push_back(nums[i]);
dfs(nums, used);
used[i] = false; //撤销访问操作
path.pop_back();
}
}
};
下面是一个基于46题排列问题的剪枝问题,这个剪枝方法也很常用。
47. 全排列 II (排列剪枝 used数组+剪枝)
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
样例:
输入:
nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
这题和46的区别就是给定数组中有重复数字需要剪枝去重。
注意首先要对给定数组排序,这样才可以让相同的数挨到一起。
去重核心代码:
当前这个数和前一个数重复了,并且站在这一层观察以往的used情况,发现前一个重复的数竟然没有被用到。说明了啥,说明了这个重复的数字在本层中被用到了,换句话说就是这个重复的数字和“我”占据了同一个位置。那么“我”这条树枝,就要全部被剪枝。
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
continue;
}
以上就是树层剪枝。
此外还有一种树枝剪枝。
代码和树层剪枝只有一个true的差别。
在这个问题上,即发现和前一个数字重复,并且当前选择的位置和前一个位置相邻,则剪枝,具体见图。
其实我也不是特别理解,只能理解这个特例。 貌似是最后被剪得只剩下从后往前排列一种情况,就去掉了其他重复。
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == true) {
continue;
}
从上图可以发现,树层剪枝不仅好用,而且好理解。
全题代码如下:
class Solution {
public:
vector<vector<int>> ans;
vector<int> path;
vector<vector<int>> permuteUnique(vector<int>& nums) {
if (nums.empty())
return ans;
vector<int> used(nums.size());
sort(nums.begin(), nums.end());
backtrack(nums, used);
return ans;
}
void backtrack(vector<int>& nums,vector<int>& used)
{
if (path.size() == nums.size())
{
ans.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++)
{
if (i > 0 && nums[i] == nums[i-1] && used[i-1] == false) //相同nums值,但之前那个重复的没有在上层用过
continue; //说明已经在当前层出现了重复的数字排列
if (used[i] == true) //避免重复选择自己
continue;
path.push_back(nums[i]);
used[i] = true;
backtrack(nums, used);
path.pop_back();
used[i] = false;
}
}
};
79. 单词搜索 (回溯维护访问矩阵)
给定一个二维网格和一个单词,找出该单词是否存在于网格中。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
样例:
board =
[
[‘A’,‘B’,‘C’,‘E’],
[‘S’,‘F’,‘C’,‘S’],
[‘A’,‘D’,‘E’,‘E’]
]
给定 word = “ABCCED”, 返回 true
给定 word = “SEE”, 返回 true
给定 word = “ABCB”, 返回 false
坑点:
我做这题主要是一开始加入的条件不明确。实际上判断第一个字符是否是word的第一个字符,可以也通过dfs来判断。这样写法会比较统一。这题主要的难点是跳出条件比较多。而且巧妙的地方是,这道题不需要维护具体的string,而是只需要维护访问矩阵viewed就行,这个矩阵要回溯维护,因为只是一次搜索时不能回头,下一次搜索还是可以用这个数。
自己虽然强行写出来了条件判断嵌入在递归结构中的写法,但是可复制性很差,还是要学习按结构来写的。
class Solution {
public:
string str;
int diraction[5] = { -1,0,1,0,-1 };
bool can_do = false;
bool exist(vector<vector<char>>& board, string word) {
int m = board.size(), n = board[0].size();
vector<vector<bool>> viewed(m,vector<bool>(n,false));
for (int i = 0; i < m; ++i)
{
for (int j = 0; j < n; ++j)
{
if (board[i][j] == word[0]) //我的写法,提前判断了是否可以开始递归
{
dfs(board, word,viewed, i, j, 0);
}
}
}
return can_do;
}
void dfs(vector<vector<char>> &board,string word, vector<vector<bool>>& viewed,
int r,int c,int cnt)
{
str.push_back(board[r][c]); //维护了具体数组
viewed[r][c] = true;
for (int i = 0; i < 4; i++) //往四个方向移动
{
int x = r + diraction[i];
int y = c + diraction[i + 1];
if (x >= 0 && x < board.size() && y >= 0 && y < board[0].size()
&&viewed[x][y] == false &&board[x][y] == word[cnt + 1])
{
dfs(board, word,viewed, x, y, cnt+1);
}
if (str == word) //终结条件在循环内部
{
can_do = true;
return;
}
}
viewed[r][c] = false; //撤回访问记录
str.pop_back(); //撤回
}
};
标答写法:
1.数组越界的跳出条件,返回
2.走了回头路,已经找到了(剪枝),值不等于该等于的,返回。
3.该等于的数已经推进到目标word的最后一位了,说明已经找到了,返回
4.我要做的事情: 记录下我现在正在访问viewed【r】【c】
5.强行循环递归,不管符不符合都进入下一层,条件判断交给一开始那三个if。
6.循环递归结束后,说明当前这个点的四个邻居都搞完了,回溯,把当前这个点的访问记录删除。以便到时候绕路回来还要用到它。
class Solution {
public:
int diraction[5] = { -1,0,1,0,-1 };
bool can_do = false;
bool exist(vector<vector<char>>& board, string word) {
int m = board.size(), n = board[0].size();
vector<vector<bool>> viewed(m, vector<bool>(n, false)); //每次开始递归,从新计数
for (int i = 0; i < m; ++i)
{
for (int j = 0; j < n; ++j)
{
dfs(board, word, viewed, i, j, 0,can_do);
}
}
return can_do;
}
void dfs(vector<vector<char>> &board, string word, vector<vector<bool>>& viewed,
int r, int c, int cnt,bool& can_do)
{
if (r < 0 || r >= board.size() || c < 0 || c >= board[0].size())
return;
if (viewed[r][c] == true || can_do == true || board[r][c] != word[cnt])
return;
if (cnt == word.length() -1)
{
can_do = true;
return;
}
viewed[r][c] = true; //“我”这一层干的事情,留下访问记录
for (int i = 0; i < 4; i++) //往四个方向移动
{
int x = r + diraction[i];
int y = c + diraction[i + 1];
dfs(board, word, viewed, x, y, cnt + 1,can_do);
}
viewed[r][c] = false; //撤回访问记录
}
};
131. 分割回文串 (分割问题 模拟切割线->转化为类组合问题)
题意:
给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
返回 s 所有可能的分割方案。
示例:
输入: “aab”
输出:
[
[“aa”,“b”],
[“a”,“a”,“b”]
]
用回溯做,算暴力做法,主要难点是要模拟出分割线,以及回溯的跳出条件比较难确定。
class Solution {
public:
vector<vector<string>> ans;
vector<string> path;
vector<vector<string>> partition(string s) {
backtrack(s, 0);
return ans;
}
bool IsReverse(string str)
{
int i = 0, j = str.length() - 1;
while (i < j)
{
if (str[i] != str[j])
return false;
++i;
--j;
}
return true;
}
void backtrack(string s,int index)
{
if (index == s.length())
{
ans.push_back(path);
return;
}
for (int i = index; i < s.length(); i++)
{
string tmp = s.substr(index,i - index +1);
if (IsReverse(tmp))
path.push_back(tmp);
else
continue; //若某部分不符合,进都不会进下一层
backtrack(s, i + 1);
path.pop_back();
}
}
};
93. 复原IP地址 (分割问题进阶 加入符号
题意:
给定一个只包含数字的字符串,复原它并返回所有可能的 IP 地址格式。
有效的 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 ‘.’ 分隔。
例如:“0.1.2.201” 和 “192.168.1.1” 是 有效的 IP 地址,但是 “0.011.255.245”、“192.168.1.312” 和 “192.168@1.1” 是 无效的 IP 地址。
样例:
输入:s = “101023”
输出:[“1.0.10.23”,“1.0.102.3”,“10.1.0.23”,“10.10.2.3”,“101.0.2.3”]
本题是131分割问题的进阶。难点在于直接在原string上操作并撤销操作。个人觉得这题完全值一个Hard。
要点:
1.用Index控制".“的加入位置。这正说明了此类问题的灵活性。
2.跳出条件要设置为已经加入了3个点”.",因此需要把已经加入的点的数量pointnum传入数组。
3.判断合法性是直接在操作后的原string上,传入头尾指针直接判断区间。
class Solution {
public:
vector<string> ans;
vector<string> restoreIpAddresses(string s) {
backtrack(s, 0, 0);
return ans;
}
void backtrack(string& s,int index,int pointnum)
{
if (pointnum == 3)
{
if (IsOk(s, index, s.length() - 1)) //单独判断最后一个区间是否合法
ans.push_back(s);
return;
}
for (int i = index; i < s.length(); ++i)
{
if (IsOk(s, index, i)) //利用合法性剪枝
{
s.insert(s.begin() + i + 1, '.');
pointnum++;
backtrack(s, i+2, pointnum); //加2是因为插入了一个逗号
s.erase(s.begin() + i + 1);
pointnum--;
}
else
break; //不合法直接总结横向遍历 退出本层
}
}
bool IsOk(string& s ,int l,int r)
{
if (l > r || l < r-9) //超出int范围
return false;
if (s[l] == '0' && l != r)
return false; //先导0
int num = 0;
for (int i = l; i <= r; ++i)
{
if (s[i] > '9' || s[i] < '0')
return false;
num = num * 10 + (s[i] - '0');
}
if (num > 255)
return false;
return true;
}
};
78. 子集 类比组合问题
三部曲模板写法,注意收集全部经过的节点。
class Solution {
public:
vector<vector<int>> ans;
vector<int> path;
vector<vector<int>> subsets(vector<int>& nums) {
backtrack(nums,0);
return ans;
}
void backtrack(vector<int>& nums,int index)
{
ans.push_back(path);
if (index == nums.size())
{
return;
}
for (int i = index; i < nums.size(); i++)
{
path.push_back(nums[i]);
backtrack(nums, i + 1);
path.pop_back();
}
}
};
双递归写法,横向只有选和不选两种情况。到叶子节点接收全部结果。
class Solution {
public:
vector<vector<int>> ans;
vector<int> path;
vector<vector<int>> subsets(vector<int>& nums) {
backtrack(nums,0);
return ans;
}
void backtrack(vector<int>& nums,int index)
{
if (index == nums.size())
{
ans.push_back(path);
return;
}
path.push_back(nums[index]); //选中index的数
backtrack(nums, index + 1);
path.pop_back();
backtrack(nums, index + 1);
}
};
491. 递增子序列(子集问题 不排序控制同层重复)
给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是2。
示例:
输入: [4, 6, 7, 7]
输出: [[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]
重点:
1.不能用排序后used[i] == false来去重,因为只要排序了,就是改变了原始的递增性。(这个例子给了个递增的就是坑人用的)
2.所以用一个set来记录每个for(每一层)的元素使用情况。
3.剪枝条件:不递增(nums[i] < path.back()) 或 元素已出现在同层中。
4.set在退出一层时不用pop。因为等于说是每个set管理一层for,你退出一层,外面还是有一个之前的set来维护 同层使用情况。
class Solution {
public:
vector<vector<int>> ans;
vector<int> path;
vector<vector<int>> findSubsequences(vector<int>& nums) {
if (nums.empty())
return ans;
backtrack(nums, 0);
return ans;
}
void backtrack(vector<int>& nums,int index)
{
if(path.size() >= 2)
ans.push_back(path);
if (index == nums.size())
{
return;
}
unordered_set<int> uset; //记录本层元素使用情况
for (int i = index; i < nums.size(); i++)
{
//剪枝:如果不是递增序列, 或者 已经在同层中(即path中的同位置)重复使用过这个元素
if (!path.empty() && nums[i] < path.back() || uset.find(nums[i]) != uset.end() )
continue;
uset.insert(nums[i]);
path.push_back(nums[i]);
backtrack(nums, i + 1);
path.pop_back();
//uset不用pop 因为推出之后回到上一层的元素使用情况
}
}
};
332. 重新安排行程(图论 map嵌套表示邻接表)
题意:
给定一个机票的字符串二维数组 [from, to],子数组中的两个成员分别表示飞机出发和降落的机场地点,对该行程进行重新规划排序。所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。
提示:
如果存在多种有效的行程,请你按字符自然排序返回最小的行程组合。例如,行程 [“JFK”, “LGA”] 与 [“JFK”, “LGB”] 相比就更小,排序更靠前
所有的机场都用三个大写字母表示(机场代码)。
假定所有机票至少存在一种合理的行程。
所有的机票必须都用一次 且 只能用一次。
样例:
输入:[[“JFK”,“SFO”],[“JFK”,“ATL”],[“SFO”,“ATL”],[“ATL”,“JFK”],[“ATL”,“SFO”]]
输出:[“JFK”,“ATL”,“JFK”,“SFO”,“ATL”,“SFO”]
解释:另一种有效的行程是 [“JFK”,“SFO”,“ATL”,“JFK”,“ATL”,“SFO”]。但是它自然排序更大更靠后
难点:
一是如何拆环,想到了用邻接表,但是题解的map嵌套非常妙。实际上用一个Index来控制邻接表的指针也可以做。
二是如何返回,这里返回了一个Bool值。这个方法是通用的,当dfs只需要返回一个正确结果时,就需要返回一个返回值;否则如果需要遍历所有节点,就返回void。
class Solution {
public:
//unordered_map<出发机场,map<到达机场,航班次数>> targets 航班次数用来看有没有飞过
unordered_map<string, map<string, int>> targets;
vector<string> ans;
vector<string> findItinerary(vector<vector<string>>& tickets) {
targets.clear();
for (vector<string>& vec : tickets)
targets[vec[0]][vec[1]]++; //记录映射关系
ans.push_back("JFK");
backtrack(tickets);
return ans;
}
bool backtrack(vector<vector<string>>& tickets)
{
if (ans.size() == tickets.size() + 1)
return true;
//map中自动排序了字典序
for (pair<const string, int>& target : targets[ans[ans.size() - 1]]) //遍历当前这个站的下一站
{
if (target.second > 0) //若到达站暂时还没有飞过
{
ans.push_back(target.first);
target.second--;
if (backtrack(tickets)) //如果下一层找到正确路径,提前结束向上返回
return true;
ans.pop_back();
target.second++;
}
}
return false;
}
};
51. N 皇后(棋盘问题 二维矩阵按行遍历)
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
输入:4
输出:[
[".Q…", // 解法 1
“…Q”,
“Q…”,
“…Q.”],
["…Q.", // 解法 2
“Q…”,
“…Q”,
“.Q…”]
]
解释: 4 皇后问题存在两个不同的解法。
难点:
把具体棋盘拜访问题抽象化,以及合法性拜访函数的写法。
class Solution {
public:
vector<vector<string>> ans;
vector<vector<string>> solveNQueens(int n) {
vector<string> chessboard(n);
string str;
for (int i = 0; i < n; i++)
str += '.';
for (int i = 0; i < n; i++)
chessboard[i] = str;
backtrack(n, 0, chessboard);
return ans;
}
private:
void backtrack(int n,int row,vector<string>& chessboard)
{
if (row == n) //检索完了,收集叶子节点结果
{
ans.push_back(chessboard);
return;
}
for (int i = 0; i < n; ++i)
{
if (IsOk(i, row, chessboard, n)) //如果合法就可以放置
{
chessboard[row][i] = 'Q';
backtrack(n, row + 1, chessboard);
chessboard[row][i] = '.'; //撤销王后
}
}
}
bool IsOk(int col, int row, vector<string>& chessboard, int n)
{
//检查列
for (int i = 0; i < row; i++)
if (chessboard[i][col] == 'Q')
return false;
//检查45度对角线(往斜上检查即可,斜下还没处理不用管)
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; --i, --j)
if (chessboard[i][j] == 'Q')
return false;
//135度对角线
for (int i = row - 1, j = col+1; i >= 0 && j < n; --i, ++j)
if (chessboard[i][j] == 'Q')
return false;
return true;
}
};
37. 解数独(棋盘问题 行列遍历回溯)
和上面的题目类似,由于只要找到一个符合条件的解就立刻返回,所以backtrack函数返回一个Bool值。
唯一难点就在需要二维遍历,再在内部回溯。详见代码注释:
class Solution {
public:
void solveSudoku(vector<vector<char>>& board) {
backtrack(board);
}
private:
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 (char k = '1'; k <= '9'; k++)
{
if (IsOk(i, j, k,board)) //填入k是否合法
{
board[i][j] = k;
if (backtrack(board)) //如果找到一组合适的立刻返回
return true;
board[i][j] = '.';//回溯撤销
}
}
return false; // 9个数都试完了,都不行,那么就返回false
}
}
return true; //遍历完整个棋盘都没有返回false,说明找到一组合理的
}
bool IsOk(int row, int col, char k, vector<vector<char>>& board)
{
for (int j = 0; j < board.size(); ++j) //同行有重复
if (board[row][j] == k)
return false;
for (int i = 0; i < board.size(); ++i) //同列重复
if (board[i][col] == k)
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] == k)
return false;
}
}
return true;
}
};