LeetCode 698.Partition_to_k_equal_subsets. Three different solutions: DFS, DP, DP topdown + DFS

Problem

Partition a set A = ( a 1 , a 2 , . . . , a n ) A=(a_1,a_2,...,a_n) A=(a1,a2,...,an) to k k k disjointed subsets. All the k k k subset has a element summation of s u m / k sum/k sum/k where s u m sum sum is the element summation of A A A.
For e.g.
[1,2,3,6,6], 3 -> [1,2,3] + [6] + [6] -> true;
[3,3,3,4], 3 -> false

Intuition

  • knapsack_without_repetition.
  • Put items one by one, each to an arbitrary suitable container, if proper.
  • Fill up containers one by one, each with arbitrary itmes, if proper.

Analysis

  • knapsack_without_repetition (X)

    • valid for k = 2;
    • invalid for k>=3;
      • There may have m m m subsets (mark as a set of set M M M) with correct sum, but only n n n of them (mark as a set of set N N N) are disjointed one by one. The knapsack algorithm can only traceback one possible subset, which means it could return a subset in M − N M-N MN. Thus, the knapsack algorithm can return false for some of the true case.
  • Intuition2 -> DFS on a tree

    • Construct a decision tree: Nodes in the m t h m^{th} mth level of the tree indicates all possible (correct) container choices of ( a 1 , a 2 , . . . a m ) (a_1, a_2, ... a_m) (a1,a2,...am).
    • DFS on the tree: Traverse the decision tree, but not entire nodes. Store a filling_status of each container. When you try to visit a node in the next level, check whether the value of it can be added to filling_status. If you can visit a node in the $n^{th} $ level, return true.
    • Time: O ( k ( n − k ) k ! ) O(k^{(n-k)}k!) O(k(nk)k!): Each item has k k k choices of containers expect the last k k k items.
    • Space: O ( n ) O(n) O(n): Store at most n n n choices in a single recursion call.
    • Improvement and notes on implementation: See implementation part.
  • Intuition3 -> DP

    • Final case: you have used n − 1 n-1 n1 items in a proper way and now you need to put the n t h n^{th} nth item into the k t h k^{th} kth container. Determine whether it is possible.
    • General case: you have used some items, say s = ( 0010...1011 ) s=(0010...1011) s=(0010...1011), where s i = 1 s_i=1 si=1 denotes you have used the i t h i^{th} ith item. Now you try to put another item properly in the current container. Determine which items you could choose.
    • DP recurrence: Starting from a state, s = ( 00...00 ) = 0 s=(00...00) = 0 s=(00...00)=0, mark all possible next_state of 0. Go to the second state of ( 00....01 ) = 1 (00....01) = 1 (00....01)=1 and mark all possible next_state of 1. … Finally, check if final state ( 11...11 ) = 2 n − 1 (11...11) = 2^n-1 (11...11)=2n1 is marked. A marked last state indicates it is reachable from the first state (0).
    • Time: O ( n ∗ 2 n ) O(n*2^n) O(n2n): There are 2 n 2^n 2n states, to find all possible next_state for a certain state, a for loop is required.
    • Space: O ( 2 n ) O(2^n) O(2n): Store whether it is possible to reach each state from the first state (0).
    • Implementation Scheme: Top-down or bottom-up.
    • Improvement and notes on implementation: See the implementation part.

Implementation of tree DFS

  • Improve running time since O ( k n − k k ! ) O(k^{n-k}k!) O(knkk!) is sort of unaffordable.

    • (Essential) Reduce the deepth of each visit of DFS: Sort A A A, then put items from the largest to smallest one by one. This means in our decision tree, value of nodes in shallow levels are much more greater than those in deeper levels. A single search thus will be easier to terminate in shallower levels. (Most containers will be filled up quickly)
    • (Essential) Cut down the number of branches of the tree to 1 / k ! 1/k! 1/k! the original number
      • Consider the program returns a false (at tree root). The first node of the first level must also retrun a false. Then there is no need to go through the second, third … k t h k^{th} kth nodes of the first level:They must return false; all containers are same. -> Reduce the number of branches to 1 / k 1/k 1/k the original.
      • More general case: Consider the program returns a false for the j t h j^{th} jth node at i t h i^{th} ith level, and filling_status[j] == 0 (container j j j is empty before putting item i i i into it) after backtracking. We do not need to visit j + 1 , j + 2 , . . . k t h j+1, j+2, ... k^{th} j+1,j+2,...kth nodes of i t h i^{th} ith level. Because those containers are also empty. Put the i t h i^{th} ith item into them will certainly return false. -> Reduce the number of branches to at most (if all i i i items are in different containers) 1 / ( k − i ) 1/(k-i) 1/(ki) the original.

    Thus, we cut the number of branches to 1 / k ! 1/k! 1/k! the original number. The time complexity becomes O ( k n − k ) O(k^{n-k}) O(knk). Since we put big items first, nodes in deeper levels will probably not be visited. The actual running time is much more less than O ( k n − k ) . O(k^{n-k}). O(knk).

  • Notes on implementation

    • Do not forget backtracking: if false -> subtract the item added to filling_status.
    • Remember you are putting items one by one
  • C++ Implementation

