回溯算法详解【Python】

这篇文章总结了到目前为止在leetcode上所遇到的回溯算法的题目,包括:生成全排列、求子集、指定路径和等等。
回溯算法实际上是穷举的过程,代码的递归形式中主要体现为做选择和撤销选择,那么首先给出回溯算法的框架:

result = []
def _backtrace(选择列表nums, 路径pre_list):
    if 满足结束条件:
        result.add(路径)
        return
    
    for 选择 in 选择列表:
        做选择
        _backtrace(剩余选择列表, 路径)
        撤销选择

其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」。

46.全排列

给定一个没有重复数字的序列,返回其所有可能的全排列。

输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]

写回溯算法时需要考虑的因素:1)决策出口,即何时觉得可以输出到结果中了。2)如何做出选择,这个就是根据题目意思从待选列表nums中去选择元素了,可能会需要nums排序,或在有重复数字的时候进行剪枝。

    def permute(self, nums):
        """
        46.permutations 对没有重复数字的数组 进行全排列
        """
        if len(nums) == 0:
            return []

        res = []

        def _backtrace(nums, pre_list):
            """
            回溯 从待选列表中选取加入
            :param nums: 待选列表
            :param pre_list: 已经加入的
            :return:
            """
            # 出口  已经选取完毕,记录结果
            if len(nums) <= 0:
                res.append(pre_list.copy())  # 这里一定要注意 是把copy的赋给res,不然res会随着pre_list而改变
                return
            else:
                for i in range(len(nums)):
                	# 1.做选择
                    pre_list.append(nums[i])
                    left_nums = nums.copy()
                    left_nums.remove(nums[i])  # 没有重复元素,可以用remove从待选列表把该数删除
                    # 2.递归
                    _backtrace(left_nums, pre_list)
                    # 3.撤销选择
                    pre_list.pop()  # return之后 pop上个已遍历过的元素

        _backtrace(nums, [])
        return res
47. 全排列 II

给定一个可包含重复数字的序列,返回所有不重复的全排列。

输入: [1,1,2]
输出:
[
[1,1,2],
[1,2,1],
[2,1,1]
]

这道题和上面的题的主要区别就在于是否存在重复数字,存在重复数字的情况会使得遍历后的结果也存在重复。比如上述例子中第一个位置的1和第二个位置的1,以哪个1为开头的结果都是一样的,所以当遍历到第二个位置的1的时候,其实可以跳过,因为以这个数字为开头的结果在上一轮中已经遍历过。此外,在回溯之前需要对原数组进行一个排序,这样才能比较容易判断当前元素是否和之前元素相同,即容易去重。

    def permuteUnique(self, nums):
        """
        47.permutations-ii 对有重复数字的数组 进行全排列,返回的是集合形式,即要求去重
        """
        nums.sort()  # 排序是为了去重
        res = []

        def _backtrace(nums, pre_list):
            if len(nums) <= 0:
                res.append(pre_list.copy())
            else:
                for i in range(len(nums)):
                    # 当前元素和上一个元素一样,不用重复加入了,这就是排序的好处
                    if i > 0 and nums[i] == nums[i - 1]:
                        continue
                    pre_list.append(nums[i])
                    left_nums = nums.copy()
                    left_nums.pop(i)  # 把下标为i的元素删除
                    _backtrace(left_nums, pre_list)
                    pre_list.pop()

        _backtrace(nums, [])
        return res
78. 子集

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。

输入: nums = [1,2,3]
输出:
[
[3],
[1],
[2],
[1,2,3],
[1,3],
[2,3],
[1,2],
[]
]

考虑上述提到的回溯算法两个要点:1)决策出口:这道题是只要进入回溯函数,就输出到结果中,因为没有要求当前的路径元素个数的限制。2)如何做出选择:按顺序从nums中加入到pre_list中即可。

def subsets(self, nums):
        """
        78.subsets
        nums不包含重复的数字,生成所有子集,包含空集
        """
        if len(nums) <= 0:
            return []
        nums.sort()

        res = []

        def _backtrace(num, pre_list):
            res.append(pre_list.copy())  # 无判断条件,直接加入
            for i in range(len(num)):
                pre_list.append(num[i])
                _backtrace(num[i + 1:], pre_list)
                pre_list.pop()

        _backtrace(nums, [])
        return res
90. 子集 II

给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。

输入: [1,2,2]
输出:
[
[2],
[1],
[1,2,2],
[2,2],
[1,2],
[]
]

