今天是回溯部分的最后一天,题目都比较难,前面2题艰难AC,第3题费了很大功夫还是失败了,但也好在最终找到了错误原因。题目的重点都在于怎么样对递归是否成果进行判断,并如何对成功与否的结果进行相应处理。
第1题(332. 重新安排行程)自己艰难地AC。这道题用DFS更合适些,但还没刷到那部分,就先练习下回溯解法。按照自己的思路,首先对备选车票按照目的地的字典序排序。然后向存放结果的vector,res中添加"JFK",再用day 29中排列问题的解法对车票进行循环遍历和递归。当当前元素对应的used表为true,或当前车票不是上一张车票的后继时跳过。
方法比较简单,但经历了多次失败。起先是因为将递归函数出口像之前的题目一样直接写成了:
if (res.size() == tickets.size() + 1) {
return;
}
而其他地方也没有任何处理。如果写成这样,递归函数会返回到上一层,但上一层的回溯函数结束后,还会对res进行pop_back(),设置used表为false,遍历下一张车票······并不会跳出整个回溯过程,而是将所有push_back()和pop_back()成对进行完,所以最终res是仅包含"JFK"的。
于是在push_back()之后,回溯函数进行前的地方也放上了上面的代码。然而得到的res虽然不再是只有"JFK"了,但最末尾的元素却总还是不正确。这是因为如果将上面的代码放在回溯函数之前,那么虽能在得到答案后立即返回到上一层,但上一层的剩余代码还会继续运行。上一层又会进行pop_back(),并继续遍历下一张车票,所以最后一张车票就会出错。正确的方法应该是把上面的代码放在回溯函数之后。这样一来,一旦找到答案,递归函数就会层层向上返回,直至返回主函数。
class Solution {
public:
vector<string> res;
vector<bool> used;
void back(vector<vector<string>>& tickets) {
if (res.size() == tickets.size() + 1) {
return;
}
for (int i = 0; i < tickets.size(); ++i) {
if (used[i] || tickets[i][0] != res.back()) {
continue;
}
res.push_back(tickets[i][1]);
used[i] = true;
back(tickets);
if (res.size() == tickets.size() + 1) {
return;
}
used[i] = false;
res.pop_back();
}
return;
}
static bool cmp(vector<string> a, vector<string> b) {
return a[1] < b[1];
}
vector<string> findItinerary(vector<vector<string>>& tickets) {
sort(tickets.begin(), tickets.end(), cmp);
used.resize(tickets.size(), false);
res.clear();
res.push_back("JFK");
back(tickets);
return res;
}
};
最终虽然AC,但时间效率比较低。其中要注意cmp函数前要加static,否则会报错。
题解则是基本思路相同,但实现上没有用used表,而是用unordered_map<string, map<string, int>>的数据结构,来完成出发地到目的地的映射。第一个string为出发地,第二个string为目的地,int为车票数量。用这样一个数据结构既可以实现判断当前票是否可以使用(车票数量为0时不能使用,否则可以),又可以实现目的地按字典序排序(map的功能)。这里的map<string, int>理论上可以用multiset<string>替代,但在实践中,因为用multiset<string>的话需要在遍历multiset时,用了某张车票后要对其进行删除操作,而删除的话迭代器会失效,所以不能。
有了上面的数据结构之后,就可以在每层递归循环遍历时只对符合要求的,既出发地与上个目的地一致的车票遍历,从中选择车票数大于0的进行递归,并在递归前后分别对车票数减1和加1。
另外,题解在返回答案方面相较于我自己的实现方式,采用了一种更聪明简洁的方式,即将递归函数返回值设置为bool类型。一旦找到答案就返回true,并对每个递归函数的返回值都进行判断,如果为true,就也在当前层立即返回true,否则在函数结束时返回false。这一部分可以作为我自己方法的改进。另外,这里也说明自己对递归函数返回值部分掌握还不到位,需要返回去day 18中的总结复习下。
class Solution {
public:
vector<string> res;
unordered_map<string, map<string, int>> targets;
bool back(vector<vector<string>>& tickets) {
if (res.size() == tickets.size() + 1) {
return true;
}
for (pair<const string, int>& target : targets[res.back()]) {
if (target.second == 0) {
continue;
}
res.push_back(target.first);
target.second--;
if (back(tickets)) {
return true;
}
res.pop_back();
target.second++;
}
return false;
}
vector<string> findItinerary(vector<vector<string>>& tickets) {
for (const vector<string>& vec : tickets) {
targets[vec[0]][vec[1]]++;
}
res.clear();
res.push_back("JFK");
back(tickets);
return res;
}
};
需要注意,在以引用方式遍历一些元素不可更改的容器时,需要在前面加上const,否则会报错。比如map中的key是不可更改的,所以遍历时应该用这样的方式:
for (pair<const string, int>& target : targets[res.back()])
或者采用非引用方式:
for (pair<string, int> target : targets[res.back()])
这种解法用的的数据结构多且复杂, 容易在数据结构类型上搞错,需要仔细。
二刷:忘记返回值应该设置成bool而非void。实现和理解更简单的版本:
class Solution {
public:
vector<string> res;
vector<bool> used;
bool back(vector<vector<string>>& tickets) {
if (res.size() == tickets.size() + 1) {
return true;
}
for (int i = 0; i < tickets.size(); i++) {
if (!used[i] && tickets[i][0] == res[res.size() - 1]) {
used[i] = true;
res.push_back(tickets[i][1]);
if (back(tickets)) {
return true;
}
res.pop_back();
used[i] = false;
}
}
return false;
}
static bool cmp(vector<string>& t1, vector<string>& t2) {
if (t1[0] == t2[0]) {
return t1[1] < t2[1];
}
else {
return t1[0] < t2[0];
}
}
vector<string> findItinerary(vector<vector<string>>& tickets) {
res.clear();
res.push_back("JFK");
used.resize(tickets.size(), false);
sort(tickets.begin(), tickets.end(), cmp);
back(tickets);
return res;
}
};
第2题(51. N皇后)自己的解法是用回溯树的每一层代表棋盘的每一行,然后在递归函数中,遍历棋盘的每一列。还需要将已经确定位置棋子的位置保存下来,其中位置中的列可根据循环中的i确定,但行(即回溯树的层)无法直接确定,所以需要将其作为递归函数的一个参数。每遍历到一个位置,就根据已经保存下的位置来判断当前位置是否合法,如果合理就将当前位置也保存下来并递归,否则就尝试下一个位置。
判断当前位置是否合法有4个条件,其中前2个关于行或列是否重复的容易判断,但关于当前节点主对角线方向和副对角线方向上是否存在棋子,则需要归纳。副对角线(左上至右下)上元素的特点为其上任意两个点的横坐标之差与纵坐标之差相等;主对角线(左下至右上)上元素的特点为其上任意两个点的横坐标与纵坐标之和相等。利用这个信息就可实现当前节点是否合法的判断。
class Solution {
public:
vector<string> path;
vector<vector<string>> res;
vector<pair<int, int>> locs;
void back(int n, int row) {
if (path.size() == n) {
res.push_back(path);
return;
}
for (int col = 0; col < n; ++col) {
bool queen = false;
for (const pair<int, int>& loc : locs) {
if (loc.second == col || loc.first - row == loc.second - col || loc.first + loc.second == row + col) {
queen = true;
break;
}
}
if (queen) {
continue;
}
string s(n, '.');
s[col] = 'Q';
path.push_back(s);
locs.push_back(pair<int, int>(row, col));
back(n, row + 1);
locs.pop_back();
path.pop_back();
}
return;
}
vector<vector<string>> solveNQueens(int n) {
path.clear();
res.clear();
locs.clear();
back(n, 0);
return res;
}
};
题解的整体思路与自己的一致,但实现方法不同。题解使用了一个vector<string>来模拟棋盘,其上值为'Q'的代表放置了棋子,值为'.'的代表没有棋子。同样将当前所在的行作为参数传递,并将当前位置是否合法的判断抽象成了函数。在这个函数中,当前棋子的同一行是不用检查的(因为回溯会保证当前行只有当前棋子),只用检查当前列,还有2个对角线。在检查时,因为棋盘是按照从第一行到最后一行的顺序填充的,所以也只用检查当前行之前行的棋盘。
如果当前位置可以放置棋子,就将棋盘对应位置设置为'Q',再递归求解下一行棋子位置,再将棋盘对应位置恢复为'.'。否则就continue尝试当前行的下一列。
class Solution {
public:
vector<vector<string>> res;
vector<string> chessBoard;
bool isVaild(int n, int row, int col) {
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;
}
void back(int n, int row) {
if (row == n) {
res.push_back(chessBoard);
return;
}
for (int col = 0; col < n; ++col) {
if (!isVaild(n, row, col)) {
continue;
}
chessBoard[row][col] = 'Q';
back(n, row + 1);
chessBoard[row][col] = '.';
}
return;
}
vector<vector<string>> solveNQueens(int n) {
res.clear();
chessBoard.resize(n, string(n, '.'));
back(n, 0);
return res;
}
};
二刷:忘记题解中判断当前棋子与已有棋子是否在一条斜线上的循环次数更少的判断方法,以及由多个相同字符组成的string的初始化,可以用s.resize(n, c),c为char。
第3题(37. 解数独)与第2题(51. N皇后)是类似的问题,但还是难很多。自己尝试失败,结合了题解关于返回值的思路才AC。自己起初的思路是像第2题一样,将棋盘的每一行填充过程视为回溯树的一层,然后在每一层中再遍历当前行的每一列。这里就陷入了误区,第2题将每一行视为树的一层进行遍历尝试的原因是,每一行里棋子的位置只可能有一个,所以每一行所做的选择的数量也只有1。做完选择后就要做下一个选择,进入下一层递归,也对应棋盘的下一行。而在这一题中,棋盘的每一行对应9列,每一列都对应字符'1'~'9'的选择,所以每一行对应的选择是9个而非1个。所以应当以1个选择作为回溯树的一层,而非棋盘的一行。
所以应该从棋盘的第0行,第0列出发。遇到非'.'的已经填充好的数字就跳过,否则就开始对当前位置从'0'~'9'遍历尝试,判断'0'~'9'中的某个数字在当前位置是否合法。合法的话就将当前位置设置为对应数字字符,进入下一层递归,递归之后再重新将当前位置设置为'.'。不合法的话就跳过并尝试下一个数字。如此直至棋盘填满为止。
在这个过程中,需要在每一层都进行遍历。因为有些位置已经填了数字,我们并不知道当前位置是否已经被填充,所以就不能只是对当前位置遍历'0'~'9',而是要对行、列、数字都进行遍历,直到遇到没填充的位置,才进行'0'~'9'的遍历。不过当前行、列之前的位置一定是已经填充过数字的,所以自己起初根据当前列是否为行的末尾,计算出下一位置,直接将下一位置对应行、列传作参数进行下一层递归,预期这样就可以节省很多不必要的判断。但得到了错误的结果,结果中很多位置仍未被填充,为此排查了很久,最后找到原因。如果遇到传入的参数为row,col,而row行的col及其之后列已经填充了数字的话,那么就要开始遍历下一行,即row + 1行。但此时row + 1行却还是会从col列开始遍历,而不是第0列。这就导致row之后行的[0, col - 1]列都被跳过。所以想要节省不必要的判断的话,只能节省当前行以前的位置,而对于当前行,还是需要从0开始遍历尝试。
还有个问题就是如何判断棋盘填满,或下一层递归尝试是否成果很重要。因为要判断尝试是否成功,所以要将递归函数的返回值设置为bool类型,而如何判定返回true还是false又成为了核心问题。起初自己尝试将返回true的条件设置为当前行为第9行时(从0开始),因为此时棋盘已经遍历结束,说明棋盘被填满。但这个做法也是错误的,原因与上一段类似,如果最后一行的最后一列位置已经被填充,那么无法进入下一层递归,row也就无法变为9,也就不会返回true,找不到正确答案了。那么正确的做法是应该像题解一样,如果回溯函数返回值为true,就也立即返回true。而如果尝试了9个数字都不对的话,就返回false。而如果棋盘成果遍历结束的话就返回true,说明棋盘已经填满。
class Solution {
public:
bool isValid(vector<vector<char>>& board, int row, int col, char num) {
int rowBegin = row / 3, colBegin = col / 3;
rowBegin *= 3, colBegin *= 3;
for (int i = rowBegin; i < rowBegin + 3; ++i) {
for (int j = colBegin; j < colBegin + 3; ++j) {
if (board[i][j] == num) {
return false;
}
}
}
for (int i = 0; i < 9; ++i) {
if (board[i][col] == num || board[row][i] == num) {
return false;
}
}
return true;
}
bool back(vector<vector<char>>& board, int rowBegin) {
for (int row = rowBegin; row < 9; ++row) {
for (int col = 0; col < 9; ++col) { // 这里要从0开始
if (board[row][col] != '.') {
continue;
}
for (char num = '1'; num <= '9'; ++num) {
if (!isValid(board, row, col, num)) {
continue;
}
board[row][col] = num;
if (col == 8 && back(board, row + 1) || col != 8 && back(board, row)) {
return true;
}
board[row][col] = '.';
}
return false;
}
}
return true;
}
void solveSudoku(vector<vector<char>>& board) {
back(board, 0);
return;
}
};
也可以像题解一样无论当前的行、列,每个递归的遍历都从第0行第0列开始。
二刷:判断填充是否合法的函数中,判断当前3*3区域时写错起始行、列下标,把row / 3 * 3少写“ / 3”。另外用的“在每个回溯中把位置作为参数,迭代要填充的数字”这种方式因为在当前位置已经被填充时,不能直接把下一个位置作为参数传递到下一层回溯函数(因为下一个位置也可能已经被填充),所以太麻烦。还是题解中的在回溯中循环遍历整个棋盘更简单方便。
class Solution {
public:
bool isOK(vector<vector<char>>& board, int row, int col, char c) {
for (int i = 0; i < 9; i++) {
if (board[i][col] == c || board[row][i] == c) {
return false;
}
}
int beginRow = row / 3 * 3, beginCol = col / 3 * 3;
for (int i = beginRow; i < beginRow + 3; i++) {
for (int j = beginCol; j < beginCol + 3; j++) {
if (board[i][j] == c) {
return false;
}
}
}
return true;
}
bool back(vector<vector<char>>& board) {
for (int i = 0; i < 9; ++i) {
for (int j = 0; j < 9; ++j) {
if (board[i][j] != '.') {
continue;
}
for (char c = '1'; c <= '9'; ++c) {
if (!isOK(board, i, j, c)) {
continue;
}
board[i][j] = c;
if (back(board)) {
return true;
}
board[i][j] = '.';
}
return false;
}
}
return true;
}
void solveSudoku(vector<vector<char>>& board) {
back(board);
}
};