class Solution {
// This is the tree-DFS solution of LeetCode 698. There are two other solutions
public:
    bool canPartitionKSubsets(vector<int>& A, int k) {
        int sum = accumulate(A.begin(), A.end(), 0);
        // Essential for this tree DFS: Cut branches at deeper levels
        sort(A.begin(), A.end()); 

        // Corner cases to save time
        if (sum % k != 0 || A.size() < k || A[A.size()-1] > sum/k) 
            return  false; 
        int starting_index = A.size() - 1;
        while (A[starting_index] == sum/k) {
            sum -= A[starting_index];
            starting_index --;
            k--;
            if (k == 1) return true; // remaining must equals sum/k
        }
        // if (k == 2) // you can write a kanpsack-without-repetition algorithm to eliminate running time.

        vector<int> filling_status;
        for (int j = 0; j < k; j++)
            filling_status.push_back(0);
        return partition_k_tree_dfs(A, sum, k, starting_index, filling_status);
    }

    bool partition_k_tree_dfs (vector<int> A, int sum, int k, int starting_index, vector<int> &filling_status) {
        if (starting_index < 0)
            return true;

        for (int j = 0; j < k; j++) {
            if (A[starting_index] + filling_status[j] <= sum/k) {
                filling_status[j] += A[starting_index];
                // recursively tackling with the next element:
                if (partition_k_tree_dfs(A, sum, k, starting_index-1, filling_status)) return true;
                filling_status[j] -= A[starting_index]; // Backtracking
            if (filling_status[j] == 0) break; 
            // Essential: If an element cannot be put in an empty subset it would not be possible to put in other
            // empty subsets as well: All subsets are same.
            // Draw a tree to see that this heuristic reduces branches to 1/k! of the orginal number.
            }
        }
            return false;
    }
};    

Implementation of DP (bottom-up scheme)

Assume a all-false boolean array state of length 2 n − 1 2^n-1 2n1. We set state[0] = true to enter the DP algorithm. After marking all next_state of state 0, we go to these marked state and mark the next_state of them.

Skip those false states (not marked). Even though they could forge a path to the final state ( 2 n − 1 2^n-1 2n1), one can not reach these unmarked states form the first state (0).

Essentially, a state is a correct order of putting some items (marked as 1 in this state) to fill the containers from 1 to k k k.

Use total[state] to indicate the total value of used items. Instead of filling up containers one by one as intuition writes, we just need to ensure that putting an item will not cross the remaining volume of the current container: a[i] <= targ_line - total % targ_line, targ_line = sum/k.

  • Save running time.

    • If the next_state of the current state has been marked as true, no need to mark it again.
    • Sort A A A ( O ( n l o g n ) < < O ( n 2 n ) O(nlogn) << O(n2^n) O(nlogn)<<O(n2n) ), then the correct next_states of a state will be continuous: If a next_state crosses the targ_line, then the following next_state(s) (even bigger) must also cross the targ_line. So, just break;.
  • Binary bitwise arithmetic: We store states as integers to save space. ( O ( 2 n ) O(2^n) O(2n) boolean array of size n n n -> O ( 2 n ) O(2^n) O(2n) bits.). It is also convenient to switch states using binary bitwise arithmeic. Here we use the following rules:

    • In C++, ^ is not the power operator, instead, using 1 < < N = 2 N 1<<N = 2^N 1<<N=2N
    • <<, >> operator have smaller priority than +, -. Thus, always use a () for binary operators to avoid debugging later.
    • Check whether the i t h i^{th} ith bit is 0 or 1: state >> i & 1 == 0 -> (XXX…i) & (000…1) -> (000… i&1) = 1 or 0.
    • Mark the i t h i^{th} ith as 1: state | 1 << i -> (XXX0…X) | (0001…0) = (XXX1…X).
  • C++ Implementation

class Solution {
// This is the DP solution - bottom-up scheme of LeetCode 698. There are two other solutions
public:
    bool canPartitionKSubsets(vector<int>& A, int k) {
        int sum = accumulate(A.begin(), A.end(), 0);
        // sort A to make correct next_state(s) of a state continuous -> save running time.
        sort(A.begin(), A.end());  
        
        // Corner cases to save running time
        if (sum % k != 0 || A.size() < k || A[A.size()-1] > sum/k) 
            return  false; 

        // reduce to smaller k if possible, to save running time
        for (int i = 0; i < A.size(); i++)
            if (A[i] == sum/k) {
                k--;
                sum -= A[i];
                A.erase(A.begin() + i);
                i--;
                if (k == 1) return true; // remaining must have a sum of sum/k
            }

        // You can write a knapsack-without-repetition algorithm for k=2, to save running time
        // if (k == 2)

        return partition_k_dp_bottom_up(A, sum/k, sum);
    }

