Skill Set - 0/1 Knapsack


Skills for tackling LeetCode problems - 0/1 Knapsack.

0/1 Knapsack can be efficiently solved by Memoization and Bottom-Up Dynamic Programming.

1. 0/1 Knapsack

Given two integer arrays to represent weights and profits of N items, we need to find a subset of these items which will give us maximum profit such that their cumulative weight is not more than a given number C. Each item can only be selected once, which means either we put an item in the knapsack or we skip it.

1] Brute-Force Solution - Recursion.

# Time complexity O(2^N). Space complexity O(N). N is the total number of items.
def solve_knapsack(profits, weights, capacity):
    return recursive(profits, weights, capacity, 0)

def recursive(profits, weights, capacity, index):
    # base
    if capacity <= 0 or index >= len(profits):
        return 0
    
    # recursive call after choosing the element at the index
    profit1 = 0
    if weights[index] <= capacity:
        profit1 = profits[index] + recursive(profits, weights, capacity - weights[index], index + 1)
    
    # recursive call after excluding the element at the index
    profit2 = recursive(profits, weights, capacity, index + 1)
    return max(profit1, profit2)

2] Top-down Dynamic Programming with Memoization. We can more efficiently solve overlapping subproblems.

Since we have two changing values index and capacity in the recursive function recursive(), we can use a 2d array to store the results of all the solved subproblems.

# Time complexity O(N * C) since there ar N * C subproblems. Space complexity O(N * C + C).
def solve_knapsack(profits, weights, capacity):
    # 2d array to store all possible profits
    dp = [[-1 for _ in range(capacity + 1)] for _ in range(len(profits))]
    return recursive(dp, profits, weights, capacity, 0)

def recursive(dp, profits, weights, capacity, index):
    if capacity <= 0 or index >= len(profits):
        return 0
    if dp[index][capacity] != -1:
        return dp[index][capacity]
    
    # recursive call after choosing the element at the index
    profit1 = 0
    if weights[index] <= capacity:
        profit1 = profits[index] + recursive(dp, profits, weights, capacity - weights[index], index + 1)
        
    # recursive call after excluding the element at the index
    profit2 = recursive(dp, profits, weights, capacity, index + 1)
    
    dp[index][capacity] = max(profit1, profit2)
    
    return dp[index][capacity]

3] Bottom-up Dynamic Programming. dp[i][c] will represent the maximum knapsack profit for capacity ‘c’ calculated from the first ‘I’ item. The function for the maximum profit is

dp[i][c] = max(dp[i-1][c], profit[i] + dp[i-1][c-weight[i]]).

where dp[i-1][c] is the profits excluding the ‘i’th item, and profit[i] + dp[i-1][c-weight[i]] is the profits including the ‘i’th profit.

# Time complexity O(N * C). Space complexity O(N * C).

def solve_knapsack(profits, weights, capacity):
    # base
    n = len(profits)
    if capacity <= 0 or n == 0 or len(weights) != n:
        return 0
    
    dp = [[0 for _ in range(capacity + 1)] for _ in range(n)]
    
    # populate the capacity = 0 colunms, the profits for 0 capacity are 0
    for i in range(n):
        dp[i][0] = 0
        
    # if it can contain the first item, then add the profit
    for c in range(capacity + 1):
        if weights[0] <= c: 
            dp[0][c] = profits[0]
            
    for i in range(n):
        for c in range(capacity + 1):
            profit1, profit2 = 0,0
            
            # including the profit at the index
            if weights[i] <= c:
                profit1 = profits[i] + dp[i-1][c - weights[i]]
            
            # excluding the profit at the index
            profit2 = dp[i-1][c]
            dp[i][c] = max(profit1, profit2)
            
    return dp[n-1][capacity]

4] Bottom-up Dynamic Programming with a single array to reduce the space complexity to O(C). We use the same array for the previous and the next iteration.

