AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Data Structures & Algorithms · Part 12

Greedy algorithms

A greedy algorithm builds a solution by repeatedly taking the locally best choice and never looking back. When that is provably optimal it is the fastest paradigm of all — but it is correct only when the problem has the greedy-choice property. This lesson proves correctness with exchange arguments, works the classics (activity selection, interval scheduling, Huffman coding, fractional knapsack), and shows exactly where greedy fails and DP is required.

⏱️ ~2.5 hours🎯 Advanced → Expert✂️ proofs & counterexamplesrunnable

Learning objectives

  • State the greedy-choice property and optimal substructure — the two proof obligations.
  • Prove a greedy algorithm optimal with an exchange (swap) argument.
  • Solve activity selection / interval scheduling and justify the 'earliest finish' rule.
  • Build a Huffman code and explain why merging the two rarest symbols is optimal.
  • Solve fractional knapsack greedily and contrast with 0/1 (which needs DP).
  • Recognize when greedy fails and produce a concrete counterexample.

1 · The greedy-choice property advanced

Start with the moment greedy burned someone — maybe you. You need to make change for 63 cents and you grab coins largest-first: a 50, a 10, and three 1s — five coins, obviously optimal. That habit is so reliable with real currency that you stop thinking about it. Then one day the coin set is {1, 3, 4} and you need 6. Largest-first grabs a 4, then two 1s — three coins — while the real answer is two 3s. The exact same "always take the biggest" rule that was provably optimal a second ago is now just wrong. Nothing about the algorithm changed; the problem did. That gap is the whole reason this section exists before any code: greedy is not a technique you apply, it's a claim you must earn.

Here's the everyday analogy that makes the danger tangible. Greedy is like driving toward a mountain by always turning onto whichever road climbs most steeply right now. On a simple hill that walks you straight to the summit. But on real terrain the steepest local road can dead-end you on a minor peak with a valley between you and the true top — and because greedy "never looks back," you're stuck. Whether local-steepest reaches the summit is a property of the landscape, not of your enthusiasm for climbing. The common mistake, and it is everywhere in production code, is assuming that because a greedy rule feels obviously right on your test cases, it is right in general. It usually isn't — and the failure only shows up on an input you didn't try.

So a greedy algorithm is correct only if two things hold, and you should think of them as proof obligations you owe before you ship. Optimal substructure (shared with DP): an optimal solution contains optimal solutions to subproblems. Greedy-choice property: a globally optimal solution can be reached by making the locally optimal choice at each step — you never need to reconsider. The second is the hard one and the one people skip. It says something remarkable — that a decision made with zero information about the future can never be regretted — and remarkable claims need proof. DP tries all choices and keeps the best; greedy proves that one choice is always safe, so it skips the search entirely. That skipped search is exactly why greedy is the fastest paradigm when it works (often Θ(n log n) where DP is Θ(n²) or worse) and exactly why it's silently wrong when it doesn't. The payoff of proving the property is speed you can trust; the cost of assuming it is a bug that passes every test and fails in the field.

Make greedy choice locally optimal Reduce to onesubproblem the rest of the input Repeat no backtracking
🗺️ How to read this diagram

Read it left to right, following the arrows — it's the whole greedy loop compressed into three steps.

  • The green box on the left is the single move greedy makes each round: the locally optimal choice, decided with no look at the future.
  • The arrow leads to the middle box — after committing, the problem shrinks to exactly one subproblem: the rest of the input, with the chosen part removed.
  • The last box is the payoff — you just repeat on that smaller problem, and the "no backtracking" label is the key claim: greedy never revisits a past choice, which is why it skips the search DP would do.

In short: greedy = make one safe local choice, reduce to a single smaller problem, repeat — never look back.

How to prove a greedy algorithm (exchange argument)Assume an optimal solution OPT that differs from the greedy one. Find the first place they diverge and swap OPT's choice for greedy's. Show the swap does not make OPT worse (and keeps it feasible). Repeating the swap turns OPT into the greedy solution without losing optimality — so greedy is optimal too. This 'greedy stays ahead / exchange' template proves almost every result below.

2 · Activity selection — earliest finish wins intermediate

