1235. Maximum Profit in Job Scheduling
Problem Description
You are given n
jobs where each job has:
- A start time (
startTime[i]
) - An end time (
endTime[i]
) - A profit value (
profit[i]
)
Your task is to select a subset of jobs that maximizes the total profit, with the constraint that no two selected jobs can have overlapping time ranges.
Important note about overlapping: Two jobs are considered non-overlapping if one job ends exactly when another job starts. In other words, if a job ends at time X
, you can select another job that starts at time X
- they won't be considered overlapping.
Example: If you have:
- Job A: starts at 1, ends at 3, profit 50
- Job B: starts at 3, ends at 5, profit 40
- Job C: starts at 2, ends at 4, profit 10
You could select jobs A and B (total profit = 90) since A ends at 3 and B starts at 3 (non-overlapping). However, you couldn't select both A and C since they overlap (A runs from 1-3, C runs from 2-4).
The goal is to find the maximum total profit possible by selecting a valid subset of non-overlapping jobs.
Intuition
The key insight is that this is a classic dynamic programming problem where we need to make optimal decisions at each step. For each job, we face a choice: either take it or skip it.
Let's think about this systematically. If we sort the jobs by their start time, we can process them in chronological order. For any job at position i
, we have two options:
-
Skip the job: If we don't take job
i
, our maximum profit is whatever we can get from the remaining jobs starting at positioni+1
. -
Take the job: If we take job
i
, we get its profit, but then we can only consider jobs that start after this job ends. We need to find the next valid job that doesn't overlap.
This naturally leads to a recursive structure: maximum_profit(i) = max(skip_job, take_job)
, where:
skip_job = maximum_profit(i+1)
take_job = profit[i] + maximum_profit(next_valid_job)
The challenge is efficiently finding the "next valid job" after taking job i
. Since we've sorted jobs by start time, we can use binary search to quickly find the first job whose start time is greater than or equal to the current job's end time. This gives us O(log n)
time for finding the next valid position instead of O(n)
with linear search.
We notice that the same subproblems (starting from position i
) will be computed multiple times in different branches of our recursion tree. This overlap suggests using memoization to cache results and avoid redundant calculations.
The base case is straightforward: when we've processed all jobs (i >= n), the profit is 0.
By combining these ideas - sorting, binary search for efficiency, and memoization for optimization - we arrive at an elegant recursive solution that explores all valid combinations while avoiding redundant work.
Learn more about Binary Search, Dynamic Programming and Sorting patterns.
Solution Approach
The implementation follows the memoization search with binary search pattern we identified in our intuition.
Step 1: Data Preparation First, we combine the three arrays into a single list of tuples and sort by start time:
jobs = sorted(zip(startTime, endTime, profit))
This creates tuples of (start, end, profit)
sorted by start time, allowing us to process jobs chronologically.
Step 2: Recursive Function with Memoization
We define a recursive function dfs(i)
that returns the maximum profit starting from job index i
:
@cache
def dfs(i):
if i >= n:
return 0
The @cache
decorator automatically memoizes results, preventing redundant calculations for the same i
.
Step 3: Processing Each Job
For job i
, we extract its details:
_, e, p = jobs[i] # start (unused), end, profit
Step 4: Finding Next Valid Job We use binary search to find the first job that starts at or after the current job's end time:
j = bisect_left(jobs, e, lo=i + 1, key=lambda x: x[0])
bisect_left
finds the leftmost position where we could inserte
lo=i + 1
ensures we only look at jobs after the current onekey=lambda x: x[0]
compares based on start times (first element of tuple)
Step 5: Making the Optimal Choice We apply our recurrence relation:
return max(dfs(i + 1), p + dfs(j))
This compares:
- Skipping job
i
:dfs(i + 1)
- Taking job
i
:p + dfs(j)
(current profit plus maximum from next valid job)
The formula can be expressed as:
where j
is the smallest index satisfying startTime[j] ≥ endTime[i]
.
Time Complexity: O(n log n)
where n
is the number of jobs
- Sorting:
O(n log n)
- Each of
n
states is computed once (memoization) - Each state does
O(log n)
binary search - Total:
O(n log n)
Space Complexity: O(n)
for the memoization cache and recursion stack
The solution elegantly combines sorting, binary search, and dynamic programming through memoization to efficiently solve what could otherwise be an exponential-time problem.
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 with 3 jobs to illustrate the solution approach:
Input:
- Job 0: start=1, end=3, profit=50
- Job 1: start=2, end=5, profit=20
- Job 2: start=3, end=6, profit=70
Step 1: Sort jobs by start time
jobs = [(1,3,50), (2,5,20), (3,6,70)]
Already sorted in this case.
Step 2: Apply recursive function starting from index 0
dfs(0)
: Processing job (1,3,50)
- Option 1 - Skip:
dfs(1)
- Option 2 - Take: profit=50 +
dfs(j)
where j is next valid job- Binary search for jobs starting at time ≥ 3 (when job 0 ends)
- Found j=2 (job starting at time 3)
- Take option: 50 +
dfs(2)
dfs(1)
: Processing job (2,5,20)
- Option 1 - Skip:
dfs(2)
- Option 2 - Take: profit=20 +
dfs(j)
where j is next valid job- Binary search for jobs starting at time ≥ 5
- No jobs found (j=3, which is ≥ n)
- Take option: 20 +
dfs(3)
= 20 + 0 = 20
dfs(2)
: Processing job (3,6,70)
- Option 1 - Skip:
dfs(3)
= 0 (base case) - Option 2 - Take: profit=70 +
dfs(j)
- Binary search for jobs starting at time ≥ 6
- No jobs found
- Take option: 70 + 0 = 70
- Return max(0, 70) = 70
Backtracking:
dfs(1)
= max(dfs(2)
, 20 +dfs(3)
) = max(70, 20) = 70dfs(0)
= max(dfs(1)
, 50 +dfs(2)
) = max(70, 50 + 70) = 120
Result: Maximum profit = 120 (by taking jobs 0 and 2)
The key insight is that job 0 ends at time 3, and job 2 starts at time 3, so they don't overlap and can both be selected. The binary search efficiently finds that job 2 is the next valid job after job 0.
Solution Implementation
1class Solution:
2 def jobScheduling(
3 self, startTime: List[int], endTime: List[int], profit: List[int]
4 ) -> int:
5 """
6 Find the maximum profit from non-overlapping jobs.
7
8 Args:
9 startTime: List of job start times
10 endTime: List of job end times
11 profit: List of job profits
12
13 Returns:
14 Maximum profit achievable by scheduling non-overlapping jobs
15 """
16 from functools import cache
17 from bisect import bisect_left
18 from typing import List
19
20 # Sort jobs by start time for efficient searching
21 jobs = sorted(zip(startTime, endTime, profit))
22 n = len(jobs)
23
24 @cache
25 def dfs(index: int) -> int:
26 """
27 Dynamic programming function to find maximum profit from index onwards.
28
29 Args:
30 index: Current job index to consider
31
32 Returns:
33 Maximum profit achievable from current index to end
34 """
35 # Base case: no more jobs to process
36 if index >= n:
37 return 0
38
39 # Extract current job details
40 current_start, current_end, current_profit = jobs[index]
41
42 # Find the next job that starts after current job ends
43 # Using binary search for efficiency
44 next_valid_job_index = bisect_left(
45 jobs,
46 current_end,
47 lo=index + 1,
48 key=lambda job: job[0] # Compare by start time
49 )
50
51 # Two choices: skip current job or take current job
52 skip_current = dfs(index + 1)
53 take_current = current_profit + dfs(next_valid_job_index)
54
55 # Return the maximum profit between the two choices
56 return max(skip_current, take_current)
57
58 # Start the recursion from the first job
59 return dfs(0)
60
1class Solution {
2 // Store jobs as [startTime, endTime, profit]
3 private int[][] jobs;
4 // Memoization array to store maximum profit from index i to end
5 private int[] memo;
6 // Total number of jobs
7 private int n;
8
9 public int jobScheduling(int[] startTime, int[] endTime, int[] profit) {
10 n = profit.length;
11 jobs = new int[n][3];
12
13 // Create jobs array with all information combined
14 for (int i = 0; i < n; i++) {
15 jobs[i] = new int[] {startTime[i], endTime[i], profit[i]};
16 }
17
18 // Sort jobs by start time in ascending order
19 Arrays.sort(jobs, (a, b) -> a[0] - b[0]);
20
21 // Initialize memoization array
22 memo = new int[n];
23
24 // Start DFS from the first job
25 return dfs(0);
26 }
27
28 /**
29 * DFS with memoization to find maximum profit from index i to end
30 * @param i current job index
31 * @return maximum profit achievable from job i onwards
32 */
33 private int dfs(int i) {
34 // Base case: no more jobs to process
35 if (i >= n) {
36 return 0;
37 }
38
39 // Return memoized result if already computed
40 if (memo[i] != 0) {
41 return memo[i];
42 }
43
44 // Get current job's end time and profit
45 int currentEndTime = jobs[i][1];
46 int currentProfit = jobs[i][2];
47
48 // Find the next job that starts after current job ends
49 int nextJobIndex = search(jobs, currentEndTime, i + 1);
50
51 // Choose maximum between:
52 // 1. Skip current job and process next job
53 // 2. Take current job and process next compatible job
54 int maxProfit = Math.max(dfs(i + 1), currentProfit + dfs(nextJobIndex));
55
56 // Memoize the result
57 memo[i] = maxProfit;
58
59 return maxProfit;
60 }
61
62 /**
63 * Binary search to find the first job whose start time >= targetTime
64 * @param jobs sorted array of jobs
65 * @param targetTime the time to search for
66 * @param startIndex starting index for search
67 * @return index of first job with start time >= targetTime, or n if none exists
68 */
69 private int search(int[][] jobs, int targetTime, int startIndex) {
70 int left = startIndex;
71 int right = n;
72
73 // Binary search for leftmost position where jobs[mid][0] >= targetTime
74 while (left < right) {
75 int mid = (left + right) >> 1; // Equivalent to (left + right) / 2
76
77 if (jobs[mid][0] >= targetTime) {
78 // Target might be at mid or to the left
79 right = mid;
80 } else {
81 // Target must be to the right
82 left = mid + 1;
83 }
84 }
85
86 return left;
87 }
88}
89
1class Solution {
2public:
3 int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit) {
4 int n = profit.size();
5
6 // Create a vector of jobs, each job is a tuple of (startTime, endTime, profit)
7 vector<tuple<int, int, int>> jobs(n);
8 for (int i = 0; i < n; ++i) {
9 jobs[i] = {startTime[i], endTime[i], profit[i]};
10 }
11
12 // Sort jobs by start time
13 sort(jobs.begin(), jobs.end());
14
15 // Memoization array to store maximum profit starting from index i
16 vector<int> memo(n);
17
18 // DFS function to find maximum profit starting from job index i
19 function<int(int)> dfs = [&](int i) -> int {
20 // Base case: if we've processed all jobs
21 if (i >= n) {
22 return 0;
23 }
24
25 // Return memoized result if already computed
26 if (memo[i]) {
27 return memo[i];
28 }
29
30 // Extract current job's details
31 auto [currentStart, currentEnd, currentProfit] = jobs[i];
32
33 // Create a dummy job with start time equal to current job's end time
34 // This is used to find the next non-overlapping job
35 tuple<int, int, int> target{currentEnd, 0, 0};
36
37 // Binary search to find the first job that starts at or after current job ends
38 int nextJobIndex = lower_bound(
39 jobs.begin() + i + 1,
40 jobs.end(),
41 target,
42 [&](const auto& left, const auto& right) -> bool {
43 return get<0>(left) < get<0>(right);
44 }
45 ) - jobs.begin();
46
47 // Two choices:
48 // 1. Skip current job and process next job
49 // 2. Take current job and process next non-overlapping job
50 int maxProfit = max(
51 dfs(i + 1), // Skip current job
52 currentProfit + dfs(nextJobIndex) // Take current job
53 );
54
55 // Store result in memoization array
56 memo[i] = maxProfit;
57
58 return maxProfit;
59 };
60
61 // Start DFS from the first job
62 return dfs(0);
63 }
64};
65
1/**
2 * Finds the maximum profit from non-overlapping jobs using dynamic programming with memoization
3 * @param startTime - Array of job start times
4 * @param endTime - Array of job end times
5 * @param profit - Array of job profits
6 * @returns Maximum profit achievable
7 */
8function jobScheduling(startTime: number[], endTime: number[], profit: number[]): number {
9 const jobCount = startTime.length;
10
11 // Memoization array to store computed results for each job index
12 const memo = new Array(jobCount).fill(0);
13
14 // Create indices array and sort jobs by start time
15 const sortedIndices = new Array(jobCount).fill(0).map((_, index) => index);
16 sortedIndices.sort((indexA, indexB) => startTime[indexA] - startTime[indexB]);
17
18 /**
19 * Binary search to find the first job whose start time is >= target time
20 * @param targetTime - The time to search for
21 * @returns Index of the first job with start time >= targetTime
22 */
23 const binarySearchNextJob = (targetTime: number): number => {
24 let left = 0;
25 let right = jobCount;
26
27 while (left < right) {
28 const mid = (left + right) >> 1;
29
30 if (startTime[sortedIndices[mid]] >= targetTime) {
31 right = mid;
32 } else {
33 left = mid + 1;
34 }
35 }
36
37 return left;
38 };
39
40 /**
41 * Depth-first search with memoization to find maximum profit starting from job index
42 * @param jobIndex - Current job index in sorted order
43 * @returns Maximum profit achievable from this job index onwards
44 */
45 const dfsMaxProfit = (jobIndex: number): number => {
46 // Base case: no more jobs to process
47 if (jobIndex >= jobCount) {
48 return 0;
49 }
50
51 // Return memoized result if already computed
52 if (memo[jobIndex] !== 0) {
53 return memo[jobIndex];
54 }
55
56 // Find the next non-overlapping job after current job ends
57 const nextJobIndex = binarySearchNextJob(endTime[sortedIndices[jobIndex]]);
58
59 // Choose maximum between:
60 // 1. Skip current job and process next job
61 // 2. Take current job profit + maximum profit from next non-overlapping job
62 const skipCurrentJob = dfsMaxProfit(jobIndex + 1);
63 const takeCurrentJob = dfsMaxProfit(nextJobIndex) + profit[sortedIndices[jobIndex]];
64
65 memo[jobIndex] = Math.max(skipCurrentJob, takeCurrentJob);
66 return memo[jobIndex];
67 };
68
69 // Start DFS from the first job in sorted order
70 return dfsMaxProfit(0);
71}
72
Time and Space Complexity
Time Complexity: O(n × log n)
, where n
is the number of jobs.
The time complexity breaks down as follows:
- Sorting the jobs takes
O(n × log n)
time - The recursive function
dfs
is called at mostn
times due to memoization with@cache
(each index from 0 to n-1 is visited at most once) - Inside each
dfs
call,bisect_left
performs a binary search which takesO(log n)
time - Therefore, the total time for all
dfs
calls isO(n × log n)
- Overall:
O(n × log n) + O(n × log n) = O(n × log n)
Space Complexity: O(n)
The space complexity consists of:
O(n)
for storing the sortedjobs
listO(n)
for the memoization cache that stores results for up ton
different indicesO(n)
for the recursion call stack in the worst case (though typically much less due to the binary search jumps)- Overall:
O(n)
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Overlap Definition
A frequent mistake is misunderstanding when jobs are considered non-overlapping. Many assume jobs cannot touch at all, but the problem explicitly allows a job to start exactly when another ends.
Incorrect assumption:
# Wrong: Treating jobs as overlapping when one ends where another starts next_job = bisect_right(jobs, current_end, lo=index + 1, key=lambda x: x[0])
Correct approach:
# Right: Jobs can start exactly when another ends (non-overlapping) next_job = bisect_left(jobs, current_end, lo=index + 1, key=lambda x: x[0])
2. Forgetting to Sort the Jobs
The binary search optimization only works if jobs are sorted by start time. Processing jobs in their original order breaks the algorithm.
Pitfall:
# Wrong: Using unsorted jobs
jobs = list(zip(startTime, endTime, profit))
Solution:
# Correct: Sort by start time first
jobs = sorted(zip(startTime, endTime, profit))
3. Incorrect Binary Search Bounds
Using lo=i
instead of lo=i+1
in binary search can cause infinite recursion since the function might repeatedly call itself with the same index.
Pitfall:
# Wrong: Can select the same job again next_job = bisect_left(jobs, current_end, lo=i, key=lambda x: x[0])
Solution:
# Correct: Search only among jobs after current one next_job = bisect_left(jobs, current_end, lo=i + 1, key=lambda x: x[0])
4. Memory Limit Issues with Large Inputs
While @cache
is convenient, it can cause memory issues with very large inputs. If encountering memory problems, consider using a bottom-up approach or manual memoization with cleanup.
Alternative for memory-constrained scenarios:
def jobScheduling(self, startTime, endTime, profit):
jobs = sorted(zip(startTime, endTime, profit))
n = len(jobs)
dp = [0] * (n + 1)
for i in range(n - 1, -1, -1):
_, end, prof = jobs[i]
next_job = bisect_left(jobs, end, lo=i + 1, key=lambda x: x[0])
dp[i] = max(dp[i + 1], prof + dp[next_job])
return dp[0]
5. Using Wrong Comparison Key in Binary Search
The key function must extract the start time for comparison, not the entire tuple or wrong field.
Pitfall:
# Wrong: Comparing entire tuples or wrong field next_job = bisect_left(jobs, current_end, lo=i + 1) # No key specified # Or next_job = bisect_left(jobs, current_end, lo=i + 1, key=lambda x: x[1]) # Using end time
Solution:
# Correct: Extract start time for comparison next_job = bisect_left(jobs, current_end, lo=i + 1, key=lambda x: x[0])
Which two pointer techniques do you use to check if a string is a palindrome?
Recommended Readings
Binary Search Speedrun For each of the Speedrun questions you will be given a binary search related problem and a corresponding multiple choice question The multiple choice questions are related to the techniques and template s introduced in the binary search section It's recommended that you have gone through at
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
Sorting Summary Comparisons We presented quite a few sorting algorithms and it is essential to know the advantages and disadvantages of each one The basic algorithms are easy to visualize and easy to learn for beginner programmers because of their simplicity As such they will suffice if you don't know any advanced
Want a Structured Path to Master System Design Too? Don’t Miss This!