879. Profitable Schemes - Explanation

Problem Link



Prerequisites

Before attempting this problem, you should be comfortable with:

  • 0/1 Knapsack Problem - This is a variation with two constraints (members and profit)
  • Recursion - Building decision trees where each item can be included or excluded
  • Memoization - Caching results to avoid redundant computation in overlapping subproblems
  • Dynamic Programming - Converting recursive solutions to iterative bottom-up approaches
  • Modular Arithmetic - Handling large results with modulo operations to prevent overflow

1. Recursion

Intuition

This is a variation of the 0/1 knapsack problem with two constraints: number of members and minimum profit. For each crime, we decide whether to include it or skip it. If we include a crime, we use its required members and gain its profit. At the end, we count paths where total profit meets the threshold and total members used does not exceed the limit.

Algorithm

  1. Define a recursive function dfs(i, n, p) where:
    • i is the current crime index.
    • n is the remaining number of members available.
    • p is the profit accumulated so far.
  2. Base case: if i equals the number of crimes, return 1 if p >= minProfit, else return 0.
  3. For the current crime:
    • Option 1: skip it and recurse with dfs(i + 1, n, p).
    • Option 2: if we have enough members, include it and recurse with dfs(i + 1, n - group[i], p + profit[i]).
  4. Sum both options and return the result modulo 10^9 + 7.
class Solution:
    def profitableSchemes(self, n: int, minProfit: int, group: List[int], profit: List[int]) -> int:
        mod = 10**9 + 7

        def dfs(i, n, p):
            if i == len(group):
                return 1 if p >= minProfit else 0

            res = dfs(i + 1, n, p)
            if n - group[i] >= 0:
                res = (res + dfs(i + 1, n - group[i], p + profit[i])) % mod

            return res

        return dfs(0, n, 0)

Time & Space Complexity

  • Time complexity: O(2N)O(2 ^ N)
  • Space complexity: O(N)O(N)

Where NN is the size of the groupgroup array.


2. Dynamic Programming (Top-Down)

Intuition

The recursive solution has overlapping subproblems because the same state (crime index, remaining members, current profit) can be reached through different paths. By memoizing results, we avoid redundant computation. We also cap profit at minProfit since any profit beyond the threshold is equivalent for counting purposes.

Algorithm

  1. Create a 3D memoization table indexed by (crime index, remaining members, current profit).
  2. Define dfs(i, n, p) with the same logic as the recursive approach.
  3. Before computing, check if the state is already in the cache.
  4. When including a crime, cap the new profit at minProfit to reduce state space.
  5. Store and return the result modulo 10^9 + 7.
class Solution:
    def profitableSchemes(self, n: int, minProfit: int, group: List[int], profit: List[int]) -> int:
        mod = 10**9 + 7
        dp = {}

        def dfs(i, n, p):
            if i == len(group):
                return 1 if p >= minProfit else 0
            if (i, n, p) in dp:
                return dp[(i, n, p)]

            res = dfs(i + 1, n, p)
            if n - group[i] >= 0:
                nxtP = min(p + profit[i], minProfit)
                res = (res + dfs(i + 1, n - group[i], nxtP)) % mod

            dp[(i, n, p)] = res
            return res

        return dfs(0, n, 0)

Time & Space Complexity

  • Time complexity: O(Nmn)O(N * m * n)
  • Space complexity: O(Nmn)O(N * m * n)

Where NN is the size of the groupgroup array, mm is the given minimum profit, and nn is the number of group members.


3. Dynamic Programming (Bottom-Up)

Intuition

We can convert the top-down solution to bottom-up by iterating through crimes in reverse order. We build up the answer by considering what happens if we include or exclude each crime, starting from the last crime and working backward to the first.

Algorithm

  1. Create a 3D DP table dp[i][j][p] representing the count of schemes considering crimes from index i onward, using at most j members, with current profit p.
  2. Initialize: for all member counts j, set dp[N][j][minProfit] = 1 (reached the goal).
  3. Iterate i from N-1 down to 0, j from 0 to n, and p from 0 to minProfit:
    • Start with the count from skipping this crime: dp[i+1][j][p].
    • If we have enough members, add the count from including this crime with updated profit (capped at minProfit).
  4. Return dp[0][n][0].
