A Greedy Choice Inside Divide and Conquer
Three-hour algorithms tutorial · greedy choices · Dijkstra · activity scheduling · divide and conquer · merge sort
Mission
Divide
Split events into smaller lists.
Choose
During merge, take the smaller front finish time.
Schedule
Scan the sorted list and keep compatible events.
Three-hour route
00:00–00:20
Relive Dijkstra
Animate the frontier and name the exact greedy choice.
00:20–00:45
The proof-shaped idea
Use the non-negative-edge invariant; distinguish a rule from its reason.
00:45–01:15
Activity scheduling
Derive the earliest-finish rule and identify the order it needs.
01:15–01:30
Break
Before returning: explain why choosing the earliest start is wrong.
01:30–01:55
A greedy choice inside merge
Split the same events, then choose the smaller front finish time at each merge step.
01:55–02:15
Transfer and counterexamples
Find the top three with the same split-and-choose pattern; break rules by removing their preconditions.
02:15–02:45
Write the lab
Write eight TODOs: greedy rules, a verifier, merge and merge_sort.
02:45–03:00
Exit proof
State a rule or split, a reason, and one limitation.
Start here — two questions about every algorithm
A greedy algorithm commits to a next choice using information available now. Divide and conquer changes the size of the question: solve smaller copies, then combine their results. They can appear in the same algorithm, but the split itself is not automatically a greedy choice. We must identify the exact line where a local choice is made and prove the information at that line is sufficient.
GREEDY
Which candidate do I take now?
Example: if two sorted queues start with finish times 10:00 and 11:00, take 10:00 first. The proof depends on both queues being sorted.
DIVIDE & CONQUER
What smaller problem has the same shape?
Example: to sort five events, sort the first two and last three separately. A one-event list is already sorted; combine the two answers afterwards.
Part 1 — Dijkstra is greedy, precisely
The algorithm does not greedily choose the cheapest next road. It greedily settles the unfinished suburb with the smallest total distance from the start. Press Play and read the amber sentence on every step.
LIVE DIJKSTRA
The greedy choice is highlighted at every step.
Step 1 of 6
Settle Circular Quay at 0
Among every unfinished candidate, this is the smallest total distance from Circular Quay. It is not the shortest road leaving the last suburb.
THE PROOF SHAPE
- 1. Assume a cheaper route to the chosen node will appear later.
- 2. That route must first cross from a settled node to an unfinished node.
- 3. With non-negative edge costs, that unfinished node would have been no dearer than the one we chose — a contradiction.
Part 2 — The schedule needs an order
The events arrive as A, D, B, E, C. How can our own code produce B, C, D, E, A before the greedy scan? That is the divide-and-conquer task in Part 4.
# The exchange argument in plain Python words# Let E be the compatible event that ends earliest.# If an optimal schedule starts with some other event X,# replace X with E.# E ends no later than X, so every later event still fits.# Therefore an optimal schedule exists that starts with E.Dijkstra uses a distance invariant. Activity scheduling uses an exchange argument: replace the first choice in an optimal answer without making the answer worse.
Worked example 1 — one long event or four short ones?
Suppose A runs from 09:00 to 12:00. B, C, D and E form a chain from 09:30 to 11:30. Picking the event that starts first sounds sensible, but it uses the wrong measurement: we want to leave room for as many later events as possible. Run both rules on the same input below.
SAME INPUT · TWO LOCAL RULES
Which event should be considered first?
The goal is the largest number of non-overlapping events, not the earliest start.
A
09:00–12:00
NEXT
B
09:30–10:00
—
C
10:00–10:30
—
D
10:30–11:00
—
E
11:00–11:30
—
Selected: — (0)
Next is A (09:00–12:00). Compare its start with the end of the last selected event.
Why earliest finish is safe — the exchange argument
- Call the earliest-finishing compatible event E. Imagine an optimal schedule whose first event is X.
- By definition, E ends no later than X. Replace X with E; the number of events stays the same.
- Every later event began after X ended, so it also begins after E ends. Nothing later becomes impossible.
- Therefore some optimal schedule begins with E. Make that choice, then repeat on the events that remain compatible.
For these times, E is B (finishes 10:00), while X could be A (finishes 12:00). Replacing A with B makes C, D and E available. Equality matters: an event starting exactly when the previous one ends is compatible.
Part 3 — A greedy rule needs a reason
'It feels efficient' is not a proof. A single counterexample is enough to reject a rule. Toggle the two coin strategies below.
COUNTEREXAMPLE
Greedy coin choice can fail
Make 6 using coins {1, 3, 4}. The tempting rule is: take the largest coin that fits.
THE QUESTION TO ASK
What remains after my choice?
- Dijkstra: non-negative edges mean no unseen route can reduce a settled distance.
- Activity scheduling: the earliest finish leaves at least as much time as every other first choice.
- Coin change: taking 4 leaves 2, which is worse than taking 3 and leaving 3.
Part 4 — Split, solve, merge
We will sort the actual five activities by finish time. The split happens at the midpoint. The interesting choice comes during merge: from two sorted halves, repeatedly take the front event that finishes earlier.
A list with zero or one item is already sorted. Stop splitting it.
Merge two sorted lists by repeatedly taking the smaller front value.
MERGE SORT LAB
The five events arrive unsorted
LOCAL CHOICE INSIDE MERGE
Which front event finishes earlier?
The two halves are already sorted. Choose only between their front events. That local choice is the greedy step inside the combine phase.
left half
right half
merged output → greedy scheduler
Why is this safe? Every later value in each half is at least its front value.
The merged order now flows straight into the greedy scheduler. Click through the same B → C → D → E → A order; A is rejected because it overlaps the chosen activities.
ACTIVITY SCHEDULER
Choose the earliest finishing compatible event
chosen
—
0 events; the long first event is deliberately skipped.
| Stage | The next move | Why it works |
|---|---|---|
| Divide | Split the list until each part has at most one event. | A zero- or one-item list is sorted. |
| Greedy merge | Take the earlier-finishing front event. | Sorted halves make this the smallest remaining event. |
| Greedy schedule | Keep each compatible event in finish-time order. | An exchange argument preserves an optimal answer. |
Worked example 2 — every step of the merge
Start with the unsorted finish times A12:00, D11:00, B10:00, E11:30, C10:30. The recursive calls must finish before the final merge begins. Here is the complete call structure; arrows on the way back mean that smaller answers have been solved.
merge_sort([A, D, B, E, C])├─ merge_sort([A, D])│ ├─ [A] └─ [D] → [D, A]└─ merge_sort([B, E, C]) ├─ [B] └─ [E, C] → [B] + [C, E] → [B, C, E]final merge: [D, A] + [B, C, E] → [B, C, D, E, A]Now pretend you are the merge function. While both halves contain items, compare only the two current fronts and advance the chosen side. The table shows four comparisons, then the final leftover append.
| Step | Fronts | Take | Output so far |
|---|---|---|---|
| 1 | D 11:00 / B 10:00 | B | B |
| 2 | D 11:00 / C 10:30 | C | B, C |
| 3 | D 11:00 / E 11:30 | D | B, C, D |
| 4 | A 12:00 / E 11:30 | E | B, C, D, E |
| 5 | A / right empty | A | B, C, D, E, A |
The loop invariant
Before every comparison, the output contains the smallest items already removed, in order. Each remaining half is still sorted. Therefore the next smallest remaining item must be one of the two fronts; taking the smaller front preserves the invariant.
The recursive argument
A list of at most one item is sorted. Assume each recursive call returns a sorted half. The invariant proves merge returns a sorted whole containing every input item. That completes the proof for lists of every size.
TEST THE PRECONDITION
What if one half is not sorted?
left
[2, 7]
right
[3, 5]
Greedy comparison sees only the fronts
min(2, 3) = 2
True smallest remaining value
2 ✓
The fronts represent their whole halves because each half is sorted. Choosing 2 is safe.
Worked example 3 — the same pattern finds a top three
The connection is not limited to event times. Imagine eight scores in input order: A42, B91, C35, D18, E76, F65, G20, H88. Split them into two groups of four. Each half returns only its own top three in descending order. The final combine step greedily takes the larger front until it has three names.
TRANSFER EXAMPLE · TOP 3
Find the three highest scores
Each recursive half returns only its own top three, already sorted high to low.
Left: A42, B91, C35, D18 → top 3
D18 discarded locally
Right: E76, F65, G20, H88 → top 3
G20 discarded locally
global top three
—
Choose the higher front score. A discarded person already has three better people in the same half, so cannot enter the global top three.
A person outside the top three of their own half already has at least three people ahead of them in that half. They cannot be in the top three of the whole set either.
Each half is sorted high to low, so its front is its best remaining candidate. The higher of those two is the best remaining candidate overall. This is the same proof shape as merge sort, with the comparison reversed.
Read the code: where does it divide, and where does it choose?
def top_k(values, k): """Return the k highest (name, score) pairs, highest first.""" if k <= 0 or not values: return [] if len(values) == 1: return values[:] middle = len(values) // 2 left = top_k(values[:middle], k) # solved left half right = top_k(values[middle:], k) # solved right half answer = [] i = j = 0 while len(answer) < k and (i < len(left) or j < len(right)): # Both halves are sorted high to low. The higher front is next. if j == len(right) or (i < len(left) and left[i][1] >= right[j][1]): answer.append(left[i]) i += 1 else: answer.append(right[j]) j += 1 return answerscores = [("A", 42), ("B", 91), ("C", 35), ("D", 18), ("E", 76), ("F", 65), ("G", 20), ("H", 88)]assert top_k(scores, 3) == [("B", 91), ("H", 88), ("E", 76)]Practice
Name the choice
After Wynyard is settled at 3, which unfinished candidate is next? Explain using total distance from Circular Quay, not the shortest edge leaving Wynyard.
frontier = [("Town Hall", 5), ("Museum", 7)]# choose one pairHint
Dijkstra chooses the smallest tentative distance among every unfinished candidate.
Trace the rule
The events are already in increasing finish-time order. Start last_end at 0 and write the ids the loop appends.
events = [("B", 9.5, 10.0), ("C", 10.0, 10.5), ("D", 10.5, 11.0), ("E", 11.0, 11.5), ("A", 9.0, 12.0)]Hint
After choosing an event, move last_end to that event's end time. A is considered last and no longer fits.
Warm-Up 3 — break a plausible rule
For amount 6 and coins [1, 3, 4], run the largest-coin rule first. Then find a better answer. A counterexample rejects one rule; it does not reject every greedy algorithm.
greedy_change(6, [1, 3, 4]) # [4, 1, 1] -> 3 coins# better answer: [3, 3] -> 2 coinsWarm-Up 4 — merge the same events by hand
The two halves are already sorted by finish time. Compare their front events, append the earlier-finishing one, and advance only that half. What order will the scheduler receive?
left = [("D", 10.5, 11.0), ("A", 9.0, 12.0)]right = [("B", 9.5, 10.0), ("C", 10.0, 10.5), ("E", 11.0, 11.5)]# [event[0] for event in merge(left, right, key=lambda e: e[2])]# returns ["B", "C", "D", "E", "A"]Hint
B beats D, then C beats D. D beats E, E beats A, and A remains. Each choice is safe because no smaller finish time hides behind either front.
Stop and explain — three new cases
For each case, write a candidate set, a local rule, and the assumption or counterexample. Predict before opening the explanation.
Case A — a hidden small value
Try to merge left=[7,2] and right=[3,5] by taking the smaller front. What first value does the rule produce? What should the first value be? Which premise failed?
Explain
The fronts are 7 and 3, so the rule emits 3. But 2 is smaller and is hidden behind 7. The recursive left half was not sorted; the merge proof cannot start.
Case B — only the best two
Find the top two of [A4, B9, C3, D7, E8, F2]. Split after C. What may each half discard, and which two are globally best?
Explain
Left returns B9,A4 and drops C3; right returns E8,D7 and drops F2. Compare the heads 9 and 8: choose B9, then E8. Any locally dropped item already has two better items in its own half.
Case C — count the work
In the final merge [D,A] + [B,C,E], how many front-to-front comparisons are needed? Is the final leftover A compared again?
Explain
Four comparisons choose B, C, D and E. The right half is then empty, so append A without another comparison. Each item moves into the result once per merge level.
Student work area
TODO 7 makes the safe greedy choice inside merge. TODO 8 recursively builds sorted halves. TODO 2 passes that sorted result into the activity scheduler, and TODO 3 greedily keeps compatible events. The remaining TODOs test the rules and their limits.
# greedy_tutorial.py — write and test every ruledef compatible(event, last_end): """An event is (name, start, end).""" # TODO 1: Can this event start after the last chosen event ends? passdef choose_activities(events): """Merge-sort by finish time, then greedily select activities.""" # TODO 2: use YOUR merge_sort with key=lambda event: event[2]. # The function is defined below; Python looks it up when this runs. ordered = [] chosen = [] last_end = 0 # TODO 3: scan ordered. Append only compatible events. for event in ordered: pass return chosendef greedy_change(amount, coins): """Repeatedly take the largest usable coin.""" # TODO 4: return the actual list of coins chosen, not just its length. passdef better_schedule(first, second): """Return the schedule containing more events. If tied, return the one that finishes earlier. """ # TODO 5: define exactly what 'better' means, including empty schedules. passdef all_schedules(events, at=0, picked=None): """Extension: recursively generate schedules for a tiny input.""" # TODO 6: use this only to CHECK a greedy rule on small data. passdef merge(left, right, key=lambda value: value): # TODO 7 — left and right are already sorted by key. # Return one new sorted list containing every value from both lists. # Compare key(left[i]) and key(right[j]); move one index. # On a tie, take LEFT first so equal keys keep their original order. return []def merge_sort(values, key=lambda value: value): # TODO 8 — divide and conquer. # Base case: a list of length 0 or 1 is already sorted. # Split; recursively sort both halves with the SAME key; merge by key. return []Part 5 — Check, then explain
EVENTS = [ ("Harbour tour", 9.0, 12.0), ("Opera", 10.5, 11.0), ("Gallery", 9.5, 10.0), ("Lunch", 11.0, 11.5), ("Coffee", 10.0, 10.5),]original = EVENTS[:]answer = choose_activities(EVENTS)assert compatible(("back-to-back", 10.0, 10.5), 10.0)assert [event[0] for event in answer] == ["Gallery", "Coffee", "Opera", "Lunch"]assert greedy_change(6, [1, 3, 4]) == [4, 1, 1] # greedy runs; it is not optimalassert better_schedule([], answer) == answerassert better_schedule(answer[:3], answer) == answersmall = [("B", 9.5, 10.0), ("C", 10.0, 10.5)]assert len(all_schedules(small)) == 4 # [], [B], [C], [B, C]assert merge([1, 5], [2, 3]) == [1, 2, 3, 5]assert merge([("left", 10)], [("right", 10)], key=lambda item: item[1]) == [ ("left", 10), ("right", 10)]assert merge_sort([7, 3, 8, 2, 5, 1]) == [1, 2, 3, 5, 7, 8]assert [event[0] for event in merge_sort(EVENTS, key=lambda event: event[2])] == [ "Gallery", "Coffee", "Opera", "Lunch", "Harbour tour"]assert EVENTS == original # the original input was not changedprint("Merge sort prepared the activities; greedy selection chose four.")Rule
What is selected next?
Reason
What invariant or exchange makes it safe?
Boundary
Give one input where a similar rule fails.
In the terminal
$ python3 greedy_tutorial.pyMerge sort prepared the activities; greedy selection chose four.TODO contract
compatible
Purpose
Say whether an event starts after last_end.
Input / output
event, last_end → True or False
State change
No change
choose_activities
Purpose
Use your merge_sort by finish time, then build chosen.
Input / output
events → compatible schedule
State change
new list
greedy_change
Purpose
Follow the largest-usable-coin rule exactly.
Input / output
amount, coins → chosen coins
State change
local amount
better_schedule / all_schedules
Purpose
Compare schedules and check small cases exhaustively.
Input / output
schedules / events → best / all
State change
new lists
merge
Purpose
Greedily choose the smaller keyed front, preserving ties.
Input / output
left, right, key → one sorted list
State change
new list
merge_sort
Purpose
Split, recursively solve with the same key, then merge.
Input / output
values, key → sorted copy
State change
slices only
Teacher checkpoints
Exit ticket
ONE MINUTE, THREE SENTENCES
- Where does merge_sort divide, and where does it make a greedy choice?
- Why is the smaller front the smallest remaining event during merge?
- Why does the later scheduling scan need an exchange argument rather than the merge proof?