332.重新安排行程
经典的 DFS 问题,同样也需要回溯。本题有几个子问题需要分别解决:
1)存在多种解法时,需要优先选择名称按自然排序更小的解法
2)使用一张机票之后,就不能再次使用
3)使用一张机票之后,当前的出发地就变更了;要如何遍历以当前出发地为起始的机票
其中 1)和 3)都涉及了记录(出发地,目的地)的映射关系。
3)的解决方法是保持一个(出发地,目的地)的 mapping(比如字典),其中 key 为一个机场,value 为 key 所能到达的所有机场的 list。注意这里需要记录所有的机场作为出发地的情况,即使有的机场在机票中没有作为出发地出现过,否则会出现找不到 key 的情况。
2)的解决方法是在 mapping 中对每个 value 进行排序。这里默认的 sort()
就能达成题目要求,但我还是详细写出了 explicit 的排序方法,cmp_to_key()
的用法第一次学会!
2)的解决方法是记录一个和之前的 mapping 结构完全相同的 mapping,区别是对于每个机场 key,该 mapping 记录了相对应的目的地的使用记录。例如,初始状态下,
ticket_records = {'JFK': ['A', 'B', 'C']}
ticket_use = {'JFK': [False, False, False]}
更新该使用记录,可以保证不会重复使用同一张机票。
除此之外,还要考虑回溯的终止条件。显然,如果能够找到一个结果,其使用了所有的机票,那么就是要找的答案。此时,ticket_use
中的所有记录均为 True
,代表着所有(起始地,目的地)都已经被使用过了。但这种方法每次都要遍历一个 {str: [str]}
的字典,复杂度太高。更简单的方法是在参数中加入一个使用过的机票数量的参数,当该参数等于输入的 list 的长度,即代表着当前结果就是最终结果。
在终止条件中,找到符合条件的结果就该直接返回。这里我用了一个全局的 return_flag
来控制,好用但有些多余。优化的方法是使得 backtrack
返回一个 bool,如果找到了解就直接返回 True。如此一来,在 for loop 可以根据 backtrack
的返回值判断是否直接结束。
from functools import cmp_to_key
def compareTicket(place1: str, place2: str) -> int:
# used with "key" in sort()
if ord(place1[0]) != ord(place2[0]):
return ord(place1[0]) - ord(place2[0])
elif ord(place1[1]) != ord(place2[1]):
return ord(place1[1]) - ord(place2[1])
else:
return ord(place1[2]) - ord(place2[2])
class Solution:
def __init__(self):
self.curr_record = []
self.ticket_records = {}
self.ticket_use = {}
self.return_flag = False
def record_tickets(self, tickets: List[List[int]]):
for ticket in tickets:
if ticket[0] not in self.ticket_records:
self.ticket_records[ticket[0]] = [ticket[1]]
else:
self.ticket_records[ticket[0]].append(ticket[1])
for ticket in tickets:
if ticket[1] not in self.ticket_records:
self.ticket_records[ticket[1]] = []
def backtrack(self, tickets: List[List[int]], start: str, num_used: int):
if num_used == len(tickets):
self.return_flag = True
return None
for i in range(len(self.ticket_records[start])):
if self.ticket_use[start][i] == False:
self.curr_record.append(self.ticket_records[start][i])
self.ticket_use[start][i] = True
self.backtrack(tickets, self.ticket_records[start][i], num_used + 1)
if self.return_flag:
break
self.curr_record.pop()
self.ticket_use[start][i] = False
def findItinerary(self, tickets: List[List[str]]) -> List[str]:
self.record_tickets(tickets)
# sort the dests corresponding to each start
for start in self.ticket_records:
self.ticket_records[start].sort(key=cmp_to_key(compareTicket))
# record the usage of each ticket [key, value[i]]
for start in self.ticket_records:
self.ticket_use[start] = [False] * len(self.ticket_records[start])
self.curr_record.append("JFK")
self.backtrack(tickets, "JFK", 0)
return self.curr_record
51. N皇后
N皇后要求 1)不能同行 2)不能同列 3)不能处于对角线。
棋盘式的数据是二维的,看似非常难。但皇后的攻击方式决定了每一行只能放置一个皇后(如果有解),所以二维的棋盘实际上是一维的回溯问题,依然可以抽象为一棵树,每一次子节点的选择就是在那一行选择放置皇后的位置。
本题要解决的子问题有两个:
1)如何根据之前的选择,决定当前行能够合法放置皇后的位置
2)如何在回溯时逆转当前放置的皇后所造成的影响
这两个问题实际都指向了合法位置的判断:怎样判断当前行的合法位置?
2)说明了不能用一个全局的二维数组来记录全局的合法位置,因为没办法判断某一个位置的禁用是由于哪一行的皇后,也就没办法进行回溯。这暗示了当前行的合法判断最好是一个当前行的本地变量来记录。
1)可以根据当前记录 curr_record
知道当前路径已经放置过的皇后,从而判断出当前行还有哪些位置是合法的。由于是当前行的本地变量,就不必回溯当前行放置的皇后造成的影响。
class Solution:
def __init__(self):
self.curr_record = []
self.results = []
def currAvailable(self, n: int, curr_row: int) -> List[bool]:
result = [True] * n
for col in self.curr_record:
result[col] = False
for i in range(len(self.curr_record)):
row_diff = abs(i - curr_row)
if self.curr_record[i] + row_diff < n:
result[self.curr_record[i] + row_diff] = False
if self.curr_record[i] - row_diff >= 0:
result[self.curr_record[i] - row_diff] = False
return result
def backtrack(self, n: int, row: int):
if len(self.curr_record) == n:
curr_result = [["."] * n for _ in range(n)]
final_curr_result = []
for i in range(n):
curr_result[i][self.curr_record[i]] = "Q"
for i in range(n):
final_curr_result.append("".join(curr_result[i]))
self.results.append(final_curr_result)
return None
curr_col_available = self.currAvailable(n, row)
for col in range(n):
if curr_col_available[col]:
self.curr_record.append(col)
self.backtrack(n, row + 1)
self.curr_record.pop()
def solveNQueens(self, n: int) -> List[List[str]]:
self.backtrack(n, 0)
return self.results
37. 解数独
本题和上一题最大的区别在于,N皇后本质还是一个一维的递归,但是数独是一个真正的二维结构。但不变的是,由于每个二维位置能够填写的数是有限的,所以抽象成树结构还是很容易。
显然,本题最大的子问题是判断当前位置的合法数字选择:对于一个空位置 [i, j]
,能够放哪些数?由于只能填写 1-9 的数字,数组是一个合适的哈希选择,其中每个位置存放一个 bool 显示当前数字(对当前位置)是否合法。
和 332 一样,本题由于只需要一个合法答案,所以 backtrack
的返回值应该是一个 bool,从而在找到结果的时候可以直接返回,结束 for loop。这里设置的全局的 return_flag
也有效,但不如直接依靠返回值更快。
在回溯前,我做了一个搜索上的优化。原始的“二维递归”按照 [0, 0] - [8, 8]
的顺序遍历,而我先遍历当前所有没有填数的位置,选取合法数字最少的位置进行递归。这种方法在做数独的时候很有效,在递归中可以有效减少回溯的次数。
class Solution:
def __init__(self):
self.return_flag = False
def currAvailable(self, board: List[List[int]], row: int, col: int) -> List[bool]:
available = [True] * 9
for j in range(9): # row unique
if board[row][j] != '.':
available[int(board[row][j]) - 1] = False
for i in range(9): # column unique
if board[i][col] != '.':
available[int(board[i][col]) - 1] = False
# 3x3 block unique
curr_block_row, curr_block_col = row // 3, col // 3
for i in range(3 * curr_block_row, 3 * curr_block_row + 3):
for j in range(3 * curr_block_col, 3 * curr_block_col + 3):
if board[i][j] != '.':
available[int(board[i][j]) - 1] = False
return available
def backtrack(self, board: List[List[int]]):
best_position = []
best_choices = [True] * 9
search_flag = True
for i in range(9):
for j in range(9):
if board[i][j] == '.':
search_flag = False
curr_choices = self.currAvailable(board, i, j)
if sum(curr_choices) < sum(best_choices):
best_position = [i, j]
best_choices = curr_choices
if search_flag:
self.return_flag = True
return None
for i in range(9):
if best_choices[i]:
board[best_position[0]][best_position[1]] = str(i+1)
self.backtrack(board)
if self.return_flag:
break
board[best_position[0]][best_position[1]] = '.'
def solveSudoku(self, board: List[List[str]]) -> None:
"""
Do not return anything, modify board in-place instead.
"""
self.backtrack(board)
总结
回溯的应用场景:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式(切割线的组合)
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,解数独等等
组合
剪枝优化:在 for loop 的循环条件上优化范围,是回溯常见的剪枝。
start_idx
:一个集合来求组合的话,就需要 start_idx
;多个集合取组合,各个集合之间相互不影响,那么就不用 start_idx
。
在组合总和中,第一次遇到允许重复元素值的条件,由于还是同个集合求组合,所以要保留 start_idx
,只是递归时不用 +1。
在组合总和II中,第一次遇到“输入数组中有重复元素值,但输出结果中不能有重复元素”。这里的(元素使用)去重就涉及了1)层次去重;2)路径去重。比较统一的思路是用一个传递的参数 used 数组来记录使用情况:当 nums[i] = nums[i - 1]
时(出现重复值的元素)
1)used[i-1] == False
,同一父节点的同一层内出现重复使用
2)used[i-1] == True
,当前路径上出现相同值的重复使用
多个集合求组合
不是同一集合求组合,不再需要 start_idx
,而是通过当前元素在输入中的位置判断当前需要遍历的集合。
切割
切割问题实际上是切割线的组合:将切割线的位置视作元素,有多少符合条件的组合,即能得到切割后的集合。
由于切割问题涉及到了截取区间,所以要保持不变量!
子集问题
子集和组合的不同在于,子集问题需要收集所有(或符合条件)的节点作为结果,而组合问题的结果都在叶子节点上。
子集问题也是去重的重灾区,used 数组依然是解决层次去重和路径去重要求的统一解法。在简单的子集两题中,可以在排序后进行层次去重:只处理第一次在同一层见到的元素值。
而在递增子序列中,不能对输入进行排序,那么就只能在当前层中记录该层(for loop)中使用过的元素,注意这个记录不需要跟随路径进行递归+回溯,是个“本层变量”。本题是区分层次去重和路径去重的重点题目!很好地梳理了递归+回溯中“去重”究竟在做什么。
注意子集问题一定要排序,否则就像递增子序列一样维护额外的本层变量。不排序的反例如下(90. 子集II)
排列
排列问题是有序的,所以不能通过 start_idx
在递归中建立单调性;相反,排列问题总是从头开始搜索,依靠元素的使用记录来进行路径上的去重。
万能的 used 数组在排列问题中依然可以去重。根据重复值的元素的前一项是否被使用过,可以判断当前的元素重复是出现在 path 上还是出现在同一层上。
注意的是 47. 全排列 II,本题中在使用 used 数组判断重复时,利用路径去重和层次去重都能完成,但还是正常的层次去重更快。运用之妙,存乎一心啊。
used 记录是一个典型的哈希,记录了(元素:是否使用)的数据,也就要考虑用数组还是 set 来进行哈希的实现。这里使用数组的复杂度会低很多,因为有很多插入、删除元素的操作,set 执行这些操作非常耗时。另外,数组是作为一个全局变量进行修改和传递的,复杂度为 O ( n ) O(n) O(n);但是 set 每层递归都要创建一个新的栈,总体复杂度为 O ( n 2 ) O(n^2) O(n2)。
另外我自己找到了另一种处理两种情况的去重方法,见下。
自己开发的双重去重
- 路径去重是发生在当前 path 上的,需要记录数组随着函数递归+回溯,也就是之前所有的递归+回溯套路。路径去重通常针对重复的元素,但可以接受重复的元素值。
- 但是单一的路径去重无法处理输入集合中有重复的情况,会生成重复的答案。
path_used
:一个回溯函数的参数
- 层次去重是发生在同一父节点的同一层内,层次内的元素值使用记录是个跟随 for loop 的本地变量,不需要递归/回溯。层次去重通常针对重复的元素值。
- 层次去重在遇到任何当前 loop 内已经遇到过的值时,都会直接跳过。
loop_used
:一个回溯函数中 for loop 内更新的本地参数loop_used
是一个典型的哈希,记录了(元素:是否使用)的数据,也就要考虑用数组还是 set 来进行哈希的实现。
复杂度分析
- 时间复杂度
- 子集问题:
- 一共有 2 n 2^n 2n 个子集,其中每一步操作都在不同子集之间互相转换(递归+回溯)
- 在叶子节点上找到子集后,要加入结果,每一次用时 O ( n ) O(n) O(n)
- 时间复杂度为 O ( n ⋅ 2 n ) O(n\cdot2^n) O(n⋅2n)
- 排列问题:
- 共有 n ! n! n! 个排列,每个排列加入最终结果需要 O ( n ) O(n) O(n)
- 时间复杂度为 O ( n ! ) O(n!) O(n!)
- 组合问题:
- 是子集问题的一种,所以也有相同的时间复杂度
- 子集问题:
- 空间复杂度
- 递归深度为 n,所以栈所用空间是 n;每一层递归的空间都是常数(如果有 used 记录,数组也是全局变量)
- 空间复杂度为 O ( n ) O(n) O(n)
HARD
重新安排行程 是一道非常经典的 DFS 问题,重点是能够拆分其子问题并且一一解决。
N皇后 是第一次遇到处理二维数据的回溯。但由于题目规则的特殊性,实际上还是一维的递归+回溯。本题的重点是要意识到用一个本地变量来记录合法的放置位置,从而进行正确的 for loop 遍历和正确的回溯,这一点在之前的回溯中很少遇到。
解数独 是真正意义上处理二维的递归+回溯,虽然本质上树的节点选取没有发生变化。值得注意的是,本题和 332 的回溯函数都可以(应该)返回 bool 从而在遇到结果时可以直接返回,这个用法很少见但是很实用。