Introduction
Greedy is a technique where you make locally optimal decisions, in the hope that you reach a global optimal solution. This is not always correct, so you have to be careful.
Imagine you are trying to find the shortest path from point A to point C. A greedy approach might simply pick the nearest neighbor at each step. However, this might not lead to the shortest path, as the nearest neighbor at one step might lead to a detour that significantly increases the overall distance.
A -(1)> B -(100)> C
-(2)> D -(2)>ie. Shortest path A -> C. A -> B -> C costs 101 but A -> D -> C costs 4.
You can use dijkstras for shortest path. Dijkstras is a greedy algorithm, but it is not as naive as the above.
There are some ways to prove a greedy algorithm works, either exchange argument or greedy stays ahead. What is most important during an interview however is having a strong intuition for when greedy will work or fail.
Let's look at some examples.
Jump Game Problems
Classic greedy problems involving reachability and minimum jumps.
01Jump GameMedium
You are given an integer array nums. You are initially positioned at the array's first index, and each element in the array represents your maximum jump length at that position.
Return true if you can reach the last index, or false otherwise.
# l->r greedy, keep track of max distance can reach so far
def canJump(self, nums: List[int]) -> bool:
n, reach = len(nums), 0
for i in range(n):
if i > reach: return False
reach = max(reach, i + nums[i])
return TrueO(n)O(1)The intuition is that starting at index 0, we extend our max range to the furthest possible index we can reach so far. Once we are at some index that is beyond our reach, we clearly cannot reach the end so we return false. Otherwise we return true.
02Jump Game IIMedium
You are given a 0-indexed array of integers nums of length n. You are initially positioned at nums[0].
Each element nums[i] represents the maximum length of a forward jump from index i. In other words, if you are at nums[i], you can jump to any nums[i + j] where:
- 0 <= j <= nums[i] and
- i + j < n
Return the minimum number of jumps to reach nums[n - 1]. The test cases are generated such that you can reach nums[n - 1].
# greedy
# say you can reach indicies 0 up to e.
# in that interval, we find the furthest we can jump to = max
# when reach e, update interval to [0, max]
# number of updates is the answer
def jump(self, nums: List[int]) -> int:
n, e, max_, res = len(nums), 0, 0, 0
for i in range(n-1):
max_ = max(max_, i + nums[i])
if i == e:
e = max_
res += 1
return resO(n)O(1)This is a very similar question to the first, we just want the number of jumps instead of a boolean feasibility check. It's the same greedy idea, we just need to track updates.
Container With Most Water
Using two pointers in a greedy fashion to maximize area.
01Container With Most WaterMedium
You are given an integer array height of length n. There are n vertical lines drawn such that the two endpoints of the ith line are (i, 0) and (i, height[i]).
Find two lines that together with the x-axis form a container, such that the container contains the most water.
Return the maximum amount of water a container can store.
Notice that you may not slant the container.
# max_{i,j : i < j} (j-i) * (min(height[i], height[j]))
def maxArea(self, height: List[int]) -> int:
res, left, right = 0, 0, len(height)-1
while left < right:
res = max(res, (right-left) * min(height[left], height[right]))
if height[left] < height[right]:
left += 1
else:
right -= 1
return resO(n)O(1)This is an example of using Two Pointers technique in a greedy fashion.
The idea is we want max_{i,j : i < j} (j-i) * (min(height[i], height[j]))
Basically out of all pairs of left,right edges, we want the maximum area rectangle of water that that rectangle can hold. Clearly we can brute force this formula in O(n^2) time, O(1) space.
However, this is wasteful. We can use a greedy, locally optimal algorithm to speed this up.
Since we want maximum area, it makes sense to start with the maximum width rectangle, being i = 0, j = n-1.
Imagine we have two edges, i,j. The lower height edge defines the amount of water the rectangle can hold. Since we can only shrink our window, (width) in order to increase the max area, we must abandon the lower height edge in search of a higher height. (ie. if we shrank the window by moving the higher height edge, all subsequent windows have max water area <= our current area, because the height of water is capped by the lower height edge.)
This is an amazing observation, because it saves a lot of redundant work. For example, if i = 0, j = n-1, and 0 is the lower height edge, we know that any rectangle with left edge i = 0 and j where j < n-1 will have less water area than the current area being (n-1 - 0) * (height[left]), because the first term the width will be strictly smaller, and the second term the minimum height will be less than or equal to height[left]. So basically, you can think of it as, on every iteration we save O(n) time. There will be O(n) iterations because we move each pointer exactly one every iteration, and we terminate once left crosses right, and both are initialized with space n apart.
This outlines a proof of the greedy stays ahead approach. We just showed that at every step, we can safely discard a set of solutions that will clearly be no better than our current. Which essentially means our greedy solution will not discard any optimal solution that exists, ie. is a smart brute force search. This is actually similar to the proof of correctness for the sliding window chapter.
This example outlines a common solution approach: model the problem mathematically, which instantly provides a brute force solution. However, you can leverage some greedy observations to make the solution optimally efficient.
Trapping Rain Water
A classic problem with multiple solution approaches from brute force to optimal greedy.
01Trapping Rain WaterHard
Given n non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it can trap after raining.
Key Idea
At any index i: we can trap WATER(i) = max(min(max_height_l[i], max_height_r[i]) - height[i], 0) water.
- max_height_l[i] is max_height from 0...i-1
- max_height_r[i] is max_height from i+1...n-1
We want sum_i WATER(i).
Solution 1: Brute Force
# O(n^2) time and O(1) space
def trap(self, height: List[int]) -> int:
def water(i):
diff = min(max(height[0:i]), max(height[i+1:])) - height[i]
return max(diff, 0)
return sum(water(i) for i in range(len(height)))Solution 2: DP with Prefix/Suffix Max
# O(n) time, O(n) space
def trap(self, height: List[int]) -> int:
n = len(height)
max_height_l = [0] * n
for i in range(1, n):
max_height_l[i] = max(max_height_l[i-1], height[i-1])
max_height_r = [0] * n
for i in range(n-2, -1, -1):
max_height_r[i] = max(max_height_r[i+1], height[i+1])
def water(i):
diff = min(max_height_l[i], max_height_r[i]) - height[i]
return max(diff, 0)
return sum(water(i) for i in range(len(height)))Solution 2b: Top-Down DP with Memoization
def trap(self, height: List[int]) -> int:
n = len(height)
@cache
def max_height_l(i):
if i == -1: return 0
return max(max_height_l(i-1), height[i])
@cache
def max_height_r(i):
if i == n: return 0
return max(height[i], max_height_r(i+1))
def water(i):
return min(max_height_l(i), max_height_r(i)) - height[i]
return sum(water(i) for i in range(len(height)))Solution 3: Optimal Two Pointers
The insight is that at index i, we don't actually need the values of both max_height_l[i], max_height_r[i] EXPLICITLY, we just need the MIN of the two values.
So if we maintain 2 pointers [l,r], and max_height_l[l] < max_height_r[r], the min of max_height_l[l], max_height_r[l] MUST BE max_height_l[l], since max_height_r[l] >= max_height_r[r].
Example: l=1, r=3, at index 1, we know max height left is 7, we don't know max_height_r[1], but we know max_height_r[3] = 9, and max_height_r[1] >= max_height_r[3] = 9, so the min MUST be 7, we don't even need to know the explicit value of max_height_r[1].
# O(n) time and O(1) space
def trap(self, height: List[int]) -> int:
max_l, max_r, l, r, res = height[0], height[-1], 0, len(height)-1, 0
while l <= r:
if max_l < max_r:
res += max_l - height[l]
l += 1
max_l = max(max_l, height[l])
else:
res += max_r - height[r]
r -= 1
max_r = max(max_r, height[r])
return resO(n)O(1)This is a very similar idea to container with most water. We realize that essentially we want to maximize diff = min(max_prefix_height(i), max_suffix_height(i)) - height[i] for each i individually, and sum them for all i.
Note that depending on how we define prefix(i) (either as inclusive of i or exclusive), we will need a max(0, diff). (think about why, if it is exclusive then the diff can be negative, which obviously doesn't make sense.)
We can brute force this in O(n^2) time and O(1) space. We can use prefix max and suffix max dp to do this in O(n) time and space. But the optimal solution of O(n) time and O(1) space requires a greedy two pointers strategy.
If we have two pointers l,r if max_prefix_height[l] < max_suffix_height[r], we know that min(max_prefix_height(l), max_suffix_height(l)) = max_prefix_height[l]. Why? Because max_suffix_height(l) >= max_prefix_height_r(r). Think about that. So we can greedily compute for index l, and increment it. Similar logic for the else case.
Greedy with Heaps and Binary Search
Some other examples combining greedy with heaps, sorted lists, and binary search.
01Maximize Profit from Task AssignmentMedium
You are given an integer array workers, where workers[i] represents the skill level of the ith worker. You are also given a 2D integer array tasks, where:
- tasks[i][0] represents the skill requirement needed to complete the task.
- tasks[i][1] represents the profit earned from completing the task.
Each worker can complete at most one task, and they can only take a task if their skill level is equal to the task's skill requirement. An additional worker joins today who can take up any task, regardless of the skill requirement.
Return the maximum total profit that can be earned by optimally assigning the tasks to the workers.
Heap Solution
def maxProfit(self, workers: List[int], tasks: List[List[int]]) -> int:
m = defaultdict(list)
for skill, profit in tasks:
heappush(m[skill], -profit)
res = 0
for worker in workers:
res += -heappop(m[worker]) if m[worker] else 0
max_rem_profit = max((-min(m[task]) for task in m if m[task]), default=0)
return res + max_rem_profitSortedList Solution
def maxProfit(self, workers: List[int], tasks: List[List[int]]) -> int:
m = defaultdict(SortedList)
for skill, profit in tasks:
m[skill].add(profit)
res = 0
for worker in workers:
res += m[worker].pop() if m[worker] else 0
max_rem_profit = max((m[task][-1] for task in m if m[task]), default=0)
return res + max_rem_profitOne-liner Solution
def maxProfit(self, workers: List[int], tasks: List[List[int]]) -> int:
m = defaultdict(SortedList)
for skill, profit in tasks:
m[skill].add(profit)
return sum(m[worker].pop() if m[worker] else 0 for worker in workers) + max((m[task][-1] for task in m if m[task]), default=0)O(n log n)O(n)This is a fairly simple problem. Just handle it with greedy intuition. Obviously you want to pair the worker with the highest profit task they can do.
02Choose K Elements With Maximum SumMedium
You are given two integer arrays, nums1 and nums2, both of length n, along with a positive integer k.
For each index i from 0 to n - 1, perform the following:
- Find all indices j where nums1[j] is less than nums1[i].
- Choose at most k values of nums2[j] at these indices to maximize the total sum.
Return an array answer of size n, where answer[i] represents the result for the corresponding index i.
def findMaxSum(self, nums1: List[int], nums2: List[int], k: int) -> List[int]:
n = len(nums1)
sl = sorted([(nums1[j], j) for j in range(n)])
heap = []
sum_max_k = [0] * n
sum_heap = 0
for i in range(n):
heappush(heap, nums2[sl[i][1]])
sum_heap += nums2[sl[i][1]]
if len(heap) == k+1:
sum_heap -= heappop(heap)
sum_max_k[i] = sum_heap
res = [0] * n
for i in range(n):
ii = bisect_left(sl, (nums1[i], -inf)) - 1
if ii >= 0:
res[i] = sum_max_k[ii]
return resO(n log n)O(n)The high level idea is if you consider nums1 in sorted order with index j, you know all previous indices j that we need to index into nums2 with. So basically you can use a heap of size k to maintain the sum of the max k. This is a min heap, so when we pop, we pop the smallest value out. This code is very common to maintaining a heap of size k:
heappush(heap, nums2[sl[i][1]])
sum_heap += nums2[sl[i][1]]
if len(heap) == k+1:
sum_heap -= heappop(heap)Now given a certain index i, (in nums1 sorted order) we have the greedy optimal max sum of nums2 elements with these corresponding indices j.
What remains is re-assigning back to our original indices i. Sum_max_k indices are relative to the sorted order of nums1.
We need to binary search in the sorted list for (nums1[i], -inf). (see sorted list chapter) -1 to the index, and that gives us the sorted order index which we can index into for sum_max_k.
03Furthest Building You Can ReachMedium
You are given an integer array heights representing the heights of buildings, some bricks, and some ladders.
You start your journey from building 0 and move to the next building by possibly using bricks or ladders.
While moving from building i to building i+1 (0-indexed):
- If the current building's height is greater than or equal to the next building's height, you do not need a ladder or bricks.
- If the current building's height is less than the next building's height, you can either use one ladder or (h[i+1] - h[i]) bricks.
Return the furthest building index (0-indexed) you can reach if you use the given ladders and bricks optimally.
Greedy Heap Solution
The greedy idea:
1. For a gap, we assume use a ladder first.
2. Later, if we don't have ladders and need to make a jump with k bricks, we check to see out of the ladders we have used, which one was used for the smallest gap. If k bricks > size of that gap, we SHOULD SWAP! ie. use a ladder here and bricks previously.
def furthestBuilding(self, heights: List[int], bricks: int, ladders: int) -> int:
used_ladder_gaps = []
n = len(heights)
for i in range(n-1):
gap = heights[i+1] - heights[i]
if gap <= 0:
continue
if ladders > 0:
ladders -= 1
heappush(used_ladder_gaps, gap)
elif used_ladder_gaps and gap > used_ladder_gaps[0]:
bricks -= heappop(used_ladder_gaps)
heappush(used_ladder_gaps, gap)
else:
bricks -= gap
if bricks < 0: return i
return n-1Binary Search Solution
def furthestBuilding(self, heights: List[int], bricks: int, ladders: int) -> int:
n = len(heights)
l, r = 0, n-1
def check(k):
gaps = [heights[i] - heights[i-1] for i in range(1, k+1) if heights[i] - heights[i-1] > 0]
gaps.sort()
return sum(gaps[:-ladders if ladders else None]) <= bricks # lol. gaps[:None] = gaps[:] = gaps.
while l < r:
m = ceil(l + (r-l) / 2)
if check(m):
l = m
else:
r = m - 1
return lO(n log n)O(n)The first solution is greedy heap. The second is with binary search.
The problem with just using a pure greedy approach, similar to binary search is that we could be using a ladder for a huge gap, but huge gap is at the end, where we would have never reached there anyways. (ie. there is no fixed length) This is why we either need to use a greedy heap solution where we swap ladders with bricks on the fly as we move left to right. OR we can fix the final building length using binary search, and once we fix the length we can use a simple greedy solution.
Binary search solution should be fairly straightforward. Max template.
Summary
The greedy paradigm is a crucial technique to be able to reason about. The intuition of when greedy will work can be subtle, and is best honed through practice.
Key Patterns:
1. Local optimality leads to global optimality - Make the best choice at each step
2. Exchange argument - Prove that swapping any other choice for the greedy choice doesn't improve the solution
3. Greedy stays ahead - Prove that at each step, the greedy solution is at least as good as any other
Common Greedy Techniques:
- Two pointers moving towards each other (Container with Most Water)
- Heap-based swapping (Furthest Building)
- Binary search + greedy check (alternative to pure greedy)
- Sorting + greedy assignment (Task Assignment)
When Greedy Works:
- Problems with optimal substructure
- Problems where local decisions don't affect future options negatively
- Problems where you can prove discarding choices is safe
When Greedy Fails:
- Shortest path in general graphs (need Dijkstra/BFS)
- Problems where early choices constrain future options
- Problems requiring global optimization (may need DP instead)