繁体   English   中英

使用回溯的所有子集的时间复杂度

[英]Time complexity for all subsets using backtracking

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

给定一组唯一整数,返回所有可能的子集。 例如。 输入 [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(nx 2^n),对吗?

是对的吗? 如果没有,谁能详细说明一下?

请注意,我在这里看到了一些答案,就像这样但无法理解。 当递归出现在画面中时,我发现我很难理解它。

您的代码工作效率不高。

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

这意味着,您不必在 getSubsets 和回溯函数中进行迭代。 “backtrack”函数可以用参数迭代“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]))
}

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

根据递归方法被调用的次数乘以每次调用的工作量来分析递归算法的时间复杂度绝对有用。 但是要小心说backtrack只调用了n次:它在顶层被调用了n次,但这忽略了所有后续的递归调用。 也是在顶层的每次调用, backtrack(nums, 0, new ArrayList<>(), length); 负责生成所有lengthlength子集,其中有n Choose length 也就是说,没有一个顶级调用会产生 2^n 个子集; 相反,从 0 到 n 的长度的n Choose length的总和是 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子集,它们永远不会出现在最终输出中(在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(length): 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)。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM