Skip to content

Priority Queue Greedy

Dynamic Selection with Priority Queues

Here's something that takes a while to click: some greedy problems can't be solved by sorting alone because the set of available choices changes over time. You sort jobs by deadline, but new jobs become eligible as you move through time. You sort guests by arrival, but rooms free up as earlier guests leave.

The priority queue bridges this gap. It lets you always pick the best option currently available, updating the pool as the world changes around you.

This lesson covers six problems. The first three — Summer Vacation, Room Allocation, and Packing Under Range Regulations — deserve the deepest treatment. They represent three distinct PQ patterns that appear over and over in competitive programming.


Example 1: ABC137D — Summer Vacation

Problem. \(n\) jobs, each with reward \(a_i\) and deadline \(b_i\) (the job takes \(b_i\) days to complete, so it must be started at least \(b_i\) days before the end). You can do one job per day over \(m\) days. Maximize total reward.

The Greedy Choice: On each day, pick the highest-reward job from the pool of currently available jobs. The priority queue always surfaces the best option.

The Wrong Approach

Your first instinct: "Sort jobs by reward descending, greedily pick the best ones." But a high-reward job might have a tight deadline that conflicts with another high-reward job. Pure reward sorting ignores timing.

Your second instinct: "Sort by deadline, earliest first." But that wastes high-value jobs on slots that could go to even better jobs arriving later.

Instead of asking "which jobs should I do first?", ask: "as time passes, which new jobs become doable, and which available job is best right now?"

The Mental Model: Iterate Days Left

The cleanest way to think about this: iterate daysLeft from 1 to \(m\). On the step where daysLeft = d, any job with deadline \(d\) becomes available — it takes exactly \(d\) days, and we now have enough days remaining to complete it. Push these jobs into a max-heap, then pop the highest reward.

This forward scan is equivalent to the reverse-time approach but has a simpler mental model: as we gain more time budget, more jobs unlock.

Step-by-Step Trace

Stare at this table. \(n = 5\) jobs, \(m = 4\) days.

Job Reward Deadline (\(b_i\))
A 3 4
B 7 2
C 1 1
D 5 3
E 4 1

Processing by daysLeft from 1 to 4:

daysLeft New jobs unlocked (deadline = daysLeft) PQ contents Pick Running total
1 C (b=1, r=1), E (b=1, r=4) {4, 1} 4 (E) 4
2 B (b=2, r=7) {7, 1} 7 (B) 11
3 D (b=3, r=5) {5, 1} 5 (D) 16
4 A (b=4, r=3) {3, 1} 3 (A) 19

Total reward = 19. We used all 4 days, skipped job C (reward 1).

Verify: Could we do better? The maximum possible is the sum of the top 4 rewards: \(7 + 5 + 4 + 3 = 19\). And all 4 fit within their deadlines. Optimal.

Event-based PQ processing timeline

The Code

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int numJobs, numDays;
    cin >> numJobs >> numDays;

    vector<vector<int>> jobsByDeadline(numDays + 1);
    for (int i = 0; i < numJobs; i++) {
        int reward, deadline;
        cin >> reward >> deadline;
        if (deadline <= numDays) {
            jobsByDeadline[deadline].push_back(reward);
        }
    }

    priority_queue<int> availableRewards;
    long long maxTotalReward = 0;

    for (int daysLeft = 1; daysLeft <= numDays; daysLeft++) {
        for (int reward : jobsByDeadline[daysLeft]) {
            availableRewards.push(reward);
        }
        if (!availableRewards.empty()) {
            maxTotalReward += availableRewards.top();
            availableRewards.pop();
        }
    }

    cout << maxTotalReward << "\n";
    return 0;
}

\(O(n \log n)\) time — each job is pushed and popped at most once.


Example 2: CSES — Room Allocation

Problem. \(n\) guests arrive on day \(a_i\) and leave on day \(b_i\). Assign each guest a room. Minimize rooms used. Two guests can share a room if their stays don't overlap (guest 1 leaves on day \(b_1\) before guest 2 arrives on day \(a_2\), where \(a_2 > b_1\)).

The Wrong Approach

You might try: "Sort by duration, assign short stays first." But duration is irrelevant — what matters is when stays overlap.

Instead of asking "which guests are easy to assign?", ask: "when a new guest arrives, is there a room that just freed up?"

