Start learning free

LESSON 387 OF 536 · HEAP

Task Scheduler in Python: O(T log 26) with a Max-Heap and a Queue

Practice O(T log 26) Read the full explanation

A CPU has to run a list of tasks. Each task is an uppercase letter from A to Z; equal letters are copies of the same task. Running one task takes one unit of time, and in each unit the CPU either runs one task or stays idle.

There is a cooldown: two copies of the same task must be at least n units apart, so after running a task the CPU needs n other units (other tasks, or idle) before it can run that task again. The tasks can run in any order.

Return the fewest units of time the CPU needs to run all the tasks.

 

Example 1:

Input: tasks = ["A","A","A","B","B","B"], n = 2
Output: 8
Explanation: One best order is A B idle A B idle A B.
After an A, two other units must pass before the next A, and B has the same rule. With only two kinds of task, the CPU has to idle twice.

Example 2:

Input: tasks = ["A","A","A","B","B","B"], n = 0
Output: 6
Explanation: With no cooldown, any order works, for example A A A B B B, and no unit is idle.

Example 3:

Input: tasks = ["A","A","A","A","A","A","B","C","D","E","F","G"], n = 2
Output: 16
Explanation: One best order is A B C A D E A F G A idle idle A idle idle A.
The six A's need two units between each other; the other tasks fill the first gaps, and the CPU idles in the rest.

 

Constraints:

  • 1 <= tasks.length <= 105
  • Every task is an uppercase English letter.
  • 0 <= n <= 100

Note:

Your algorithm should run in O(T log 26) time, where T is the number of tasks (log 26 because a heap here never holds more than 26 kinds of task), and use O(1) extra space: at most 26 counts.

The tutorial simulates the CPU with a max-heap: in each unit, run the task with the most copies left, then let it wait in a queue until its cooldown is over. When no task is ready, the time jumps straight to the moment the first waiting task is ready again. (The answer also has a counting shortcut: if the most frequent task appears m times and c tasks appear that often, it is the bigger of tasks.length and (m - 1) · (n + 1) + c.)

