DSAGreedy Algorithms

Greedy Algorithms

A greedy algorithm makes the locally optimal choice at each step with the hope that a series of locally optimal choices will produce a globally optimal solution. The word "hope" is not a weakness — for the right class of problems, it is provably correct. Understanding when greedy works is the core skill.

Two Key Properties

A problem is amenable to a greedy solution if it has both:

1. Greedy Choice Property A globally optimal solution can be reached by making locally optimal (greedy) choices. The choice made at each step never needs to be reconsidered.

2. Optimal Substructure An optimal solution to the problem contains optimal solutions to its subproblems. After making the greedy choice, the remaining problem has the same structure as the original.

If a problem lacks either property, greedy will likely give a wrong answer. Dynamic programming is the fallback for problems that have optimal substructure but not the greedy choice property.

When Greedy Fails

Consider the coin change problem with coins [1, 3, 4] and target 6. Greedy takes the largest coin first: 4 + 1 + 1 = 3 coins. But the optimal is: 3 + 3 = 2 coins.

Greedy fails here because the coin set does not have the greedy choice property for an arbitrary target. Standard US/EU coins (1, 5, 10, 25) happen to work with greedy because of their specific denominations.

Warning
Never apply greedy without proving (even informally) that the greedy choice property holds. For coin change, fractional knapsack, and interval scheduling it holds. For 0/1 knapsack and general coin change it does not — use DP instead.
Proving Correctness: Exchange Argument

The exchange argument is the standard proof technique for greedy algorithms:

  1. Assume an optimal solution O exists that differs from the greedy solution G
  2. Identify the first position where O and G differ
  3. Show you can "exchange" O's choice for G's choice without making the solution worse
  4. Repeat until O becomes G — proving G is also optimal

This is abstract, but we will see it concretely in Activity Selection below.

Activity Selection Problem

Given n activities each with a start and end time, select the maximum number of non-overlapping activities.

Greedy choice: Always pick the activity that finishes earliest.

Why it works: Finishing earliest leaves the most room for future activities. Any solution that picks a later-finishing activity instead can swap it for the earliest-finishing one without losing activities (exchange argument).

JS
// Activity Selection — O(n log n) for sort, O(n) after
function activitySelection(activities) {
  // Sort by end time (greedy choice: earliest end first)
  activities.sort((a, b) => a.end - b.end);

  const selected = [activities[0]];
  let lastEnd = activities[0].end;

  for (let i = 1; i < activities.length; i++) {
    // An activity is compatible if it starts after the last one ends
    if (activities[i].start >= lastEnd) {
      selected.push(activities[i]);
      lastEnd = activities[i].end;
    }
  }

  return selected;
}

const activities = [
  { name: 'A', start: 1, end: 4 },
  { name: 'B', start: 3, end: 5 },
  { name: 'C', start: 0, end: 6 },
  { name: 'D', start: 5, end: 7 },
  { name: 'E', start: 3, end: 9 },
  { name: 'F', start: 5, end: 9 },
  { name: 'G', start: 6, end: 10 },
  { name: 'H', start: 8, end: 11 },
  { name: 'I', start: 8, end: 12 },
];

console.log(activitySelection(activities).map(a => a.name));
// ['A', 'D', 'H'] — 3 non-overlapping activities
Jump Game — Can You Reach the End?

Each element in nums represents the maximum jump length from that position. Can you reach the last index?

Greedy choice: Track the farthest index reachable at each step. If you ever find yourself at an index beyond the current farthest reach, you are stuck.

JS
// Jump Game I — O(n) time, O(1) space
function canJump(nums) {
  let maxReach = 0;

  for (let i = 0; i < nums.length; i++) {
    if (i > maxReach) return false;  // can't reach position i
    maxReach = Math.max(maxReach, i + nums[i]);
  }

  return true;
}

console.log(canJump([2, 3, 1, 1, 4])); // true
console.log(canJump([3, 2, 1, 0, 4])); // false — stuck at index 3
Jump Game II — Minimum Number of Jumps

Same setup — find the minimum number of jumps to reach the last index.

Greedy choice: At each "level" (current jump window), pick the jump that extends your reach the furthest. Only increment the jump counter when you exhaust the current window.

JS
// Jump Game II — O(n) time, O(1) space
function jump(nums) {
  let jumps = 0;
  let currentEnd = 0;   // end of the current jump's reach
  let farthest = 0;     // farthest reachable from current window

  for (let i = 0; i < nums.length - 1; i++) {
    farthest = Math.max(farthest, i + nums[i]);

    if (i === currentEnd) {
      // We've exhausted the current jump's window
      // Must take another jump to reach farthest
      jumps++;
      currentEnd = farthest;

      if (currentEnd >= nums.length - 1) break;
    }
  }

  return jumps;
}

