Part VI · More Patterns Pattern 17 4 problems

Greedy

Take the best-looking option right now and never look back. When that is safe, it beats every other approach on speed and code size. The whole skill is knowing when it is safe.

Greedy code is short. Usually one pass, sometimes a sort first, and a running number or two. The hard part is not the code. It is the argument that the local choice can never cost you the global optimum. Without that argument, a greedy answer is a guess.

Contents

  1. When to use
  2. Core idea
  3. The templates
  4. Common mistakes
  5. Jump Game
  6. Gas Station
  7. Partition Labels
  8. Task Scheduler
  9. Recap

When to use

The trigger. An optimisation question where one obvious choice at each step looks right, and the constraints ask for O(n) or O(n log n). If you can say why that choice is always safe, go greedy. If you cannot, reach for DP.

Core idea

Make the locally best choice, commit to it, and shrink the problem. To prove this is correct, use an exchange argument. Take any optimal answer. If it disagrees with greedy at the first step, swap its choice for the greedy one. Show the result is still valid and no worse. Repeat step by step until the optimal answer is the greedy answer. So greedy is optimal too.

The exchange argument, worked once

Interval scheduling: keep the most non-overlapping meetings. Greedy picks the meeting that ends first.

  1. Take any best schedule. Call its earliest meeting X. Call greedy’s first pick G.
  2. G ends no later than X, because greedy chose the earliest end of all.
  3. Swap X out and G in. Every later meeting started after X ended, so it also starts after G ends. Nothing new overlaps.
  4. The count is unchanged, so the new schedule is still best, and it now agrees with greedy on step one. Repeat on the rest.
G ends X ends best schedule greedy pick after swap X Y Z G G Y Z G: the earliest end of all meetings still 3 time 0 2 4 6 8 10 12
Figure 17.1 — Swapping X for the earlier-ending G keeps every later meeting legal, so greedy loses nothing.

Reading the figure. Each bar is a meeting on a time line. Red X is the first meeting of some best schedule. Amber G is greedy’s first pick. The dashed lines show that G ends before X does. Y and Z start after X ends, so they also start after G ends. The green row keeps the same count with no clash.

That is the template for every greedy proof: swap in the greedy choice, nothing breaks, nothing gets worse. The full solution is Non-overlapping Intervals on page 04, which was a greedy all along.

When greedy fails

No exchange argument, no greedy. The classic trap is Coin Change with coins [1, 3, 4] and amount 6. Largest-coin-first takes 4 + 1 + 1, three coins. The best answer is 3 + 3, two coins. The swap fails: replacing a 3 with the bigger 4 leaves a remainder that costs more coins. When you cannot make the swap work, try a small counterexample. If one turns up, switch to DP.
public static class GreedyCoins
{
    /// <summary>Largest coin first. Shown only to prove greedy can be wrong.</summary>
    /// <param name="coins">Positive denominations, unlimited supply of each.</param>
    /// <param name="amount">Target sum, 0 or more.</param>
    /// <returns>The number of coins greedy uses, or -1 if greedy gets stuck.</returns>
    /// <example><c>GreedyCoins.Count([1, 3, 4], 6)</c> returns 3.
    /// <c>GreedyCoins.Count([5, 2], 6)</c> returns -1.</example>
    public static int Count(int[] coins, int amount)
    {
        int count = 0;                    // 0: no coins used yet

        // The greedy choice: biggest coin first. OrderByDescending sorts high to low
        // and leaves the caller's array alone, unlike Array.Sort.
        // Invariant: count coins already sum to (original amount - amount).
        foreach (int coin in coins.OrderByDescending(c => c))
        {
            count += amount / coin;       // int / int rounds down: how many of this coin fit
            amount %= coin;               // %: what is left after taking those coins
        }

        // 0 left means greedy hit the target. Anything else means stuck: -1.
        return amount == 0 ? count : -1;
    }
}

The second example is worse than wrong. Greedy takes one 5, is left with 1, and gives up, even though 2 + 2 + 2 works. DP gets both right.

The templates