Picture a single conference room and a stack of booking requests. Everyone wants it; the requests overlap in a tangle. Your job is to say yes to as many meetings as possible (not the longest, not the most important — the most meetings). Sit with that for a second and your instinct will probably reach for one of two rules: "let in whoever asks first," or "let in the shortest meetings so I can pack more." Both feel reasonable. Both are wrong, and that is the entire lesson of this section — the obvious greedy rule is not the correct one.

The rule that actually works is almost counterintuitive: always take the activity that finishes earliest among those still compatible with what you've already booked. Why finish time and not start time? Because the resource you're rationing isn't "who's polite enough to ask first," it's the room's future. Every meeting you accept costs you the room until it ends, so the cheapest possible commitment — the one that frees the room soonest for everything after — is the one that ends earliest. Finishing early is greedy's way of "keeping the most options open." That reframing ("what am I actually spending?") is the move you'll make in every greedy problem: find the resource, then pick the choice that consumes the least of it.

The formal statement: given activities with start/finish times, pick the largest set that don't overlap. Sort by finish time and sweep, keeping each activity whose start is at or after the last kept finish — that's Θ(n log n), dominated by the sort. Below the exchange proof shows why earliest-finish is safe, and the warning box after the code shows concrete inputs where start-time and shortest-duration greedily produce fewer meetings. Read those counterexamples carefully — being able to break the wrong rules is how you build faith in the right one.

Given activities with start/finish times, pick the largest set that don't overlap. The greedy rule: always take the activity that finishes earliest among those still compatible. Sorting by finish time and sweeping is Θ(n log n).

Exchange proof. Let greedy pick activity g (earliest finish) and suppose some optimal set OPT picks a different first activity o. Since g finishes no later than o, replacing o with g in OPT leaves all later activities still compatible (they started after o finished, hence after g finished). The swapped set is the same size and still valid — so greedy's first choice is safe. Induct on the remainder.

time → (activities sorted by finish time) (1,4) keep (3,5) skip (0,6) skip (5,7) keep (6,10) skip (8,11) keep each kept start ≥ last kept finish Earliest finish keeps the room free soonest. Sweep the finish-sorted list; keep an activity only when its start is at or after the last kept finish (green), skip the overlaps (red). The kept set is the largest possible.
🗺️ How to read this diagram

Read top to bottom — the activities are already sorted by finish time, and time runs left to right, so each bar's right edge is when it ends.

  • Each bar is one booking request spanning its (start, finish); a bar further right ends later.
  • Green bars are the ones greedy keeps; red bars are skipped because they overlap something already kept.
  • The rule that decides each bar: a request is kept only when its start is at or after the last kept finish — that's why every green bar begins to the right of the previous green bar's end.
  • The payoff: picking the earliest-finishing compatible activity each time frees the room soonest, so the green set is as large as any set can be.

In short: Sort by finish, then greedily keep every activity that starts after the last kept one ended — the earliest-finish rule maximizes the count.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — activity selection by earliest finish
pythondef select_activities(activities):
    """activities: list of (start, finish). Return a max non-overlapping subset."""
    chosen = []
    last_finish = float("-inf")
    for start, finish in sorted(activities, key=lambda a: a[1]):  # earliest finish first
        if start >= last_finish:            # compatible with the last chosen
            chosen.append((start, finish))
            last_finish = finish
    return chosen

acts = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 9), (5, 9), (6, 10), (8, 11), (8, 12), (2, 14), (12, 16)]
picked = select_activities(acts)
print("selected", len(picked), "activities:", picked)
selected 4 activities: [(1, 4), (5, 7), (8, 11), (12, 16)]
Sort by finish, not start or durationSorting by earliest start or shortest duration both give wrong answers on simple inputs (a long early-finishing job vs two short late ones). Only 'earliest finish' has an exchange-argument proof. The choice of greedy criterion is the whole ballgame.

3 · Interval partitioning — minimum resources advanced

Flip the last problem on its head and it's suddenly a university registrar's nightmare. In activity selection you had one room and turned meetings away. Now nobody gets turned away — every lecture must be scheduled — and instead you're buying rooms. How few rooms can you rent so that no two overlapping lectures ever land in the same one? Rent too many and you waste money; assign carelessly and you'll double-book. This is interval partitioning, and it shows up anywhere a "how many parallel resources do I need?" question hides: exam rooms, CPU cores for overlapping jobs, machines on a factory floor, database connections in a pool.

