Start learning free

LESSON 391 OF 540 · HEAP

Task Scheduler in TypeScript: Max-Heap and a Cooldown 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 15
    Ctrl + Enter to check

    TYPESCRIPT · HEAP

    Task Scheduler in TypeScript, explained

    Problem overview

    A CPU has a list of tasks, each an uppercase letter from A to Z; equal letters are copies of the same task. Running a task takes one unit of time, and in each unit the CPU runs one task or stays idle. There is a cooldown n: two copies of the same task must be at least n units apart, so after running a task the CPU needs n other units, whether tasks or idle time, before it can run that task again. The tasks can run in any order.

    The method leastInterval(tasks, n) returns the fewest units of time needed to run all of them.

    For ["A","A","A","B","B","B"] with 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 six As plus one each of B, C, D, E, F, G, with n = 2, the answer is 16: A B C A D E A F G A idle idle A idle idle A.

    The budget is O(T log 26) time, where T is the number of tasks, and O(1) extra space: at most 26 counts.

    Approach: always run the task with the most copies left

    Only how many copies each task has matters, not which letter it is. So the first step is to count them: 26 counters, one per letter.

    The idle time comes from tasks that have to wait. The task with the most copies left needs the most room between its copies, so it should run as early as possible; otherwise its long chain of cooldowns ends up alone at the end, full of idle units. The lesson therefore simulates the CPU with a max-heap of counts (a structure whose top is always the biggest value). In each unit, it runs the task with the most copies left. Then the task waits in a queue until its cooldown is over, and goes back into the heap.

    The queue is a plain array of entries [left, readyTime]: the copies the task has left and the time unit at whose end its cooldown is over, so it can run again one unit later. Tasks enter the queue in the order they run, so they also leave it in order of ready time, and the front is always the next one to become ready.

    When the heap is empty but tasks are still cooling, the CPU idles. Rather than looping once per idle unit, the simulation sets the time directly to the moment the first waiting task is ready. That jump counts all the idle units in between at once.

    The lesson provides a ready-to-use MaxHeap of numbers with insert(value), extract(), size() and isEmpty().

    Step by step

    1. Count the tasks in count, 26 zeros made with new Array(26).fill(0): index 0 for A, up to 25 for Z.
    2. Loop over tasks with for...of and add one to count[task.charCodeAt(0) - 65]. charCodeAt(0) is the letter's code and 65 is the code of A.
    3. Create the heap with new MaxHeap() and insert every count that is bigger than 0. Letters that never appear have nothing to run.
    4. Create the waiting queue cooling, an empty number[][], and a counter time that starts at 0.
    5. Simulate with a while loop that runs while the heap is not empty or cooling still holds a task. The work is done only when no task is waiting to run and none is cooling.
    6. Start a unit: time++.
    7. If the heap is empty, nothing can run now, so jump: time = cooling[0][1].
    8. Otherwise run a task: heap.extract() gives the biggest count, and subtracting one gives the copies left after this run.
    9. If copies are left, push [left, time + n] onto cooling. A task run at time can run again at time + n + 1, after n other units. A task with no copies left is finished and isn't queued.
    10. Release a ready task: if cooling is not empty and its front entry has a ready time equal to time, remove it with shift() and insert its count back into the heap.
    11. Return time: the unit in which the last task ran.

    The solution in TypeScript

    class Solution {
        leastInterval(tasks: string[], n: number): number {
            const count: number[] = new Array(26).fill(0);
            for (const task of tasks) {
                count[task.charCodeAt(0) - 65]++;
            }
            const heap = new MaxHeap();
            for (const c of count) {
                if (c > 0) {
                    heap.insert(c);
                }
            }
            const cooling: number[][] = [];
            let time = 0;
            while (!heap.isEmpty() || cooling.length > 0) {
                time++;
                if (heap.isEmpty()) {
                    time = cooling[0][1];
                } else {
                    const left = heap.extract() - 1;
                    if (left > 0) {
                        cooling.push([left, time + n]);
                    }
                }
                if (cooling.length > 0 && cooling[0][1] === time) {
                    heap.insert(cooling.shift()[0]);
                }
            }
            return time;
        }
    }
    

    TypeScript details worth knowing

    Letters to indexes. A string is not a number, so task - 65 would not work. task.charCodeAt(0) returns the numeric code of the first character, and subtracting 65 maps A to 0 and Z to 25.

    count[...]++ increases the element in place. The array starts with 26 zeros, so no element is ever undefined.

    shift() removes and returns the first element of an array. It has to move the other elements, so it is not constant time in general, but cooling never holds more than 26 entries, so it stays cheap.

    Entries are arrays. Each queue entry is a two-element number[]. cooling.shift()[0] takes the first element of the removed entry, the count of copies left. The type annotations are erased when the code is compiled to JavaScript, so they have no effect at run time.

    No integer type. time is a number and never overflows for the sizes here; its largest value is far below the point where doubles lose precision.

    Tracing the simulation

    For ["A","A","A","B","B","B"] with n = 2, the counts are 3 and 3. Each row shows the state at the end of one turn of the loop:

    timewhat happenscooling after the turn
    1run a task with 3 copies, 2 left[[2, 3]]
    2run the other, 2 left[[2, 3], [2, 4]]
    3heap empty: idle; the first task is ready at 3 and returns to the heap[[2, 4]]
    4run it, 1 left; the second task is ready and returns[[1, 6]]
    5run it, 1 left[[1, 6], [1, 7]]
    6heap empty: idle; the first task returns[[1, 7]]
    7run it, none left; the second task returns[]
    8run it, none left[]

    The loop ends with time equal to 8. When the heap is empty, the jump time = cooling[0][1] can skip several units at once: for ["A","A"] with n = 3, the first A runs at time 1 and must wait until time 4, so the loop jumps straight there and the answer is 5.

    Complexity

    Time: O(T log 26). Counting takes O(T). Every task is extracted and inserted again a number of times equal to its copies, so the total number of heap operations is proportional to T, and each costs O(log 26) because the heap never holds more than 26 counts. The jumps make idle units free, so the loop doesn't run once per idle unit, even with a large n.

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

    Edge cases and common mistakes

    • No cooldown. With n = 0 the answer is just the number of tasks, since every task is ready again at once.
    • One task. ["A"] with n = 100 gives 1: there is no second copy to wait for.
    • Few kinds, long cooldown. ["A","A"] with n = 3 gives 5, and ["A","B","C"] with n = 5 gives 3, since different tasks don't wait for each other.
    • Off by one on the cooldown. Two copies of a task must have n units between them, so the second can run at time + n + 1. The queue stores time + n as the unit at whose end the wait is over.
    • Using the smallest count first. Running the task with the fewest copies leaves the long chain for the end and adds idle units.
    • Forgetting to check the queue in the loop condition. If the loop stopped when the heap was empty, tasks still cooling would never run.

    Testing

    The lesson's code gives these results:

    leastInterval(["A","A","A","B","B","B"], 2)                       -> 8
    leastInterval(["A","A","A","B","B","B"], 0)                       -> 6
    leastInterval(["A","A","A","A","A","A","B","C","D","E","F","G"], 2) -> 16
    leastInterval(["A"], 100)                                         -> 1
    leastInterval(["A","A"], 3)                                       -> 5
    leastInterval(["A","B","C"], 5)                                   -> 3
    
    Keep going with TypeScript500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free