【问题标题】:Time complexity for all subsets using backtracking使用回溯的所有子集的时间复杂度
【发布时间】:2021-12-20 22:34:38
【问题描述】:

我试图了解使用回溯的时间复杂度。问题是

给定一组唯一整数,返回所有可能的子集。 例如。输入 [1,2,3] 将返回 [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3 ]] 我正在使用回溯解决它:

private List<List<Integer>> result = new ArrayList<>();

public List<List<Integer>> getSubsets(int[] nums) {
    
    for (int length = 1; length <= nums.length; length++) { //O(n)
        backtrack(nums, 0, new ArrayList<>(), length);
    }
    result.add(new ArrayList<>());
    return result;
}

private void backtrack(int[] nums, int index, List<Integer> listSoFar, int length) {
    if (length == 0) {
        result.add(listSoFar);
        return;
    }
    
    for (int i = index; i < nums.length; i++) { // O(n)
        List<Integer> temp = new ArrayList<>(); 
        temp.addAll(listSoFar);                 // O(2^n)
        temp.add(nums[i]);
        backtrack(nums, i + 1, temp, length - 1);
    }
}

代码运行良好,但我无法理解时间/空间复杂度。

我在想的是递归方法被调用了 n 次。在每次调用中,它都会生成最多包含 2^n 个元素的子列表。那么时间和空间,都是O(n x 2^n),对吗?

对吗?如果没有,谁能详细说明?

请注意,我在这里看到了一些答案,例如 this 但无法理解。当递归涉及到图片时,我发现它有点难以理解。

【问题讨论】:

  • 如果您期望结果是每个长度 (1-n) 的所有组合,我认为复杂度会类似于 n * n!
  • n^2 会怎样?你能详细说明吗?你是在说时间复杂度还是空间复杂度?

标签: java subset backtracking


【解决方案1】:

您的代码运行效率不高。

就像链接中的第一个解决方案一样,您只考虑是否包含数字。 (比如得到组合)

这意味着,您不必在 getSubsets 和回溯函数中进行迭代。 “回溯”函数可以用参数迭代“nums”数组

private List<List<Integer>> result = new ArrayList<>();
public List<List<Integer>> getSubsets(int[] nums) {
    
    backtrack(nums, 0, new ArrayList<>(), new ArrayList<>());
    return result;
}

private void backtrack(int[] nums, int index, List<Integer> listSoFar) 
// This function time complexity 2^N, because will search all cases when the number included or not
{
    if (index == nums.length) {
        result.add(listSoFar);
        return;
    }
    
    // exclude num[index] in the subset 
    backtrack(nums, index+1, listSoFar)
    // include num[index] in the subset
    backtrack(nums, index+1, listSoFar.add(nums[index]))
}

【讨论】:

  • 我想了解我发布的代码的时间复杂度,而不是寻找最佳或替代解决方案。此代码也产生错误的答案。 include 调用应该在 exclude 调用之前,结果应该从 listSoFar 的所有元素中添加一个new ArrayList&lt;&gt;(listSoFar)。感谢您提供帮助,但请在发布之前验证您的解决方案!
【解决方案2】:

您对空间复杂性的看法完全正确。最终输出的总空间为 O(n*2^n),这支配了程序使用的总空间。不过,时间复杂度的分析略有偏差。最佳情况下,在这种情况下,时间复杂度与空间复杂度相同,但这里有一些低效率(其中一个是您实际上并没有回溯),因此时间复杂度实际上是 O(n^2 *2^n) 最多。

根据递归方法被调用的次数乘以每次调用所做的工作量来分析递归算法的时间复杂度绝对是有用的。但是要注意backtrack 只被称为n 次:它在顶层被称为n 次,但这忽略了所有后续的递归调用。同样,顶层的每个调用,backtrack(nums, 0, new ArrayList&lt;&gt;(), length); 负责生成所有大小为length 的子集,其中有n Choose length。也就是说,没有一个顶级调用会产生 2^n 个子集;相反,n Choose length 的总和从 0 到 n 是 2^n:

