【编程之路】面试必刷TOP101:递归 / 回溯(55-61,Python实现)

【面试必刷TOP101】系列包含:


55.没有重复项数字的全排列

如果是线型递归,子问题直接回到父问题不需要回溯,但是如果是树型递归,父问题有很多分支,我需要从子问题回到父问题,进入另一个子问题。因此回溯是指在递归过程中,从某一分支的子问题回到父问题进入父问题的另一子问题分支,因为有时候进入第一个子问题的时候修改过一些变量,因此回溯的时候会要求改回父问题时的样子才能进入第二子问题分支。

这道题目就是很典型的回溯类题目。回溯其实也是暴力解法,但是又一些题目可以通过剪枝对算法进行优化,这道题目要找出所有的排列,其实还是比较简单的。算法的思路主要就是:选择与撤销

例如:1开头的有,[1,2,3],接着3撤销,2撤销,然后选择3,再选择2,就有了[1,3,2]。
在这里插入图片描述
全排列就是对数组元素交换位置,使每一种排列都可能出现。因为题目要求按照字典序排列输出,那毫无疑问第一个排列就是数组的升序排列,它的字典序最小,后续每个元素与它后面的元素交换一次位置就是一种排列情况,但是如果要保持原来的位置不变,那就不应该从它后面的元素开始交换而是从自己开始交换才能保证原来的位置不变,不会漏情况。

如何保证每个元素能和从自己开始后的每个元素都交换位置,这种时候我们可以考虑递归。为什么可以使用递归?我们可以看数组[1,2,3,4],如果遍历经过一个元素2以后,那就相当于我们确定了数组到该元素为止的前半部分,前半部分1和2的位置都不用变了,只需要对3,4进行排列,这对于后半部分而言同样是一个全排列,同样要对从每个元素开始往后交换位置,因此后面部分就是一个子问题。那我们考虑递归的几个条件:
(1)终止条件: 要交换位置的下标到了数组末尾,没有可交换的了,那这就构成了一个排列情况,可以加入输出数组。
(2)返回值: 每一级的子问题应该把什么东西传递给父问题呢,这个题中我们是交换数组元素位置,前面已经确定好位置的元素就是我们返还给父问题的结果,后续递归下去会逐渐把整个数组位置都确定,形成一种排列情况。
(3)本级任务: 每一级需要做的就是遍历从它开始的后续元素,每一级就与它交换一次位置。

如果只是使用递归,我们会发现,上例中的1与3交换位置以后,如果2再与4交换位置的时候,我们只能得到3412这种排列,无法得到1432这种情况。这是因为遍历的时候1与3交换位置在2与4交换位置前面,递归过程中就默认将后者看成了前者的子问题,但是其实我们1与3没有交换位置的情况都没有结束,相当于上述图示中只进行了第一个分支。因此我们用到了回溯。处理完1与3交换位置的子问题以后,我们再将其交换回原来的情况,相当于上述图示回到了父节点,那后续完整的情况交换我们也能得到。
在这里插入图片描述
step 1:先将数组排序,获取字典序最小的排列情况。
step 2:递归的时候根据当前下标,遍历后续的元素,交换二者位置后,进入下一层递归。
step 3:处理完一分支的递归后,将交换的情况再换回来进行回溯,进入其他分支。
step 4:当前下标到达数组末尾就是一种排列情况。

class Solution:
    def recursion(self, res: List[List[int]], num: List[int], index: int):
        if index == len(num)-1:
            res.append(num.copy())
        else:
            # 遍历后续的元素
            for i in range(index,len(num)):
                # 交换位置
                num[i], num[index] = num[index], num[i]
                # 递归
                self.recursion(res,num,index+1)
                # 回溯
                num[i], num[index] = num[index], num[i]

                
    def permute(self , num: List[int]) -> List[List[int]]:
        num.sort()
        res = list(list())
        self.recursion(res,num,0)
        return res

在这里插入图片描述