Template A — one pass with a running summary
public static class GreedyScanTemplate
{
    /// <summary>Template A: walk once, keep one running summary, never go back.</summary>
    /// <param name="items">The input, read left to right.</param>
    /// <param name="initial">The summary before any item is seen. Jump Game: 0.</param>
    /// <param name="stuck">True when the summary proves we cannot go on.
    /// Jump Game: (reach, i) => i > reach, so index i is a wall.</param>
    /// <param name="extend">Folds item i into the summary, keeping the best option.
    /// Jump Game: (reach, i, jump) => Math.Max(reach, i + jump).</param>
    /// <param name="giveUp">The failure answer. Jump Game: false.</param>
    /// <param name="finish">Turns the summary into the answer. Jump Game: _ => true.</param>
    /// <returns>finish(state) after the pass, or giveUp if stuck fired.</returns>
    /// <example><c>Scan([3, 2, 1, 0, 4], 0, (r, i) => i > r,
    /// (r, i, j) => Math.Max(r, i + j), false, _ => true)</c> returns false.</example>
    public static TResult Scan<TState, TResult>(
        int[] items,
        TState initial,
        Func<TState, int, bool> stuck,
        Func<TState, int, int, TState> extend,
        TResult giveUp,
        Func<TState, TResult> finish)
    {
        TState state = initial;

        // i is the position. 0: start at the first item.
        // Invariant: state is correct for items[0..i-1] at the top of every pass.
        for (int i = 0; i < items.Length; i++)
        {
            // The summary proves we cannot go on. Stop with the failure answer.
            if (stuck(state, i))
            {
                return giveUp;
            }

            // Fold this item in, keeping the best option seen so far.
            state = extend(state, i, items[i]);
        }

        // The pass finished without getting stuck. Turn the summary into the answer.
        return finish(state);
    }
}

Jump Game, Gas Station and Partition Labels all fit this shape. The thinking goes into choosing state. Once you have it, the loop writes itself. In C# the placeholders become delegate parameters, so the template compiles and can be tested. In an interview, inline them.

Template B — sort, then take what fits
public static class GreedySortTemplate
{
    /// <summary>Template B: sort so the safest choice comes first, then take greedily.</summary>
    /// <param name="items">The candidates, in any order.</param>
    /// <param name="sortKey">The order the exchange argument needs.
    /// Interval scheduling: m => m.End.</param>
    /// <param name="boundary">The limit before anything is taken.
    /// Interval scheduling: int.MinValue, so the first meeting always fits.</param>
    /// <param name="fits">The item does not clash with what we kept.
    /// Interval scheduling: (m, last) => m.Start >= last.</param>
    /// <param name="update">The new limit after taking an item.
    /// Interval scheduling: m => m.End.</param>
    /// <returns>How many items were taken.</returns>
    /// <example>Meetings (1, 4), (3, 5), (0, 6), (5, 7), (6, 10), (8, 11) sorted by end
    /// give 3.</example>
    public static int TakeWhatFits<T>(
        IEnumerable<T> items,
        Func<T, int> sortKey,
        int boundary,
        Func<T, int, bool> fits,
        Func<T, int> update)
    {
        int chosen = 0;              // 0: nothing taken yet
        int last = boundary;         // the limit before anything is taken

        // OrderBy is a stable sort, so ties keep their input order.
        // Invariant: chosen is the best count for the items seen so far.
        foreach (T item in items.OrderBy(sortKey))
        {
            // The item fits after the last one we kept. Take it.
            if (fits(item, last))
            {
                chosen += 1;         // 1: this item joins the answer
                last = update(item); // the new limit, e.g. this meeting's end
            }
        }

        return chosen;
    }
}
sorted by end time take (1, 4) fits skip (3, 5) clash skip (0, 6) clash take (5, 7) fits skip (6, 10) clash take (8, 11) fits 0 2 4 6 8 10
Figure 17.2 — Sorted by end, one left-to-right pass keeps three meetings, the most possible.

Reading the figure. Rows are in the order the loop sees them, sorted by end time. Green bars are taken. Red bars start before last, so they clash and are skipped. The dashed amber lines mark last after each take, at 4 and then 7. Notice that each take ends as early as it can, which leaves the most room on the right.

The sort carries the proof. Sort by end and interval scheduling is optimal. Sort by start or by length and it is not. Always say why you picked the key. In C#, OrderBy is stable and copies the data. Array.Sort sorts in place but is not stable. Pick OrderBy when ties must keep their input order.

Common mistakes

The problems

1. Jump Game Medium

Problem

You stand on index 0 of an array. Each value nums[i] is the longest jump allowed from index i. Shorter jumps are also allowed. Return whether you can reach the last index.

Approach

Solution