console.log(jump([2, 3, 1, 1, 4])); // 2  (index 0→1→4)
console.log(jump([2, 3, 0, 1, 4])); // 2  (index 0→1→4)
Tip
Think of Jump Game II as BFS on levels. Each jump is a BFS level, and \`farthest\` is the maximum index reachable in the next level. This BFS intuition makes the correctness obvious.
Gas Station

There are n gas stations in a circle. Station i has gas[i] liters and costs cost[i] to reach the next station. Find the starting station from which you can complete a full circuit, or return -1 if impossible.

Key observations:

  1. If total gas < total cost, no solution exists
  2. If a solution exists, it is unique
  3. If you run out of gas at station k starting from station s, then no station between s and k can be a valid starting point (they all have even less accumulated gas)

JS
// Gas Station — O(n) time, O(1) space
function canCompleteCircuit(gas, cost) {
  let totalGas = 0;
  let currentGas = 0;
  let startStation = 0;

  for (let i = 0; i < gas.length; i++) {
    const net = gas[i] - cost[i];
    totalGas += net;
    currentGas += net;

    // If we can't reach the next station, reset
    if (currentGas < 0) {
      startStation = i + 1;  // try starting from the next station
      currentGas = 0;
    }
  }

  // If total gas < total cost, no solution
  return totalGas >= 0 ? startStation : -1;
}

console.log(canCompleteCircuit([1,2,3,4,5], [3,4,5,1,2])); // 3
console.log(canCompleteCircuit([2,3,4],     [3,4,3]));      // -1
Assign Cookies

Each child has a greed factor g[i] (minimum cookie size they will accept). Each cookie has size s[j]. Assign at most one cookie per child to maximize the number of content children.

Greedy choice: Sort both arrays. Assign the smallest sufficient cookie to the least greedy child.

JS
// Assign Cookies — O(n log n + m log m)
function findContentChildren(g, s) {
  g.sort((a, b) => a - b);  // greed factors, ascending
  s.sort((a, b) => a - b);  // cookie sizes, ascending

  let child = 0;
  let cookie = 0;

  while (child < g.length && cookie < s.length) {
    if (s[cookie] >= g[child]) {
      child++;  // this cookie satisfies this child
    }
    cookie++;   // move to next cookie regardless
  }

  return child;  // number of content children
}

console.log(findContentChildren([1, 2, 3], [1, 1]));    // 1
console.log(findContentChildren([1, 2], [1, 2, 3]));    // 2
Interval Scheduling Maximization

Select the maximum number of non-overlapping intervals from a list. This is equivalent to Activity Selection above, but phrased as a LeetCode problem (Non-overlapping Intervals asks for minimum removals — same idea, complement answer).

JS
// Non-overlapping Intervals — minimum intervals to remove
// Equivalent to: (total - maximum non-overlapping)
function eraseOverlapIntervals(intervals) {
  if (intervals.length === 0) return 0;

  // Sort by end time — greedy: keep intervals that end earliest
  intervals.sort((a, b) => a[1] - b[1]);

  let keep = 1;
  let lastEnd = intervals[0][1];

  for (let i = 1; i < intervals.length; i++) {
    if (intervals[i][0] >= lastEnd) {
      keep++;
      lastEnd = intervals[i][1];
    }
    // else: this interval overlaps — skip (remove) it
  }

  return intervals.length - keep;  // number removed
}

console.log(eraseOverlapIntervals([[1,2],[2,3],[3,4],[1,3]])); // 1 (remove [1,3])
console.log(eraseOverlapIntervals([[1,2],[1,2],[1,2]]));        // 2
Greedy vs Dynamic Programming

Property

Greedy

Dynamic Programming

Makes choices

One at a time, irrevocably

Considers all subproblem results

Revisits choices

Never

Implicitly via table lookup

Time complexity

Usually O(n log n) or O(n)

Usually O(n²) or O(n·W)

Correctness guarantee

Only when greedy choice property holds

Always (if recurrence is correct)

Coin change (arbitrary coins)

Wrong

Correct

Fractional knapsack

Correct

Overkill

Activity selection

Correct

Overkill

0/1 Knapsack

Wrong

Correct

Greedy Problem Recognition
  • Problem asks for maximum/minimum count of items selected or removed

  • Elements can be sorted by a single key (end time, ratio, size) to get the optimal order

  • Local decisions do not create "debt" — choosing early never blocks better future choices

  • The problem involves scheduling, coverage, or resource allocation

  • Exchange argument naturally shows swapping the greedy choice makes things worse or equal

Summary of Classic Greedy Problems

Problem

Greedy Choice

Time

Activity Selection

Earliest end time first

O(n log n)

Jump Game I

Track max reach

O(n)

Jump Game II

Extend reach per level

O(n)

Gas Station

Reset start when tank empties

O(n)

Assign Cookies

Smallest sufficient cookie

O(n log n)

Interval Scheduling

Earliest end time first

O(n log n)

Fractional Knapsack

Highest value/weight ratio first

O(n log n)

Huffman Coding

Merge two lowest-frequency nodes

O(n log n)

Note
When a problem has both overlapping subproblems and optimal substructure but greedy fails (like coin change), reach for dynamic programming. When only optimal substructure exists and greedy choice works, greedy is always the better choice — it is faster and simpler.