时间复杂度: O ( n ∗ n ! ) O(n*n!) O(nn!),n 个元素的数组进行全排列的递归,每次递归都要遍历数组。
空间复杂度: O ( n ) O(n) O(n),递归栈的最大深度为数组长度 n,res 属于返回必要空间。

56.有重复项数字的全排列

这道题类似 没有重复项数字的全排列,但是因为交换位置可能会出现相同数字交换的情况,出现的结果需要去重,因此不便于使用交换位置的方法。

我们就使用临时数组去组装一个排列的情况:每当我们选取一个数组元素以后,就确定了其位置,相当于对数组中剩下的元素进行全排列添加在该元素后面,给剩余部分进行全排列就是一个子问题,因此可以使用递归。
(1)终止条件: 临时数组中选取了n个元素,已经形成了一种排列情况了,可以将其加入输出数组中。
(2)返回值: 每一层给上一层返回的就是本层级在临时数组中添加的元素,递归到末尾的时候就能添加全部元素。
(3)本级任务: 每一级都需要选择一个不重复元素加入到临时数组末尾(遍历数组选择)。

回溯的思想也与没有重复项数字的全排列类似,对于数组[1,2,2,3],如果事先在临时数组中加入了1,后续子问题只能是[2,2,3]的全排列接在1后面,对于2开头的分支达不到,因此也需要回溯:将临时数组刚刚加入的数字pop掉,同时vis修改为没有加入,这样才能正常进入别的分支。

step 1:先对数组按照字典序排序,获取第一个排列情况。
step 2:准备一个数组暂存递归过程中组装的排列情况。使用额外的vis数组用于记录哪些位置的数字被加入了
step 3:每次递归从头遍历数组,获取数字加入:首先根据vis数组,已经加入的元素不能再次加入了;同时,如果当前的元素 num[i] 与 同一层 的前一个元素 num[i-1] 相同且 num[i-1] 未用,也不需要将其纳入。
step 4:进入下一层递归前将 vis 数组当前位置标记为使用过。
step 5:回溯的时候需要修改 vis 数组当前位置标记,同时去掉刚刚加入数组的元素。
step 6:临时数组长度到达原数组长度就是一种排列情况。
在这里插入图片描述
可以参考一下这个题解,写的更加清楚详细:回溯算法解含有重复数字的全排列 II

class Solution:
    def recursion(self, res: List[List[int]], num: List[int], temp: List[int], vis: List[int]):
        # 临时数组满了,则直接加入到输出
        if len(temp) == len(num):
            res.append(temp.copy())
            return
        for i in range(len(num)):
            # 已加入的元素不再加入
            if vis[i] == 1:
                continue
            # 同一个层的前一个元素如果未用,则不能加入
            if i > 0 and num[i-1] == num[i] and not vis[i-1]:
                continue
            # 标记使用,组装进 temp
            vis[i] = 1
            temp.append(num[i])
            self.recursion(res, num, temp, vis)
            # 回溯
            vis[i] = 0
            temp.pop()
        
    def permuteUnique(self , num: List[int]) -> List[List[int]]:
        #先按字典序排序
        num.sort()
        #标记每个位置的元素是否被使用过
        vis = [0] * len(num)
        # 用来记录结果
        res = list(list())
        # 该数组用于组装后续排列:前面排列好了,后面待加入新的元素
        temp = list()
        # 递归获取
        self.recursion(res, num, temp, vis)
        return res

时间复杂度: O ( n ∗ n ! ) O(n*n!) O(nn!),全排列的全部情况为 n ! n! n!,每次递归过程都是遍历数组查找元素,这里是O(n)。
空间复杂度: O ( n ) O(n) O(n),递归栈的最大深度为数组长度 n,临时数组temp的空间也为 O ( n ) O(n) O(n),res 属于返回必要空间。

57.岛屿数量

57.1 深度优先搜索DFS

矩阵中多处聚集着1,要想统计1聚集的堆数而不重复统计,那我们可以考虑每次找到一堆相邻的1,就将其全部改成0,而将所有相邻的1改成0的步骤又可以使用深度优先搜索(dfs):当我们遇到矩阵的某个元素为1时,首先将其置为了0,然后查看与它相邻的上下左右四个方向,如果这四个方向任意相邻元素为1,则进入该元素,进入该元素之后我们发现又回到了刚刚的子问题,又是把这一片相邻区域的1全部置为0,因此可以用递归实现。

