Problem
You have a list of jobs, each with a start time, an end time, and a profit value. You can run at most one job at a time, but a new job may start the instant another finishes. Find the maximum total profit you can earn by choosing a non-overlapping subset of jobs.
- Input: startTime = [1, 2, 3, 3], endTime = [3, 4, 5, 6], profit = [50, 10, 40, 70]
- Output: 120
- Explanation: Job 0 (time 1โ3, $50) then job 3 (time 3โ6, $70) yields $120 โ taking job 2 instead of job 3 only gives $90.
Intuition
Sort jobs by end time so you can process them in chronological order of completion. For each job the only decision is take it or skip it. If you take it, you want the best profit from all jobs that finish before this one starts โ and because the list is sorted, that "last compatible job" boundary can be found with binary search rather than a full scan.
Approach 1 โ DP with Linear Scan
Sort jobs by end time. Build a DP table where dp[i] is the maximum profit achievable from any subset of the first i jobs. For each job, either skip it (carry forward dp[i-1]) or take it and pair it with the best compatible prefix found by scanning backwards.
- Create (endTime, startTime, profit) triples and sort by end time.
- Set
dp[0] = 0;dp[i]will hold the best profit using the firstisorted jobs. - For job
i, scan backwards through jobs 1โฆi-1 to find the last one whose end time โค this job's start time. - Set
dp[i] = max(dp[i-1], profit_i + dp[last_compatible]). - Return
dp[n].
1from typing import List
2
3def jobScheduling(startTime: List[int], endTime: List[int], profit: List[int]) -> int:
4 jobs = sorted(zip(endTime, startTime, profit)) # sort by end time
5 n = len(jobs)
6 dp = [0] * (n + 1) # dp[i] = max profit from first i jobs
7
8 for i in range(1, n + 1):
9 end_i, start_i, profit_i = jobs[i - 1]
10
11 # Scan backwards for the last job that finishes by start_i
12 last_compatible = 0
13 for j in range(i - 1, 0, -1):
14 if jobs[j - 1][0] <= start_i: # end time of job j โค start of current
15 last_compatible = j
16 break
17
18 dp[i] = max(dp[i - 1], profit_i + dp[last_compatible])
19
20 return dp[n]Time: O(nยฒ) โ sorting is O(n log n), but the inner backwards scan dominates. Space: O(n) โ the DP array and the sorted jobs list.
Approach 2 โ DP with Binary Search
Identical logic to Approach 1, but replace the backwards linear scan with a binary search on the sorted end-time array. Since jobs are already ordered by end time, "the last job finishing by start_i" is an upper-bound query that resolves in O(log n).
- Sort jobs by end time and extract their end times into a separate array.
- For each job
i, callbisect_right(end_times, start_i, 0, i)โ the return value is the count of previous jobs whose end โคstart_i, which doubles as the dp index for that compatible prefix. - Set
dp[i] = max(dp[i-1], profit_i + dp[count]). - Return
dp[n].
1from bisect import bisect_right
2from typing import List
3
4def jobScheduling(startTime: List[int], endTime: List[int], profit: List[int]) -> int:
5 jobs = sorted(zip(endTime, startTime, profit)) # sort by end time
6 n = len(jobs)
7 end_times = [job[0] for job in jobs] # extracted for binary search
8 dp = [0] * (n + 1) # dp[i] = max profit from first i jobs
9
10 for i in range(1, n + 1):
11 end_i, start_i, profit_i = jobs[i - 1]
12
13 # bisect_right returns count of end_times[0:i] that are โค start_i,
14 # which is exactly the dp index of the best compatible prefix
15 last_compatible = bisect_right(end_times, start_i, 0, i)
16
17 dp[i] = max(dp[i - 1], profit_i + dp[last_compatible])
18
19 return dp[n]Time: O(n log n) โ sorting plus O(log n) binary search per job. Space: O(n) โ the DP array, sorted jobs list, and end-times array.
Complexity Summary
| Approach | Time | Space | When to use |
|---|---|---|---|
| DP + Linear Scan | O(nยฒ) | O(n) | Small inputs (โค 1 000 jobs) where simplicity matters |
| DP + Binary Search | O(n log n) | O(n) | Standard โ required for n up to 50 000 as in LeetCode constraints |
Common Mistakes
- Sorting by start time instead of end time โ the DP correctness depends on processing jobs in the order they finish; sorting by start time means earlier dp values can't represent "all non-overlapping predecessors."
- Using
bisect_leftinstead ofbisect_rightโbisect_leftwould exclude jobs ending exactly atstart_i, but the problem explicitly allows a new job to begin the instant the previous one ends. - Treating the binary search result as a 0-indexed job position โ
bisect_rightreturns a count (how many jobs end bystart_i), not a job index; using it directly asdp[last_compatible]is correct because dp is 1-indexed. - Returning
dp[n-1]instead ofdp[n]โdp[n]represents the optimal choice over all n jobs;dp[n-1]omits the last job from consideration. - Forgetting to restrict the binary search range to
[0, i)โ searching the fullend_timesarray would include jobs not yet processed, though in practice valid inputs (whereend > start) make this a no-op, relying on it is fragile.
Related Problems
non-overlapping-intervalsโ greedy take on the same "pick non-overlapping intervals" problem, minimizing removals instead of maximizing profitmerge-intervalsโ foundational interval overlap detection; understanding this makes the compatibility check here intuitivelongest-increasing-subsequenceโ uses the same O(n log n) pattern of DP transitions accelerated by binary search on a sorted auxiliary arraypartition-labelsโ greedy interval partitioning where "when does an interval close" drives each decision, same structural reasoningbest-time-to-buy-and-sell-stock-with-cooldownโ DP where a "cooldown" constraint links consecutive decisions, the same take-or-skip structure with a compatibility condition