def solve_knapsack(profits, weights, capacity):
    # base
    n = len(profits)
    if capacity <= 0 or n == 0 or len(weights) != n:
        return 0
    
    dp = [0 for _ in range(capacity + 1)]
        
    # if it can contain the first item, then add the profit
    for c in range(capacity + 1):
        if weights[0] <= c: 
            dp[c] = profits[0]
            
    for i in range(1, n):
        for c in range(capacity, -1, -1): # we can never overwrite dp[c - weights[i]]
            profit1, profit2 = 0,0
            
            # including the profit at the index
            if weights[i] <= c:
                profit1 = profits[i] + dp[c - weights[i]]
            
            # excluding the profit at the index
            profit2 = dp[c]
            dp[c] = max(profit1, profit2)
            
    return dp[capacity]

2. Equal Subset Sum Partition

Given a set of positive numbers, find if we can partition it into two subsets such that the sum of elements in both subsets is equal.

1] Basic Recursion. Two equal subsets have a sum equal to a half of the sum of all the given numbers: S/2. So the problem is the same as to find a subset of the given numbers that has a total sum of S/2.

# Time complexity O(2^N). Space complexity O(N).

def can_partition(num):
    S = sum(num)
    if S % 2 != 0:
        return False
    return recursion(num, S / 2, 0)

def recursion(num, S, index):
    # base
    if S == 0:
        return True
    
    n = len(num)
    if n == 0 or index >= n:
        return False
    
    # recursive call after choosing the number at the index
    if num[index] <= S:
        if (recursion(num, S - num[index], index + 1)):
            return True
        
    # recursive call after excluding the number at the index
    return recursion(num, S, index + 1)

2] Top-Down Dynamic Programming with Memoization.

# Time complexity O(N * S). Space complexity O(N * S). N is the count of total numbers and S is the totoal sum of all the numbers.
def can_partition(num):
    S = sum(num)
    if S % 2 != 0:
        return False
    
    dp = [[-1 for _ in range(int(S/2) + 1) ] for _ in range(len(num))]
    return recursion(dp, num, int(S / 2), 0)

def recursion(dp, num, S, index):
    # base
    if S == 0:
        return True
    
    n = len(num)
    if n == 0 or index >= n:
        return False
    
    # recursive call after choosing the number at the index
    if dp[index][S] == -1:
        if num[index] <= S:
            if recursion(dp, num, S - num[index], index + 1):
                dp[index][S] = 1
                return True
        
    # recursive call after excluding the number at the index
    dp[index][S] = recursion(dp, num, S, index + 1)
    
    return dp[index][S]

3] Bottom-Up Dynamic Programming.

# Time complexity O(N * S). Space complexity O(N * S).
def can_partition(num):
    S = sum(num)
    
    if S % 2 != 0:
        return False
    
    S = int(S / 2)
    n = len(num)
    dp = [[False for _ in range(S + 1)] for _ in range(n)]
    
    for i in range(n): # for 0 sum
        dp[i][0] = True
        
    for j in range(1, S + 1): # for the first number
        dp[0][j] = (num[0] == j)
        
    for i in range(1, n):
        for j in range(1, S+1):
            if dp[i-1][j]: # same as the above cell
                dp[i][j] = dp[i-1][j]
            elif j >= num[i]:
                dp[i][j] = dp[i-1][j - num[i]]
    
    # The bottom right is the answer
    return dp[n-1][S]

3. Subset Sum

Given a set of positive numbers, determine if a subset exists whose sum is equal to a given number ‘S’.

1] Bottom-up Dynamic Programming.

For every possible sum S, we have two options:

  • Exclude the number. See whether we can get the sum S from the subset excluding this number dp[index-1][S]
  • Include the number if its value is not more than S. See if we can find a subset to get the remaining sum dp[index-1][s-sum[index]].

If either of the above two scenarios returns true, we can find a subset with a sum equal to S.

