【回溯算法】排列组合问题

力扣上涉及排列组合相关的题目如下:

序号 题目 分类 特点
1 77. 组合 组合问题 数组无重复元素,每个元素至多能选择一次,组合长度固定且不能重复
2 39. 组合总和 组合问题 数组无重复元素,每个元素可以多次重复选择
3 40. 组合总和 II 组合问题 数组有重复元素,每个元素只能选择一次
4 216. 组合总和 III 组合问题 数组无重复元素,每个元素只能选择一次
5 78. 子集 子集问题 数组无重复元素,每个元素至多能选择一次,组合不能重复
6 90. 子集 II 子集问题 数组有重复元素,每个元素至多能选择一次,组合不能重复
7 46. 全排列 排列问题 数组无重复元素,每个元素只能选择一次
8 47. 全排列 II 排列问题 数组有重复元素,每个元素只能选择一次,组合不能重复
9 491. 递增子序列 子集问题 数组有重复元素,每个元素只能选择一次,组合不能重复

排列与组合的区别

  • 排列:在每次迭代的过程中,都需要从数组 \(nums\)第一个元素,即 \(nums[0]\),开始选择;

  • 组合:在每次迭代的过程中,都需要从数组 \(nums\)当前元素开始选择。

    image

    image

注意,对于排列,我们需要通过保证元素之间的相对顺序不变来防止出现重复的子集

应用

应用1:Leetcode.77

题目

77. 组合

输入:n = 4, k = 2
输出:[[2,4], [3,4], [2,3], [1,2], [1,3], [1,4],]

分析

以题目中的用例为例,其搜索过程如下:

image

每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。

图中可以发现 \(n\) 相当于树的宽度,\(k\) 相当于树的深度。

图中每次搜索到了叶子节点,我们就找到了一个结果,相当于只需要把达到叶子节点的结果收集起来,就可以求得 \(n\) 个数中 \(k\) 个数的组合集合。

代码实现

方法一:

class Solution:
    def combine(self, n: int, k: int) -> List[List[int]]:
        results = list()
        self.dfs(n, k, results, list(), 1)
        return results

    def dfs(self, n, k, results, path, start):
        if len(path) == k:
            results.append(list(path))
            return

        # 剪枝:区间长度+已经选择的元素小于k,就不再继续递归遍历
        if len(path) + n - start + 1 < k:
            return

        for i in range(start, n + 1):
            path.append(i)
            # 不能重复选择,下一次选择时,start 的值就是 i + 1
            self.dfs(n, k, results, path, i + 1)
            path.pop()

方法二:

class Solution:
    def combine(self, n: int, k: int) -> List[List[int]]:
        results = list()
        self.dfs(1, n, k, list(), results)
        return results

    def dfs(self, start, n, k, path, results):
        if len(path) == k:
            results.append(list(path))
            return 

        if len(path) + n - start + 1 < k:
            return

        # 选择当前位置
        path.append(start)
        self.dfs(start + 1, n, k, path, results)
        path.pop()

        # 不选择当前位置
        self.dfs(start + 1, n, k, path, results)
        return

应用2:Leetcode.39

题目

39. 组合总和

输入:\(candidates = [2,3,6,7]\), \(target = 7\)
输出:\([[2,2,3],[7]]\)

分析

我们直接对每一个数字都进行回溯遍历,找到所有满足条件的路径即可。

题目中的数组无重复元素,并且每一个元素可以多次重复选择

回溯的时候,我们使用数组的索引作为遍历条件,退出条件为:当已经选择的元素之和等于目标值,或者,已经遍历的完数组中的所有元素。

对于每一个数字,都有两种策略:选择不选择

  • 如果选择当前数字,因为同一个元素可以重复选择,所以,索引不变

  • 如果不选择当前数字,则索引加一,即继续向后面枚举数组中的剩余元素。

以题目中的用例为例,其回溯的过程如下:

image

代码实现

方法一