public static class JumpGame
{
    /// <summary>Whether the last index is reachable from index 0.</summary>
    /// <param name="nums">Non-negative jump lengths. nums[i] is the longest jump from i.</param>
    /// <returns>True if some sequence of jumps lands on the last index.</returns>
    /// <example><c>JumpGame.CanJump([2, 3, 1, 1, 4])</c> returns true.
    /// <c>JumpGame.CanJump([3, 2, 1, 0, 4])</c> returns false.</example>
    public static bool CanJump(int[] nums)
    {
        // reach: the farthest index any route found so far can land on.
        // 0: we start on index 0, so it is reachable for free.
        int reach = 0;

        // i is the index we stand on. 0: start where we stand.
        // Invariant: every index 0..reach is reachable using nums[0..i-1].
        for (int i = 0; i < nums.Length; i++)
        {
            // i beyond reach: no earlier index can hop this far. i is a wall.
            if (i > reach)
            {
                return false;
            }

            // From i we can land anywhere up to i + nums[i]. Keep the farther reach.
            reach = Math.Max(reach, i + nums[i]);
        }

        // We never hit a wall, so the last index, nums.Length - 1, was reached.
        return true;
    }
}

Walkthrough

nums = [3, 2, 1, 0, 4]:

nums index 3 0 2 1 1 2 0 3 4 4 4 > reach 3: a wall, false reach = 3: every index up to 3 is reachable, none past it
Figure 17.3 — Every arc stops at index 3, so reach never passes 3 and index 4 cannot be reached.

Reading the figure. Each blue arc is the longest jump from its index, ending at i + nums[i]. All three arcs land on index 3, the amber cell with jump 0. The green bar is reach. Index 4, in red, sits past the bar, so the loop returns false there.

TimeO(n)SpaceO(1)

The follow-up: fewest jumps

public static class JumpGameII
{
    /// <summary>Jump Game II: fewest jumps to reach the last index.</summary>
    /// <param name="nums">Non-negative jump lengths. The last index is reachable.</param>
    /// <returns>The minimum number of jumps.</returns>
    /// <example><c>JumpGameII.MinJumps([2, 3, 1, 1, 4])</c> returns 2.</example>
    public static int MinJumps(int[] nums)
    {
        int jumps = 0;          // 0: no jumps taken yet
        int windowEnd = 0;      // 0: with zero jumps we can only be on index 0
        int farthest = 0;       // 0: farthest index reachable with one more jump

        // Stop before the last index: nums.Length - 1. Standing on it needs no jump.
        // Invariant: indexes up to windowEnd are reachable in `jumps` jumps.
        for (int i = 0; i < nums.Length - 1; i++)
        {
            farthest = Math.Max(farthest, i + nums[i]);  // best landing from this window

            // i == windowEnd: this window is used up. Take one more jump.
            if (i == windowEnd)
            {
                jumps += 1;              // 1: one jump moves us to the next window
                windowEnd = farthest;    // the next window ends at the best landing
            }
        }

        return jumps;
    }
}

This is BFS by levels without a queue. Each window is one level. Say that link out loud.

Edge cases to raise

Say this out loud: “I do not pick a jump. I track the farthest index any route can reach. Every index before it is reachable, because jumps can be short. If I ever stand past it, I am stuck.”

2. Gas Station Medium

Problem

n stations sit on a circle. Station i gives gas[i] fuel, and driving to station i + 1 costs cost[i]. You start with an empty tank. Return the start index that lets you drive one full loop, or -1. The answer is unique if it exists.

Approach

Solution

public static class GasStation
{
    /// <summary>Start station for one full loop, or -1 if none exists.</summary>
    /// <param name="gas">Fuel gained at each station.</param>
    /// <param name="cost">Fuel spent driving from station i to station i + 1.</param>
    /// <returns>The unique valid start index, or -1.</returns>
    /// <example><c>GasStation.CanCompleteCircuit([1, 2, 3, 4, 5], [3, 4, 5, 1, 2])</c>
    /// returns 3.</example>
    public static int CanCompleteCircuit(int[] gas, int[] cost)
    {
        // Each station needs a matching cost. A length mismatch is a caller bug.
        if (gas.Length != cost.Length)
        {
            throw new ArgumentException("gas and cost must have the same length");
        }

        // Sum in long: int Sum() throws OverflowException on large inputs.
        // Less fuel than distance overall: no start can work. -1 means "none".
        if (gas.Sum(g => (long)g) < cost.Sum(c => (long)c))
        {
            return -1;
        }

        int start = 0;     // 0: the first candidate is station 0
        long tank = 0;     // 0: we arrive at the candidate with an empty tank

        // i is the station we leave from. 0: begin at the first candidate.
        // Invariant: tank is the fuel left after driving from start through i - 1,
        // and it never dropped below 0 on the way.
        for (int i = 0; i < gas.Length; i++)
        {
            tank += gas[i] - cost[i];   // fill up at i, then pay the drive to i + 1

            // Below 0: start cannot reach i + 1. Nor can any station between
            // start and i, since each was reached with tank >= 0 and still failed.
            if (tank < 0)
            {
                start = i + 1;          // + 1: the next station is the first fresh hope
                tank = 0;               // 0: restart with an empty tank
            }
        }

        // Total fuel covers total cost, so the last surviving start completes the loop.
        return start;
    }
}