# 后续四个方向遍历
if(i - 1 >= 0 && grid[i - 1][j] == '1') 
    dfs(grid, i - 1, j);
if(i + 1 < n && grid[i + 1][j] == '1') 
    dfs(grid, i + 1,j);
if(j - 1 >= 0 && grid[i][j - 1] == '1') 
    dfs(grid, i, j - 1);
if(j + 1 < m && grid[i][j + 1] == '1') 
    dfs(grid, i, j + 1);

(1)终止条件: 进入某个元素修改其值为0后,遍历四个方向发现周围都没有1,那就不用继续递归,返回即可,或者递归到矩阵边界也同样可以结束。
(2)返回值: 每一级的子问题就是把修改后的矩阵返回,因为其是函数引用,也不用管。
(3)本级任务: 对于每一级任务就是将该位置的元素置为0,然后查询与之相邻的四个方向,看看能不能进入子问题。

step 1:优先判断空矩阵等情况。
step 2:从上到下从左到右遍历矩阵每一个位置的元素,如果该元素值为1,统计岛屿数量。
step 3:接着将该位置的1改为0,然后使用dfs判断四个方向是否为1,分别进入4个分支继续修改。
在这里插入图片描述

def dfs(self, grid: List[List[chr]], i: int, j: int):
        n = len(grid)
        m = len(grid[0])
        # 访问后置为 0 
        grid[i][j] = '0'
        # 向左遍历
        if i-1 >= 0 and grid[i-1][j] == '1':
            self.dfs(grid, i-1, j)
        # 向右遍历
        if i+1 < n and grid[i+1][j] == '1':
            self.dfs(grid, i+1, j)
        # 向上遍历
        if j-1 >= 0 and grid[i][j-1] == '1':
            self.dfs(grid, i, j-1)
        # 向下遍历
        if j+1 < m and grid[i][j+1] == '1':
            self.dfs(grid, i, j+1)
            
    def solve(self , grid: List[List[str]]) -> int:
        n = len(grid)
        if n == 0:
            return 0
        m = len(grid[0])
        count = 0
        # 遍历矩阵
        for i in range(n):
            for j in range(m):
                if grid[i][j] == '1':
                    count = count + 1
                    self.dfs(grid, i, j)
        return count

时间复杂度: O ( n m ) O(nm) O(nm),其中 m、n 为矩阵的长和宽,需要遍历整个矩阵,每次 dfs 搜索需要经过每个值为 1 的元素,但是最坏情况下也只是将整个矩阵变成 0,因此相当于最坏遍历矩阵 2 次。
空间复杂度: O ( n m ) O(nm) O(nm),最坏情况下整个矩阵都是。1,递归栈深度为 m n mn mn

57.2 广度优先搜索BFS

广度优先搜索与深度优先搜索不同,它是将与某个节点直接相连的其它所有节点依次访问一次之后,再往更深处,进入与其他节点直接相连的节点。bfs的时候我们常常会借助队列的先进先出,因为从某个节点出发,我们将与它直接相连的节点都加入队列,它们优先进入,则会优先弹出,在它们弹出的时候再将与它们直接相连的节点加入,由此就可以依次按层访问。

统计岛屿的方法可以和方法一同样遍历解决,为了去重我们还是要将所有相邻的1一起改成0,这时候同样遍历连通的广度优先搜索(bfs)可以代替dfs。

step 1:优先判断空矩阵等情况。
step 2:从上到下从左到右遍历矩阵每一个位置的元素,如果该元素值为1,统计岛屿数量。
step 3:使用bfs将遍历矩阵遇到的1以及相邻的1全部置为0:利用两个队列辅助(C++可以使用pair),每次队列进入第一个进入的1,然后遍历队列,依次探讨队首的四个方向,是否符合,如果符合则置为0,且位置坐标加入队列,继续遍历,直到队列为空。
在这里插入图片描述