class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        results = list()
        path = list()
        self.dfs(candidates, target, results, list(), 0, 0)
        return results

    def dfs(self, candidates, target, results, path, total, start):
        if total > target:
             return

        if total == target:
            results.append(list(path))
            return

        for i in range(start, len(candidates)):
            path.append(candidates[i])

            # 可以重复选择,下一次选择时,start 的值还是 i
            self.dfs(candidates, target, results, path, total + candidates[i], i)
            path.pop()
        return

方法二

def dfs(candidates: List, target: int, results: List, path: List, start: int):
    # 如果已经到达决策树的底层,则停止回溯
    if start == len(candidates):
        return

    # 如果满足回溯条件,则保存结果
    if target == 0:
        results.append(path[:])
        return

    # 1.如果不选择当前数字
    dfs(candidates, target, results, path, start + 1)

    # 2.如果选择当前数字
    if target - candidates[start] >= 0:
        # 做选择
        path.append(candidates[start])
        # 继续回溯
        dfs(candidates, target - candidates[start], results, path, start)
        # 撤销选择
        path.pop()

class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        results = list()
        path = list()
        dfs(candidates, target, results, path, 0)
        return results

应用3:Leetcode.40

题目

40. 组合总和 II

输入: \(candidates = [10,1,2,7,6,1,5]\), \(target = 8\),
输出: \([[1,1,6],[1,2,5],[1,7],[2,6]]\)

分析

题目中的数组有重复元素,并且每一个元素只能选择一次

直接对每一个数字进行回溯遍历,枚举所有的选择。

由于数组中可能存在重复元素,所以,可以通过预先对数组排序,将相同的元素排在一起,递归时跳过相同元素,通过这种方式去重,从而避免不同的组合选择相同的元素。

回溯的时候,我们使用数组的索引作为遍历条件,每次回溯后,下一次回溯就从剩余的元素中选择

退出条件为:当已经选择的元素之和等于目标值,或者,已经遍历的完数组中的所有元素。

以如下用例为例:

\(candidates = [1, 1, 2]\), \(target = 3\)

其回溯过程如下:

image

代码实现

class Solution:
    def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
        if not candidates:
            return []

        results = list()
        path = list()

        self.dfs(sorted(candidates), target, path, results, 0)
        return results

    def dfs(self, candidates: List[int], target: int, path: List[int], results: List[List[int]], start: int):
        if target == 0:
            results.append(path[:])
            return

        # 从剩余元素中进行选择
        for i in range(start, len(candidates)):
            if target < candidates[i]:
                break

            # 去重:跳过同一层已经使用过的元素
            if i > start and candidates[i] == candidates[i - 1]:
                continue

            # 不能重复选择,下一次选择时,start 的值就是 i + 1
            path.append(candidates[i])
            self.dfs(candidates, target - candidates[i], path, results, i + 1)
            path.pop()

应用4:Leetcode.216

题目

216. 组合总和 III

输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]

分析

题目可以转换为:从数组 \(nums = [1,2,3,4,5,6,7,8,9]\) 中,选择 \(k\) 个数,使其和为 \(n\)

即数组无重复元素,并且每一个元素只能选择一次

直接对每一个数字进行回溯遍历,枚举所有的选择。

我们使用数组的索引作为遍历条件,每次回溯后,下一次回溯就从剩余的元素中选择,避免重复选择相同元素。

代码实现

方法一

MIN_NUM = 1
MAX_NUM = 9

class Solution:
    def combinationSum3(self, k: int, n: int) -> List[List[int]]:
        results = list()
        self.dfs(k, n, results, list(), MIN_NUM, 0)
        return results

    def dfs(self, k, n, results, path, start, total):
        if total > n or len(path) > k:
            return

        if len(path) == k:
            if total == n:
                results.append(path[::])
            return

        for i in range(start, MAX_NUM + 1):
            path.append(i)

            # 不能重复选择,下一次选择时,start 的值就是 i + 1
            self.dfs(k, n, results, path, i + 1, total + i)
            path.pop()

方法二

MIN_NUM = 1
MAX_NUM = 9

