代码随想录算法训练营Day30 | 332. 重新安排行程 | 51. N皇后 | 37. 解数独 | 总结

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(n2n)
    • 排列问题:
      • 共有 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 从而在遇到结果时可以直接返回,这个用法很少见但是很实用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值