There's a beautiful lower bound that makes the answer feel inevitable once you see it. Stand at the busiest instant of the day — the moment when the most lectures are running simultaneously. If seven lectures overlap at 10:05 a.m., then at 10:05 you physically need seven rooms; there is no cleverness that fits seven concurrent lectures into six rooms. That peak simultaneous count is called the depth, and it's a floor you cannot go under. The genuinely surprising part — the thing worth proving — is that the depth is also achievable: greedy never needs more rooms than the depth. So the minimum is exactly the maximum overlap, no more and no less.

The greedy that hits that bound: sort by start time; assign each interval to any currently free resource, opening a new one only when none is free. The common mistake here is over-engineering the "which free room?" decision — agonizing over which idle room to reuse. It doesn't matter; any free room works, because a room is free precisely when its last lecture has ended, and all that matters is whether some room is free. A min-heap keyed on end times makes "is any room free?" an O(log n) check (peek the room that frees soonest), giving Θ(n log n) overall. If you find yourself wanting to backtrack and reshuffle assignments, that's the tell you've stopped trusting the greedy-choice property — the whole point is that you never have to.

A dual problem: schedule all intervals using the fewest resources (rooms, machines) so overlapping intervals get different resources. The greedy: sort by start time; assign each interval to any free resource, opening a new one only when none is free. The number of resources equals the maximum overlap ('depth') — and you cannot beat the depth, so greedy is optimal.

time → (every lecture must be placed) R1 (1,5) (8,9) R2 (2,6) R3 (3,7) depth = 3 lectures overlap here → 3 rooms Minimum rooms = maximum overlap. At the busiest instant three lectures run at once, so no schedule fits in fewer than 3 rooms — and sweeping by start time, reusing any freed room, never needs more.
🗺️ How to read this diagram

