[Notes] Backtracking(回溯)

Posted by 西维蜀黍的OJ Blog on 2023-09-10, Last Modified on 2025-04-16

策略

  1. 先找到base case
  2. 回溯逻辑

解析

17. Letter Combinations of a Phone Number

ref

39. Combination Sum

  • we need to avoid duplicates, e.g., [2,2,3] and [3,2,2] are the same

    • how to achieve:
      • in our decision tree, one branch is to include the current element ([2,2]), and another branch is not to include the current element ([2])
      • i.e., 可不可以在搜索的时候就去重呢?答案是可以的。做法:遇到这一类相同元素不计算顺序的问题,我们在搜索的时候就需要 按某种顺序搜索。具体的做法是:每一次搜索的时候自增 下一轮搜索的起点 begin
  • Base case:

    • total == target -> found a combination

    • begin >= len(candidates) or total > target -> cannot find any combination

  • 复杂度分析

    • 时间复杂度:$O(2^{target/m})$,m is the mim value in nums, target/m 是树的深度
    • 空间复杂度:O(target/m)。除答案数组外,空间复杂度取决于递归的栈深度,在最差情况下需要递归 O(target/m)层。
    # backtracking
    #   - time: O(2^(target/m)), m is the mim value in nums, target/m 是树的深度
    #   - space: O(target/m), m is the mim value in nums, O(target/m) 是递归时栈使用的空间(栈深度),target/m 是树的深度
    def combinationSum(self, candidates, target):
        """
        :type candidates: List[int]
        :type target: int
        :rtype: List[List[int]]
        """
        res = []

        def dfs(start, curSequence, curSum):
            # only consider [start, len(candidates)) from candidates
            if curSum == target:  # find a combination
                res.append(curSequence[:])  # couldn't directly use curSequence, since we still use curSequence later
                return
            if curSum > target or start >= len(candidates):  # cannot find any combination
                return

            # case 1: 包括当前 start 指向的元素
            curSequence.append(candidates[start])
            dfs(start, curSequence, curSum + candidates[start])
            curSequence.pop(-1)

            # case 2: 不包括 start 指向的元素,且 start 自增
            dfs(start + 1, curSequence, curSum)

        dfs(0, [], 0)
        return res

Ref

40. Combination Sum II

  • 和 3 sum 题目的去重逻辑非常像:
    • 对 candidates 先排序,变成 [1,1,2,5,6,7,10]
    • 如果当前元素和前一个元素值相等, start += 1,直到不满足该条件
    # backtracking - myself
    #  - time: O(n*2^n)
    #  - space: O(n)
    def combinationSum2(self, candidates, target):
        """
        :type candidates: List[int]
        :type target: int
        :rtype: List[List[int]]
        """
        res = []
        candidates.sort()

        def dfs(start, subArray, curSum):
            if curSum == target:
                res.append(subArray[:])
                return
            if start >= len(candidates) or curSum > target:
                return

            # include candidates[start]
            subArray.append(candidates[start])
            # "start + 1", since [Each number in candidates may only be used once in the combination.]
            dfs(start + 1, subArray, curSum + candidates[start])

            # skip candidates[start] and also the candidates with the same value of candidates[start]
            subArray.pop(-1)
            start += 1
            while start <= len(candidates) - 1 and candidates[start] == candidates[start - 1]:
                start += 1
            dfs(start, subArray, curSum)

        dfs(0, [], 0)
        return res

ref

46. Permutations

思路

  • permutations的总数量:有 n 个位置,第一个位置可以选 n 个选项,第二个位置可以选 n-1 个选项,最后一个位置可以选 1 个选项,所以总共有 n * (n-1) * (n-2) *… *1 = n!
  • 想如何把这个大问题化解成一个小问题
  • base case 是如果只有一个元素,则直接深拷贝后返回

复杂度分析

  • 时间复杂度:$O(n*n!)$,其中 n 为序列的长度。

    • 时间复杂度和数组排列的方案数成线性关系,方案数为 $N×(N−1)×(N−2)…×2×1$,即复杂度为 $O(N!)$ ;数组旋转操作 使用 $O(N)$ ;因此总体时间复杂度为 O(N!N) 。
  • 空间复杂度:O(n*n!),其中 n 为序列的长度。

    • 递归函数在递归过程中需要为每一层递归函数分配栈空间,所以这里需要额外的空间且该空间取决于递归的深度,这里可知递归调用深度为 O(n)。
    • 答案数组 需要 O(n*n!)
    # backtracking - myself (逻辑有问题,虽然提交后,是完全通过的)
    #  - time: O(n!*n), n! = n * (n-1) * (n-2) ... * 1
    #     - 算法的复杂度首先受 backtrack 的调用次数制约,permute 的调用次数为 n! + n!/1! + n!/2! + n!/3! ... + n!/(n-1)! < 2n! + n!/2 + n!/2^2 + n!/2^{n-2} < 3n!
    #  - space: O(n!*n), O(n!*n) for the output list, n for calling stack (for recursion)
    def permute(self, nums):
        """
        :type nums: List[int]
        :rtype: List[List[int]]
        """
        res = []

        # base case
        if len(nums) == 1:
            return [nums[:]]

        for _ in range(len(nums)):  # run n times, time: O(n)
            n = nums.pop(0)  # remove the first element from nums
            perms = self.permute(nums)  # time: O(n!)
            for perm in perms: # time: O(n), e.g., we have [2, 3], [3, 2]. Then we are gonna add 1 into them 
                perm.append(n)
            res.extend(perms)

            nums.append(n)  # append the removed element to the end

        return res
      
      
    def permute(self, nums: List[int]) -> List[List[int]]:
        self.res = []
        self.backtrack(nums, 0)
        return self.res

    def backtrack(self, nums: List[int], idx: int):
        if idx == len(nums):
            self.res.append(nums[:])
            return
        for i in range(idx, len(nums)):
            nums[idx], nums[i] = nums[i], nums[idx]
            self.backtrack(nums, idx + 1)
            nums[idx], nums[i] = nums[i], nums[idx]      

ref

78. Subsets

  • 遍历数组,对于数组中的每一个整数,每一步都向输出子集中所有子集添加(或不添加)这个整数,并生成新的子集

    • 记原序列中元素的总数为 n。原序列中的每个数字 ai 的状态可能有两种,即「在子集中」和「不在子集中」
  • 复杂度分析

    • 时间复杂度:$O(n×2^n)$)。一共 $2^n$ 个状态,每种状态需要 O(n) 的时间来构造子集。
      • 在整个递归调用的过程中,start 是从小到大递增的,当 start 增加到 n 的时候,记录答案并终止递归。可以看出进行枚举的时间复杂度是 $O(2^n)$
    • 空间复杂度:O(n),不考虑返回结果所占用的空间。临时数组 t 的空间代价是 O(n),递归时栈空间的代价为 O(n)。
# solution 1 - brute force (recursion)
# - time: O(n*2^n)??
# - space: O(n)
	def subsets(self, nums: List[int]) -> List[List[int]]:
		res = []

		def backtracking(start, curSubsets):
			# to consider the elements in nums: [start, len(nums))
			if start >= len(nums):
				res.append(curSubsets.copy())
				return

			# case 1: decision to include nums[i]
			curSubsets.append(nums[start])
			backtracking(start + 1, curSubsets)

			# case 2: decision not to include nums[i]
			curSubsets.pop(-1)
			backtracking(start + 1, curSubsets)

		backtracking(0, [])
		return res

ref

90. Subsets II

Ref

ref

131. Palindrome Partitioning

ref