同样地,由于包含重复元素,所以需要将原数组排序后剪枝。

    def subsetsWithDup(self, nums):
        """
        90.subsets-ii
        nums包含重复的数字,生成所有子集,无重复子集
        :param nums:
        :return:
        """
        if len(nums) <= 0:
            return []
        res = []

        nums.sort()  # 先排序

        def _backtrace(num, pre_list):
        	res.append(pre_list.copy())  # 无判断条件,直接加入
            #  和 有重复数字,求全排列 一样
            for i in range(len(num)):
                if i > 0 and num[i] == num[i - 1]:  # 当前的和上一个起始值一样,直接跳过,因为上一次以上一个值为开头的序列已经遍历过了
                    continue
                pre_list.append(num[i])
                _backtrace(num[i + 1:], pre_list)
                pre_list.pop()

        _backtrace(nums, [])

        return res
39. 组合总和

给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。

说明:
所有数字(包括 target)都是正整数。
解集不能包含重复的组合。

输入: candidates = [2,3,6,7], target = 7,
所求解集为:
[
[7],
[2,2,3]
]

1)决策出口:当pre_list路径中所有数之和等于target,输出到res中。
2)做出选择:由于每个数字可以使用多次,所以nums就是原数组。此外,这里可以使用剪枝来降低复杂度——对原数组排序,若从待选列表中加入当前数之后的总和大于target,那么该数之后的元素都不需要考虑了。

    def combinationSum(self, candidates, target):
        """
        39.combination-sum
        从candidates选取所有集合,使得集合中数和为target,每个数可以重复
        返回的是集合形式,即去重
        """
        if len(candidates) <= 0:
            return []

        res = []
        candidates.sort()  # 排序,方便之后的剪枝

        def _backtrace(nums, pre_list):
            if sum(pre_list) == target:
                res.append(pre_list.copy())
                return
            else:
                for i in range(len(nums)):
                    left_num = target - nums[i] - sum(pre_list)
                    # # 如果剩余值为负数,说明超过了,剪枝,后面的candidates都不需要考虑了
                    if left_num < 0:
                        break
                    pre_list.append(nums[i])
                    _backtrace(nums[i:], pre_list)  # nums更新为该数及该数之后
                    pre_list.pop()  # 记得把当前值移出路径,才能进入下一个值的路径

        _backtrace(candidates, [])
        return res
40. 组合总和 II

给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。

说明:
所有数字(包括目标数)都是正整数。
解集不能包含重复的组合。

输入: candidates = [10,1,2,7,6,1,5], target = 8,
所求解集为:
[
[1, 7],
[1, 2, 5],
[2, 6],
[1, 1, 6]
]

1)决策出口:当pre_list路径中所有数之和等于target,输出到res中。
2)做出选择:这里跟上一道题的区别在于每个数只能使用一次,而且原数组可能存在重复元素。所以需要先排序,然后剪枝判断当前元素是不是和上一个相等,如果相等那么直接continue,因为以这个数为开头的上一轮已经遍历过了。

    def combinationSum2(self, candidates, target):
        """
        40.combination-sum-i
        candidates包含重复元素,要求每个数只能使用一次,返回集合(答案去重)
        """
        if len(candidates) <= 0:
            return []
        res = []
        candidates.sort()

        def _backtrace(nums, pre_list):
            if sum(pre_list) == target:
                res.append(pre_list.copy())
                return
            else:
                for i in range(len(nums)):
                    left_num = target - sum(pre_list) - nums[i]
                    if left_num < 0:
                        break
                    if i > 0 and nums[i] == nums[i - 1]:
                        continue
                    pre_list.append(nums[i])
                    _backtrace(nums[i + 1:], pre_list)  # 每个数只能使用一次,所以numsnums更新为该数之后,不包括该数
                    pre_list.pop()

        _backtrace(candidates, [])
        return res
79. 单词搜索

给定一个二维网格和一个单词,找出该单词是否存在于网格中。

单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

board =
[
[‘A’,‘B’,‘C’,‘E’],
[‘S’,‘F’,‘C’,‘S’],
[‘A’,‘D’,‘E’,‘E’]
]
给定 word = “ABCCED”, 返回 true
给定 word = “SEE”, 返回 true
给定 word = “ABCB”, 返回 false

    def exist(self, board, word):
        """
        搜索是否存在路径序列为word的路径
        标准dfs
        :param board:
        :param word:
        :return:
        """
        m = len(board)
        n = len(board[0])
        go = [[1, 0], [0, 1], [-1, 0], [0, -1]]  # 前进方向数组

        def dfs(x, y, word):
            """
            从(x,y)开始搜索是否存在word序列
            """
            res = False
            # 1. 先判断直接return
            if len(word) == 1 and board[x][y] == word[0]:  # 递归出口,只剩一个字符时
                return True
            elif board[x][y] != word[0]:  # 如果不相等,直接false
                return False
            # 2. 继续扩展
            board[x][y] = '*'  # 如果不是以上两种情况,说明第一个字符匹配,置为访问过
            # 由该点扩展到下一点
            for i in range(len(go)):
                newx = x + go[i][0]
                newy = y + go[i][1]
                if newx >= m or newx < 0 or newy >= n or newy < 0:  # 出范围了,必须要在下一条语句前面!!
                    continue
                if board[newx][newy] == '*':  # 访问过
                    continue
                res = dfs(newx, newy, word[1:])  # 往后退一个字符
                if res:  # 找到的话直接return,不需要扩展到所有结果
                    return True

            # 如果由(x,y)扩展的所有状态都不符合,那么说明(x,y)这点也不合理,重新还原
            if res == False:
                board[x][y] = word[0]  # 还原回去

            return False

        for i in range(m):
            for j in range(n):
                if board[i][j] == word[0]:
                    res = dfs(i, j, word)
                    if res:
                        return True
        return False
131. 分割回文串

给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
返回 s 所有可能的分割方案。

输入: “aab”
输出:
[
[“aa”,“b”],
[“a”,“a”,“b”]
]

乍一想,这道题很难想到怎么用回溯法。仔细一想,我们在分割时,其实可以递归的分解子问题:从前往后找,直到找到当前串是一个回文串,那么就把原串分为两部分:前半部分使我们找到的回文串,后半部分就去继续递归地寻找。所以回溯算法的思路就是这样:对当前的待选列表ss,从开始位置开始,逐步扩大右边界,查看ss的前缀子串是否为回文子串,如果找到了那么就对后半部分递归的调用回溯。

    def partition(self, s):
        """
        将s分成回文子串的集合
        :param s:
        :return:
        """
        if len(s) == 1:
            return [s]

        res = []

        def _backtrace(ss, pre_list):
            if len(ss) == 0:
                res.append(pre_list.copy())
            for i in range(1, len(ss) + 1):
                if ss[:i] == ss[:i][::-1]:
                    pre_list += [ss[:i]]  # 符合条件,加入
                    _backtrace(ss[i:], pre_list)
                    pre_list.pop()  # 回归递归,要回归原来的pre_list
                    # 上面三行可以简化为一行: _backtrace(ss[i:], pre_list + [ss[:i]])

        _backtrace(s, [])
        return res
51. N皇后

n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
PS:皇后可以攻击同一行、同一列、左上左下右上右下四个方向的任意单位。

给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。
每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。

输入: 4
输出: [
[".Q…", // 解法 1
“…Q”,
“Q…”,
“…Q.”],
["…Q.", // 解法 2
“Q…”,
“…Q”,
“.Q…”]
]
解释: 4 皇后问题存在两个不同的解法

首先,因为是在 n×n 的棋盘上放置n个,所以实际上是每行都放置一个。那么整个过程就是依次在第0,1,2…行上选择位置放置皇后。套用回溯法的模板,这里的待选列表是需要放置的这一行,这里的路径是已经更新过的整个棋盘。所以我们首先定义一个空棋盘board,然后在回溯函数_backtrace(board, row)中去在第row行选择位置放上皇后,即把棋盘的对应位置更新为Q.
1)决策出口:当待更新的这一行row已经超出索引范围,说明所有行都被更新完毕,此时可以保存结果
2)做出选择:如何在待更新的这一行row选取位置放皇后,需要满足题目所给的条件——在这个位置的正上方、左上方、右上方都没有皇后(不需要考虑正下方,因为还没轮到下面的行)

 def solveNQueens(self, n):
        """
        :type n: int
        :rtype: List[List[str]]
        """
        import copy
        board = [['.'] * n for _ in range(n)]  # 初始化空棋盘
        res = []

        def isValid(board, row, col):
            """
            判断在board的[row,col]处放置皇后是否有效
            """
            # 1. 判断列是否有冲突
            for i in range(len(board[0])):
                if board[i][col] == 'Q':
                    return False

            # 2. 判断左斜上方方向是否有冲突
            i, j = row - 1, col - 1
            while i >= 0 and j >= 0:
                if board[i][j] == 'Q':
                    return False
                i -= 1
                j -= 1

            # 3. 判断右斜上方方向
            i, j = row - 1, col + 1
            while i >= 0 and j < len(board[0]):
                if board[i][j] == 'Q':
                    return False
                i -= 1
                j += 1

            return True

        def _backtrace(row, board):
            """
            :param row: 当前的需要更新的待选行,相当于待选列表nums
            :param board: 当前的棋盘,相当于路径pre_list
            :return:
            """
            # 1. 决策出口:当待选行已经超越最后一行,保存结果
            if row == len(board):
                res.append(["".join(i) for i in board]) 
                return

            for col in range(len(board[0])):  # 每一列都有可能成为放置皇后的地方
                if not isValid(board, row, col):
                    continue
                # 2. 做出选择
                board[row][col] = 'Q'
                _backtrace(row + 1, board)
                # 3. 撤销选择
                board[row][col] = '.'

        _backtrace(0, board)
        return res