Walkthrough

gas = [1, 2, 3, 4, 5], cost = [3, 4, 5, 1, 2]. Net per station is [-2, -2, -2, 3, 3]. Totals are 15 and 15, so an answer exists.

station gas - cost tank after start 0 -2 -2: reset → 1 1 -2 -2: reset → 2 2 -2 -2: reset → 3 3 3 3 3 4 3 6 3 Totals are 15 and 15, so the last start standing, station 3, completes the loop.
Figure 17.4 — Three failures push the start to station 3, and from there the tank never drops below zero.

Reading the figure. Each column is one station. Red bars are a tank that went below zero. Each red bar resets the tank and moves start one past the failure. Green bars are a tank that stayed at zero or above. The green station cell is the answer, the last start that never failed.

TimeO(n)SpaceO(1)

Edge cases to raise

Say this out loud: “If the tank goes negative at station i, every start from the current candidate through i fails too, because each was reached with fuel to spare and still ran dry. So I jump the candidate to i plus one.”

3. Partition Labels Medium

Problem

Split a string into as many parts as possible so that each letter appears in at most one part. Return the sizes of the parts, in order.

Approach

Solution

public static class PartitionLabels
{
    /// <summary>Sizes of the most parts where no letter spans two parts.</summary>
    /// <param name="s">The string to split. Lowercase letters a to z.</param>
    /// <returns>Part sizes, left to right. They sum to s.Length.</returns>
    /// <example><c>PartitionLabels.Sizes("ababcbacadefegdehijhklij")</c>
    /// returns [9, 7, 8].</example>
    public static List<int> Sizes(string s)
    {
        // last[c - 'a']: the final index of letter c. A part holding c must reach it.
        // 26: one slot per lowercase letter. An array beats a Dictionary here.
        var last = new int[26];

        // Later indexes overwrite earlier ones, so each slot ends with the last index.
        for (int i = 0; i < s.Length; i++)   // 0: scan from the first letter
        {
            last[s[i] - 'a'] = i;            // - 'a': map 'a'..'z' to 0..25
        }

        var sizes = new List<int>();
        int start = 0;    // 0: the first part begins at index 0
        int end = 0;      // 0: so far the part only has to reach index 0

        // i is the index we read. 0: start at the first letter.
        // Invariant: end is the farthest last-index of any letter in s[start..i].
        for (int i = 0; i < s.Length; i++)
        {
            end = Math.Max(end, last[s[i] - 'a']);   // this letter may push the cut later

            // i == end: every letter in this part is finished. Cut here.
            if (i == end)
            {
                sizes.Add(end - start + 1);   // + 1: both ends are inclusive
                start = i + 1;                // + 1: the next part starts after i
            }
        }

        return sizes;
    }
}

Walkthrough

s = "ababcbacadefegdehijhklij":

a 0 b 1 a 2 b 3 c 4 b 5 a 6 c 7 a 8 d 9 e 10 f 11 e 12 g 13 d 14 e 15 h 16 i 17 j 18 h 19 k 20 l 21 i 22 j 23 end = 8 size 9 end 14 → 15 size 7 end 19 → 22 → 23 size 8 s i
Figure 17.5 — The cut lands where i meets end, giving parts of size 9, 7 and 8.

Reading the figure. Each cell is one letter, with its index below. Amber cells are last copies that set or push end. The label above each part shows how end grew. A red line is a cut, made the moment i == end. The green numbers are the answer.

TimeO(n)SpaceO(1), at most 26 letters

Edge cases to raise

Say this out loud: “Each part must reach the last copy of every letter inside it. I track that farthest index and cut the moment I reach it. Cutting as early as allowed gives the most parts.”

4. Task Scheduler Medium

Problem

A CPU runs tasks given as letters. Each slot runs one task or stays idle. Two runs of the same task need at least n slots between them. Return the fewest slots needed to run every task. Order is free.

Approach

Solution

