Builds on Top-K Heap and Interval Scheduling, which you haven’t finished yet.
Greedy with Regret (Heap-Based Undo)
You are offered n online courses. courses[i] = [duration_i, lastDay_i]: course i takes duration_i days in a row, and it has to be finished no later than day lastDay_i. You start on day 1 and can only follow one course at a time, so the courses you choose run back to back.
Return the largest number of courses you can complete.
Greedy with Regret
events sorted by deadline ➔ max-heap of durations ➔ eject longest duration| 1 | import heapq |
| 2 | |
| 3 | def schedule_course(courses: list[list[int]]) -> int: |
| 4 | """Course Schedule III: the most courses that can each finish by their last day. |
| 5 | Time: O(N log N) | Space: O(N) |
| 6 | """ |
| 7 | courses.sort(key=lambda c: c[1]) # Sort by last day, not by duration |
| 8 | max_heap: list[int] = [] # Negated durations of the courses taken |
| 9 | total_time = 0 |
| 10 | |
| 11 | for duration, last_day in courses: |
| 12 | # 1. Take Every Course First: |
| 13 | # Taking first and fixing later is what makes the greedy safe: no decision is final until the limit breaks. |
| 14 | total_time += duration |
| 15 | heapq.heappush(max_heap, -duration) # Take it first |
| 16 | |
| 17 | # 2. Regret the Longest Course When the Deadline Breaks: |
| 18 | # Dropping the longest course restores the deadline (it is at least as long as the new course) and leaves total_time as small as possible. |
| 19 | if total_time > last_day: |
| 20 | longest = -heapq.heappop(max_heap) # Regret: drop the longest course taken |
| 21 | total_time -= longest |
| 22 | |
| 23 | # 3. Return How Many Courses Survived: |
| 24 | # Each course stays in max_heap only while it still fits, so the heap size is the number of courses taken. |
| 25 | return len(max_heap) |
max_heap holds the most courses that can all finish by the current last_day, and of all such sets it has the smallest total_time.Take every course in order of last_day: add its duration to total_time and push it onto max_heap, and if total_time now passes last_day, pop the longest course taken so far. Dropping the longest keeps the count the same as before this course while leaving total_time as small as possible, so every later course has the most room to fit. The trap is the sort key: sort by last_day, not by duration, or a short course with a late deadline can push out a course whose early deadline was still reachable.
See it run
Greedy with Regret on courses = [[4, 7], [5, 6], [2, 14], [4, 11], [5, 10], [4, 13]], step by step
last_day nonetotal_time 0len(max_heap) 0
Each column in the first two rows is one course, sorted by last day. The bottom row is the array of max_heap, which stores negated durations.
takingthe course taken at this steppushedthe value just pushed onto max_heaplongestthe longest course taken, at the heap rootdroppeda course that was removed from max_heap
The courses are sorted by last_day: [5, 6], [4, 7], [5, 10], [4, 11], [4, 13], [2, 14]. max_heap is empty.
Gotcha 1 of 4
- The trap
Gotcha 1: Sorting the courses by
durationinstead oflast_dayMistakecourses.sort(key=lambda c: c[0])Fixcourses.sort(key=lambda c: c[1])What happens: Each course is
[duration, last_day], soc[0]is the duration. Shortest-first can take a short course with a late deadline first. That uses time that a course with an early deadline needed. The early course then misses its deadline, and fewer courses fit.Example: Course Schedule III (LC 630): courses = [[3, 3], [1, 10], [1, 10]]Mistake: returns 2Fix: returns 3
- Each interval has a fixed start and end, as in Non-overlapping Intervals (LC 435), so nothing can be moved earlier and there is no running total to go over. See Interval Scheduling
- No budget or deadline can be exceeded by an earlier pick, as in Jump Game (LC 55), so a choice never has to be taken back. See Greedy
Deep Dive
Why taking every course first, then dropping the longest, is safe
Top-K Heap keeps the best k items and evicts the weakest when there are too many. This card has no fixed k. The limit is a deadline: the courses taken must finish by each last_day. The card evicts the longest taken course when the limit breaks.
Courses are sorted by last_day. Taken courses run one after another, so after the course with last_day the total time is total_time. If total_time is at most last_day, every taken course so far finishes in time, because earlier courses have earlier or equal last days and finish earlier.
If total_time is more than last_day, one course must go. Dropping the longest course taken so far gives the smallest total_time that is possible with the same number of courses. A smaller total_time leaves the most room for later courses. If the new course is the longest, it is the one dropped, and the set stays as it was.
The board uses the courses [4, 7], [5, 6], [2, 14], [4, 11], [5, 10], [4, 13]. Sorted by last_day, they are [5, 6], [4, 7], [5, 10], [4, 11], [4, 13], [2, 14]. The max_heap regrets two courses of 5 days, and schedule_course returns 4. Course Schedule III (LC 630) has the same shape.
The trap: the sort key and the skip rule
The sort key must be last_day. On the board's courses, sorting by duration ends with 3 courses, but the correct answer is 4. A short course with a late deadline is taken first, and it uses time that a course with an early deadline needed.
A second wrong idea is to skip a course that does not fit: if total_time + duration <= last_day. A skipped course can never be taken back, and a long course taken earlier stays in the schedule. On the board's courses, this rule returns 3. The blueprint takes the new course first and drops the longest course. This keeps the count and lowers total_time.
max_heap stores negated durations, because heapq is a min-heap. The pop returns a negative number. Write longest = -heapq.heappop(max_heap), then total_time -= longest. Without the minus sign, the subtraction adds the days.
The answer is len(max_heap), the number of courses kept. total_time is the number of days they use, which is a different value.
The cost, and the same idea in other problems
Sorting costs O(N log N). Each course is pushed once and popped at most once, and each heap operation costs O(log N). So the time is O(N log N) and the space is O(N).
Furthest Building You Can Reach (LC 1642) uses the same take first, regret later idea. Each climb is first paid for with a ladder. When the ladders run out, the smallest climb in the heap is paid for with bricks instead. There the heap is a min-heap, because the regret is the smallest climb.
Minimum Number of Refueling Stops (LC 871) takes every station passed, adds its fuel to a max-heap, and uses the largest fuel only when the car would otherwise stop. The heap holds choices that are made later, when they are needed.
The regret is possible only because of an exchange argument: swapping a longer taken course for a shorter new one never makes the schedule worse. A problem where a choice cannot be swapped this way, such as Non-overlapping Intervals (LC 435), is sorted by end time and has no regret step.
60-Second Pitch
"Each course has a duration and a last day. I sort the courses by last day, so the order of taking is fixed. I take every course first. I add its duration to total_time and push it onto a max-heap. If total_time passes the last day, I pop the longest course taken so far. That keeps the count the same as before this course, and it makes total_time as small as possible, so later courses have the most room. At the end, the heap size is the answer. The sort costs O(N log N), each heap operation costs O(log N), and the space is O(N)."
Common Questions
- Why do you sort by
last_dayand not byduration? Courses are taken one after another, so each course must finish by its own last day. If courses are in
last_dayorder, one check oftotal_timeagainst the currentlast_daycovers every earlier course. Sorting bydurationcan take a short course with a late deadline first and block a course with an early deadline.- Why drop the longest course and not the new one?
The longest course uses the most days. Removing it gives the smallest
total_timefor the same count of courses. If the new course is the longest, the pop removes the new course itself, and the set stays as it was.- What are the time and space complexities?
O(N log N)time: the sort costsO(N log N), and each course makes one push and at most one pop atO(log N).O(N)space formax_heap.- How is this different from Non-overlapping Intervals (LC 435)?
An interval has a fixed start and end, so it cannot move earlier, and there is no running total. Sorting by end time and keeping the earliest end is enough. Here the courses have only a duration and a last day, so a taken course can be removed later to make room.
- Can you prove that the greedy gives the maximum number of courses?
Use an exchange argument. Among all sets of the same size that fit, the heap keeps the one with the smallest total time. Swapping a longer course for a shorter one only lowers the total time, so no deadline breaks. Dropping the longest course therefore keeps the best set for every later course.
SWE Reasoning
Spot it
- You choose as many items as possible under a deadline or a budget.
- Items have a cost and a limit. Taking an item uses cost, and the total cost must stay within the limit.
- A plain greedy choice can be wrong, but an earlier choice can be undone by removing the most expensive item taken.
- Sorting by the limit makes the order of taking fixed, and a heap finds the most expensive taken item.
The invariant, step by step
- Sort
coursesbylast_day.max_heapis empty andtotal_timeis 0. - For each course, add
durationtototal_timeand push-durationontomax_heap. The course is taken for now. - If
total_timeis at mostlast_day, every taken course finishes in time. Nothing else happens. - If
total_timeis more thanlast_day, pop the longest duration frommax_heapand subtract it fromtotal_time. - The count is the same as before this course, and
total_timeis the smallest possible for that count. So later courses have the most room. - At the end,
max_heapholds the most courses that fit. The answer islen(max_heap).
Traps
- Sorting by
duration. A short course with a late deadline is taken first and blocks a course with an early deadline. Sort bylast_day. - Skipping a course that does not fit. A long course taken earlier stays. Take the new course first, and drop the longest course when the limit breaks.
- A missing minus sign on the pop.
longestis negative, andtotal_time -= longestadds days. Use-heapq.heappop(max_heap). - Returning
total_time. The question asks for the number of courses. Returnlen(max_heap).
Practice
Builds on Top-K Heap and Interval Scheduling, which you haven’t finished yet.
Greedy with Regret (Heap-Based Undo)
You are offered n online courses. courses[i] = [duration_i, lastDay_i]: course i takes duration_i days in a row, and it has to be finished no later than day lastDay_i. You start on day 1 and can only follow one course at a time, so the courses you choose run back to back.
Return the largest number of courses you can complete.
Greedy with Regret
events sorted by deadline ➔ max-heap of durations ➔ eject longest duration| 1 | import heapq |
| 2 | |
| 3 | def schedule_course(courses: list[list[int]]) -> int: |
| 4 | """Course Schedule III: the most courses that can each finish by their last day. |
| 5 | Time: O(N log N) | Space: O(N) |
| 6 | """ |
| 7 | courses.sort(key=lambda c: c[1]) # Sort by last day, not by duration |
| 8 | max_heap: list[int] = [] # Negated durations of the courses taken |
| 9 | total_time = 0 |
| 10 | |
| 11 | for duration, last_day in courses: |
| 12 | # 1. Take Every Course First: |
| 13 | # Taking first and fixing later is what makes the greedy safe: no decision is final until the limit breaks. |
| 14 | total_time += duration |
| 15 | heapq.heappush(max_heap, -duration) # Take it first |
| 16 | |
| 17 | # 2. Regret the Longest Course When the Deadline Breaks: |
| 18 | # Dropping the longest course restores the deadline (it is at least as long as the new course) and leaves total_time as small as possible. |
| 19 | if total_time > last_day: |
| 20 | longest = -heapq.heappop(max_heap) # Regret: drop the longest course taken |
| 21 | total_time -= longest |
| 22 | |
| 23 | # 3. Return How Many Courses Survived: |
| 24 | # Each course stays in max_heap only while it still fits, so the heap size is the number of courses taken. |
| 25 | return len(max_heap) |
max_heap holds the most courses that can all finish by the current last_day, and of all such sets it has the smallest total_time.Take every course in order of last_day: add its duration to total_time and push it onto max_heap, and if total_time now passes last_day, pop the longest course taken so far. Dropping the longest keeps the count the same as before this course while leaving total_time as small as possible, so every later course has the most room to fit. The trap is the sort key: sort by last_day, not by duration, or a short course with a late deadline can push out a course whose early deadline was still reachable.
See it run
Greedy with Regret on courses = [[4, 7], [5, 6], [2, 14], [4, 11], [5, 10], [4, 13]], step by step
last_day nonetotal_time 0len(max_heap) 0
Each column in the first two rows is one course, sorted by last day. The bottom row is the array of max_heap, which stores negated durations.
takingthe course taken at this steppushedthe value just pushed onto max_heaplongestthe longest course taken, at the heap rootdroppeda course that was removed from max_heap
The courses are sorted by last_day: [5, 6], [4, 7], [5, 10], [4, 11], [4, 13], [2, 14]. max_heap is empty.
Gotcha 1 of 4
- The trap
Gotcha 1: Sorting the courses by
durationinstead oflast_dayMistakecourses.sort(key=lambda c: c[0])Fixcourses.sort(key=lambda c: c[1])What happens: Each course is
[duration, last_day], soc[0]is the duration. Shortest-first can take a short course with a late deadline first. That uses time that a course with an early deadline needed. The early course then misses its deadline, and fewer courses fit.Example: Course Schedule III (LC 630): courses = [[3, 3], [1, 10], [1, 10]]Mistake: returns 2Fix: returns 3
- Each interval has a fixed start and end, as in Non-overlapping Intervals (LC 435), so nothing can be moved earlier and there is no running total to go over. See Interval Scheduling
- No budget or deadline can be exceeded by an earlier pick, as in Jump Game (LC 55), so a choice never has to be taken back. See Greedy
Deep Dive
Why taking every course first, then dropping the longest, is safe
Top-K Heap keeps the best k items and evicts the weakest when there are too many. This card has no fixed k. The limit is a deadline: the courses taken must finish by each last_day. The card evicts the longest taken course when the limit breaks.
Courses are sorted by last_day. Taken courses run one after another, so after the course with last_day the total time is total_time. If total_time is at most last_day, every taken course so far finishes in time, because earlier courses have earlier or equal last days and finish earlier.
If total_time is more than last_day, one course must go. Dropping the longest course taken so far gives the smallest total_time that is possible with the same number of courses. A smaller total_time leaves the most room for later courses. If the new course is the longest, it is the one dropped, and the set stays as it was.
The board uses the courses [4, 7], [5, 6], [2, 14], [4, 11], [5, 10], [4, 13]. Sorted by last_day, they are [5, 6], [4, 7], [5, 10], [4, 11], [4, 13], [2, 14]. The max_heap regrets two courses of 5 days, and schedule_course returns 4. Course Schedule III (LC 630) has the same shape.
The trap: the sort key and the skip rule
The sort key must be last_day. On the board's courses, sorting by duration ends with 3 courses, but the correct answer is 4. A short course with a late deadline is taken first, and it uses time that a course with an early deadline needed.
A second wrong idea is to skip a course that does not fit: if total_time + duration <= last_day. A skipped course can never be taken back, and a long course taken earlier stays in the schedule. On the board's courses, this rule returns 3. The blueprint takes the new course first and drops the longest course. This keeps the count and lowers total_time.
max_heap stores negated durations, because heapq is a min-heap. The pop returns a negative number. Write longest = -heapq.heappop(max_heap), then total_time -= longest. Without the minus sign, the subtraction adds the days.
The answer is len(max_heap), the number of courses kept. total_time is the number of days they use, which is a different value.
The cost, and the same idea in other problems
Sorting costs O(N log N). Each course is pushed once and popped at most once, and each heap operation costs O(log N). So the time is O(N log N) and the space is O(N).
Furthest Building You Can Reach (LC 1642) uses the same take first, regret later idea. Each climb is first paid for with a ladder. When the ladders run out, the smallest climb in the heap is paid for with bricks instead. There the heap is a min-heap, because the regret is the smallest climb.
Minimum Number of Refueling Stops (LC 871) takes every station passed, adds its fuel to a max-heap, and uses the largest fuel only when the car would otherwise stop. The heap holds choices that are made later, when they are needed.
The regret is possible only because of an exchange argument: swapping a longer taken course for a shorter new one never makes the schedule worse. A problem where a choice cannot be swapped this way, such as Non-overlapping Intervals (LC 435), is sorted by end time and has no regret step.
60-Second Pitch
"Each course has a duration and a last day. I sort the courses by last day, so the order of taking is fixed. I take every course first. I add its duration to total_time and push it onto a max-heap. If total_time passes the last day, I pop the longest course taken so far. That keeps the count the same as before this course, and it makes total_time as small as possible, so later courses have the most room. At the end, the heap size is the answer. The sort costs O(N log N), each heap operation costs O(log N), and the space is O(N)."
Common Questions
- Why do you sort by
last_dayand not byduration? Courses are taken one after another, so each course must finish by its own last day. If courses are in
last_dayorder, one check oftotal_timeagainst the currentlast_daycovers every earlier course. Sorting bydurationcan take a short course with a late deadline first and block a course with an early deadline.- Why drop the longest course and not the new one?
The longest course uses the most days. Removing it gives the smallest
total_timefor the same count of courses. If the new course is the longest, the pop removes the new course itself, and the set stays as it was.- What are the time and space complexities?
O(N log N)time: the sort costsO(N log N), and each course makes one push and at most one pop atO(log N).O(N)space formax_heap.- How is this different from Non-overlapping Intervals (LC 435)?
An interval has a fixed start and end, so it cannot move earlier, and there is no running total. Sorting by end time and keeping the earliest end is enough. Here the courses have only a duration and a last day, so a taken course can be removed later to make room.
- Can you prove that the greedy gives the maximum number of courses?
Use an exchange argument. Among all sets of the same size that fit, the heap keeps the one with the smallest total time. Swapping a longer course for a shorter one only lowers the total time, so no deadline breaks. Dropping the longest course therefore keeps the best set for every later course.
SWE Reasoning
Spot it
- You choose as many items as possible under a deadline or a budget.
- Items have a cost and a limit. Taking an item uses cost, and the total cost must stay within the limit.
- A plain greedy choice can be wrong, but an earlier choice can be undone by removing the most expensive item taken.
- Sorting by the limit makes the order of taking fixed, and a heap finds the most expensive taken item.
The invariant, step by step
- Sort
coursesbylast_day.max_heapis empty andtotal_timeis 0. - For each course, add
durationtototal_timeand push-durationontomax_heap. The course is taken for now. - If
total_timeis at mostlast_day, every taken course finishes in time. Nothing else happens. - If
total_timeis more thanlast_day, pop the longest duration frommax_heapand subtract it fromtotal_time. - The count is the same as before this course, and
total_timeis the smallest possible for that count. So later courses have the most room. - At the end,
max_heapholds the most courses that fit. The answer islen(max_heap).
Traps
- Sorting by
duration. A short course with a late deadline is taken first and blocks a course with an early deadline. Sort bylast_day. - Skipping a course that does not fit. A long course taken earlier stays. Take the new course first, and drop the longest course when the limit breaks.
- A missing minus sign on the pop.
longestis negative, andtotal_time -= longestadds days. Use-heapq.heappop(max_heap). - Returning
total_time. The question asks for the number of courses. Returnlen(max_heap).