class Solution:
    def profitableSchemes(self, n: int, minProfit: int, group: List[int], profit: List[int]) -> int:
        mod = 10**9 + 7
        N = len(group)

        dp = [[[0] * (minProfit + 1) for j in range(n + 2)] for i in range(N + 1)]
        for j in range(n + 1):
            dp[N][j][minProfit] = 1

        for i in range(N - 1, -1, -1):
            for j in range(n + 1):
                for p in range(minProfit + 1):
                    res = dp[i + 1][j][p]
                    if j >= group[i]:
                        nxtP = min(profit[i] + p, minProfit)
                        res = (res + dp[i + 1][j - group[i]][nxtP]) % mod
                    dp[i][j][p] = res

        return dp[0][n][0]

Time & Space Complexity

  • Time complexity: O(Nmn)O(N * m * n)
  • Space complexity: O(Nmn)O(N * m * n)

Where NN is the size of the groupgroup array, mm is the given minimum profit, and nn is the number of group members.


4. Dynamic Programming (Space Optimized)

Intuition

Since each crime's DP state only depends on the next crime's state, we can reduce the 3D table to 2D. We iterate through crimes and update the table in-place. By processing member counts in reverse order, we ensure we use the previous iteration's values before overwriting them.

Algorithm

  1. Create a 2D DP table dp[j][p] for member count j and profit p.
  2. Initialize: for all j, set dp[j][minProfit] = 1.
  3. Iterate i from N-1 down to 0:
    • Iterate j from n down to 0 (reverse to avoid using updated values).
    • For each profit p from 0 to minProfit:
      • Add the contribution from including the crime if we have enough members.
  4. Return dp[n][0].
class Solution:
    def profitableSchemes(self, n: int, minProfit: int, group: List[int], profit: List[int]) -> int:
        mod = 10**9 + 7
        N = len(group)

        dp = [[0] * (minProfit + 1) for j in range(n + 2)]
        for j in range(n + 1):
            dp[j][minProfit] = 1

        for i in range(N - 1, -1, -1):
            for j in range(n, -1, -1):
                for p in range(minProfit + 1):
                    res = dp[j][p]
                    if j >= group[i]:
                        nxtP = min(profit[i] + p, minProfit)
                        res = (res + dp[j - group[i]][nxtP]) % mod
                    dp[j][p] = res

        return dp[n][0]

Time & Space Complexity

  • Time complexity: O(Nmn)O(N * m * n)
  • Space complexity: O(mn)O(m * n)

Where NN is the size of the groupgroup array, mm is the given minimum profit, and nn is the number of group members.


Common Pitfalls

Not Capping Profit at minProfit

When accumulating profit in the DP state, tracking values beyond minProfit wastes memory and causes state explosion. Any profit exceeding the threshold is functionally equivalent for counting valid schemes. Always cap the profit dimension at minProfit to reduce the state space from potentially unbounded to O(minProfit).

Forgetting the Modulo Operation

The problem requires results modulo 10^9 + 7 due to potentially huge answer values. Forgetting to apply modulo when summing the include and skip options leads to integer overflow. Apply modulo after every addition operation, not just at the end.

Incorrect Base Case Logic

The base case must return 1 when all crimes are considered AND the accumulated profit meets the threshold. A common mistake is returning 1 unconditionally or checking the wrong condition. The check p >= minProfit must happen only when i == len(group).

Off-by-One in Member Count Check

When deciding whether to include a crime, the condition should be n >= group[i] or equivalently n - group[i] >= 0. Using strict inequality n > group[i] incorrectly excludes cases where exactly enough members are available.

Iterating in Wrong Direction for Space Optimization

In the space-optimized bottom-up solution, iterating j (member count) in the wrong direction causes using updated values from the current iteration instead of the previous one. Process members from high to low to ensure correct dependency ordering.