public static class TaskScheduler
{
    /// <summary>Fewest CPU slots to run all tasks with cooldown n between repeats.</summary>
    /// <param name="tasks">Task labels, uppercase 'A' to 'Z'. Repeats are allowed.</param>
    /// <param name="n">Minimum number of slots between two runs of the same task.</param>
    /// <returns>The minimum total slots, idle slots included.</returns>
    /// <example><c>TaskScheduler.LeastInterval(['A', 'A', 'A', 'B', 'B', 'B'], 2)</c>
    /// returns 8.</example>
    public static int LeastInterval(char[] tasks, int n)
    {
        // No tasks: no slots. 0 also keeps Max() below off an empty count.
        if (tasks.Length == 0)
        {
            return 0;
        }

        // counts[c - 'A']: how often task c appears. 26: one slot per letter.
        var counts = new int[26];
        foreach (char task in tasks)
        {
            counts[task - 'A']++;        // - 'A': map 'A'..'Z' to 0..25
        }

        // top: how many times the most common task must run.
        int top = counts.Max();
        // ties: how many tasks share that top count. They all sit in the last row.
        int ties = counts.Count(count => count == top);

        // top - 1: full rows. The last row needs no cooldown after it.
        // n + 1: each full row is the task plus n cooling slots.
        int frame = (top - 1) * (n + 1) + ties;

        // Too many tasks to fit the gaps: rows widen, no idle, answer = task count.
        return Math.Max(frame, tasks.Length);
    }
}

Walkthrough

tasks = A A A B B B, n = 2:

row 1 A B idle row 2 A B idle last row A B full rows: top - 1 = 2 each n + 1 = 3 slots wide last row: the ties = 2 tasks frame = 2 * 3 + 2 = 8 schedule A B idle A B idle A B 8 slots
Figure 17.6 — The most frequent tasks fix a frame of 2 full rows plus a last row, 8 slots in all.

Reading the figure. Each row starts with A and is n + 1 slots wide. So n slots always sit between two runs of A. Dashed cells are idle slots that nothing could fill. The green last row holds the tied tasks. The bottom strip is the same schedule read left to right.

TimeO(tasks.Length)SpaceO(1), at most 26 labels
The simulation alternative. A max-heap of counts plus a cooldown queue also works. Each step runs the task with the most remaining runs. It is O(tasks.Length × log k) and easier to extend if the interviewer asks for the actual schedule. C# has no max-heap, so the code below gives PriorityQueue<int, int> the priority -count. The cooldown line is a plain Queue<T>, since tasks leave it in the order they entered. Mention it, then give the formula as the faster answer.
public static class TaskSchedulerSim
{
    /// <summary>Task Scheduler by simulation: run the task with the most runs left.</summary>
    /// <param name="tasks">Task labels, uppercase 'A' to 'Z'.</param>
    /// <param name="n">Minimum number of slots between two runs of the same task.</param>
    /// <returns>The minimum total slots, idle slots included.</returns>
    /// <example><c>TaskSchedulerSim.LeastInterval(['A', 'A', 'A', 'B', 'B', 'B'], 2)</c>
    /// returns 8.</example>
    public static int LeastInterval(char[] tasks, int n)
    {
        // 26: one count per letter. - 'A' maps 'A'..'Z' to 0..25.
        var counts = new int[26];
        foreach (char task in tasks)
        {
            counts[task - 'A']++;
        }

        // PriorityQueue is a min-heap. Priority -count puts the biggest count first.
        var ready = new PriorityQueue<int, int>();
        foreach (int count in counts)
        {
            if (count > 0)                    // 0: letters that never appear stay out
            {
                ready.Enqueue(count, -count);
            }
        }

        // cooling: tasks waiting out their cooldown, oldest first. Queue is FIFO.
        var cooling = new Queue<(int Left, int ReadyAt)>();
        int time = 0;                         // 0: no slot used yet

        // Each pass is one slot.
        // Invariant: every task is either in ready, in cooling, or finished.
        while (ready.Count > 0 || cooling.Count > 0)
        {
            time += 1;                        // 1: this pass fills one slot

            // Run the task with the most runs left. No task ready means an idle slot.
            if (ready.TryDequeue(out int left, out _) && left - 1 > 0)
            {
                // - 1: one run done. + n: it may run again n slots from now.
                cooling.Enqueue((left - 1, time + n));
            }

            // The oldest cooling task finished its wait. Put it back in the heap.
            if (cooling.Count > 0 && cooling.Peek().ReadyAt == time)
            {
                var (back, _) = cooling.Dequeue();
                ready.Enqueue(back, -back);
            }
        }

        return time;
    }
}

Edge cases to raise

Say this out loud: “The most frequent task sets the frame: top minus one full rows of width n plus one, then a last row of the tied tasks. If the other tasks overflow the gaps, there is no idle and the answer is just the task count.”

Recap

The six things to carry forward

Where this goes next

Greedy works on values. Pattern 18, the Trie, works on the shape of strings: a tree keyed by characters that stores shared prefixes once. It turns “which words start with this?” into a walk of a few steps.


← 16 — Union-Find 18 — Trie (Prefix Tree) →