简体   繁体   中英

Time complexity for all subsets using backtracking

I am trying to understand the time complexity while using backtracking. The problem is

Given a set of unique integers, return all possible subsets. Eg. Input [1,2,3] would return [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]] I am solving it using backtracking as this:

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);
    }
}

The code works fine, but I am having trouble understanding the time/space complexity.

What I am thinking is here the recursive method is called n times. In each call, it generates the sublist that may contain max 2^n elements. So time and space, both will be O(nx 2^n), is that right?

Is that right? If not, can any one elaborate?

Note that I saw some answers here, like this but unable to understand. When recursion comes into the picture, I am finding it a bit hard to wrap my head around it.

Your code works not efficiently.

Like first solution in the link, you only think about the number will be included or not. (like getting combination)

It means, you don't have to iterate in getSubsets and backtrack function. "backtrack" function could iterate "nums" array with parameter

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]))
}

You're exactly right about space complexity. The total space of the final output is O(n*2^n), and this dominates the total space used by the program. The analysis of the time complexity is slightly off though. Optimally, time complexity would, in this case, be the same as the space complexity, but there are a couple inefficiencies here (one of which is that you're not actually backtracking) such that the time complexity is actually O(n^2*2^n) at best.

It can definitely be useful to analyze a recursive algorithm's time complexity in terms of how many times the recursive method is called times how much work each call does. But be careful about saying backtrack is only called n times: it is called n times at the top level, but this is ignoring all the subsequent recursive calls. Also every call at the top level, backtrack(nums, 0, new ArrayList<>(), length); is responsible for generating all subsets sized length , of which there are n Choose length . That is, no single top-level call will ever produce 2^n subsets; it's instead that the sum of n Choose length for lengths from 0 to n is 2^n:

在此处输入图片说明

Knowing that across all recursive calls, you generate 2^n subsets, you might then want to ask how much work is done in generating each subset in order to determine the overall complexity. Optimally, this would be O(n), because each subset varies in length from 0 to n, with the average length being n/2, so the overall algorithm might be O(n/2*2^n) = O(n*2^n), but you can't just assume the subsets are generated optimally and that no significant extra work is done.

In your case, you're building subsets through the listSoFar variable until it reaches the appropriate length, at which point it is appended to the result. However, listSoFar gets copied to a temp list in O(n) time for each of its O(n) characters, so the complexity of generating each subset is O(n^2), which brings the overall complexity to O(n^2*2^n). Also, some listSoFar subsets are created which never figure into the final output (you never check to see that there are enough numbers remaining in nums to fill listSoFar out to the desired length before recursing), so you end up doing unnecessary work in building subsets and making recursive calls which will never reach the base case to get appended to result , which might also worsen the asymptotic complexity. You can address the first of these inefficiencies with back-tracking, and the second with a simple break statement. I wrote these changes into a JavaScript program, leaving most of the logic the same but re-naming/re-organizing a little bit:

 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, ']');

The key difference is using back-tracking to avoid making copies of the partial subset every time you add a new element to it, such that each is built in O(length) = O(n) time rather than O(n^2) time, because there is now only O(1) work done per element added. Popping off the last character added to the partial result after each recursive call allows you to re-use the same array across recursive calls, thus avoiding the O(n) overhead of making temp copies for each call. This, along with the fact that only subsets which appear in the final output are built, allows you to analyze the total time complexity in terms of the total number of elements across all subsets in the output: O(n*2^n).

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

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