其他

接下来有两道关于生成排列的问题:生成按照字典序的下一个排列生成第k个排列。这两道题如果想要用回溯法生成全排列然后再去筛选的话,复杂度比较高,所以这两道题不是用回溯法去穷举的,而是根据规律去做的。在这里一并写到回溯这章,可以和生成全排列做个对比。

31. 下一个排列

实现获取下一个排列的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。
如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。
必须原地修改,只允许使用额外常数空间。

以下是一些例子,输入位于左侧列,其相应输出位于右侧列。

1,2,3 → 1,3,2
3,2,1 → 1,2,3
1,1,5 → 1,5,1

解法:下一个排列的话,实际上高位是不需要动的。比如说1234的下一个排列是1243,高位12是不需要动的。

  1. 从后往前寻找,找到第一个down_idx,这个位置的数比它相邻后面的数要小,即nums[down_idx] < nums[down_idx+1],就比如12343的位置
  2. 那么down_idx这个位置就是需要改变的位置,要生成下一个排列的话,我们就需要从[down_idx, len(nums)-1]中找到最小的比比nums[down_idx]大的数,去和down_idx的数进行交换
  3. 最后把[down_idx + 1, len(nums)-1]升序排列。因为这个区间本身就是降序的,所以只要首尾交换即可
    def nextPermutation(self, nums):
        down_idx = None  # 从这一位开始改变,即从后往前第一个不递增的idx

        # 第一步,从后往前找,找到第一个不递增的idx
        for i in range(len(nums)-2, -1, -1):
            if nums[i] < nums[i+1]:
                down_idx = i
                break
        # 没有找到,原数组已经最大,那么就反序,输出最小排列
        if down_idx is None:
            nums.reverse()
        else:
            # 第二步,从后往前,找[down_idx, len-1]第一个比nums[down_idx]大的数,交换
            for i in range(len(nums)-1, down_idx, -1):
                if nums[i] > nums[down_idx]:
                    nums[i], nums[down_idx] = nums[down_idx], nums[i]
                    break

            # 第三步,将[down_idx+1, len-1]按升序排列,因为原来是降序的,所以只需要首尾互换即可
            i, j = down_idx+1, len(nums)-1
            while i < j:
                nums[i], nums[j] = nums[j], nums[i]
                i += 1
                j -= 1
        return nums
60. 第k个排列

给出集合 [1,2,3,…,n],其所有元素共有 n! 种排列。
按大小顺序列出所有排列情况,并一一标记,当 n = 3 时, 所有排列如下:
“123”
“132”
“213”
“231”
“312”
“321”
给定 n 和 k,返回第 k 个排列。

说明:
给定 n 的范围是 [1, 9]。
给定 k 的范围是[1, n!]。

输入: n = 3, k = 3
输出: “213”

我们不可能求出所有的排列,然后找到第 k 个之后返回。因为排列的组合是 N!,要比 2^n 还要高很多,非常有可能超时。我们必须使用一些巧妙的方法。
我们以题目中的 n= 3 k = 3 为例:
“123”
“132”
“213”
“231”
“312”
“321”
可以看出 1xx,2xx 和 3xx 都有两个,如果你知道阶乘的话,实际上是 2!个。 我们想要找的是第 3 个。那么我们可以直接跳到 2 开头,我们排除了以 1 开头的排列,问题缩小了,我们将 2 加入到结果集,我们不断重复上述的逻辑,知道结果集的元素为 n 即可。

    def getPermutation(self, n, k):
        """
        从n个数[1,n]中返回第k个全排列(从小到大)
        如果直接回溯法,找出所有全排列 然后找出第k个,显然时间复杂度太高
        这里可以先找出第一个数字是什么,把n!= n * (n-1)!, 如当n=3时,1xx,2xx,3xx的个数都是2!个,所以k//2!即可求到开始数字是哪一个
        :param n:
        :param k:
        :return: 字符串
        """
        import math
        candidates = [str(i) for i in range(1, n + 1)]  # 返回是字符串,所以初始化为字符串列表
        res = ""

        while n != 0:
            facto = math.factorial(n - 1)
            # k // facto 是不行的, 比如在 k % facto == 0的情况下就会有问题
            first_num_idx = math.ceil(k / facto) - 1

            # 我们把candidates[i]加入到结果集,然后将其弹出candidates(不能重复使用元素)
            res += candidates[first_num_idx]
            candidates.pop(first_num_idx)

            # k 缩小了 facto *  i
            k -= facto * first_num_idx
            # 每次迭代我们实际上就处理了一个元素,n 减去 1,当n == 0 说明全部处理完成,我们退出循环
            n -= 1

        return res
  • 4
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值