前言:
算法训练系列是做《代码随想录》一刷,个人的学习笔记和详细的解题思路,总共会有60篇博客来记录,计划用60天的时间刷完。
内容包括了面试常见的10类题目,分别是:数组,链表,哈希表,字符串,栈与队列,二叉树,回溯算法,贪心算法,动态规划,单调栈。
博客记录结构上分为 思路,代码实现,复杂度分析,思考和收获,四个方面。
如果这个系列的博客可以帮助到读者,就是我最大的开心啦,一起LeetCode一起进步呀;)
目录
直觉上来看 这道题和回溯法没有什么关系,更像是图论中的深度优先搜索。
实际上确实是深搜,但这是深搜中使用了回溯的例子,在查找路径的时候,如果不回溯,怎么能查到目标路径呢。
所以我倾向于说本题应该使用回溯法,那么我也用回溯法的思路来讲解本题,其实深搜一般都使用了回溯法的思路,在图论系列中我会再详细讲解深搜。
这里就是先给大家拓展一下,原来回溯法还可以这么玩!
这道题目有几个难点:
针对以上问题我来逐一解答!
如何理解死循环?
为什么要举这个例子呢,就是告诉大家,出发机场和到达机场也会重复的,如果在解题的过程中没有对集合元素处理好,就会死循环;
记录映射关系?
有多种解法,字母序靠前排在前面,让很多同学望而退步,如何该记录映射关系呢 ?
一个机场映射多个机场,机场之间要靠字母序排列,一个机场映射多个机场,可以使用std::unordered_map,如果让多个机场之间再有顺序的话,就是用std::map 或者std::multimap 或者 std::multiset。
如果对map 和 set 的实现机制不太了解,也不清楚为什么 map、multimap就是有序的同学,可以看这篇文章**关于哈希表,你该了解这些! (opens new window)**。
这样存放映射关系可以定义为 unordered_map
或者 unordered_map
。
含义如下:
unordered_map
unordered_map
这两个结构,我选择了后者,因为如果使用unordered_map
遍历multiset的时候,不能删除元素,一旦删除元素,迭代器就失效了。
再说一下为什么一定要增删元素呢,正如开篇我给出的图中所示,出发机场和到达机场是会重复的,搜索的过程没及时删除目的机场就会死循环。
所以搜索的过程中就是要不断的删multiset里的元素,那么推荐使用unordered_map
。
在遍历 unordered_map<出发机场, map<到达机场, 航班次数>> targets
的过程中,可以使用"航班次数"这个字段的数字做相应的增减,来标记到达机场是否使用过了。
如果“航班次数”大于零,说明目的地还可以飞,如果如果“航班次数”等于零说明目的地不能飞了,而不用对集合做删除元素或者增加元素的操作。
相当于说我不删,我就做一个标记!
回溯法
这道题目我使用回溯法,那么下面按照我总结的回溯模板来:
- void backtracking(参数) {
- if (终止条件) {
- 存放结果;
- return;
- }
-
- for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
- 处理节点;
- backtracking(路径,选择列表); // 递归
- 回溯,撤销处理结果
- }
- }
本题以输入:[["JFK", "KUL"], ["JFK", "NRT"], ["NRT", "JFK"]为例,抽象为树形结构如下:
在讲解映射关系的时候,已经讲过了,使用unordered_map
来记录航班的映射关系,我定义为全局变量。
当然把参数放进函数里传进去也是可以的,我是尽量控制函数里参数的长度。
参数里还需要ticketNum,表示有多少个航班(终止条件会用上)。
代码如下:
- class Solution(object):
- def __init__(self):
- self.path = ["JFK"]
- self.tickets_dict = collections.defaultdict(list)
-
- def findItinerary(self, tickets):
-
- def backtracking(self,tickets,startPoint):
注意函数返回值我用的是bool!
我们之前讲解回溯算法的时候,一般函数返回值都是void,这次为什么是bool呢?
因为我们只需要找到一个行程,就是在树形结构中唯一的一条通向叶子节点的路线;所以找到了这个叶子节点了直接返回,这个递归函数的返回值问题我们在讲解二叉树的系列的时候,在这篇**二叉树:递归函数究竟什么时候需要返回值,什么时候不要返回值? (opens new window)**详细介绍过。
当然本题的targets和result都需要初始化;
拿题目中的示例为例,输入: [["MUC", "LHR"], ["JFK", "MUC"], ["SFO", "SJC"], ["LHR", "SFO"]] ,这是有4个航班,那么只要找出一种行程,行程里的机场个数是5就可以了;所以终止条件是:我们回溯遍历的过程中,遇到的机场个数,如果达到了(航班数量+1),那么我们就找到了一个行程,把所有航班串在一起了。
- if len(path) == len(tickets) + 1:
- return True
已经看习惯回溯法代码的同学,到叶子节点了习惯性的想要收集结果,但发现并不需要,本题的result相当于 回溯算法:求组合总和! (opens new window) 中的path,也就是本题的result就是记录路径的(就一条),在如下单层搜索的逻辑中result就添加元素了;
回溯的过程中,如何遍历一个机场所对应的所有机场呢?
C++ 语言特性的讨论:(一刷没看)
这里刚刚说过,在选择映射函数的时候,不能选择unordered_map
, 因为一旦有元素增删multiset的迭代器就会失效,当然可能有牛逼的容器删除元素迭代器不会失效,这里就不在讨论了。
可以说本题既要找到一个对数据进行排序的容器,而且还要容易增删元素,迭代器还不能失效。
所以我选择了unordered_map
来做机场之间的映射;
可以看出 通过unordered_map
里的int字段来判断 这个集合里的机场是否使用过,这样避免了直接去删元素;
Python 写法:
- # 单层搜索逻辑
- for _ in self.tickets_dict[startPoint]:
- # 必须及时删除,避免出现死循环
- endPoint = self.tickets_dict[startPoint].pop(0)
- self.path.append(endPoint)
- if self.backtracking(tickets,endPoint):
- return True
- self.tickets_dict[startPoint].append(endPoint)
- self.path.pop()
- import collections
- class Solution(object):
- def __init__(self):
- self.path = ["JFK"]
- self.tickets_dict = collections.defaultdict(list)
-
- def findItinerary(self, tickets):
- """
- :type tickets: List[List[str]]
- :rtype: List[str]
- """
- # 把ticket数组中的机票都放进dictionary里面
- for item in tickets:
- if item[0] in self.tickets_dict:
- self.tickets_dict[item[0]].append(item[1])
- else: self.tickets_dict[item[0]] = [item[1]]
- # 把每个出发地后面的数组排序
- for airport in self.tickets_dict:
- self.tickets_dict[airport].sort()
- self.backtracking(tickets,"JFK")
- return self.path
-
- def backtracking(self,tickets,startPoint):
- # 终止条件
- if len(self.path) == len(tickets)+1:
- return True
- # 单层搜索逻辑
- for _ in self.tickets_dict[startPoint]:
- # 必须及时删除,避免出现死循环
- endPoint = self.tickets_dict[startPoint].pop(0)
- self.path.append(endPoint)
- if self.backtracking(tickets,endPoint):
- return True
- self.tickets_dict[startPoint].append(endPoint)
- self.path.pop()
(自己分析的,不确定)
时间复杂度O(N logN )
N为tickets数组里面机票的个数,首先需要遍历存入数组中O(N),然后需要排序,O(NlogN),然后在backtracking里面,需要遍历每一个pair处理O(N);总体来说O(NlogN);
空间复杂度:O(N)
主要是递归的栈的深度,最大为O(N);
Python中的defaultdict():
1,collections.defaultdict类的介绍:
defaultdict是Python内建dict类的一个子类,第一个参数为default_factory
属性提供初始值,默认为None。它覆盖一个方法并添加一个可写实例变量。它的其他功能与dict相同,但会为一个不存在的键提供默认值,从而避免KeyError异常。
2,一般的dict类型会导致KeyError异常:
一般dict类型:
KeyError异常:
defaultdict类避免KeyError异常:
Reference:
本题学习时间:80分钟。
都知道n皇后问题是回溯算法解决的经典问题,但是用回溯解决多了组合、切割、子集、排列问题之后,之前解决的问题都是一维的回溯问题,遇到这种二维矩阵还会有点不知所措;
首先来看一下皇后们的约束条件:
确定完约束条件,来看看究竟要怎么去搜索皇后们的位置,其实搜索皇后的位置,可以抽象为一棵树;下面用一个 3 * 3 的棋盘,将搜索过程抽象为一棵树,如图:
从图中,可以看出,深度就是矩阵的高度N,宽度就是树形结构中就是矩阵的宽度N,那么我们用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了;
按照总结的如下回溯模板,我们来依次分析:
- void backtracking(参数) {
- if (终止条件) {
- 存放结果;
- return;
- }
- for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
- 处理节点;
- backtracking(路径,选择列表); // 递归
- 回溯,撤销处理结果
- }
- }
-
我依然是定义全局变量二维数组result来记录最终结果;
参数n是棋盘的大小,然后用row来记录当前遍历到棋盘的第几层了;
- class Solution:
- def solveNQueens(self, n: int) -> List[List[str]]:
- def backtracking(board, row, n):
在如下树形结构中:
可以看出,当递归到棋盘最底层(也就是叶子节点)的时候,就可以收集结果并返回了;代码如下:
- if row == n:
- temp_res = []
- for temp in board:
- temp_str = "".join(temp)
- temp_res.append(temp_str)
- res.append(temp_res)
递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置;每次都是要从新的一行的起始位置开始搜,所以都是从0开始;
- for col in range(n):
- if not isVaild(board, row, col):
- continue
- board[row][col] = 'Q'
- backtracking(board, row+1, n)
- board[row][col] = '.'
按照如下标准去重:
代码如下:
- def isVaild(board,row, col):
- #判断同一列是否冲突
- for i in range(len(board)):
- if board[i][col] == 'Q':
- return False
- # 判断左上角是否冲突
- i = row -1
- j = col -1
- while i>=0 and j>=0:
- if board[i][j] == 'Q':
- return False
- i -= 1
- j -= 1
- # 判断右上角是否冲突
- i = row - 1
- j = col + 1
- while i>=0 and j < len(board):
- if board[i][j] == 'Q':
- return False
- i -= 1
- j += 1
- return True
在这份代码中,细心的同学可以发现为什么没有在同行进行检查呢?
因为在单层搜索的过程中,每一层递归,只会选for循环(也就是同一行)里的一个元素,所以不用去重了;
- # 回溯算法 N皇后问题
- # time: O(N!);space:O(N)
- class Solution(object):
- def __init__(self):
- self.result = []
- self.board = []
- def solveNQueens(self, n):
- """
- :type n: int
- :rtype: List[List[str]]
- """
- if n==0: return []
- self.board = [["."] * n for _ in range(n)]
- self.backtracking(0,n)
- return self.result
-
- def backtracking(self,row,n):
- # 终止条件,如果走到最后一行,说明已经找到一个解
- if row == n:
- temp_res = []
- for temp in self.board:
- temp_str = "".join(temp)
- temp_res.append(temp_str)
- self.result.append(temp_res)
- for col in range(n):
- if not self.isValid(row,col,n):
- continue
- self.board[row][col] = "Q"
- self.backtracking(row+1,n)
- self.board[row][col] = "."
-
- def isValid(self,row,col,n):
- # 判断同一列
- for i in range(n):
- if self.board[i][col] == "Q":
- return False
- # 判断左上角那条线
- i = row -1
- j = col -1
- while i >= 0 and j>=0:
- if self.board[i][j] == "Q":
- return False
- i -= 1
- j -= 1
- # 判断右上角那条线
- i = row -1
- j = col +1
- while i >=0 and j
- if self.board[i][j] == "Q":
- return False
- i -= 1
- j += 1
- return True
3. 复杂度分析
-
时间复杂度:O(n!)
其实如果看树形图的话,直觉上是O(n^n),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!),n!表示n * (n-1) * .... * 1;
-
空间复杂度:O(n)
递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n);
4. 思考与收获
- 本题是我们解决棋盘问题的第一道题目,如果从来没有接触过N皇后问题的同学看着这样的题会感觉无从下手,可能知道要用回溯法,但也不知道该怎么去搜。这里我明确给出了棋盘的宽度就是for循环的长度,递归的深度就是棋盘的高度,这样就可以套进回溯法的模板里了;
Rreference:代码随想录 (programmercarl.com)
本题学习时间:60分钟
Leetcode37. 解数独
1. 思路
棋盘搜索问题可以使用回溯法暴力搜索,只不过这次我们要做的是二维递归。
怎么做二维递归呢?
对比之前的题?
大家已经跟着「代码随想录」刷过了如下回溯法题目,例如:77.组合(组合问题) (opens new window),131.分割回文串(分割问题) (opens new window),78.子集(子集问题) (opens new window),46.全排列(排列问题) (opens new window),以及**51.N皇后(N皇后问题) (opens new window)**,其实这些题目都是一维递归。
**N皇后问题 (opens new window)**是因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来来遍历列,然后一行一列确定皇后的唯一位置。
本题就不一样了,本题中棋盘的每一个位置都要放一个数字(而N换后是一行只放一个皇后),并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。
因为这个树形结构太大了,我抽取一部分,如图所示:
2. 代码实现
2.1 递归函数及参数
**递归函数的返回值需要是bool类型,为什么呢?**因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值;
- class Solution:
- def solveSudoku(self, board: List[List[str]]) -> None:
-
- def backtracking(self, board: List[List[str]]) -> bool:
2.2 递归终止条件
本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。
不用终止条件会不会死循环?
递归的下一层的棋盘一定比上一层的棋盘多一个数,等数填满了棋盘自然就终止(填满当然好了,说明找到结果了),所以不需要终止条件!
**那么有没有永远填不满的情况呢?**这个问题在递归单层搜索逻辑里在来讲!
2.3 递归单层搜索逻辑
在树形图中可以看出我们需要的是一个二维的递归(也就是两个for循环嵌套着递归);一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性;
- def backtracking(self, board: List[List[str]]) -> bool:
- # 若有解,返回True;若无解,返回False
- for i in range(len(board)): # 遍历行
- for j in range(len(board[0])): # 遍历列
- # 若空格内已有数字,跳过
- if board[i][j] != '.': continue
- for k in range(1, 10):
- if self.is_valid(i, j, k, board):
- board[i][j] = str(k)
- if self.backtracking(board): return True
- board[i][j] = '.'
- # 若数字1-9都不能成功填入空格,返回False无解
- return False
- return True # 有解
注意这里return false的地方,这里放return false 是有讲究的。
因为如果一行一列确定下来了,这里尝试了9个数都不行,说明这个棋盘找不到解决数独问题的解!那么会直接返回, 这也就是为什么没有终止条件也不会永远填不满棋盘而无限递归下去;
2.4 判断棋盘是否合法
判断棋盘是否合法有如下三个维度:
- 同行是否重复
- 同列是否重复
- 9宫格里是否重复
- def is_valid(self, row: int, col: int, val: int, board: List[List[str]]) -> bool:
- # 判断同一行是否冲突
- for i in range(9):
- if board[row][i] == str(val):
- return False
- # 判断同一列是否冲突
- for j in range(9):
- if board[j][col] == str(val):
- return False
- # 判断同一九宫格是否有冲突
- start_row = (row // 3) * 3
- start_col = (col // 3) * 3
- for i in range(start_row, start_row + 3):
- for j in range(start_col, start_col + 3):
- if board[i][j] == str(val):
- return False
- return True
2.5 整体代码实现
- # 回溯算法 解数独
- # time:O(9^m),m为“.”的数目;space:O(N^2)
- class Solution(object):
- def solveSudoku(self, board):
- """
- :type board: List[List[str]]
- :rtype: None Do not return anything, modify board in-place instead.
- """
- self.backtracking(board)
- return board
- def backtracking(self,board):
- for i in range(len(board)): # 遍历行
- for j in range(len(board[0])): # 遍历列
- if board [i][j] != "." : continue
- for k in range(1,10):
- if self.isValid(i,j,k,board):
- board[i][j] = str(k)
- if self.backtracking(board): return True
- board[i][j] = "."
- # 如果1-9都不能成功填入空格,return False无解
- return False
- # 两层for循环都遍历完了,表格全部填满了,有解了
- return True
- def isValid(self,row,col,val,board):
- # 判断同一行是否冲突
- for i in range(9):
- if board[row][i] == str(val):
- return False
- # 判断同一列是否冲突
- for j in range(9):
- if board[j][col] == str(val):
- return False
- # 判断同一个九宫格是否冲突
- startRow = (row//3)*3
- startCol = (col//3)*3
- for i in range(startRow,startRow+3):
- for j in range(startCol,startCol+3):
- if board[i][j] == str(val):
- return False
- return True
3. 复杂度分析
-
时间复杂度:O(9^m)
m是'.'的数目。
-
空间复杂度:O(n^2)
递归的深度是n^2,N为数独的长或者宽
4. 思考与收获
- 解数独可以说是非常难的题目了,如果还一直停留在单层递归的逻辑中,这道题目可以让大家瞬间崩溃,所以我在开篇就提到了二维递归,这也是Carl自创词汇,希望可以帮助大家理解解数独的搜索过程,一波分析之后,在看代码会发现其实也不难,唯一难点就是理解二维递归的思维逻辑。这样,解数独这么难的问题,也被我们攻克了。
Reference:代码随想录 (programmercarl.com)
本题学习时间:60分钟。
本篇学习时间为3个多小时,总结字数10000+;本篇的三道题都很难,重新安排行程给我们打开了回溯问题的其他思路;N皇后和解数独这两个棋盘问题,涉及到二维的回溯算法解题。(求推荐!)