879. Profitable Schemes
Problem Description
You have a group of n
members who can participate in various crimes. Each crime has two properties:
- The
i-th
crime generatesprofit[i]
amount of profit - The
i-th
crime requires exactlygroup[i]
members to participate
There are important constraints to consider:
- If a member participates in one crime, they cannot participate in any other crime
- A profitable scheme is defined as any subset of crimes that:
- Generates at least
minProfit
total profit - Uses at most
n
total members across all selected crimes
- Generates at least
Your task is to count how many different profitable schemes can be formed. Since the answer can be very large, return it modulo 10^9 + 7
.
For example, if you have 5 members total, and you need at least 3 profit, you could select:
- Crime A that needs 2 members and gives 2 profit
- Crime B that needs 2 members and gives 2 profit This would use 4 members total and generate 4 profit, which satisfies the requirements (≤5 members and ≥3 profit).
The problem asks you to find all possible combinations of crimes that meet these criteria and return the count of such combinations.
Intuition
This problem is essentially asking us to find all valid subsets of crimes that satisfy our constraints. For each crime, we have a choice: either include it in our scheme or skip it. This binary choice pattern immediately suggests a recursive approach where we explore both possibilities at each step.
Let's think about what information we need to track as we make these decisions:
- Which crime we're currently considering - we need an index
i
to track our position in the crime list - How many members we've already used - we need
j
to ensure we don't exceedn
members - How much profit we've accumulated - we need
k
to check if we've reachedminProfit
At each crime, we face two choices:
- Skip the crime: Move to the next crime without changing our member count or profit
- Take the crime: Add its required members to our count and its profit to our total (but only if we have enough members available)
The base case occurs when we've considered all crimes. At that point, we check if our accumulated profit meets the minimum requirement. If yes, we've found one valid scheme; if not, this path doesn't contribute to our answer.
A key optimization insight: once we've reached minProfit
, any additional profit doesn't create a "different" scheme - we only care about meeting the threshold. So we can cap our profit tracking at minProfit
using min(k + profit[i], minProfit)
. This reduces the state space and prevents unnecessary computation.
The recursive nature with overlapping subproblems (same states reached through different paths) makes this perfect for memoization. By caching results for each unique (i, j, k)
state, we avoid recalculating the same scenarios multiple times.
Learn more about Dynamic Programming patterns.
Solution Approach
The solution implements a recursive approach with memoization using Python's @cache
decorator. Let's walk through the implementation step by step.
We define a recursive function dfs(i, j, k)
where:
i
represents the index of the current crime being consideredj
represents the total number of members already usedk
represents the total profit accumulated so far
Base Case:
When i >= len(group)
, we've considered all crimes. At this point, we check if our accumulated profit k
equals minProfit
. If yes, we've found a valid scheme and return 1
. Otherwise, return 0
.
Recursive Cases:
For each crime at index i
, we have two choices:
-
Skip the current crime: We move to the next crime without changing our member count or profit:
ans = dfs(i + 1, j, k)
-
Take the current crime: We can only do this if adding the required members doesn't exceed our limit:
if j + group[i] <= n: ans += dfs(i + 1, j + group[i], min(k + profit[i], minProfit))
Notice the crucial optimization here: we use
min(k + profit[i], minProfit)
instead of justk + profit[i]
. This caps the profit atminProfit
because any profit beyond the minimum threshold doesn't create a different scheme - we only care whether we've met the threshold or not. This significantly reduces the state space.
Memoization:
The @cache
decorator automatically memoizes the function, storing results for each unique (i, j, k)
combination. This prevents redundant calculations when the same state is reached through different paths.
Modulo Operation:
Since the answer can be very large, we apply modulo 10^9 + 7
before returning:
return ans % (10**9 + 7)
Initial Call:
We start the recursion with dfs(0, 0, 0)
, meaning we begin at the first crime with no members used and zero profit.
The time complexity is O(m * n * minProfit)
where m
is the number of crimes, n
is the number of members, and minProfit
is the profit threshold. The space complexity is the same due to memoization storage.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a small example to illustrate the solution approach.
Example:
n = 5
(total members available)minProfit = 3
(minimum profit required)group = [2, 2]
(members needed for each crime)profit = [2, 3]
(profit from each crime)
We'll trace through the recursive calls starting with dfs(0, 0, 0)
:
Step 1: dfs(0, 0, 0)
- Currently at crime 0, with 0 members used and 0 profit
- Two choices:
- Skip crime 0 →
dfs(1, 0, 0)
- Take crime 0 (needs 2 members, gives 2 profit) →
dfs(1, 2, 2)
- Skip crime 0 →
Step 2a: dfs(1, 0, 0) (from skipping crime 0)
- Currently at crime 1, with 0 members used and 0 profit
- Two choices:
- Skip crime 1 →
dfs(2, 0, 0)
- Take crime 1 (needs 2 members, gives 3 profit) →
dfs(2, 2, 3)
- Skip crime 1 →
Step 2b: dfs(1, 2, 2) (from taking crime 0)
- Currently at crime 1, with 2 members used and 2 profit
- Two choices:
- Skip crime 1 →
dfs(2, 2, 2)
- Take crime 1 (needs 2 members, gives 3 profit) →
dfs(2, 4, min(2+3, 3))
=dfs(2, 4, 3)
- Skip crime 1 →
Step 3: Evaluating base cases (i = 2)
dfs(2, 0, 0)
: profit = 0 < minProfit = 3 → returns 0dfs(2, 2, 3)
: profit = 3 = minProfit → returns 1 ✓dfs(2, 2, 2)
: profit = 2 < minProfit = 3 → returns 0dfs(2, 4, 3)
: profit = 3 = minProfit → returns 1 ✓
Step 4: Backtracking and summing results
dfs(1, 0, 0)
= 0 + 1 = 1dfs(1, 2, 2)
= 0 + 1 = 1dfs(0, 0, 0)
= 1 + 1 = 2
Result: There are 2 profitable schemes:
- Take only crime 1 (uses 2 members, generates 3 profit)
- Take both crimes (uses 4 members, generates 5 profit, capped at 3 for state tracking)
The key insight is how we cap the profit at minProfit
when taking crime 1 in Step 2b. Even though the actual profit would be 5, we track it as 3 because any profit ≥ 3 satisfies our requirement, reducing the state space.
Solution Implementation
1class Solution:
2 def profitableSchemes(
3 self, n: int, minProfit: int, group: List[int], profit: List[int]
4 ) -> int:
5 from functools import cache
6 from typing import List
7
8 MOD = 10**9 + 7
9
10 @cache
11 def dfs(scheme_idx: int, people_used: int, current_profit: int) -> int:
12 """
13 Dynamic programming function to count valid schemes.
14
15 Args:
16 scheme_idx: Current index in the schemes being considered
17 people_used: Number of people already used
18 current_profit: Total profit accumulated so far (capped at minProfit)
19
20 Returns:
21 Number of valid schemes from this state
22 """
23 # Base case: processed all schemes
24 if scheme_idx >= len(group):
25 # Valid scheme if we've reached minimum profit requirement
26 return 1 if current_profit == minProfit else 0
27
28 # Option 1: Skip current scheme
29 ways = dfs(scheme_idx + 1, people_used, current_profit)
30
31 # Option 2: Include current scheme if we have enough people
32 if people_used + group[scheme_idx] <= n:
33 # Cap profit at minProfit to avoid unnecessary states
34 new_profit = min(current_profit + profit[scheme_idx], minProfit)
35 ways += dfs(scheme_idx + 1, people_used + group[scheme_idx], new_profit)
36
37 # Return result modulo MOD to prevent overflow
38 return ways % MOD
39
40 # Start DFS with: first scheme, 0 people used, 0 profit
41 return dfs(0, 0, 0)
42
1class Solution {
2 // Memoization cache: dp[crimeIndex][peopleUsed][profitEarned]
3 private Integer[][][] dp;
4 private int totalCrimes;
5 private int maxPeople;
6 private int targetProfit;
7 private int[] peopleRequired;
8 private int[] crimeProfit;
9 private final int MOD = (int) 1e9 + 7;
10
11 /**
12 * Calculate the number of profitable crime schemes.
13 *
14 * @param n Maximum number of people available
15 * @param minProfit Minimum profit required
16 * @param group Array where group[i] is the number of people required for crime i
17 * @param profit Array where profit[i] is the profit from crime i
18 * @return Number of different profitable schemes modulo 10^9 + 7
19 */
20 public int profitableSchemes(int n, int minProfit, int[] group, int[] profit) {
21 // Initialize instance variables
22 totalCrimes = group.length;
23 maxPeople = n;
24 targetProfit = minProfit;
25 peopleRequired = group;
26 crimeProfit = profit;
27
28 // Initialize memoization table
29 // Dimensions: [number of crimes][people used + 1][profit earned + 1]
30 dp = new Integer[totalCrimes][maxPeople + 1][targetProfit + 1];
31
32 // Start DFS from crime 0, with 0 people used and 0 profit earned
33 return dfs(0, 0, 0);
34 }
35
36 /**
37 * Recursive DFS with memoization to count valid schemes.
38 *
39 * @param crimeIndex Current crime being considered
40 * @param peopleUsed Number of people used so far
41 * @param profitEarned Total profit earned so far (capped at targetProfit)
42 * @return Number of valid schemes from this state
43 */
44 private int dfs(int crimeIndex, int peopleUsed, int profitEarned) {
45 // Base case: all crimes have been considered
46 if (crimeIndex >= totalCrimes) {
47 // Return 1 if we've reached the target profit, 0 otherwise
48 return profitEarned == targetProfit ? 1 : 0;
49 }
50
51 // Check memoization cache
52 if (dp[crimeIndex][peopleUsed][profitEarned] != null) {
53 return dp[crimeIndex][peopleUsed][profitEarned];
54 }
55
56 // Option 1: Skip the current crime
57 int totalSchemes = dfs(crimeIndex + 1, peopleUsed, profitEarned);
58
59 // Option 2: Commit the current crime (if we have enough people)
60 if (peopleUsed + peopleRequired[crimeIndex] <= maxPeople) {
61 // Cap the profit at targetProfit to avoid array out of bounds
62 int newProfit = Math.min(profitEarned + crimeProfit[crimeIndex], targetProfit);
63 totalSchemes += dfs(crimeIndex + 1,
64 peopleUsed + peopleRequired[crimeIndex],
65 newProfit);
66 }
67
68 // Apply modulo to prevent overflow
69 totalSchemes %= MOD;
70
71 // Store result in cache and return
72 dp[crimeIndex][peopleUsed][profitEarned] = totalSchemes;
73 return totalSchemes;
74 }
75}
76
1class Solution {
2public:
3 int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
4 int numSchemes = group.size();
5
6 // dp[schemeIndex][peopleUsed][profitEarned] = number of ways
7 // Initialize with -1 to indicate unvisited states
8 int dp[numSchemes][n + 1][minProfit + 1];
9 memset(dp, -1, sizeof(dp));
10
11 const int MOD = 1e9 + 7;
12
13 // DFS with memoization to count profitable schemes
14 // schemeIdx: current scheme being considered (0-indexed)
15 // peopleUsed: number of people used so far
16 // profitEarned: profit earned so far (capped at minProfit)
17 function<int(int, int, int)> dfs = [&](int schemeIdx, int peopleUsed, int profitEarned) -> int {
18 // Base case: processed all schemes
19 if (schemeIdx >= numSchemes) {
20 // Return 1 if we've reached minimum profit, 0 otherwise
21 return profitEarned == minProfit ? 1 : 0;
22 }
23
24 // Return memoized result if already computed
25 if (dp[schemeIdx][peopleUsed][profitEarned] != -1) {
26 return dp[schemeIdx][peopleUsed][profitEarned];
27 }
28
29 // Option 1: Skip current scheme
30 int totalWays = dfs(schemeIdx + 1, peopleUsed, profitEarned);
31
32 // Option 2: Include current scheme if we have enough people
33 if (peopleUsed + group[schemeIdx] <= n) {
34 // Cap profit at minProfit to avoid array out of bounds
35 int newProfit = min(profitEarned + profit[schemeIdx], minProfit);
36 totalWays += dfs(schemeIdx + 1, peopleUsed + group[schemeIdx], newProfit);
37 }
38
39 // Apply modulo to prevent overflow
40 totalWays %= MOD;
41
42 // Memoize and return result
43 return dp[schemeIdx][peopleUsed][profitEarned] = totalWays;
44 };
45
46 // Start DFS from scheme 0, with 0 people used and 0 profit earned
47 return dfs(0, 0, 0);
48 }
49};
50
1function profitableSchemes(n: number, minProfit: number, group: number[], profit: number[]): number {
2 const numSchemes = group.length;
3 const MOD = 1e9 + 7;
4
5 // dp[schemeIndex][peopleUsed][profitEarned] = number of ways
6 // Initialize with -1 to indicate unvisited states
7 const dp: number[][][] = Array(numSchemes)
8 .fill(null)
9 .map(() =>
10 Array(n + 1)
11 .fill(null)
12 .map(() =>
13 Array(minProfit + 1).fill(-1)
14 )
15 );
16
17 /**
18 * DFS with memoization to count profitable schemes
19 * @param schemeIdx - current scheme being considered (0-indexed)
20 * @param peopleUsed - number of people used so far
21 * @param profitEarned - profit earned so far (capped at minProfit)
22 * @returns number of ways to form profitable schemes
23 */
24 const dfs = (schemeIdx: number, peopleUsed: number, profitEarned: number): number => {
25 // Base case: processed all schemes
26 if (schemeIdx >= numSchemes) {
27 // Return 1 if we've reached minimum profit, 0 otherwise
28 return profitEarned === minProfit ? 1 : 0;
29 }
30
31 // Return memoized result if already computed
32 if (dp[schemeIdx][peopleUsed][profitEarned] !== -1) {
33 return dp[schemeIdx][peopleUsed][profitEarned];
34 }
35
36 // Option 1: Skip current scheme
37 let totalWays = dfs(schemeIdx + 1, peopleUsed, profitEarned);
38
39 // Option 2: Include current scheme if we have enough people
40 if (peopleUsed + group[schemeIdx] <= n) {
41 // Cap profit at minProfit to avoid array out of bounds
42 const newProfit = Math.min(profitEarned + profit[schemeIdx], minProfit);
43 totalWays += dfs(schemeIdx + 1, peopleUsed + group[schemeIdx], newProfit);
44 }
45
46 // Apply modulo to prevent overflow
47 totalWays %= MOD;
48
49 // Memoize and return result
50 dp[schemeIdx][peopleUsed][profitEarned] = totalWays;
51 return totalWays;
52 };
53
54 // Start DFS from scheme 0, with 0 people used and 0 profit earned
55 return dfs(0, 0, 0);
56}
57
Time and Space Complexity
The time complexity is O(m × n × minProfit)
, where:
m
is the length of thegroup
array (number of crimes/jobs)n
is the maximum number of members that can be usedminProfit
is the minimum profit threshold
This is because the recursive function dfs(i, j, k)
has three parameters:
i
ranges from0
tom
(length of group array)j
ranges from0
ton
(number of members used)k
ranges from0
tominProfit
(accumulated profit, capped at minProfit)
Due to the @cache
decorator, each unique combination of (i, j, k)
is computed only once, resulting in O(m × n × minProfit)
total states.
The space complexity is O(m × n × minProfit)
as well, which comes from:
- The memoization cache storing all possible states:
O(m × n × minProfit)
- The recursion call stack depth which is at most
O(m)
(one call per crime/job)
Since O(m × n × minProfit) > O(m)
, the overall space complexity is O(m × n × minProfit)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Not Capping the Profit State
The Pitfall: A natural but incorrect approach would be to track the exact profit without capping it:
# WRONG approach - will cause TLE or MLE
def dfs(i, j, k):
if i >= len(group):
return 1 if k >= minProfit else 0 # Check if profit meets threshold
ans = dfs(i + 1, j, k)
if j + group[i] <= n:
ans += dfs(i + 1, j + group[i], k + profit[i]) # Accumulate exact profit
return ans % MOD
Why This Fails: This creates too many unique states. If profits can be large (e.g., up to 100) and you have many crimes, the total profit could reach thousands, creating thousands of distinct states for the profit dimension alone. This leads to:
- Time Limit Exceeded (TLE) due to exploring too many states
- Memory Limit Exceeded (MLE) from storing too many memoized results
The Solution: Cap the profit at minProfit
:
new_profit = min(current_profit + profit[scheme_idx], minProfit)
This works because once we've achieved minProfit
, any additional profit doesn't create a "different" valid scheme - we only care about meeting the threshold. This optimization reduces the profit dimension from potentially unbounded values to at most minProfit + 1
distinct states (0 through minProfit).
2. Incorrect Base Case Logic
The Pitfall: Writing the base case as:
# WRONG - counts schemes with profit < minProfit as valid
if i >= len(group):
return 1
Or checking profit incorrectly:
# WRONG - double counts when profit exceeds minProfit
if i >= len(group):
return 1 if k >= minProfit else 0
Why This Fails:
- The first version counts all possible subsets, including those that don't meet the profit requirement
- The second version, when combined with profit capping, would incorrectly handle cases where we've already capped the profit
The Solution: Check for exact equality with minProfit (after capping):
if scheme_idx >= len(group):
return 1 if current_profit == minProfit else 0
Since we cap profit at minProfit
, reaching exactly minProfit
means we've met or exceeded the threshold.
3. Forgetting the Modulo Operation
The Pitfall: Only applying modulo at the final return:
# RISKY - intermediate calculations might overflow
def dfs(i, j, k):
# ... base case ...
ans = dfs(i + 1, j, k)
if j + group[i] <= n:
ans += dfs(i + 1, j + group[i], min(k + profit[i], minProfit))
return ans # No modulo here
# Only modulo at the end
return dfs(0, 0, 0) % MOD
Why This Fails: While Python handles arbitrary precision integers, in other languages this could cause integer overflow. Additionally, even in Python, very large numbers can slow down arithmetic operations.
The Solution: Apply modulo at each step:
return ways % MOD
This keeps numbers manageable and ensures consistency across different programming languages.
Which data structure is used to implement priority queue?
Recommended Readings
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Don’t Miss This!