Problem overview
A CPU has to run a list of tasks, given to leastInterval(tasks []byte, n int) int. Each task is an uppercase letter, and 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.
There is a cooldown: two copies of the same task must be at least n units apart. After running a task, the CPU needs n other units, either other tasks or idle time, before it can run that task again. The tasks can run in any order, and the function returns the fewest units of time needed to run them all.
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 and the answer is 6, the number of tasks.
The lesson asks for O(T log 26) time, where T is the number of tasks and the 26 is the number of different letters, and for O(1) extra space: at most 26 counts.
Approach: always run the task with the most copies left
The names of the tasks don't matter, only how many copies each one has. So the first step is to count them: 26 counters, one per letter.
Then the CPU is simulated. In each unit, run the task that has the most copies left among the ones that are not cooling down. A task with many copies left needs the most room between its copies, so starting it early leaves the fewest idle units at the end. A max-heap of counts gives that task in O(log 26).
After running a task, it has to wait. The simulation keeps a queue, cooling, of the tasks that are waiting. Each entry holds {left, readyTime}: the copies the task has left, and the time unit at whose end its cooldown is over. A task run at time t can run again at t + n + 1, after n other units, so its ready time is t + n. Tasks join the queue in the order they run, so they also leave it in order of ready time: only the front of the queue can be ready first.
Sometimes the heap is empty while tasks still cool down. The CPU has nothing to run, so it idles until the front of the queue is ready. Instead of stepping through each idle unit, the time jumps straight to that ready time, which counts all the idle units at once.
Here is the simulation for A A A B B B with n = 2, printing one line per turn of the loop:
time 1 run, left 2
time 2 run, left 2
idle, jump to 3
time 4 run, left 1
time 5 run, left 1
idle, jump to 6
time 7 run, left 0
time 8 run, left 0
total 8
Units 1 and 2 run the two tasks, the heap is then empty, and unit 3 is idle. The same pattern repeats, and the last pair runs at units 7 and 8. For A A A B with n = 3 the simulation gives 9: A runs at units 1, 5 and 9, with B in unit 2 and the rest idle.
The statement also mentions a counting shortcut: if the most frequent task appears m times and c tasks appear that often, the answer is the bigger of len(tasks) and (m - 1) * (n + 1) + c. This lesson practises the heap simulation, which builds the schedule instead of only counting it.
Step by step
- Count the copies.
count := make([]int, 26)has one place per letter: index 0 for A up to 25 for Z. - Loop over the tasks with
for _, task := range tasks. - Add one to the right counter:
count[task-'A']++. Letters are numbers in alphabetical order, so subtracting'A'turns A into 0, B into 1, and so on. - Create the heap:
taskHeap := &maxHeap{}. The lesson provides themaxHeaptype, where the biggest count is on top. The variable can't be calledheap, because that is the package name. - Loop over the counts with
for _, c := range count. - Push only the non-zero counts:
if c > 0 { heap.Push(taskHeap, c) }. Letters that never appear have nothing to run. - Create the cooldown queue:
cooling := [][]int{}, with entries{left, readyTime}. - Start the clock:
time := 0. It counts the units used so far and is the answer at the end. - Loop while work remains:
for taskHeap.Len() > 0 || len(cooling) > 0. The work is done only when nothing is waiting to run and nothing is cooling down. - Start a new unit:
time++. - Nothing can run? If
taskHeap.Len() == 0, jump the clock:time = cooling[0][1]. This makes the idle units count in one step. - Otherwise run a task: in the
elsebranch,left := heap.Pop(taskHeap).(int) - 1takes the biggest count and uses up one copy.heap.Popreturnsany, and.(int)turns it back into anint. - Queue it if copies remain: if
left > 0, append[]int{left, time + n}tocoolingand store the grown slice back. A finished task is not queued. - Release a ready task. Still inside the loop, check
len(cooling) > 0 && cooling[0][1] == time. The queue is in order of ready time, so only its front can be ready, and the length check comes first so the front is only read when it exists. - Move it back to the heap: push
cooling[0][0]withheap.Push, then drop it from the queue withcooling = cooling[1:]. - Return
time. It is the unit in which the last task ran.
The solution in Go
func leastInterval(tasks []byte, n int) int {
count := make([]int, 26)
for _, task := range tasks {
count[task-'A']++
}
taskHeap := &maxHeap{}
for _, c := range count {
if c > 0 {
heap.Push(taskHeap, c)
}
}
cooling := [][]int{}
time := 0
for taskHeap.Len() > 0 || len(cooling) > 0 {
time++
if taskHeap.Len() == 0 {
time = cooling[0][1]
} else {
left := heap.Pop(taskHeap).(int) - 1
if left > 0 {
cooling = append(cooling, []int{left, time + n})
}
}
if len(cooling) > 0 && cooling[0][1] == time {
heap.Push(taskHeap, cooling[0][0])
cooling = cooling[1:]
}
}
return time
}
The Go details worth a closer look:
[]byteandtask-'A'. Each task is abyte, and'A'is an untyped constant that converts tobytehere. The differencetask-'A'is abyte, so for'C'it is 2, and any integer type is valid as a slice index. The lesson restricts tasks to A to Z, so the index is always between 0 and 25.count[...]++. Go allows++on an indexed element, but only as a statement, not as an expression.- The heap holds
ints. The type is provided forcontainer/heap, so values go in withheap.Pushand come out withheap.Pop. BecausePopreturnsany,.(int)is a type assertion. It would panic if the value weren't anint, which can't happen here. - Queue with a slice.
cooling = cooling[1:]drops the first element without copying. With at most 26 tasks waiting at once, a plain slice is enough for a queue. - The name
time. It is an ordinary variable here. It would only clash with the standardtimepackage in a file that imports it. - Why the jump lands right. After
time = cooling[0][1], the nextifseescooling[0][1] == timeand puts that task back in the heap. The units skipped over are the idle ones, and the clock now shows the last idle unit: no task ran in it, and the next turn starts the unit after it.
Complexity
Time: O(T log 26). Counting is O(T). Every copy of every task is popped from the heap once and pushed back at most once more, and the heap holds at most 26 counts, so each operation costs O(log 26). Idle stretches are skipped in one step, so every turn of the loop either runs a task or jumps to a moment when a task is ready and then runs it. The number of turns is at most about twice the number of tasks, whatever the idle time. With 100,000 copies of A and n = 100, the function returns 10099900.
Space: O(1). The counts hold 26 integers, the heap at most 26, and the queue at most 26 entries.
Edge cases and common mistakes
- No cooldown. With
n = 0,A A A B B Bgives 6. A task is queued with ready time equal to the current time, and the check right after releases it in the same turn. - One task.
Awithn = 5gives 1: there is no second copy, so nothing has to wait. - A single repeated task.
A A Awithn = 2gives 7: A, two idle units, A, two idle units, A. - Plenty of other tasks.
A B C D E Fwithn = 3gives 6. Every task is different, so nothing ever waits. - Many tasks, big cooldown.
A A Bwithn = 100gives 102: A, B, 99 idle units, A. - Several tasks with the same count.
A A B B C Cwithn = 1gives 6, with no idle unit: the three tasks take turns. - Reading the queue front without checking.
cooling[0]panics on an empty slice. Both reads of the front are guarded: the jump runs only while some task is cooling down, and the release check testslen(cooling) > 0first. - Running the smallest count first. That leaves the biggest task for last, with no other task to fill its cooldown, and the schedule gets longer.
- Off by one in the cooldown. A task run at time
tneedsnunits between its copies, so it may run again att + n + 1. The ready timet + nis the unit before that. - Simulating every idle unit. It works, but it spends time on the idle stretches. The jump avoids that.
Testing
tasks = A A A B B B,n = 2gives8.tasks = A A A B B B,n = 0gives6.tasks = A A A A A A B C D E F G,n = 2gives16.tasks = A,n = 5gives1.tasks = A A A,n = 2gives7.tasks = A A A B,n = 3gives9.tasks = A A B,n = 100gives102.tasks = A B C D E F,n = 3gives6.tasks = A A B B C C,n = 1gives6.
