homework.wenqian.dev
< Back to index
Tutorial 92026-10-01

A Greedy Choice Inside Divide and Conquer

Three-hour algorithms tutorial · greedy choices · Dijkstra · activity scheduling · divide and conquer · merge sort

Mission

Finish line: Use divide and conquer to sort the same activities that a greedy scheduler will select. Inside each merge, justify why taking the smaller front finish time is safe.

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.

A useful test: If you remove the assumption that each recursive half is correctly solved, can the local choice still be trusted? If not, the two ideas are connected by that assumption.

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.

Q
Circular Quay
W
Wynyard
3
T
Town Hall
M
Museum
8
C
Central
A
Airport

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.

Invariant: when a node is settled, its recorded distance is final — because every remaining road costs 0 or more.
chosen nowfrontier candidatesettled

THE PROOF SHAPE

  1. 1. Assume a cheaper route to the chosen node will appear later.
  2. 2. That route must first cross from a settled node to an unfinished node.
  3. 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

Activity scheduling: To select the most non-overlapping events, consider them by increasing finish time and keep each compatible event.

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.

python
# 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.
Why this differs from Dijkstra:
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

  1. Call the earliest-finishing compatible event E. Imagine an optimal schedule whose first event is X.
  2. By definition, E ends no later than X. Replace X with E; the number of events stays the same.
  3. Every later event began after X ended, so it also begins after E ends. Nothing later becomes impossible.
  4. 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.

411= 6 · 3 coins

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

One algorithm, two ideas: Merge sort divides the events into smaller lists. While combining two sorted halves, it repeatedly makes a provably safe greedy choice: take the smaller front finish time.

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.

Base case:
A list with zero or one item is already sorted. Stop splitting it.
Combine step:
Merge two sorted lists by repeatedly taking the smaller front value.

MERGE SORT LAB

The five events arrive unsorted

A12:00D11:00B10:00E11:30C10:30
Frame 1 of 6 · numbers are finish times

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.

Why this choice is safe: each half is already sorted, so its front is its smallest remaining finish time. The smaller of the two fronts must be the smallest value remaining overall. Choosing it cannot hide a smaller value later in either half.

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

09:0010:0011:0012:00
A · Harbour tour
D · Opera rehearsal
B · Gallery
E · Lunch
C · Coffee chat
Rule: sort by end time, then take an event only if it starts after the previous chosen event ends.

chosen

—

0 events; the long first event is deliberately skipped.

StageThe next moveWhy it works
DivideSplit the list until each part has at most one event.A zero- or one-item list is sorted.
Greedy mergeTake the earlier-finishing front event.Sorted halves make this the smallest remaining event.
Greedy scheduleKeep 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.

text
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.

StepFrontsTakeOutput so far
1D 11:00 / B 10:00BB
2D 11:00 / C 10:30CB, C
3D 11:00 / E 11:30DB, C, D
4A 12:00 / E 11:30EB, C, D, E
5A / right emptyAB, 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.

Where the ideas meet: The midpoint split makes the recursive work balanced; it is the divide-and-conquer step. Choosing a smaller front during merge is the greedy step. Each layer moves at most n items, and there are about log₂ n layers, so the sort takes O(n log n) time and O(n) extra space. The later scheduling scan takes O(n) time.

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.

Why can D18 and G20 be dropped?
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.
Why take the larger front?
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?

python
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)]
Do not over-generalize: Divide and conquer does not always contain a greedy step. Binary search also discards half, but it chooses the half using the target's position in a sorted array, not by taking a locally best answer. For any algorithm, name the exact candidate set, the rule, and the condition that makes the rule safe.

Practice

Warm-Up 15 min

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.

python
frontier = [("Town Hall", 5), ("Museum", 7)]# choose one pair
("Town Hall", 5)
Hint

Dijkstra chooses the smallest tentative distance among every unfinished candidate.

Warm-Up 28 min

Trace the rule

The events are already in increasing finish-time order. Start last_end at 0 and write the ids the loop appends.

python
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)]
["B", "C", "D", "E"]
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.

python
greedy_change(6, [1, 3, 4])  # [4, 1, 1]  -> 3 coins# better answer: [3, 3]         -> 2 coins

Warm-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?

python
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

Only edit this part: The eight TODOs below are the whole program. Read the comment above each one, write it, then run the terminal check before moving on.

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.

python
# 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 []
The connection in code: choose_activities calls your merge_sort. merge_sort recursively divides, while merge makes a greedy choice between the two sorted fronts. The final scan makes a second, differently justified greedy choice.

Part 5 — Check, then explain

python
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

Run rule: Do one TODO, run the check, read the first failing sentence, and fix only that promise before moving on.
terminal
$ python3 greedy_tutorial.pyMerge sort prepared the activities; greedy selection chose four.

TODO contract

TODO 1

compatible

Purpose

Say whether an event starts after last_end.

Input / output

event, last_end → True or False

State change

No change

TODO 2–3

choose_activities

Purpose

Use your merge_sort by finish time, then build chosen.

Input / output

events → compatible schedule

State change

new list

TODO 4

greedy_change

Purpose

Follow the largest-usable-coin rule exactly.

Input / output

amount, coins → chosen coins

State change

local amount

TODO 5–6

better_schedule / all_schedules

Purpose

Compare schedules and check small cases exhaustively.

Input / output

schedules / events → best / all

State change

new lists

TODO 7

merge

Purpose

Greedily choose the smaller keyed front, preserving ties.

Input / output

left, right, key → one sorted list

State change

new list

TODO 8

merge_sort

Purpose

Split, recursively solve with the same key, then merge.

Input / output

values, key → sorted copy

State change

slices only

Teacher checkpoints

Before code: Reject 'the cheapest road'. Ask for total distance from the start.
After TODO 1: Change >= to >. Do back-to-back activities still fit?
After TODO 3: Why is the long Harbour tour skipped even though it starts at 09:00?
After TODO 4: A working function is not yet an optimal algorithm. Run 6 / [1, 3, 4].
After TODO 7: With left D,A and right B,C,E, why is choosing B first safe?
After TODO 8: Trace A,D,B,E,C through split, greedy merge, and the scheduler. Where does each proof apply?

Exit ticket

ONE MINUTE, THREE SENTENCES

  1. Where does merge_sort divide, and where does it make a greedy choice?
  2. Why is the smaller front the smallest remaining event during merge?
  3. Why does the later scheduling scan need an exchange argument rather than the merge proof?