Facebook Pixel

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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. Skip the job: If we don't take job i, our maximum profit is whatever we can get from the remaining jobs starting at position i+1.

  2. 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 insert e
  • lo=i + 1 ensures we only look at jobs after the current one
  • key=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: dfs(i)=max(dfs(i+1),profit[i]+dfs(j))dfs(i) = \max(dfs(i+1), profit[i] + dfs(j))

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 Evaluator

Example 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) = 70
  • dfs(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 most n 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 takes O(log n) time
  • Therefore, the total time for all dfs calls is O(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 sorted jobs list
  • O(n) for the memoization cache that stores results for up to n different indices
  • O(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])
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which two pointer techniques do you use to check if a string is a palindrome?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More