Read left to right for time, and top to bottom for rooms — each row (R1, R2, R3) is one rented room, and each bar is a lecture placed in that room.

  • Every bar is a lecture that must be scheduled somewhere; two bars in the same row never overlap (that's what makes a single room valid).
  • The dashed vertical line marks the busiest instant — the moment the most lectures run simultaneously.
  • At that line three bars stack up (R1, R2, R3), so the depth is 3: you physically cannot serve three concurrent lectures with two rooms.
  • The payoff: greedy (sort by start, reuse any freed room, open a new one only when none is free) never exceeds that depth — so 3 is both the floor and the answer.

In short: The fewest rooms you can possibly need equals the maximum number of intervals overlapping at any instant, and start-time greedy hits exactly that.

Try it — minimum rooms via a min-heap of end times
pythonimport heapq

def min_rooms(intervals):
    if not intervals:
        return 0
    intervals.sort(key=lambda x: x[0])      # by start time
    heap = []                               # end times of rooms in use (min at top)
    for start, end in intervals:
        if heap and heap[0] <= start:
            heapq.heappop(heap)             # a room freed up -> reuse it
        heapq.heappush(heap, end)
    return len(heap)                        # rooms never freed = peak overlap

meetings = [(0, 30), (5, 10), (15, 20)]
print("rooms needed:", min_rooms(meetings))            # 2
print("rooms:", min_rooms([(1, 5), (2, 6), (3, 7), (8, 9)]))  # 3
rooms needed: 2
rooms: 3

4 · Huffman coding — optimal prefix codes expert

This one isn't a textbook toy — it's running billions of times a second inside every zip file, PNG, and JPEG you've ever opened. Here's the problem it solves. If you store text with a fixed 8 bits per character, the letter e (which shows up constantly) costs exactly as much as q (which barely appears). That's obviously wasteful: you're spending the same on your most common symbol and your rarest. The intuition is the same one Morse code got right in 1838 — give the frequent symbols short codes and the rare ones long codes, and the total shrinks. Huffman coding is the algorithm that finds the provably shortest such assignment, and it does it greedily.

But variable-length codes hide a nasty trap you have to design around. If e is 0 and t is 01, then when the decoder reads 01 it can't tell whether that's a t or an e followed by the start of something else. The bitstream becomes ambiguous — unreadable. The fix is the prefix code: no codeword may be a prefix of another. Then the instant the decoder has read a complete codeword it knows it's complete, because nothing longer starts with those bits. Think of phone numbers: if 911 is a complete emergency number, no other number can start with 911, or the operator wouldn't know when you've finished dialing. Prefix-freeness is what makes a stream of glued-together codewords decodable with no separators.

Huffman builds an optimal prefix code (no codeword is a prefix of another) that minimizes total encoded length given symbol frequencies. The greedy move is disarmingly simple: repeatedly merge the two lowest-frequency nodes into a parent whose frequency is their sum, until one tree remains; the path from the root spells each symbol's code. Why the two rarest? Because in a binary tree the deepest leaves get the longest codes, and you want your longest codes spent on your least-frequent symbols — so you push the two rarest to the bottom and let everything more common float up shallower. The common mistake is trying to be clever about it — sorting once and pairing greedily left-to-right, or grouping by some other heuristic — which quietly produces a valid but suboptimal tree. The exact "always the two current-rarest, re-heapified after each merge" discipline is what the proof below requires; deviate and you lose optimality. It runs in Θ(n log n) with a heap and is the backbone of DEFLATE/gzip and JPEG.

Why merging the two rarest is safe. In an optimal tree the two deepest leaves are siblings (else move one up and shorten the code). An exchange argument shows the two lowest-frequency symbols can be made those deepest siblings without increasing cost — so greedily pairing them is optimal, and the rest follows by induction on the merged 'super-symbol'.

merge the two lowest-frequency nodes, repeat 11 0 1 af=5 6 0 1 cf=2 → 100 4 b,d,r deepest Rarest symbols sink deepest. Each merge combines the two lowest-frequency nodes into a parent whose frequency is their sum; the common a lands shallow (short code 0), the rare symbols end deep with long codes.
🗺️ How to read this diagram

Read from the root at the top down to the leaves — each left branch adds a 0 bit and each right branch a 1, so a symbol's code is the path spelled from the root.

  • Circles are internal (merged) nodes labelled with their combined frequency; boxes are the actual symbol leaves.
  • Each step merges the two current lowest-frequency nodes into a parent whose frequency is their sum — that's the single greedy move, repeated until one tree remains.
  • The common symbol a (green, high frequency) attaches near the root, so its code is short (0); the rare symbols (red) get pushed to the bottom and earn the longest codes.
  • The payoff: spending your longest codewords on your least-frequent symbols is exactly what minimizes total encoded bits.

In short: Greedily merging the two rarest nodes builds a tree where frequent symbols sit shallow (short codes) and rare ones sit deep (long codes) — the provably shortest prefix code.

Try it — build a Huffman code and check it compresses
pythonimport heapq
from collections import Counter

def huffman_codes(text):
    freq = Counter(text)
    if len(freq) == 1:                      # single-symbol edge case
        return {next(iter(freq)): "0"}
    # nodes as (freq, tie_id, node); node is a symbol str or a (left, right) tuple
    nodes = [(f, i, sym) for i, (sym, f) in enumerate(freq.items())]
    heapq.heapify(nodes)
    counter = len(nodes)
    while len(nodes) > 1:
        f1, _, n1 = heapq.heappop(nodes)    # two rarest
        f2, _, n2 = heapq.heappop(nodes)
        heapq.heappush(nodes, (f1 + f2, counter, (n1, n2)))
        counter += 1
    root = nodes[0][2]

    codes = {}
    def walk(node, prefix):  # noqa
        if isinstance(node, str):
            codes[node] = prefix or "0"
        else:
            walk(node[0], prefix + "0")
            walk(node[1], prefix + "1")
    walk(root, "")
    return codes

text = "abracadabra"
codes = huffman_codes(text)
encoded_bits = sum(len(codes[c]) for c in text)
print("codes:", {k: codes[k] for k in sorted(codes)})
print("Huffman bits:", encoded_bits, "vs fixed 3-bit:", 3 * len(text))
codes: {'a': '0', 'b': '110', 'c': '100', 'd': '101', 'r': '111'}
Huffman bits: 23 vs fixed 3-bit: 33

5 · Fractional knapsack — where greedy beats DP advanced

Imagine you're a gold prospector with one backpack and a cave full of loot. There's gold dust, silver nuggets, a pile of copper — each pile has a total value and a weight, and your pack holds only so many kilos. You want to walk out with the most valuable haul. The magic ingredient in this version of the story is that everything is pourable: if you can't fit the whole pile of gold dust, you scoop in half of it and take half the value. That single word — pourable, divisible, fractional — is what makes this problem greedy-friendly, and it's worth pausing on, because it's the exact hinge that the next section (0/1 knapsack) removes to break greedy.

The right instinct is to rank by value-per-weight — the density, value ÷ weight — and greedily pour in the densest stuff first, topping off the last bit of space with a fraction of whatever's next-densest. Notice this is the same "what am I actually spending?" reframing from activity selection: the scarce resource is weight capacity, so you want the most value per unit of the thing you're rationing. A very common mistake is to sort by raw value instead — grabbing the single most valuable pile first — which can fill your pack with something heavy and mediocre while a lighter, denser pile sits behind. Density, not headline value, is the correct greedy key here.

Unlike 0/1 knapsack, in the fractional version you may take a fraction of an item. Now greedy is optimal: sort by value-per-weight and take as much of the densest item as fits, breaking the last item fractionally. Θ(n log n). The fractionality is what unlocks the exchange argument — any suboptimal packing can be improved by swapping in a denser fraction: if your pack contains any unit of a less-dense item while a denser item is still available, pour out that unit and pour in the denser one, and your total value strictly rises. Because that swap always improves things, no packing that isn't densest-first can be optimal — which is exactly the greedy-choice property, proven. Keep that swap picture in mind, because the moment items become indivisible you'll no longer be able to make it, and that's precisely when greedy dies and DP takes over.

sort by density (value ÷ weight) (60,10) → 6.0/kg densest (100,20) → 5.0/kg (120,30) → 4.0/kg fill pack (W = 50 kg) all of (60,10) · 10 kg all of (100,20) · 20 kg ⅔ of (120,30) · 20 kg value = 60 + 100 + 80 = 240 last item taken fractionally to top off W Densest first, fraction the last. Rank items by value-per-weight and pour them in until the pack is full, taking two-thirds of the last item to exactly fill W=50 — total value 240, provably optimal because fractions allow the density swap.
🗺️ How to read this diagram

Read left to right: the left column is the items ranked, the arrow is the greedy fill, and the right column is what ends up in the pack.

  • The left boxes list every item sorted by density (value ÷ weight), densest at the top — not by raw value.
  • The arrow is the greedy step: pour items into the pack in that density order until the capacity W is used up.
  • The green boxes are items taken whole; the amber box is the last item, taken only fractionally (⅔ of it) to exactly top off the 50 kg — this fractional bite is what greedy relies on.
  • The payoff line sums to 240: because you can always pour out a less-dense unit for a denser one and gain value, densest-first is provably optimal.

In short: Sort by value-per-weight, take whole items densest-first, and slice the final item to fill the remaining capacity exactly — divisibility makes this optimal.

Try it — fractional knapsack
pythondef fractional_knapsack(items, W):
    """items: list of (value, weight). Return max value for capacity W."""
    items = sorted(items, key=lambda it: it[0] / it[1], reverse=True)  # densest first
    total = 0.0
    for value, weight in items:
        if W <= 0:
            break
        take = min(weight, W)               # take a fraction of the last item
        total += value * (take / weight)
        W -= take
    return round(total, 2)

items = [(60, 10), (100, 20), (120, 30)]    # (value, weight)
print("fractional, W=50:", fractional_knapsack(items, 50))   # 240.0
print("fractional, W=25:", fractional_knapsack(items, 25))
fractional, W=50: 240.0
fractional, W=25: 135.0

6 · When greedy FAILS — and DP saves you expert

This is the section that keeps you out of production incidents, so slow down here. Everything so far taught you to trust greedy; this teaches you to distrust it, which is the more valuable skill by far. Return to the prospector. Take away the "pourable" property — now every item is a solid gold bar you must take whole or not at all — and the same density-first greedy that was provably optimal one section ago becomes provably wrong. That's 0/1 knapsack, and the numbers in the lab below are brutal: greedy grabs the densest bar (value 60, weight 10), then the next it can fit, and walks out with 160; the optimal packing skips the densest bar entirely and leaves with 220. Greedy underperforms by a third, silently, on a three-item input.

Here's the everyday version of the trap so it sticks. Greedy failing is like taking the job with the highest starting salary and later discovering it had no room to grow, while the offer you turned down would have doubled in two years. The locally best move (biggest number now) foreclosed the globally best outcome — and because greedy never reconsiders, you can't undo it. The precise tell, the thing to scan for in any problem, is a choice that is irrevocable AND interacts with future choices. Committing to the densest bar is irrevocable (it's in the pack) and it interacts (it eats capacity a better pair needed). When both are true, one greedy commitment can strand you, and no amount of care in later steps repairs an early wrong turn.

The most important skill is knowing when not to be greedy. If a locally optimal choice can foreclose a better global solution, greedy is wrong and you need DP — which is greedy's opposite discipline: instead of committing to one choice, DP tries all choices at each step and keeps the best, paying more time (here Θ(nW) versus greedy's Θ(n log n)) to buy correctness. Two canonical failures anchor this: 0/1 knapsack (taking the densest item first can waste capacity) and min-coin change with non-canonical denominations — the {1,3,4}-make-6 counterexample from §1, where largest-first gives three coins and the optimum is two. The discipline this whole lesson is really teaching: don't reach for DP out of fear and don't reach for greedy out of hope — prove the greedy-choice property, and if you can't, that failure to prove it is itself the signal to use DP.

greedy: densest first (W = 50) (60,10)✓ (100,20)✓ (120,30) won't fit total = 160 (30 kg used) optimum: skip the densest (60,10) skipped (100,20)✓ (120,30)✓ total = 220 (50 kg used) Indivisibility breaks greedy. Grabbing the densest bar (60,10) locks in 10 kg that a better pair needed; greedy stalls at 160 while the optimum skips it and packs (100,20)+(120,30) for 220 — a third more, on three items.
🗺️ How to read this diagram

Compare the two panels side by side: the left is what density-first greedy does, the right is the true optimum for the same three items and the same 50 kg capacity.

  • Each box is an item (value, weight); green means it went into the pack, red means it was left out.
  • On the left, greedy takes the densest item (60,10) first, then (100,20) — but that fills 30 kg and the big (120,30) no longer fits, so greedy stops at 160.
  • On the right, the optimum skips the densest item entirely and packs (100,20)+(120,30), using all 50 kg for 220.
  • The payoff: the irrevocable early grab of the densest bar foreclosed the better pair — the exact "irrevocable AND interacts" trap that means you need DP, not greedy.

In short: When items can't be split, the locally densest choice can waste capacity a better combination needed — greedy 160 vs optimal 220 — so 0/1 knapsack needs DP.

Try it — greedy vs DP on 0/1 knapsack (greedy is wrong)
pythondef greedy_01(items, W):
    items = sorted(items, key=lambda it: it[0] / it[1], reverse=True)
    total, w = 0, 0
    for value, weight in items:
        if w + weight <= W:                 # 0/1: take whole item or none
            total += value; w += weight
    return total

def dp_01(items, W):
    dp = [0] * (W + 1)
    for value, weight in items:
        for cap in range(W, weight - 1, -1):
            dp[cap] = max(dp[cap], value + dp[cap - weight])
    return dp[W]

items = [(60, 10), (100, 20), (120, 30)]    # (value, weight)
W = 50
print("greedy 0/1:", greedy_01(items, W), " DP 0/1:", dp_01(items, W))
greedy 0/1: 160  DP 0/1: 220
The tell-tale signGreedy fails when a choice is irrevocable but interacts with future choices — committing to the densest 0/1 item (value 60, weight 10) blocks a better pair (100+120 for weight 50). Whenever a locally best move can leave you unable to reach the global best, reach for DP. Proving the greedy-choice property is how you rule this out before shipping.
ProblemGreedy correct?Why
fractional knapsackyesfractions let you always improve by density swap
0/1 knapsacknoindivisible items — needs DP (Θ(nW))
activity selectionyesearliest-finish exchange argument
min coins, canonicalyese.g. standard currency
min coins, arbitrarynoe.g. {1,3,4} for 6 — needs DP

Checkpoint expert

✓ Checkpoint — you can move on when you can…

  • State the greedy-choice property and give the exchange-argument proof template.
  • Justify why activity selection sorts by finish time (not start or duration).
  • Explain why Huffman merges the two lowest-frequency nodes.
  • Contrast fractional knapsack (greedy) with 0/1 knapsack (DP) and say why.
  • Produce a counterexample where greedy min-coin change is suboptimal.
✓ Knowledge check

Sketch the exchange argument that 'earliest finish time' is optimal for activity selection.

Show answer
Take any optimal solution OPT and let g be greedy's first pick (earliest finish). If OPT's first activity o differs, then since g finishes no later than o, swapping o→g keeps every later OPT activity compatible (each started after o finished ≥ after g finished). The swapped set has the same size and is still valid, so greedy's choice is safe. Recurse on the activities that start after g finishes.
✓ Knowledge check

Your teammate insists greedy 'take the highest value-per-weight item' solves 0/1 knapsack because it works for fractional. Give the one-line reason they are wrong.

Show answer
In 0/1 you cannot take a fraction of the last item, so the densest item can consume capacity that would have been better spent on a combination — e.g. items (60,10),(100,20),(120,30) with W=50: greedy takes 60+100=160 but the optimum is 100+120=220. Indivisibility breaks the density exchange argument, so 0/1 needs DP.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Assign cookies to greedy childrenBeginner

Context: A gentle greedy: match cookies to children so as many children as possible are content — the pattern of 'sort both sides, sweep with two pointers'.

Your task: Each child i has greed g[i] (min cookie size to be content); each cookie has size s[j]. Maximize the number of content children (one cookie per child).

Requirements:

  • Sort both greed and cookie lists.
  • Give the smallest adequate cookie to the least greedy remaining child.
  • Return how many children are satisfied.

💡 Hint: Two pointers over sorted arrays: advance the child pointer only when a cookie satisfies them.

Show solution
Solution
pythondef find_content_children(g, s):
    g.sort(); s.sort()
    child = cookie = 0
    while child < len(g) and cookie < len(s):
        if s[cookie] >= g[child]:           # this cookie contents this child
            child += 1
        cookie += 1
    return child

print(find_content_children([1, 2, 3], [1, 1]))    # 1
print(find_content_children([1, 2], [1, 2, 3]))    # 2
1
2
Exercise 2 · Non-overlapping intervals (min removals)Intermediate

Context: The complement of activity selection: how many intervals must you delete so the rest do not overlap? A staple scheduling/calendar problem.

Your task: Given intervals, return the minimum number to remove so no two overlap.

Requirements:

  • Keep the max non-overlapping set greedily (earliest end), remove the rest.
  • Answer = total - kept.
  • Verify on overlapping and disjoint inputs.

💡 Hint: Sort by end time; count how many you can keep with start >= last kept end.

Show solution
Solution
pythondef erase_overlap(intervals):
    if not intervals:
        return 0
    intervals.sort(key=lambda x: x[1])      # earliest end first
    kept, last_end = 0, float("-inf")
    for start, end in intervals:
        if start >= last_end:
            kept += 1; last_end = end
    return len(intervals) - kept

print(erase_overlap([(1, 2), (2, 3), (3, 4), (1, 3)]))   # 1
print(erase_overlap([(1, 2), (1, 2), (1, 2)]))           # 2
print(erase_overlap([(1, 2), (2, 3)]))                   # 0
1
2
0
Exercise 3 · Jump game — can you reach the end?Advanced

Context: A deceptively simple greedy: track the farthest index reachable so far. Appears in reachability and packet-forwarding style problems.

Your task: Given an array where a[i] is the max jump length from i, determine if you can reach the last index starting from index 0.

Requirements:

  • Track the farthest reachable index in one pass.
  • If the current index exceeds the farthest reachable, you're stuck -> False.
  • Return True if farthest >= last index.

💡 Hint: Greedy invariant: at each i, extend reach = max(reach, i + a[i]); fail the moment i > reach.

Show solution
Solution
pythondef can_jump(nums):
    farthest = 0
    for i, jump in enumerate(nums):
        if i > farthest:                    # unreachable gap
            return False
        farthest = max(farthest, i + jump)
    return True

print(can_jump([2, 3, 1, 1, 4]))   # True
print(can_jump([3, 2, 1, 0, 4]))   # False
print(can_jump([0]))               # True (already at end)
True
False
True
Exercise 4 · Minimum jumps to reach the endExpert

Context: The optimization version of jump game: not just 'can you', but the fewest jumps. A greedy BFS-by-levels over reachable ranges.

Your task: Return the minimum number of jumps to reach the last index (assume it is reachable).

Requirements:

  • Treat each 'jump' as a BFS level: expand the current reachable window.
  • When you pass the end of the current window, increment jumps and set the new window edge to the farthest seen.
  • Achieve O(n); verify on known cases.

💡 Hint: Maintain cur_end (edge of the current jump's reach) and farthest; bump the jump count when i hits cur_end.

Show solution
Solution
pythondef min_jumps(nums):
    jumps = cur_end = farthest = 0
    for i in range(len(nums) - 1):
        farthest = max(farthest, i + nums[i])
        if i == cur_end:                    # must jump now to go further
            jumps += 1
            cur_end = farthest
    return jumps

print(min_jumps([2, 3, 1, 1, 4]))          # 2
print(min_jumps([2, 3, 0, 1, 4]))          # 2
print(min_jumps([1, 1, 1, 1]))             # 3
2
2
3
Exercise 5 · Task scheduler with cooldownProfessional

Context: A CPU/task-runner problem: identical tasks need n idle cycles between runs. The greedy insight (schedule the most frequent task first) has a clean closed-form, but a heap-based simulation is the robust general solution.

Your task: Given task labels and a cooldown n, return the least number of time units to run all tasks (idle cycles allowed).

Requirements:

  • Greedy: always run the most frequent remaining task that is off cooldown.
  • Simulate with a max-heap of counts and a cooldown queue.
  • Verify against the known formula answer on examples.

💡 Hint: Pop the highest-count tasks for a window of n+1 slots, decrement, and re-queue after the cooldown.

Show solution
Solution
pythonimport heapq
from collections import Counter, deque

def least_interval(tasks, n):
    counts = Counter(tasks)
    heap = [-c for c in counts.values()]    # max-heap via negation
    heapq.heapify(heap)
    time = 0
    cooldown = deque()                      # (ready_time, remaining_count)
    while heap or cooldown:
        time += 1
        if heap:
            remaining = heapq.heappop(heap) + 1   # ran one instance
            if remaining < 0:
                cooldown.append((time + n, remaining))
        if cooldown and cooldown[0][0] == time:
            heapq.heappush(heap, cooldown.popleft()[1])
    return time

print(least_interval(["A","A","A","B","B","B"], 2))   # 8
print(least_interval(["A","A","A","B","B","B"], 0))   # 6
print(least_interval(["A","A","A","A","B","C"], 2))   # 10
8
6
10
Exercise 6 · Gas station circuitIndustry scenario

Context: A real routing/logistics greedy: can a delivery truck complete a circular route, and from where? The one-pass greedy proof (if total gas >= total cost, a unique valid start exists) is a classic whiteboard result.

Your task: Given gas[i] at station i and cost[i] to travel to i+1 (circular), return the starting index from which you can complete the loop, or -1 if impossible.

Requirements:

  • If total gas < total cost, return -1 (impossible).
  • Track a running tank; whenever it goes negative, no start in [current_start..i] works, so reset start to i+1.
  • Prove: the surviving start completes the loop. Verify on examples.

💡 Hint: The key lemma: if the tank drops below zero at station i, none of the stations from the current candidate start up to i can be the answer — jump the start past i.

Show solution
Solution
pythondef can_complete_circuit(gas, cost):
    if sum(gas) < sum(cost):
        return -1                           # not enough fuel overall
    start = 0
    tank = 0
    for i in range(len(gas)):
        tank += gas[i] - cost[i]
        if tank < 0:                        # can't reach i+1 from `start`
            start = i + 1                   # ...nor from anything in between
            tank = 0
    return start

print(can_complete_circuit([1, 2, 3, 4, 5], [3, 4, 5, 1, 2]))   # 3
print(can_complete_circuit([2, 3, 4], [3, 4, 3]))               # -1
print(can_complete_circuit([5, 1, 2, 3, 4], [4, 4, 1, 5, 1]))   # 4
3
-1
4
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in