class Solution:
    def combinationSum3(self, k: int, n: int) -> List[List[int]]:
        results = list()
        self.dfs(k, n, results, list(), MIN_NUM, 0)
        return results

    def dfs(self, k, n, results, path, start, total):
        if total == n and len(path) == k:
            results.append(path[::])
            return

        if start > MAX_NUM or len(path) > k:
            return

        # 不选择当前数字
        self.dfs(k, n, results, path, start + 1, total)

        # 选择当前数字
        path.append(start)
        self.dfs(k, n, results, path, start + 1, total + start)
        path.pop()

应用5:Leetcode.78

题目

78. 子集

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

分析

题目特点:数组无重复元素,每个元素至多能选择一次组合不能重复

如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点

其实子集也是一种组合问题,因为它的集合是无序的,子集 \(\{1,2\}\) 和子集 \(\{2,1\}\) 是一样的。

那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从 startIndex 开始,而不是从 0 开始!

以题目中的用例为例,求子集抽象为树型结构,如下:

image

可以看出遍历这棵树的时候,把路径上所有的节点都记录下来,就是要求的子集的集合

代码实现

方法一:

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        results = list()
        self.dfs(nums, results, list(), 0)
        return results

    def dfs(self, nums, results, path, start):
        results.append(list(path))

        # 可要可不要,start最多为len(nums),当start=len(nums),下面的for循环不会执行,递归已经结束了
        if start == len(nums):
            return

        for i in range(start, len(nums)):
            path.append(nums[i])
            # 不能重复选择,下一次选择时,start 的值就是 i + 1
            self.dfs(nums, results, path, i + 1)
            path.pop()
        return

方法二:

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        results = list()
        self.dfs(0, nums, list(), results)
        return results

    def dfs(self, start, nums, path, results):
        if start == len(nums):
            results.append(list(path))
            return

        path.append(nums[start])
        self.dfs(start + 1, nums, path, results)
        path.pop()

        self.dfs(start + 1, nums, path, results)
        return

应用6:Leetcode.90

题目

90. 子集 II

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

分析

数组有重复元素,每个数可以选择,可以不选择,并且每一个元素至多只能选择一次,选择的组合不能有重复

由于组合不能有重复,我们首先对数组排序,用于回溯的时候去重。

这里我们需要用一个 \(visited\) 数组记录已经访问过的元素,用于回溯时,跳过已经选择过的元素。同时,对于未选择的元素,如果它与前一个未选择过的元素相同,则跳过当前元素,这样,就可以避免出现相同的组合。

代码实现

class Solution:
    def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
        if not nums:
            return list()

        results = list()
        nums.sort()
        visited = [False] * len(nums)
        self.dfs(nums, results, list(), visited, 0)
        return results

    def dfs(self, nums, results, path, visited, start):
        results.append(list(path))

        for i in range(start, len(nums)):
            if visited[i]:
                continue

            # 去重:跳过同层的相等元素
            if i > 0 and nums[i] == nums[i - 1] and not visited[i - 1]:
                continue

            path.append(nums[i])
            visited[i] = True
            # 不能重复选择,下一次选择时,start 的值就是 i + 1
            self.dfs(nums, results, path, visited, i + 1)
            path.pop()
            visited[i] = False
        return

因为 \(start\) 下一次递归时,是从 \(i + 1\) 开始的,因此,也可以去掉 \(visited\) 数组:

class Solution:
    def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
        if not nums:
            return list()

        results = list()
        nums.sort()
        self.dfs(nums, results, list(), 0)
        return results

    def dfs(self, nums, results, path, start):
        results.append(list(path))

        for i in range(start, len(nums)):
            if i > start and nums[i] == nums[i - 1]:
                continue

            path.append(nums[i])
            # 不能重复选择,下一次选择时,start 的值就是 i + 1
            self.dfs(nums, results, path, i + 1)
            path.pop()

        return

应用7:Leetcode.46

题目

46. 全排列

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

分析

数组无重复元素,并且每一个元素只能选择一次

这里我们需要用一个 \(visited\) 数组记录已经访问过的元素,避免在一个组合中重复选择相同的元素。

我们使用 \(visited\) 作为遍历条件,每次回溯后,下一次回溯就从没有访问过的的元素中选择,避免重复选择相同元素。

代码实现

