算法训练营DAY30|332.重新安排行程、51. N皇后、37. 解数独_让你明白算法!的博客-CSDN博客
这篇文章的续作,也相当于二刷了,对于之前没写的题的题解做一个补充。
再次刷n皇后和解数独仍然具有一种陌生感,感觉仅仅是见到过,而对于思路没有一点进展,可能还是刷的太少,而第一题相当于第一次刷,这次我们对这三道题做一下讲解。
本文章与我近些日子写的一样也有一些我自己的扩展理解,而并不是“抄题解”
第一道:重新安排行程
332. 重新安排行程 - 力扣(LeetCode)https://leetcode.cn/problems/reconstruct-itinerary/description/
第一道困难题,这道题好像很多题解是根据深搜来讲的,但是深搜我还没有系统的刷过,而卡尔哥给出的思路是更适合回溯,所以这道题我也用回溯讲解。
思路:这道题看着很复杂,实际做起来呢?如果是回溯,我认为也是很复杂的,有一些细节想不到根本无法解题。
首先我们要有一个十分合适的容器来存储对于起始地点到<目的地,到达目的地的次数>这样一个映射,起始地点到目的地映射我们可以理解,那为什么要写成这样呢?为什么还要加一个次数?
这是我们首先要说的点,这主要是防止路程回绕,导致递归的无终止进行。一个地点,可以在这道题里成为起始点和目的地,这是我们要知道的。而我们需要知道,如果一个目的地作为一个起始点要到达的目标,应该被飞几次,明确这一点就不会死循环,就比如官方给出的示例2,有两个回绕,我们直接看可以知道,这就是两个地点的回绕,但是我们要告诉计算机两个地带你回绕一次就可以了不能一直绕,听到这里,可能大多数读者云里雾里的,但是没关系,后面的解答可以解决你的困惑,只需要对上面这个容器选择是为什么选这样的,有一个大概的印象即可,因为这里还要涉及到后面的返回值问题,所以我们放在后面说。
接下来我们在进行递归之前,对该容器初始化,该容器存储的是一个起始地点它要到达的目的地最多能飞几次,明确一下容器含义,我们开始初始化。
for(vector<string>ticket:tickets)targets[ticket[0]][ticket[1]]++;
ticket【0】就是我们的起始地点,而ticket【1】是我们的目的地,将每一个这样的键值对能使用的次数+1,我们使用ticket从题目给定的tickets里取数字,写成这种for形式仅仅是为了缩减代码,如果看不懂这个新特性可以查一下。
题目要求每次从“JFK”开始,所以把它先加到答案数组里,然后进行递归。
递归思路:当我们找到了答案数组里的元素等于当前票数+1返回答案,为什么要这样?
对于给定两个测试用例的观察就是这样,就比如是票是从a到b的,票只有一张,但是你答案要求的是返回两个城市去的顺序,所以肯定是a,b。所以答案的城市数目,一定是票数量+1。
再然后for循环,遍历的不是题给我们的票,而是从上次的票据来看,我们要根据上次票据的目的地也就是加进答案数组的城市,作为本次的起始地点,这个位置的确定我们就用当前答案数组里城市个数-1,就可以了,因为答案数组里城市个数减1就对应我们上一次票据存储进去的下标。
通过之前初始化的容器计数,如果当前票的目的地还能去,那么就把当前票上的目的地加入到答案数组里,这里做一下解释,我们知道必须从JFK开始作为起始地点,所以我们一开始进来就是找那个JFK的目的地,加入到答案数组,所以接下来的每一次遍历后的填数,实际上都是填写的以上一次填入的城市作为起始地点找的目的地城市。
举个例子,我们第一次进来根据targets[res[res.size()-1]]找到的是JFK,查容器可知,JFK对应的目的地是否还有可以去的次数,如果有那么进入判断,把该目的地加入进去答案数组里,不要误以为加进来的是.first所以加的是起始地点,你仔细观看,发现实则是目的地,因为pair的第二个部分是int类型。
如果你还是不能理解我说的是什么,去看官方给出的示例,输入和输出,仔细观看输出部分,结合输入部分来思考,为什么那个位置应该填那个城市,相信你就能想明白了。
如果有多个起始位置指定的目的地怎么办?
这个问题我放在后面和判别答案字典序一起说。
不要忘记加进来之后,使容器对应目的地能去的次数-1,然后接着递归,递归失败返回来时候,在递归代码下面写上回溯代码就可以了。
class Solution {
public:
unordered_map<string,map<string,int>>targets;
bool back(int n,vector<string>&res){
if(res.size()==n+1){
return true;
}
for(pair<const string,int>&target :targets[res[res.size()-1]]){
if(target.second>0){
res.push_back(target.first);
target.second--;
if(back(n,res))return true;
res.pop_back();target.second++;
}
}
return false;
}
vector<string> findItinerary(vector<vector<string>>& tickets) {
vector<string>res;
for(vector<string>ticket:tickets)targets[ticket[0]][ticket[1]]++;
res.push_back("JFK");
back(tickets.size(),res);
return res;
}
};
思路的分析我们就到这里,网上应该有比我们更加详细的思路分析,还有画图什么的。我主要做的是对于读者们解题时候可能遇到的各种想不通的问题的分析和解答。这一点在网上做的人很少,可以说几乎没有,大部分就是按照自己讲解思路,一贯到底,然后结束了。
我所想到的问题,想不通的点,也许就是各读者想要问的问题!
为什么把递归函数返回值写成void?
很多回溯、递归代码是不需要返回值的,比如求解排列组合问题,求解分割问题等,如果大家对这些有兴趣,可以看我往期题解,它们都是void返回值,那这道题为什么特殊呢?
其实不是这道题特殊,而是这类题特殊,刷过二叉树相关题的应该会有所了解,当题目要求我们仅返回一个解的时候,我们找到一个答案,不需要再向下找了,所以我们应该及时返回答案,所以用这种返回类型,当找到答案也就是if(res.size()==n+1)成立,直接返回true。
那为什么一次返回true会导致直接返回答案?
最低层提示找到了答案返回了true,会回溯到上一层,这样上一层的判断就会为true,然后返回true到上一层递归,以此类推,一层回溯一层,最终就返回去了。
下面的return false只是摆设吗?
这个相信很多人都想不明白或者没有想过,return false只是为了应付函数返回值吗?
仔细观察好像每次递归是发生在for循环里面,然后和一层一层递归下去的,而找到了答案后,肯定也从这个位置往上返回,看起来并不可能走到返回false上,但其实它很有作用,不信把它改成return true就知道了。在[["JFK","KUL"],["JFK","NRT"],["NRT","JFK"]]这个测试用例,通不过。
还记得之前说的容器的作用吗?没错它就是在目的地已经不能接着飞的时候,也就是产生过回绕的时候,及时跳出以避免死循环用的。它不是摆设,它必须写成return false,以代表跳出回绕且当前还没有找到正确答案,还需要接着寻找,返回true代表已经找到答案了,直接返回,而发生回绕并不意味着一定找到了答案。而针对于这个测试用例,我们发现JFK的目的地(按字典序)应该先飞往KUL,但是KUL没有要飞向的目的地,而跳出了循环,直接返回true,造成了答案的错误。此时应该回溯,它应该先飞往NRT,然后才能有解,这也是回溯的作用,而帮助递归做回溯的在本题来看正是这个return false!
字典序问题:
题目要求遇到多种答案时返回字典序靠前的,这里我们是怎么解决的?好像并没有体现在代码上啊
这里其实就是我们要注重容器的选择,外层我们使用无序的map做映射,而里层的目的地到次数的映射我们选用的是map做映射,map具有自动根据字典序排列的作用,又由于我们for循环填入的是答案数组起始的对应的目的地,所以是容器帮助我们填入了字典序靠前的城市答案。
这也就回答了前面说的答案,当如果一个起始位置指向了多个目的地,容器帮助我们从字典序较小的开始选择。
还有个要注意的点是,for循环里的pair<const string,int>&target,这里的引用不能省略,因为循环里要直接对target进行改动,而改动必须要影响到容器的实际数据,如果忘记写&会造成答案的错误。
差不多要说的点就这些,如果读者还有其他的疑问,可以写下来,我们一起讨论。
第二道题n皇后
51. N 皇后 - 力扣(LeetCode)https://leetcode.cn/problems/n-queens/description/很多题解都有关于n皇后的题解,这是一道经典的回溯棋盘问题,但是经典并不意味着对于第一次见到的读者来说就易做。
接下来我们来分析一下解题思路:
先想一下什么时候能到达递归的终止条件?也就是皇后填满棋盘的情况,也即是我们遍历的行此时到达了整个棋盘的最下一行,n==cheek.size,所以重要的是我们递归函数已经确定下来需要一个棋盘引用参数保存棋盘写入,和一个n代表本次遍历到哪一行,行数已经等于棋盘行数直接回溯(这是因为行数从0开始)。
当然如果你的参数定义为外部声明就不需要传参了,所以这不是必要传参,只是这些东西是一定要用到的。
然后说一下递归思路,递归次数决定树深度,递归内的for循环决定树宽度,这句话大家一定听过,就不多说了。for'循环里应该有什么呢?首先是对于当前位置的放皇后是否合法的判断,判断成功了放入皇后,没成功继续使列++,直到该行已经被放入了皇后,这个时候改动棋盘当前位置为皇后标记,然后进入下一次递归。
听着不是很难,我们看看判断部分,判断部分要保证同行同列两条对角线都不能存在皇后,同行已经由传参记录了,保证我们每次递归已经是传入下一行填入,这样避免了我们做无用功,即同一行添加皇后,所以不用特判。
然后是判断列。这个好做,就是遍历本行上面的各行,当前列是否有皇后,斜线判断有两条,千万不要忘记,一条是指向左上角的也就是【i-1】【j-1】还有一个指向右上角是【i-1】【j+1】
这一点需要特别关注一下。
class Solution {
public:
vector<vector<string>>res;
void back(int n,int row,vector<string>& chessboard){
if(n==row){
res.push_back(chessboard);return;
}
for(int col=0;col<n;++col){
if(isvaild(row,col,n,chessboard)){
chessboard[row][col]='Q';
back(n,row+1,chessboard);
chessboard[row][col]='.';
}
}
}
bool isvaild(int row,int col,int n,vector<string>& chessboard){
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;
}
vector<vector<string>> solveNQueens(int n) {
vector<string>chessboard(n,string(n,'.'));
back(n,0,chessboard);
return res;
}
};
以上是能够通过的代码。
然后我们做一下疑问解释:
把行保存下来是用来判定递归返回条件的,而且不用判断行是否有重复的皇后,那么可不可以把列也保存下来,然后像行那样,每次都传进列+1,回溯时候再-1,这样是不是就不需要判断列了,以节省代码?
答:这是不行的,看起来好像行得通,但是行虽然是每一行都一定是遍历下一个行,找出一个合适的位置填皇后,但是列不是,它不一定每次都从上一行的列之后开始填入,当填写皇后时候,起初是试错,也就是说一开始填写的皇后位置,可能在那一层的时候是合法的,但是往后填皇后时候,可能导致某些皇后斜线冲突,而导致回溯。如果回溯多了,那么每一行填的皇后列数必然是不可能遵循每一行皇后都严格按照上一行皇后的列数+1位置排布的,而且仔细想一下,下一行填皇后如果是【i+1】【j+1】下标填的话,那也一定是不对的,会有斜线冲突,所以这本来就不成规律性。
根据官方所给测试样例,也可知,答案皇后的填写位置看起来是无规律的。
所以不能保存列为参数每次传入,以找到位置,而是应该列每次从0开始,找位置插入。
拿4皇后举例,我们填入第一个皇后在第一行第一列的这种解就是不存在的,经过回溯之后,皇后填入在第一行第二个列,那么下一行我们就要禁止其他皇后填在第一列了吗?显然这是错误的
这道题没有太多的注意事项和难想的点,再说一个需要注意的就是判断部分,判断斜线的循环语句,每次开始是从传进来的行列下一个位置开始判断,也就是判断的是左上角延伸的那条斜线,那么就从【i-1】【j-1】开始看,如果是右上角延伸的线,那就从【i-1】【j+1】开始判断,而不是从传进来的数字开始判断,因为当前位置填的皇后,你把当前位置当作重复皇后位置,那么一定是一个解也搜不出来的。
第三道题解数独
37. 解数独 - 力扣(LeetCode)https://leetcode.cn/problems/sudoku-solver/description/回溯经典解棋盘问题,不过对比于n皇后这道题要更难一些。
这道题不需要像n皇后那样,用行数判断是否返回答案,而且一行可能需要多次填数才可以,所以不把行做参数传入,下一次递归也不知道从行的哪一列填入,于是列也不保存,那该怎么遍历填数呢?
用一个双for循环,来看当前哪个位置没有被填入数据,我们就填哪个!这是一个二维递归,当某时判断的位置为空,我们开始考虑填数,填数只能从‘1’——‘9’中选择一个填进去,判断规则是同一行同一列不能有相同数,这个好判断我们不说这个,在3*3格内我们判断不能有同数出现该怎么做?判断部分代码传参为行列数,用startrow代表该位置所在的九宫格内的起始行,startcol代表该位置所在九宫格内的起始列,startrow=row/3*3,startcol=col/3*3
随便举个例子,行除3的意思是判该行在第几个九宫格内,比如第一行/3等于0,就是第0个九宫格内,第5行除3等于1,就是第一个九宫格内,然后×3得到的即是该九宫格起始行,列也是相同的道理
class Solution {
public:
bool back(vector<vector<char>>& board){
for(int i=0;i<board.size();++i){
for(int j=0;j<board[0].size();++j){
if(board[i][j]=='.'){
for(char k='1';k<='9';++k){
if(isvaild(i,j,k,board)){
board[i][j]=k;
if(back(board))return true;
board[i][j]='.';
}
}
return false;
}
}
}
return true;
}
bool isvaild(int row,int col,char k,vector<vector<char>>& board){
for(int i=0;i<9;++i)if(board[row][i]==k)return false;
for(int i=0;i<9;++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;
}
void solveSudoku(vector<vector<char>>& board) {
back(board);
return;
}
};
这道题看着答案感觉思路很清晰。
疑问解答:
为什么这道题也需要返回bool类型,这个解释在上面的第一道题已经说过了,我们需要找一个答案就返回bool,而n皇后需要找出所有答案,所以不能用bool返回,会丢解。
这道题中的递归部分在填数的后面写成return false有用吗?
这个是本道题需要重点说的,它十分有用,它必须要写,原因是因为可能之前填数的没有问题,但是之后某一位置填数出现了1——9这些数字都填不了那个位置的情况就需要向上回溯了,所以一定要有这个返回。如果没有那么将得到一个错误答案,也就是某些位置不能被正确填入,但是它仍然向下递归
这样三道题都讲完了,除了第一道题难一些之外我认为后两道题多练一练并不是很难,这里需要掌握的是什么时候递归返回值需要写成bool类型,这是为了什么?这个很重要对于解题。
本期内容就到这里
如果对您有用的话别忘了一键三连哦,如果是互粉回访我也会做的!
大家有什么想看的题解,或者想看的算法专栏、数据结构专栏,可以去看看往期的文章,有想看的新题目或者专栏也可以评论区写出来,讨论一番,本账号将持续更新。
期待您的关注