The Greedy with a Min-Heap

Sort guests by arrival time. Maintain a min-heap of (departure_day, room_number). The top of the heap is the room that frees up earliest.

For each guest: 1. Check if the earliest-freeing room has departure < current arrival (room is free) 2. If yes, reuse that room: pop it, push (new_departure, same_room) 3. If no, allocate a new room: push (new_departure, new_room)

Step-by-Step Trace

Stare at this table. 5 guests:

Guest Arrival Departure
G1 1 3
G2 2 5
G3 4 6
G4 4 7
G5 6 8

Sorted by arrival (already sorted):

Step Guest Min-heap top Room freed? Action Heap after
1 G1 (1-3) empty New room 1 {(3,R1)}
2 G2 (2-5) (3,R1) \(3 < 2\)? No New room 2 {(3,R1), (5,R2)}
3 G3 (4-6) (3,R1) \(3 < 4\)? Yes Reuse R1 {(5,R2), (6,R1)}
4 G4 (4-7) (5,R2) \(5 < 4\)? No New room 3 {(5,R2), (6,R1), (7,R3)}
5 G5 (6-8) (5,R2) \(5 < 6\)? Yes Reuse R2 {(6,R1), (7,R3), (8,R2)}

Result: 3 rooms. This equals the maximum number of overlapping intervals at any point (guests G2, G3, G4 all overlap on days 4-5).

Manual verification: On day 4, G2 (2-5), G3 (4-6), and G4 (4-7) are all present. You need at least 3 rooms. We used exactly 3. Optimal.

The Code

auto arrivalCompare = [](const array<int, 3>& guest1, const array<int, 3>& guest2) {
    return guest1[0] < guest2[0];
};
sort(guests.begin(), guests.end(), arrivalCompare);

priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> activeRooms;
int totalRooms = 0;
vector<int> roomAssignment(numGuests);

for (int i = 0; i < numGuests; i++) {
    if (!activeRooms.empty() && activeRooms.top().first < guests[i][0]) {
        int freedRoom = activeRooms.top().second;
        activeRooms.pop();
        roomAssignment[guests[i][2]] = freedRoom;
        activeRooms.push({guests[i][1], freedRoom});
    } else {
        totalRooms++;
        roomAssignment[guests[i][2]] = totalRooms;
        activeRooms.push({guests[i][1], totalRooms});
    }
}

Example 3: ABC214E — Packing Under Range Regulations

Problem. \(10^9\) boxes (positions on a number line), \(N\) balls. Ball \(i\) must be placed in a box numbered in \([L_i, R_i]\). Each box holds at most 1 ball. Can all balls be placed?

The Structural Insight: Earliest Deadline First

This is the Earliest Deadline First (EDF) pattern applied to interval assignment. Instead of asking "which ball goes where?", ask: "at each position, which available ball is most urgent to place?"

The most urgent ball is the one with the smallest \(R_i\) — its deadline is tightest. If you don't place it soon, you'll miss its window entirely.

The Algorithm

  1. Sort balls by \(L_i\) (left endpoint).
  2. Sweep positions left to right. At each position:
  3. Add all balls whose \(L_i \leq\) current position to a min-heap (keyed on \(R_i\))
  4. If the heap's top has \(R_i <\) current position, a ball's deadline has passed. Impossible.
  5. Otherwise, pop the top (most urgent ball) and assign it to this position.

Step-by-Step Trace

5 balls:

Ball \(L\) \(R\)
A 1 3
B 1 2
C 2 4
D 3 5
E 3 3

Sorted by \(L\): B(1,2), A(1,3), C(2,4), E(3,3), D(3,5).

Pos Balls added Min-heap (by \(R\)) Top \(R <\) pos? Assign Heap after
1 B(R=2), A(R=3) {2, 3} \(2 < 1\)? No B (\(R=2\)) {3}
2 C(R=4) {3, 4} \(3 < 2\)? No A (\(R=3\)) {4}
3 E(R=3), D(R=5) {3, 4, 5} \(3 < 3\)? No E (\(R=3\)) {4, 5}
4 {4, 5} \(4 < 4\)? No C (\(R=4\)) {5}
5 {5} \(5 < 5\)? No D (\(R=5\)) {}

All 5 balls placed. Yes.

