Problem overview
The method leastInterval receives tasks, an Array[Char] of uppercase letters, and a cooldown n. 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. Two copies of the same task must be at least n units apart: after running a task, the CPU needs n other units (other tasks or idle) before it can run that task again. Tasks can run in any order. The method returns the fewest units of time needed to run them all.
For tasks = [A, A, A, B, B, B] and n = 2, one best order is A B idle A B idle A B: 8 units. 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.
Approach: always run the task with the most copies left
Only how many copies each task has matters, not the order of the input, so the first step is to count them. There are at most 26 letters.
Then simulate the CPU. In each unit, run the task with the most copies left. Those copies need the most room between them, so starting them early leaves the fewest idle units at the end. After a task runs, it waits out its cooldown before it can be picked again.
Two structures do the work:
- a max-heap of the counts of tasks that are ready to run, with the biggest count on top (Scala's
PriorityQueueis a max-heap); - a queue of tasks that are cooling down, each stored as a tuple
(left, readyTime): the copies it has left and the time at whose end its cooldown is over, so it can run again one unit later.
Tasks join the queue in the order they ran, so they also leave it in order of ready time, and the front is always the first one ready. When the heap is empty but tasks are still cooling, the CPU idles, and the time jumps straight to the moment the first one is ready instead of counting each idle unit.
The heap never holds more than 26 counts, so each heap operation is effectively constant: the total is O(T log 26) for T tasks, using O(1) extra space (at most 26 counts).
The statement's note also mentions a counting formula for this problem. This lesson practises the simulation.
Step by step
- Count the copies:
val count = new Array[Int](26), one place per letter, starting at zeros. - Loop over the tasks with
for (task <- tasks). - Add one to the letter's count at
count(task - 'A'). Subtracting'A'turns A into 0, B into 1, up to Z as 25. - Create the heap:
PriorityQueue[Int](), whose top is the biggest count. - Loop over the counts with
for (c <- count). - Enqueue the nonzero ones: letters that never appear have nothing to run.
- Create the cooling queue:
mutable.Queue[(Int, Int)]()of(left, readyTime)tuples. - Start
timeat 0, avar. It is the answer once everything has run. - Simulate while work remains:
heap.nonEmpty || cooling.nonEmpty. The work is done only when nothing waits to run and nothing is cooling. - Start a new unit:
time += 1. - If the heap is empty, no task can run, so the CPU idles until the first cooling task is ready:
time = cooling.head._2. This counts all the idle units at once. - Otherwise run a task:
val left = heap.dequeue() - 1is how many copies the most frequent task has after this one. - Start its cooldown if
left > 0: enqueue(left, time + n)intocooling. A task run attimecan run again attime + n + 1. A task with no copies left is done. - Release a ready task: if
cooling.nonEmpty && cooling.head._2 == time, take it out withcooling.dequeue()and put its count,._1, back into the heap. - Return
time, the unit in which the last task ran.
The solution in Scala
In a file of your own, the code needs import scala.collection.mutable and import scala.collection.mutable.PriorityQueue at the top.
class Solution {
def leastInterval(tasks: Array[Char], n: Int): Int = {
val count = new Array[Int](26)
for (task <- tasks) {
count(task - 'A') += 1
}
val heap = PriorityQueue[Int]()
for (c <- count) {
if (c > 0) {
heap.enqueue(c)
}
}
val cooling = mutable.Queue[(Int, Int)]()
var time = 0
while (heap.nonEmpty || cooling.nonEmpty) {
time += 1
if (heap.isEmpty) {
time = cooling.head._2
} else {
val left = heap.dequeue() - 1
if (left > 0) {
cooling.enqueue((left, time + n))
}
}
if (cooling.nonEmpty && cooling.head._2 == time) {
heap.enqueue(cooling.dequeue()._1)
}
}
time
}
}
For tasks = [A, A, A, B, B, B] and n = 2, the loop goes like this:
time=1 ran task, left=2
time=2 ran task, left=2
idle jump to 3
ready: (2,3)
time=4 ran task, left=1
ready: (2,4)
time=5 ran task, left=1
idle jump to 6
ready: (1,6)
time=7 ran task, left=0
ready: (1,7)
time=8 ran task, left=0
The two tasks each have 3 copies. Units 1 and 2 run one copy of each, and both go into the cooling queue with 2 copies left, ready at the end of units 3 and 4. The heap is now empty, so the time jumps to 3, the first ready time: that is the idle unit. The first task returns to the heap and runs in unit 4, the second returns at the end of unit 4 and runs in unit 5. Another idle jump covers unit 6, and the last two copies run in units 7 and 8. The result is 8.
The Scala details worth a closer look:
- Characters are numbers.
'C' - 'A'is theInt2, sotask - 'A'works as an index.count(task - 'A') += 1updates the array element in place. - Tuples and
._1,._2. A tuple's items are read as._1and._2, counting from 1.cooling.head._2is the ready time of the front task, andcooling.dequeue()._1is the number of copies it has left. enqueue((left, time + n))has two pairs of parentheses: the outer pair is the call, the inner pair builds the tuple.- Max-heap of
Int.PriorityQueue[Int]()returns the biggest element fromdequeue(), so no custom ordering is needed. headanddequeue()on the queue.headlooks at the front without removing it;dequeue()removes and returns it. Both throw an exception on an empty queue, which is why the code checkscooling.nonEmptyfirst and only readsheadin the idle branch when the loop condition guarantees something is cooling.- Heap and queue don't share a type here. The heap holds
Intcounts, the queue holds tuples; themutable.Queueis first in, first out, which is what keeps the cooling tasks in ready-time order.
Complexity
Time: O(T log 26), where T is the number of tasks. Counting takes O(T). Each unit of simulated work runs one task, with a few heap and queue operations; the heap holds at most 26 counts. Idle stretches are skipped in one jump, so the loop doesn't depend on how large n is.
Space: O(1). The count array has 26 places, and the heap and the queue together never hold more than 26 tasks.
Edge cases and common mistakes
- No cooldown. With
n = 0,time + nequalstime, so the task is ready again straight away, andAAABBBtakes 6 units. - One kind of task.
AAwithn = 3gives 5: A, three idle units, A. A singleAwithn = 100gives 1, because the last copy needs no cooldown after it. - Plenty of other tasks. For
ABCDwithn = 5, each task runs once, nothing repeats, and the answer is 4 with no idling. - Many different tasks filling the gaps. Six A's and six other letters with
n = 2give 16: the other tasks fill the first gaps and the CPU idles in the rest. - No idle time at all.
AAABBBCCwithn = 2gives 8, the number of tasks, because there is always something else to run. - Counting the cooldown as a gap of
nunits between starts. Two copies of a task must have at leastnother units between them, so the next start isn + 1units later, notn.
Testing
AAABBBwithn = 2gives 8.AAABBBwithn = 0gives 6.AAAAAABCDEFGwithn = 2gives 16.Awithn = 100gives 1.AAwithn = 3gives 5.ABCDwithn = 5gives 4.AAABBBCCwithn = 2gives 8.