class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        results = list()
        visited = [False] * len(nums)
        self.dfs(nums, visited, results, list())
        return results

    def dfs(self, nums, visited, results, path):
        if len(path) == len(nums):
            results.append(list(path))
            return

        for i in range(len(nums)):
            if visited[i]:
                continue
            visited[i] = True
            path.append(nums[i])
            self.dfs(nums, visited, results, path)
            path.pop()
            visited[i] = False

应用8:Leetcode.47

题目

47. 全排列 II

分析

数组有重复元素,并且每一个元素只能选择一次,选择的组合不能有重复

由于组合不能有重复,我们首先对数组排序,用于回溯的时候去重。

这里我们需要用一个 \(visited\) 数组记录已经访问过的元素,用于回溯时,跳过已经选择过的元素。同时,对于未选择的元素,如果它与前一个未选择过的元素相同,则跳过当前元素,这样,就可以避免出现相同的组合。

如后面的代码实现所示,下面两种判断条件都能实现组合的去重:

  • 树枝去重

    表示在决策树的树枝上去重,这种复杂度更高。

    if i >= 1 and nums[i] == nums[i - 1] and visited[i - 1]:
        continue
    

    已经访问过的元素,一定是路径上的元素,当重复元素较多的时候,重复的元素会产生很多重复的组合,会产生很多相同的树枝。

    具体去重的过程如下:

    image

  • 同层去重

    表示在决策树的同层去重,这种复杂更低,效率更高

    if i >= 1 and nums[i] == nums[i - 1] and not visited[i - 1]:
        continue
    

    在源头就去掉重复的元素,如果当前路径上的元素与其他组合的元素相同,从这个节点停止遍历子节点。

    具体去重的过程如下:

    image

代码实现

class Solution:
    def permuteUnique(self, nums: List[int]) -> List[List[int]]:
        if not nums:
            return []
        results = list()
        visited = [False] * len(nums)
        nums.sort()  # 对数组排序,便于回溯时去重
        self.dfs(nums, visited, results, list())
        return results

    def dfs(self, nums, visited, results, path):
        if len(path) == len(nums):
            results.append(list(path))
            return

        for i in range(len(nums)):
            # 跳过已经选择过的元素
            if visited[i]:
                continue

            # 去重:如果当前元素未被访问,且与前一个未选择过的元素相同,则跳过
            if i >= 1 and nums[i] == nums[i - 1] and not visited[i - 1]:
                continue

            path.append(nums[i])
            visited[i] = True
            self.dfs(nums, visited, results, path)
            path.pop()
            visited[i] = False
        return

应用9:Leetcode.491

题目

491. 递增子序列

输入:nums = [4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]

分析

这道题也是求子集问题,但是与 47. 全排列 II 不同的是,这道题不能排序,否则所有的子序列都是递增子序列。

对于无序数组,去重的思路:

  • 使用一个集合 level_used, 用于记录决策树同一层已经出现过的元素;

  • 使用一个数组 visited,用于记录决策树上同一条路径上已经出现过的元素;

以下面的例子:

nums = [4,7,6,7]

去重过程如下:

image

代码实现

class Solution:
    def findSubsequences(self, nums: List[int]) -> List[List[int]]:
        results = list()
        visited = [False] * len(nums)
        self.dfs(nums, results, list(), visited, 0)
        return results

    def dfs(self, nums, results, path, visited, start):
        # 子集:记录路径上的所有节点
        if len(path) > 1:
            results.append(list(path))

        # 用于同一层的元素去重
        level_used = set()
        for i in range(start, len(nums)):
            # 遇到同一条路径上的重复元素,则跳过
            if visited[i]:
                continue

            # 路径上遇到一个递减的元素,或者遇到同层已经出现过的元素,就跳过
            if (len(path) != 0 and nums[i] < path[-1]) or (nums[i] in level_used):
                continue

            # 记录同一层重复的元素
            level_used.add(nums[i])

            path.append(nums[i])
            visited[i] = True
            self.dfs(nums, results, path, visited, i + 1)
            path.pop()
            visited[i] = False

参考:

posted @ 2023-05-08 15:36  LARRY1024  阅读(37)  评论(0编辑  收藏  举报