STEP 1 OF 12
    Ctrl + Enter to check

    PYTHON · HEAP

    Task Scheduler in Python, explained

    Problem overview

    The method leastInterval receives tasks, a list of uppercase letters, and a cooldown n. Equal letters are copies of the same task. Running a task takes one unit of time, and in each unit the CPU either runs one task or stays idle. Two copies of the same task must be at least n units apart: after running a task, the CPU needs n other units, which can be other tasks or idle time, before it runs that task again. The tasks can run in any order.

    The method returns the fewest units of time needed to run everything.

    For tasks = ["A", "A", "A", "B", "B", "B"] and n = 2 the answer is 8: A B idle A B idle A B. With only two kinds of task, the CPU has to idle twice. With n = 0 there is no cooldown, any order works, and the answer is 6. For ["A", "A", "A", "A", "A", "A", "B", "C", "D", "E", "F", "G"] and n = 2 the answer is 16: the six A's need two units between them, the other tasks fill the first gaps, and the CPU idles in the rest.

    Approach: always run the task with the most copies left

    Only the number of copies of each task matters, not what the tasks are called. So the first step is to count them.

    Then simulate the CPU one unit at a time. In every unit, run the task with the most copies left. Tasks with many copies need the most room between their runs, so starting them early leaves the fewest idle units at the end. After a task runs, it has to wait for its cooldown, so it leaves the pool of ready tasks and waits in a queue. When its cooldown is over, it goes back to the pool with its smaller count.

    Two data structures do the work:

    • A max-heap of counts for the tasks that can run now. Python's heapq is a min-heap, so the counts are stored negated, and the most frequent task is on top. There are at most 26 kinds of task, so the heap never holds more than 26 entries, which is where log 26 in the time comes from.
    • A queue (deque) of tasks that are cooling down. Each entry is a tuple (left, readyTime): the copies the task has left, and the time unit at whose end its cooldown is over. Tasks enter in the order they run, so they leave in the order of their ready times, and the front is always the next one ready.

    When no task is ready, the CPU idles. Instead of looping once per idle unit, the simulation jumps straight to the moment the first waiting task is ready. That keeps the loop to at most one turn per task run.

    Here is the simulation on ["A", "A", "A", "B", "B", "B"] with n = 2:

    time 1: run, 2 left
    time 2: run, 2 left
    idle: time jumps to 3
      at end of 3: task with 2 left is ready
    time 4: run, 1 left
      at end of 4: task with 2 left is ready
    time 5: run, 1 left
    idle: time jumps to 6
      at end of 6: task with 1 left is ready
    time 7: run, 0 left
      at end of 7: task with 1 left is ready
    time 8: run, 0 left
    8
    

    The task run at time 1 is ready again at the end of time 3, and the one run at time 2 at the end of time 4. Units 3 and 6 are the two idle units, so the total is 8.

    Step by step

    1. Count the tasks with count = Counter(tasks). Counter maps each letter to how many times it appears.
    2. Build the heap list. heap holds every count from count.values(), negated, so the biggest count is the smallest value.
    3. Arrange it as a heap with heapq.heapify(heap). It works in place and returns None, so its result isn't assigned.
    4. Create cooling as an empty deque(), the queue of waiting tasks.
    5. Start time at 0. It counts the units used so far and ends as the answer.
    6. Loop while heap or cooling holds something. The work is done only when no task is ready and none is cooling down.
    7. Add 1 to time: a new unit starts.
    8. If the heap is empty, no task can run now. Set time to the ready time of the first waiting task, cooling[0][1], which counts every idle unit in between at once.
    9. Otherwise run a task. Pop the top of the heap, negate it back, and subtract 1: left is how many copies the task still has.
    10. Queue it if copies remain. If left > 0, append (left, time + n) to cooling. A task run at time can run again at time + n + 1, after n other units. Tasks with no copies left are done.
    11. Release a ready task. If cooling is not empty and its front has ready time equal to time, remove it with popleft() and push its count, negated, back onto the heap.
    12. Return time. It is the unit in which the last task ran.

    The solution in Python

    class Solution:
        def leastInterval(self, tasks: List[str], n: int) -> int:
            count = Counter(tasks)
            heap = [-c for c in count.values()]
            heapq.heapify(heap)
            cooling = deque()
            time = 0
            while heap or cooling:
                time += 1
                if not heap:
                    time = cooling[0][1]
                else:
                    left = -heapq.heappop(heap) - 1
                    if left > 0:
                        cooling.append((left, time + n))
                if cooling and cooling[0][1] == time:
                    heapq.heappush(heap, -cooling.popleft()[0])
            return time
    

    Python details worth a closer look:

    • Counter and deque. Both come from the collections module. In a file of your own, you would add from collections import Counter, deque and import heapq.
    • Negative counts. The heap holds values like -3. Popping gives -3, and -heapq.heappop(heap) - 1 turns it into 3 - 1 = 2: negate first, then subtract. Writing it as -(heappop(heap) - 1) would give 4 instead.
    • heapify returns None. heap = heapq.heapify(heap) would replace the list with None. The call is a statement on its own.
    • Truthiness of containers. while heap or cooling and if not heap use the fact that an empty list or deque counts as false. cooling and cooling[0][1] == time checks that the queue has an item before reading its front.
    • A deque for the queue. append adds at the back and popleft removes from the front, both in constant time. A list's pop(0) shifts every element and would be slower on long queues.
    • Tuples. cooling[0][1] is the second item of the front tuple, the ready time. cooling.popleft()[0] removes the front tuple and takes its first item, the number of copies left.
    • The name time. It is an ordinary variable here. It would hide the time module if the file imported it, which this solution doesn't.

    Complexity

    Time: O(T log 26), where T is the number of tasks. Counting takes O(T). Each turn of the loop either runs a task, one per copy, or jumps over idle time, and a jump is always followed by running a task, so there are at most 2T turns. Each turn does a heap operation on at most 26 entries, which costs O(log 26), a constant. The heap and the queue never hold more than 26 kinds of task.

    Space: O(1). The counter, the heap and the queue each hold at most 26 entries.

    Edge cases and common mistakes

    • A single task. ["A"] with n = 5 gives 1. The only copy runs, nothing is left, and the cooldown never matters.
    • No cooldown. With n = 0 a task is queued with ready time equal to time, so it is released at the end of the same unit. The CPU never idles and the answer is the number of tasks.
    • One frequent task. ["A", "A", "A"] with n = 2 gives 7: A idle idle A idle idle A. The idle units are counted by the jumps.
    • Many kinds of task. ["A", "A", "A", "B", "B", "B", "C", "C", "C"] with n = 2 gives 9: no idle time is needed, because the three tasks fill each other's cooldowns.
    • A very long cooldown. ["A", "A", "B", "B"] with n = 100 gives 103. The jumps cross the long idle stretches in a single turn each.
    • The ready time. A task run at time can run again at time + n + 1. That is why the queue stores time + n, the unit at whose end the cooldown is over, and releases the task at the end of that unit. Storing time + n + 1 would release it one unit late.
    • A counting shortcut. The answer also equals the bigger of len(tasks) and (m - 1) * (n + 1) + c, where m is the highest count and c is how many tasks have that count. For the first example that is max(6, 2 * 3 + 2) = 8. The simulation is the lesson's approach; the formula agrees with it on the examples above.

    Testing

    • tasks = ["A", "A", "A", "B", "B", "B"], n = 2 gives 8.
    • tasks = ["A", "A", "A", "B", "B", "B"], n = 0 gives 6.
    • tasks = ["A", "A", "A", "A", "A", "A", "B", "C", "D", "E", "F", "G"], n = 2 gives 16.
    • tasks = ["A"], n = 5 gives 1.
    • tasks = ["A", "A", "A"], n = 2 gives 7.
    • tasks = ["A", "A", "A", "B", "B", "B", "C", "C", "C"], n = 2 gives 9.
    • tasks = ["A", "A", "B", "B"], n = 100 gives 103.
    Keep going with Python500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free