# Time complexity O(N * S). Space complexity O(N * S).
def can_partition(num, sum):
    n = len(num)
    dp = [[False for _ in range(sum + 1)] for _ in range(n)]
    
    # for sum = 0
    for i in range(n):
        dp[i][0] = True
        
    # for the first number
    for s in range(1, sum + 1):
        dp[0][s] = True if num[0] == s else False
    
    
    for i in range(1, n):
        for s in range(1, sum + 1):
            # for those we can get the sum from the above cell
            if dp[i-1][s]:
                dp[i][s] = dp[i-1][s]
            # see whether including the num at index can sum to the target
            elif s >= num[i]:
                dp[i][s] = dp[i - 1][s - num[i]]
    # the bottom-right is the answer
    return dp[n-1][sum]

Improve the algorithm to reduce the space complexity to O(S).

def can_partition(num, sum):
    n = len(num)
    dp = [False for _ in range(sum + 1)]
    
    # for sum = 0
    dp[0] = True
    
    # for the first number
    for s in range(1, sum+1):
        dp[s] = (num[0] == s)
        
    for i in range(1, n):
        for s in range(sum, -1, -1): # reverse order
            if not dp[s] and s >= num[i]:
                dp[s] = dp[s - num[i]]
    return dp[sum]

4. Minimum Subset Sum

Given a set of positive numbers, partition the set into two subsets with minimum difference between their subset sums.

1] Brute-Force Solution: Recursion.

# Time complexity O(2^N). Space complexity O(N).

def can_partition(num):
    return recursion(num, 0, 0, 0)

def recursion(num, index, sum1, sum2):
    if index == len(num):
        return abs(sum1 - sum2)
    
    # recursion call after including the number at the index in the first set
    diff1 = recursion(num, index + 1, sum1 + num[index], sum2)
    
    # recursion call after including the number at the index in the second set
    diff2 = recursion(num, index + 1, sum1, sum2 + num[index])
    
    return min(diff1, diff2)

2] Top-Down Dynamic Programming with Memoization.

def can_partition(num):
    dp = [[-1 for _ in range(sum(num) + 1)] for _ in range(len(num))]
    
    return recursion(dp, num, 0,0,0)

def recursion(dp, num, index, sum1, sum2):
    # base
    if index == len(num):
        return abs(sum1 - sum2)
    
    if dp[index][sum1] == -1:
        diff1 = recursion(dp, num, index + 1, sum1 + num[index], sum2)
        diff2 = recursion(dp, num, index + 1, sum1, sum2 + num[index])
        dp[index][sum1] = min(diff1, diff2)
        
    return dp[index][sum1]

3] Bottom-Up Dynamic Programming. This problem is equivalent to finding a subset whose sum is as close to half of the total sum S/2 as possible. (Because in this way we can get the minimum difference.) Essentially, we need to calculate all the possible sums up to S/2. To populate the array dp[totalNumbers][S/2 + 1] in the bottom fashion, for every possible sums we have two options:

  • Exclude the number. See if we can get the sum S from the subset excluding the number dp[index-1][S]
  • Include the number if its value is not more than S. See if we can find a subset to get the remaining sum dp[index-1][s - num[index]].
# Time complexity O(N * S). Space complexity O(N * S).
def can_partition(num):
    s = sum(num)
    n = len(num)
    dp = [[False for _ in range(int(s/2) + 1)] for _ in range(n)]
    
    # for sum = 0
    for i in range(n):
        dp[i][0] = True
        
    # for the first number
    for j in range(int(s/2) + 1):
        dp[0][j] = (num[0] == j)
        
    # process all subsets
    for i in range(1, n):
        for j in range(1, int(s/2) + 1):
            if dp[i-1][j]: # same as the above cells
                dp[i][j] = dp[i-1][j]
            elif j >= num[i]: # find subset that can sum to the target
                dp[i][j] = dp[i-1][j - num[i]]

    sum1 = 0
    # find the larget index in the last row that is true
    for i in range(int(s/2), -1, -1): # find in reversive order
        if dp[n-1][i]:
            sum1 = i # sum of the first subset, this sum is closest to s/2
            break
    sum2 = s - sum1
    return abs(sum2 - sum1)

  TOC