What breaks if you use a max-heap instead? At position 1, you'd assign A (\(R=3\)) — the least urgent ball. B (\(R=2\)) stays in the heap. At position 2, you'd assign C (\(R=4\)). Now B is still unassigned with \(R=2\), and position 2 was its last chance. At position 3, the heap has {2, 3, 5}. Top \(= 2 < 3\). B's deadline passed. Failure. Always pick the most urgent, not the least.

The Code

auto leftEndpointCompare = [](const pair<int, int>& ball1, const pair<int, int>& ball2) {
    return ball1.first < ball2.first;
};
sort(balls.begin(), balls.end(), leftEndpointCompare);

priority_queue<int, vector<int>, greater<int>> deadlineHeap;
int ballIndex = 0;
for (int currentPosition = 1; currentPosition <= maxPosition; currentPosition++) {
    while (ballIndex < numBalls && balls[ballIndex].first <= currentPosition) {
        deadlineHeap.push(balls[ballIndex].second);
        ballIndex++;
    }
    if (!deadlineHeap.empty() && deadlineHeap.top() < currentPosition) {
        cout << "No\n";
        return;
    }
    if (!deadlineHeap.empty()) {
        deadlineHeap.pop();
    }
}
cout << (deadlineHeap.empty() ? "Yes" : "No") << "\n";

Important detail: With \(10^9\) possible positions but only \(N\) balls, you don't iterate all positions. Instead, use coordinate compression — only visit positions where something changes (ball starts or you need to place).


Example 4: ABC195D — Shipping Center

Problem. \(N\) items (size \(W_i\), value \(V_i\)), \(M\) boxes (capacity \(X_i\)). Each box holds 1 item. \(Q\) queries: given a subset of available boxes, maximize total value.

The Greedy: Most Valuable First, Tightest Fit

Sort items by value descending. For each item (most valuable first), assign it to the smallest box that fits — this leaves larger boxes free for bigger items.

Why tightest fit? If you assign a small item to a huge box, you "waste" capacity. A later, larger, less-valuable item might have needed that box. By always using the smallest sufficient box, you maximize the remaining capacity for future assignments.

Trace

Items: (\(W=3, V=10\)), (\(W=5, V=8\)), (\(W=2, V=6\)). Boxes: [4, 6, 3].

Sorted by value: (3,10), (5,8), (2,6).

Item \(W\) \(V\) Available boxes Smallest fit Assign?
1 3 10 {3, 4, 6} 3 Yes, box 3
2 5 8 {4, 6} 6 Yes, box 6
3 2 6 {4} 4 Yes, box 4

Total value \(= 10 + 8 + 6 =\) 24. All items fit.

What would happen if we assigned item 1 to box 6 instead? Remaining boxes {3, 4}. Item 2 (\(W=5\)) doesn't fit in 3 or 4. We lose value 8. Tightest fit prevents this.

The Code

auto valueCompareDescending = [](const pair<int, int>& item1, const pair<int, int>& item2) {
    return item1.second > item2.second;
};
sort(items.begin(), items.end(), valueCompareDescending);

for (const auto& item : items) {
    auto bestBoxIt = availableBoxes.lower_bound(item.first);
    if (bestBoxIt != availableBoxes.end()) {
        totalValue += item.second;
        availableBoxes.erase(bestBoxIt);
    }
}

Exchange argument: If we assigned a valuable item to a large box and a less valuable item to a tight-fitting box, swapping assignments (if the valuable item fits in the smaller box) never worsens the result.


Example 5: ABC080D — Recording

Problem. Record \(N\) TV programs. Program \(i\) airs on channel \(c_i\) from time \(s_i\) to \(t_i\). A recorder can record consecutive programs on the same channel, but switching channels requires a gap. Minimize recorders.

Key insight: Two programs can share a recorder if: - Same channel AND the second starts right when the first ends (no gap needed) - Different channels AND there's a gap between them

Solution: Model as interval scheduling with channel constraints. Sort by start time. Use a PQ of (end_time, channel, recorder_id). For each program, try to reuse a recorder — prefer one that finished on the same channel (no gap needed), otherwise one that finished early enough on a different channel. If neither works, allocate a new recorder.

Challenge: Why does preferring "same channel, latest finish" over "different channel, earliest finish" give the optimal result? Think about what resource you're conserving.