from queue import Queue
class Solution:       
    def solve(self , grid: List[List[str]]) -> int:
        n = len(grid)
        # 空矩阵的情况
        if n == 0:
            return 0
        m = len(grid[0])
        # 记录岛屿数量
        count = 0
        # 遍历矩阵
        for i in range(n):
            for j in range(m):
                # 遇到 1 要将这个 1 及其相邻的 1 都置为 0
                if grid[i][j] == '1':
                    count = count + 1
                    grid[i][j] = '0'
                    # 记录后续 bfs 的坐标
                    q = Queue()
                    q.put([i,j])
                    # 开始 bfs
                    while not q.empty():
                        temp = q.get()
                        row = temp[0]
                        col = temp[1]
                        # 检查 4 个方向
                        if row-1 >= 0 and grid[row-1][col] == '1':
                            q.put([row-1,col])
                            grid[row-1][col] = '0'
                        if row+1 < n and grid[row+1][col] == '1':
                            q.put([row+1,col])
                            grid[row+1][col] = '0'
                        if col-1 >= 0 and grid[row][col-1] == '1':
                            q.put([row,col-1])
                            grid[row][col-1] = '0'
                        if col+1 < m and grid[row][col+1] == '1':
                            q.put([row,col+1])
                            grid[row][col+1] = '0'
        return count

时间复杂度: O ( n m ) O(nm) O(nm),其中 m、n 为矩阵的长和宽,需要遍历整个矩阵,每次 bfs 搜索需要经过每个值为 1 的元素,但是最坏情况下也只是将整个矩阵变成 0,因此相当于最坏遍历矩阵 2 次。
空间复杂度: ( m i n ( n , m ) ) (min(n, m)) (min(n,m)),bfs 最坏情况队列大小为长和宽的较小值。

58.字符串的排列

此题思路请参考:56.有重复项数字的全排列
在这里插入图片描述

class Solution:
    def recursion(self, res:List[str], string:str, temp:str, vis:List[int]):
        if len(temp) == len(string):
            res.append(temp)
            return
        # 遍历所有元素选取一个加入
        for i in range(len(string)):
            # 如果该元素已经被加入过则不需要再加入了
            if vis[i] == 1:
                continue
            # 当前的元素 string[i] 与同一层的前一个元素 string[i-1] 相同,且 string[i-1] 未用过
            if i > 0 and string[i] == string[i-1] and not vis[i-1]:
                continue
            # 标记使用过
            vis[i] = 1
            # 加入临时字符串
            temp = temp + string[i]
            self.recursion(res, string, temp, vis)
            # 回溯
            vis[i] = 0
            temp = temp[:-1]
            
    def Permutation(self , str: str) -> List[str]:
        # 先按字典排序,使重复的字符串相邻
        a = list(str)
        a.sort()
        str = "".join(a)
        # 标记每个位置的字符是否被使用过
        vis = [0] * len(str)
        res = []
        temp = ""
        # 递归获取
        self.recursion(res, str, temp, vis)
        return res

时间复杂度: O ( n ∗ n ! ) O(n*n!) O(nn!),全排列的全部情况为 n ! n! n!,每次递归过程都是遍历字符串查找元素,这里是O(n)。
空间复杂度: O ( n ) O(n) O(n),递归栈的最大深度为字符串长度 n,临时字符串 temp 的空间也为 O(n),res 属于返回必要空间。

59.N皇后问题

n 个皇后,不同行不同列,那么肯定棋盘每行都会有一个皇后,每列都会有一个皇后。如果我们确定了第一个皇后的行号与列号,则相当于接下来在 n − 1 n−1 n1 行中查找 n − 1 n−1 n1 个皇后,这就是一个子问题,因此使用递归:
(1)终止条件: 当最后一行都被选择了位置,说明 n 个皇后位置齐了,增加一种方案数返回。
(2)返回值: 每一级要将选中的位置及方案数返回。
(3)本级任务: 每一级其实就是在该行选择一列作为该行皇后的位置,遍历所有的列选择一个符合条件的位置加入数组,然后进入下一级。