知道在所有递归调用中,您会生成 2^n 个子集,然后您可能想询问在生成每个子集时做了多少工作以确定整体复杂性。最佳情况下,这将是 O(n),因为每个子集的长度从 0 到 n 不等,平均长度为 n/2,所以整体算法可能是 O(n/2*2^n) = O(n *2^n),但您不能只假设子集是最佳生成的,并且没有完成任何重要的额外工作。

在您的情况下,您正在通过 listSoFar 变量构建子集,直到它达到适当的长度,此时它被附加到结果中。然而,listSoFar 的每个 O(n) 个字符在 O(n) 时间内被复制到一个临时列表中,因此生成每个子集的复杂度为 O(n^2),这使整体复杂度为 O( n^2*2^n)。此外,创建了一些 listSoFar 子集,它们永远不会出现在最终输出中(您永远不会检查nums 中是否有足够的数字在递归之前将listSoFar 填充到所需的length),所以你最终在构建子集和进行递归调用方面做不必要的工作,这些调用永远不会到达基本情况以附加到result,这也可能会恶化渐近复杂性。您可以通过回溯来解决这些低效率中的第一个问题,然后使用简单的 break 语句来解决第二个问题。我将这些更改写入了一个 JavaScript 程序,大部分逻辑保持不变,但稍微重新命名/重新组织:

function getSubsets(nums) {
  let subsets = [];
  for (let length = 0; length <= nums.length; length++) {
    // refactored "backtrack" function:
    genSubsetsByLength(length); // O(length*(n Choose length))
  }
  return subsets;

  function genSubsetsByLength(length, i=0, partialSubset=[]) { 
    if (length === 0) {
      subsets.push(partialSubset.slice()); // O(n): copy partial and push to result
      return;
    }
    while (i < nums.length) {
      if (nums.length - i < length) break; // don't build partial results that can't finish
      partialSubset.push(nums[i]); // O(1)
      genSubsetsByLength(length - 1, ++i, partialSubset);
      partialSubset.pop(); // O(1): this is the back-tracking part
    }
  }
}
for (let subset of getSubsets([1, 2, 3])) console.log(`[`, ...subset, ']');

关键的区别是使用回溯来避免每次添加新元素时都复制部分子集,这样每个子集的构建时间为 O(length) = O(n) 而不是 O(n ^2) 时间,因为现在每个添加的元素只完成 O(1) 工作。在每次递归调用后弹出添加到部分结果中的最后一个字符允许您在递归调用中重复使用相同的数组,从而避免为每次调用制作 temp 副本的 O(n) 开销。这一点,以及仅构建最终输出中出现的子集的事实,允许您根据输出中所有子集的元素总数来分析总时间复杂度:O(n*2^n)。

【讨论】:

  • 谢谢你!我想我明白了,但仍然需要花一些时间写出来才能完全明白!
  • @Ufder 没问题!如果有任何特定部分在您完成后仍不清楚,请随时询问。
  • 刚刚看完解释,说得通!我了解从listSoFar 添加元素的更改,以递归方式传递它并删除该元素以进行下一次迭代以具有相同的listSoFar。一个后续问题,假设我正在查看您的 genSubsetsByLength,我会说它的 O(2^n),因为我知道这是生成子集的时间复杂度。但是,我如何从函数中推断出来呢?即分支因子为2,高度为n? (2 可能是因为一个元素可以存在或不存在,但看for 并不清楚)。无论如何,感谢您的帮助!
  • @Ufder 从技术上讲,genSubsetsByLength(length=k)(或 backtrack(..., length=k) 函数调用)是 O(n* n 选择 k),因为每个顶级调用只生成大小为 k 的子集(不是全部2^n 个子集),并且有 n Choose k 个子集,大小为 k,每个长度为 O(n)getSubsets 中从 0 到 n 的所有调用一起生成 2^n 个子集。
  • 很难从代码中直接从递归树的角度分析genSubsetsByLength,因为分支的数量因调用和级别而异(genSubsetsByLength(k, i, partial) 的分支因子是(n-i))。由于i 在每次调用之前都会递增,因此每次递归调用产生的调用都比其父调用少一个,并且您会得到一个涉及阶乘函数的分支结构。
猜你喜欢
  • 2019-04-03
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-01-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多