Example 6: UVa 11264 — Coin Collector

Problem. Given coin denominations where change is given using the greedy algorithm (largest denomination first), you make a purchase and want to maximize the number of distinct coins in your change.

Structural insight: In a canonical coin system, the greedy change algorithm uses specific denominations. To maximize distinct coins, choose a payment amount that forces the greedy change algorithm to use as many different denominations as possible.

Solution: Greedily accumulate denominations from smallest to largest: include denomination \(d_i\) if the running sum stays below \(d_{i+1}\).

Try it yourself: Given denominations \([1, 5, 10, 25, 50]\), what running sum do you build? Start: 1. Add 5: sum \(= 6 < 10\), include. Add 10: sum \(= 16 < 25\), include. Add 25: sum \(= 41 < 50\), include. Add 50: sum \(= 91\), no larger denomination to check. Include. Answer: 5 distinct coins.


When to Use PQ Greedy

Here's a summary of the patterns you've seen:

  • "Best available" selection (Summer Vacation): Options become available over time, always pick the best current option. Use a max-heap.
  • Resource reuse (Room Allocation): When finished resources become available again. Sort by start, min-heap on end times.
  • Earliest Deadline First (Packing Under Range): Assign the most urgent item first. Sort by start, min-heap on deadlines.
  • Tightest fit assignment (Shipping Center): Match items to resources with minimal waste. Use a multiset/ordered set with lower_bound.
  • Event-driven processing (Recording): Sort events by time, maintain state in a heap, handle transitions.
  • Merge-based construction (Huffman Coding): Repeatedly merge the two smallest elements. Covered in depth in Chapter 8 Lesson 3.

Instead of asking "should I sort by start or end or value?", ask: "what changes over time, and at each moment, what's the best choice from the current pool?" If the pool changes dynamically, you need a priority queue.

PQ greedy decision flow


Try It

Warm-up 1: Summer Vacation by Hand

3 jobs, 3 days:

Job Reward Deadline
A 10 1
B 5 2
C 8 1

Predict before tracing: What's the maximum reward? Which job gets skipped?

Trace the forward processing:

  • daysLeft = 1: unlock A(10) and C(8) (deadline 1). PQ = {10, 8}. Pick 10 (A). Total = 10.
  • daysLeft = 2: unlock B(5) (deadline 2). PQ = {8, 5}. Pick 8 (C). Total = 18.
  • daysLeft = 3: no new jobs. PQ = {5}. Pick 5 (B). Total = 23.

All 3 jobs fit. Total = 23. No job gets skipped.

Warm-up 2: Room Allocation Edge Case

3 guests, all arriving on the same day:

Guest Arrival Departure
G1 1 5
G2 1 3
G3 1 4

How many rooms? Trace the algorithm. Predict first.

Since all arrive on day 1 and no room has freed up before any arrival, you need 3 rooms. The min-heap never has a departure < arrival for any guest.

Challenge: EDF Failure Mode

Consider these balls: A(1,2), B(1,2), C(1,2), with only 2 box positions available (positions 1 and 2). Can all 3 balls be placed? How does the algorithm detect failure?

At position 1: add all 3 balls. PQ = {2, 2, 2}. Assign one. PQ = {2, 2}. At position 2: PQ top = 2. Is \(2 < 2\)? No. Assign one. PQ = {2}. No more positions. PQ is not empty (1 ball remains). Output: No.

The algorithm detects failure when the heap isn't empty after processing all positions.

Problems

Problem Link Difficulty
ABC137D — Summer Vacation atcoder.jp/contests/abc137/tasks/abc137_d 400pts
CSES — Room Allocation cses.fi/problemset/task/1164 Medium
ABC214E — Packing Under Range Regulations atcoder.jp/contests/abc214/tasks/abc214_e 500pts
ABC195D — Shipping Center atcoder.jp/contests/abc195/tasks/abc195_d 400pts
ABC080D — Recording atcoder.jp/contests/abc080/tasks/abc080_d 400pts
UVa 11264 — Coin Collector onlinejudge.org Medium
LC 1094 — Car Pooling leetcode.com/problems/car-pooling Medium
LC 621 — Task Scheduler leetcode.com/problems/task-scheduler Medium
LC 767 — Reorganize String leetcode.com/problems/reorganize-string Medium