The Knapsack problem is a constrained optimization problem with one or two linear, inequality constraints:
A naive solution to this problem could be found be generating all possible subsets and isolating the subsets that match the criteria. (similar to 78. subsets and 90. subsets II). However, this solution is suboptimal by nature because it is a purely exhaustive algorithm (brute-force) with no overlapping subproblems. In other words, the solution tree in this approach will not have any repeating nodes if the input is unique. Hence no overlapping subproblems. We lose the opportunity to memoize.
The knapsack solution on the other hand will have overlapping subproblems which makes memoization possible. Asymptotically, A memoized algorithm is more optimal.
Below is a visual comparison between the solution trees of the exhaustive and the 01 knapsack approaches to a sample problem asking for subsets whose sum is equal to some targetSum:
Suboptimal, exhaustive solution:

Optimal memoized 01 knapsack

Time:
O(2^N) + O(2^N) + 1 => O(2^N) O(N*C)Space: O(N)
def knapsack(weights, profits, capacity):
# helper
def recurse(i,c):
if c < 0 or i > len(weights)-1: # -- NOTE [1]
return 0
if weights[i] > c: # if indv weight is greater than remaning capcity in sack
return 0
if (i,c) in memo: # -- NOTE [1]
return memo[(i,c)]
res = max(profits[i] + recurse(i+1, c-weights[i]), recurse(i+1, c)) # max out of inlcude & exclude - optimization KS
memo[(i,c)] = res
return res
# main
memo = {}
return recurse(0, capacity)Given N items find out if they can be partitioned into 2 subsets whose sum = trgtSum
This problem can be converted into:
Given N items, find out if there exists a subset whose sum = trgtSum/2
Unlike classical knapsack, here we have only one inequality constraint
Time:
O(2^N) + O(2^N) + 1 => O(2^N) O(N*trgtSum) => Pseudo polynomial aka. time does not only depend on the size of the input but also its numeric valueSpace: O(N)
def bool_knapsack(nums, trgtSum):
# helper
def f(i, subsetSum): # i, subsetSum are two inequality constraints
if subsetSum == 0:
return True
if subsetSum < 0:
return False
if i > len(num)-1:
return False
if (i, subsetSum) in memo:
return memo[(i, subsetSum)]
result = f(i+1, subsetSum-num[i]) or f(i+1, subsetSum) # include or exclude - boolean knapsack
memo[(i,subsetSum)] = result
return result
# main
memo = {}
if sum(num) % 2 != 0:
return False
trgtSum = sum(num)//2
return f(0, trgtSum)In the Equal subset sum problem, the sum of all items had to be divisible by 2 . Otherwise we returned False. We were 100% fair; allocating exactly half to each subset.
If sum is not divisible by 2, should we give up in despair? No! We can get as close as possible to being fair. We divide the items into 2 subsets (s1, s2) such that they have the min abs difference between their sums. This precisely is the ask in this problem.
Partition the set into two subsets with a minimum difference between their subset sums.
Time:
O(2^N) + O(2^N) + 1 => O(2^N) O(N*s1) => Pseudo polynomial - where s1 is the sum of the subset with larger sum
[999, 999, 998] >> [1, 1, 2]Space: O(N)
def minAbsDiffSubsets_knapsack(nums):
# helper
def f(i, s1, s2):
if i == len(nums): # -- NOTE
return abs(s1-s2)
if (i, s1) in memo:
return memo[(i,s1)]
result = min( f(i+1, s1+nums[i], s2), f(i+1, s1, s2+nums[i]) )
memo[(i, s1)] = result
return result
# main
memo = {}
return f(0,0,0) #i, s1, s2
# NOTE:
# -----
# Update diff at leaf node only
# It's only at the leaf node that all items in the input have been categorized into one of the 2 subsets.
Time:
O(2^N) + O(2^N) + 1 => O(2^N) O(N*trgtSum) => Pseudo polynomialSpace: O(N)
def count_knapsack(nums):
# helper
def f(i, s1, s2):
if i == len(nums):
return abs(s1-s2)
if (i, s1) in memo:
return memo[(i,s1)]
result = min( f(i+1, s1+nums[i], s2), f(i+1, s1, s2+nums[i]) )
memo[(i, s1)] = result
return result
# main
memo = {}
return f(0,0,0) #i, s1, s2Given a set of +ve integers, assign either + or - to the items such that the result is equal to target. Find out the total ways that can be built, whic evaluates to target.
s1 - s2 = diff :--[1]
s1 + s2 = sums(num) :--[2]
Adding [1] & [2]:
2s1 = diff + sums(num)
s1 = [ diff + sums(nums) ] / 2
Boils down to finding a subset whose sum = s1Time:
O(2^N) + O(2^N) + 1 => O(2^N) O(N*trgtSum) => Pseudo polynomialSpace: O(N)
def find_target_subsets(num, s):
# helper:
def f(i, trgtSum):
if trgtSum == 0:
return 1
if i == len(num):
return 0
if (i, trgtSum) in memo:
return memo[(i, trgtSum)]
result = f(i+1, trgtSum-num[i]) + f(i+1, trgtSum)
memo[(i,trgtSum)] = result
return result
# main:
memo = {}
if (s + sum(num)) % 2 != 0:
return 0
trgtSum = (s + sum(num)) / 2
return f(0, trgtSum)Same problem as above except zeros are also allowed in the input set. (one line change)
def findTargetSumWays(self, nums: List[int], target: int) -> int:
# helper
def f(i, trgtSum):
if i == len(nums) and trgtSum == 0: # --- NOTE [1]
return 1
# if trgtSum == 0: # does not work when a 0 is trailing +ve integers
# return 1
if i == len(nums):
return 0
if trgtSum < 0:
return 0
if (i, trgtSum) in memo:
return memo[(i, trgtSum)]
result = f(i+1, trgtSum-nums[i]) + f(i+1, trgtSum)
memo[(i,trgtSum)] = result
return result
# main
nums.sort()
memo = {}
if (sum(nums) + target) % 2 != 0:
return 0
trgtSum = (sum(nums) + target) // 2
return f(0, trgtSum)
# NOTE [1]
# --------
'''
Had nums not conatined any zeros (only +ve integeres)
we would've afforded to return 1 (add to the count) as soon as we see a targtSum = 0
This also holds if items included zero that is followed by +ve integers
However, if a zero is trailing +ve integers ex: [1,2,0] we would miss one scenario that
also adds up to trgtSum
-> scenario [1] : 1,2 count = 1
-> sceanrio [2] (missed scenario) : 1,2,0 count = 2
To overcome this we could only return 1 if:
a. trgtSum = 0, AND
b. i == len(nums)
This way we would delay capturing the first scenario at the benifit of being able to capture the 2nd scenario as well
'''