    bool partition_k_dp_bottom_up(vector<int> A, int targ_line, int sum) {
        int N = A.size();
        bool path[1<<N] = {false}; // 2^A.szie() is wrong, ^ is a logic operator
        // in Java, no need to initialize boolean variables, but in C++, you should.
        int total[1<<N]; // in each state, total value of the used items.
        total[0] = 0; 
        path[0] = true; // allow us to start at n=0
        for (int state = 0; state < (1<<N); state++) {
            if (!path[state]) continue; // impossible to be reached from state 0
            for (int i = 0; i < N; i++) {
                int next_state = state | (1<<i);
                if (next_state != state && !path[next_state]) {
                    // if next_state = true, it has been visted , do not calculate it again
                    if (A[i] <= targ_line - (total[state] % targ_line)) {
                        path[next_state] = true;
                        total[next_state] = total[state] + A[i];
                    }
                    else
                        break;  // A is sorted, if a next_state crosses the targ_line, 
                                // then the following next_state(s) must cross too. 
                }
            }
        }
        return path[(1<<N) - 1];
    }

};

Implementation of DP (top-down scheme with graph DFS)

We start from the top state ( 2 n − 1 2^n-1 2n1), remove an item in the last ( k t h k^{th} kth) container which creates n n n possible previous_state (s). Recursively go through these n n n previous_state(s) and check whether it is possible to reach the first state(0).

This is essentially a DFS on graph. States can be set as nodes in a tree whose root is 2 n − 1 2^n-1 2n1. But for a certain node, there are various nodes (previous_state) to reach it (e.g. (011)->(001), (101)->(001)). Thus, it is a graph.

In this third solution, the DFS part is similar to the first solution, and the DP part is similar to the second solution. Check them in previous solutions.

  • Notes on implementation

    • Must use memorization, so that the time complexity can be O ( n 2 n ) O(n2^n) O(n2n).
  • C++ Implementation

class Solution {
    // This is the DP solution - top-down scheme (also a DFS on graph) of LeetCode 698. 
    // There are two other solutions for this problem.
public:
    bool canPartitionKSubsets(vector<int>& A, int k) {
        int sum = accumulate(A.begin(), A.end(), 0);
        sort(A.begin(), A.end());

        // Corner cases to save running time
        if (sum % k != 0 || A.size() < k || A[A.size()-1]> sum/k)
            return  false; 

        // reduce to smaller k if possible, to save running time
        for (int i = 0; i < A.size(); i++) 
                if (A[i] == sum/k) {
                    k--;
                    sum -= A[i];
                    A.erase(A.begin() + i);
                    i--;
                    if (k == 1) return true; // remaining must have a sum of sum/k
                }

        // to save running time, you can write a knapsack-without-repetition algorithm for k=2
        // if (k == 2) 

        unordered_map<int, bool> visited;
        // 1<< A.szie() - 1 is wrong, it will return 1<<(A.size()-1)
        return partition_k_dp_top_down(sum/k, (1<<A.size()) - 1, sum, A, visited);
    }

    bool partition_k_dp_top_down(int targ_line, int state, int total, vector<int> &A, unordered_map<int, bool>& visited) {
        cout << state << endl;
        // base case
        if (total == 0)
            return true;
        
        if (visited.find(state) != visited.end())
            return visited[state];

        // Maximum number that is valid to remove in the current container
        int max_remove = (total - 1) % targ_line + 1;
        // Essentially is total % targ_line, 
        // or targ_line (if total % targ_line == 0)

        for (int i = A.size() - 1; i >=0; i--) {
            if ( ((state>>i) & 1) == 1 && A[i] <= max_remove) // i is not state
                if (partition_k_dp_top_down(targ_line, state - (1<<i) , total - A[i], A, visited)) return true;
                // state - 2^i is wrong: ^ is not power operator; 
                // total becomes 0 after severl function calls
        }
        visited[state] = false;
        return false; 
    }

};

Comparison between three solutions

  • Performance
    • DFS: 16ms (beat 51%)
    • DP-bottom-up: 32ms (beat 47%)
    • DP-top-down: 8ms (beat 70%)
  • Summary
    This is actually the first LeetCode problem I solve. (except Prob. 1 and 2) . I did it since I just learned knapsack algorithm. I spent really a lot of time on the above three solutions and definitely, learned a lot: DFS, binary operations and deeper understanding of recursion, complexity of recursion and DP recurrence.
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值