step 1:对于第一行,皇后可能出现在该行的任意一列,我们用一个数组 pos 记录皇后出现的位置,下标为行号,元素值为列号
step 2:如果皇后出现在第一列,那么第一行的皇后位置就确定了,接下来递归地在剩余的 n−1 行中找 n−1 个皇后的位置。
step 3:每个子问题检查是否符合条件,我们可以对比所有已经记录的行,对其记录的列号查看与当前行列号的关系:即是否 同行、同列或是同一对角线
在这里插入图片描述

class Solution:
    # 判断处于 [row,col] 的皇后是否满足条件,pos 记录了皇后出现的位置
    def isValid(self, pos: List[int], row: int, col: int):
        # 遍历所有已经记录的行,其实就是对比之前记录的结果
        for i in range(row):
            # 不能同行、同列、同一斜线
            if row == i or col == pos[i] or abs(row-i) == abs(col-pos[i]):
                return False
        return True
        
    # 递归查找
    def recursion(self, n:int, row:int, pos:List[int], res:int):
        if row == n:
            res = res + 1
            return res
        # 对于第 row 行来说,遍历所有列,来判断在这一行是否存在合适的列
        for i in range(n):
            if self.isValid(pos, row, i):
                pos[row] = i
                res = self.recursion(n, row+1, pos, res)
        return res
        
    def Nqueen(self , n: int) -> int:
        # res 记录结果数
        res = 0 
        # pos 记录皇后出现的位置,下标为行号,元素值为列号
        pos = [0] * n
        return self.recursion(n, 0, pos, res)

时间复杂度: O ( n ∗ n ! ) O(n*n!) O(nn!) i s V a l i d isValid isValid 函数每次检查复杂度为 O(n),递归过程相当于对长度为 n 的数组求全排列,复杂度为 O ( n ! ) O(n!) O(n!)
空间复杂度: O ( n ) O(n) O(n),辅助数组和栈空间最大为 O(n)。

60.括号生成

相当于一共 n 个左括号和 n 个右括号,可以给我们使用,我们需要依次组装这些括号。每当我们使用一个左括号之后,就剩下 n-1 个左括号和 n 个右括号给我们使用,结果拼在使用的左括号之后就行了,因此后者就是一个子问题,可以使用递归:
(1)终止条件: 左右括号都使用了 n 个,将结果加入数组。
(2)返回值: 每一级向上一级返回后续组装后的字符串,即子问题中搭配出来的括号序列。
(3)本级任务: 每一级就是保证左括号还有剩余的情况下,使用一次左括号进入子问题,或者右括号还有剩余且右括号使用次数少于左括号的情况下使用一次右括号进入子问题。

但是这样递归不能保证括号一定合法,我们需要保证 左括号出现的次数比右括号多时 我们再使用右括号就一定能保证括号合法了,因此每次需要检查左括号和右括号的使用次数。

step 1:将空串与左右括号各自使用了 0 个送入递归。
step 2:若是左右括号都使用了 n 个,此时就是一种结果。
step 3:若是左括号数没有到达 n 个,可以考虑增加左括号,或者右括号数没有到达 n 个且左括号的使用次数多于右括号就可以增加右括号。
在这里插入图片描述

class Solution:
    def recursion(self, left:int, right:int, temp:str, res:List[str], n:int):
        # 左右括号都用完了,就加入结果
        if left == n and right == n:
            res.append(temp)
            return
        # 使用左括号
        if left < n:
            self.recursion(left+1, right, temp+'(', res, n)
        # 使用右括号,保证左括号的使用次数比右括号多
        if right < left:
            self.recursion(left, right+1, temp+')', res, n)
        
    def generateParenthesis(self , n: int) -> List[str]:
        # 记录结果
        res = list()
        # 记录每次组装的字符串
        temp = str()
        # 递归
        self.recursion(0, 0, temp, res, n)
        return res

时间复杂度: O ( 4 n n ) O(\frac{4^n}{\sqrt{n}}) O(n 4n),复杂度取决于有多少个合法括号组合,这是第 n 个卡特兰数,由 4 n n n \frac{4^n}{n\sqrt{n}} nn 4n 渐近界定的。
空间复杂度: O ( n ) O(n) O(n),递归栈最大空间,其中 res 数组是返回时必须要的,不算额外空间。

61.矩阵最长递增路径

61.1 深度优先搜索DFS

既然是查找最长的递增路径长度,那我们首先要找到这个路径的起点,起点不好直接找到,就从上到下从左到右遍历矩阵的每个元素。然后以每个元素都可以作为起点查找它能到达的最长递增路径。

如何查找以某个点为起点的最长递增路径呢?我们可以考虑深度优先搜索,因为我们查找递增路径的时候,每次选中路径一个点,然后找到与该点相邻的递增位置,相当于进入这个相邻的点,继续查找递增路径,这就是递归的子问题。因此递归过程如下:
(1)终止条件: 进入路径最后一个点后,四个方向 要么是矩阵边界要么没有递增的位置,路径不能再增长,返回上一级。
(2)返回值: 每次返回的就是本级之后的子问题中查找到的路径长度加上本级的长度。
(3)本级任务: 每次进入一级子问题,先初始化后续路径长度为 0,然后遍历四个方向(可以用数组表示,下标对数组元素的加减表示去往四个方向),进入符合不是边界且在递增的邻近位置作为子问题,查找子问题中的递增路径长度。因为有四个方向,所以最多有四种递增路径情况,因此要维护当级子问题的最大值。

step 1:使用一个 dp 数组记录 i,j 处的单元格拥有的最长递增路径,这样在递归过程中如果访问到就不需要重复访问。
step 2:遍历矩阵每个位置,都可以作为起点,并维护一个最大的路径长度的值。
step 3:对于每个起点,使用 dfs 查找最长的递增路径:只要下一个位置比当前的位置数字大,就可以深入,同时累加路径长度。

class Solution:
    global n, m
    def dfs(self, matrix:List[List[int]], dp:List[List[int]], i:int, j:int):
        # dp数组用于记录 i,j 处的单元格拥有的最长递增路径
        if dp[i][j] != 0:
            return dp[i][j]
        dp[i][j] = dp[i][j] + 1
        # 向左
        if i-1 >= 0 and matrix[i-1][j] > matrix[i][j]:
            dp[i][j] = max(dp[i][j], self.dfs(matrix, dp, i-1, j)+1)
        # 向右
        if i+1 < n and matrix[i+1][j] > matrix[i][j]:
            dp[i][j] = max(dp[i][j], self.dfs(matrix, dp, i+1, j)+1)
        # 向上
        if j-1 >= 0 and matrix[i][j-1] > matrix[i][j]:
            dp[i][j] = max(dp[i][j], self.dfs(matrix, dp, i, j-1)+1)
        # 向下
        if j+1 < m and matrix[i][j+1] > matrix[i][j]:
            dp[i][j] = max(dp[i][j], self.dfs(matrix, dp, i, j+1)+1)
        return dp[i][j]
         
    def solve(self , matrix: List[List[int]]) -> int:
        global n, m
        # 矩阵不为空
        if len(matrix) == 0 or len(matrix[0]) == 0:
            return 0
        res = 0
        n = len(matrix)
        m = len(matrix[0])
        # dp数组用于记录 [i,j] 处单元格拥有的最长递增路径
        dp = [[0 for col in range(m)] for row in range(n)]
        # 循环遍历,矩阵的每个位置都作为一次起点
        for i in range(n):
            for j in range(m):
                res = max(res, self.dfs(matrix, dp, i, j))
        return res

时间复杂度: O ( m n ) O(mn) O(mn),m、n 分别为矩阵的两边,遍历整个矩阵以每个点作为起点,然后递归相当于遍历填充 dp 矩阵。
空间复杂度: O ( m n ) O(mn) O(mn),辅助矩阵的空间是一个二维数组。

  • 6
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

G皮T

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值