Problem overview
The method LeastInterval receives a char[] of tasks, each an uppercase letter from A to Z, and an int n, the cooldown. 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 idles. Two copies of the same task must be at least n units apart: after running a task, n other units (other tasks or idle ones) must pass before it can run again. The tasks may run in any order, and the method returns the fewest units of time needed.
For AAABBB 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 the same tasks take 6 units, since any order works. For AAAAAABCDEFG with n = 2 the answer is 16: the six A's need two units between each other, 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 letter matters, not their order in the input, so the first step is counting them. The 26 counts are all the state the problem has.
Then the CPU is simulated. At 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. A max-heap gives that task quickly, and since there are at most 26 kinds, the heap never holds more than 26 entries, which is where log 26 in the time bound comes from.
A task that just ran can't run again for n units, so it waits in a queue of cooling tasks. Tasks enter that queue in the order they run, so they leave it in order of ready time, and the front is always the first one that will be ready. Each queue entry is {left, readyTime}: how many copies the task has left, and the time at whose end its cooldown is over. A task run at time gets readyTime = time + n and can run again one unit later, after n other units.
If the heap is empty but tasks are still cooling, the CPU must idle. Rather than step through the idle units one by one, the code jumps time straight to the front task's ready time.
For AAABBB with n = 2, the counts are 3 and 3. The loop goes like this:
| time | what happens |
|---|---|
| 1 | a task runs (2 copies left), and waits until time 3 |
| 2 | the other task runs (2 left), and waits until time 4 |
| 3 | the heap is empty: idle. The first task is ready and goes back to the heap |
| 4 | it runs (1 left). The second task is ready and goes back to the heap |
| 5 | the second task runs (1 left) |
| 6 | idle again; the first task is ready |
| 7 | it runs (0 left). The second task is ready |
| 8 | it runs (0 left). Nothing is left, so the answer is 8 |
Step by step
- Count the letters.
countis anint[]with 26 places. A newintarray starts filled with zeros. - Loop over the tasks with
foreach (char task in tasks)and add one at indextask - 'A'. Characters are numbers underneath, so'A' - 'A'is 0,'B' - 'A'is 1, up to 25 for'Z'. - Build the max-heap.
PriorityQueue<int, int>is a min-heap by default, so it is created withComparer<int>.Create((x, y) => y - x), which makes the biggest priority come out first. - Fill it. For each
cincount, ifc > 0, callheap.Enqueue(c, c). The count is both the element and its priority, and letters that never appear (count 0) are left out. - Create the cooling queue, a
Queue<int[]>, andtime, anintstarting at 0. - Loop while work remains:
heap.Count > 0 || cooling.Count > 0. The job is done only when nothing waits to run and nothing is cooling. - Start a unit:
time++. - If the heap is empty, the CPU idles until the front of
coolingis ready:time = cooling.Peek()[1]. - Otherwise run a task:
left = heap.Dequeue() - 1is how many copies it has after this run. - Start its cooldown if
left > 0:cooling.Enqueue(new int[]{left, time + n}). A task with no copies left is finished and isn't queued. - Release a ready task. If
cooling.Count > 0 && cooling.Peek()[1] == time, dequeue it and put its count back into the heap. Check that the queue is not empty before reading its front. Only the front can be ready, because the queue is in order of ready time. - Return
time, the unit in which the last task ran.
The solution in C#
class Solution {
public int LeastInterval(char[] tasks, int n) {
int[] count = new int[26];
foreach (char task in tasks) {
count[task - 'A']++;
}
PriorityQueue<int, int> heap = new PriorityQueue<int, int>(Comparer<int>.Create((x, y) => y - x));
foreach (int c in count) {
if (c > 0) {
heap.Enqueue(c, c);
}
}
Queue<int[]> cooling = new Queue<int[]>();
int time = 0;
while (heap.Count > 0 || cooling.Count > 0) {
time++;
if (heap.Count == 0) {
time = cooling.Peek()[1];
} else {
int left = heap.Dequeue() - 1;
if (left > 0) {
cooling.Enqueue(new int[]{left, time + n});
}
}
if (cooling.Count > 0 && cooling.Peek()[1] == time) {
int ready = cooling.Dequeue()[0];
heap.Enqueue(ready, ready);
}
}
return time;
}
}
The C# details worth knowing:
- Chars are numbers.
task - 'A'is anint, so it can index the array directly. It only works for uppercase letters, which the lesson guarantees. PriorityQueueis a min-heap. It is available in .NET 6 and later. Without the reversed comparer,Dequeue()would return the task with the fewest copies left. Enqueueing priorities 5, 1 and 3 dequeues the one with 1 first; with the reversed comparer it dequeues 5 first.- Elements and priorities.
Enqueue(c, c)gives the same number as element and priority.Dequeue()returns the element, soheap.Dequeue()is the count of copies. Queue<T>methods.Peek()looks at the front without removing it andDequeue()removes it. On an empty queue both throwInvalidOperationException, which is why the code checksCountfirst. In the idle branch,coolingis never empty: the loop only runs while the heap or the queue has something, and the heap is empty there.&&and||short-circuit. Incooling.Count > 0 && cooling.Peek()[1] == time, thePeeknever runs on an empty queue.- The jump. After
time++, settingtime = cooling.Peek()[1]may skip several idle units at once. ForAAwithn = 3, time goes from 1 straight to 4, and the second A runs at 5.
Complexity
Let T be the number of tasks.
Time: O(T log 26). Counting takes O(T). Each loop turn either runs one task, so there are exactly T of those, or jumps over idle time. A jump is always followed by a release, because the front task is ready at the new time, so jumps are at most as many as runs. Every turn does a constant number of heap operations on at most 26 entries, each costing O(log 26). The cooldown n doesn't add turns, because idle units are skipped in one step.
Space: O(1). The count array has 26 places, and the heap and the queue each hold at most 26 entries.
Edge cases and common mistakes
- No cooldown. With
n = 0the answer is the number of tasks:AAABBBgives 6. - One task.
Awithn = 100gives 1: there is no second copy to wait for. - Idle time is needed.
AAwithn = 3gives 5: A, three idle units, A. - No idle units.
ABCDwithn = 5gives 4, because no task repeats.AAABBCwithn = 1gives 6, because the other tasks fill every gap. - Many copies of one task. 100,000 copies of
Awithn = 100gives 10,099,900:99,999 * 101 + 1. The answer is far larger than the input, which still fits in anint. - Running the task with the fewest copies first. The big groups are left for the end, with nothing to fill their gaps, so idle time grows.
- Reading
cooling.Peek()on an empty queue throwsInvalidOperationException. - Skipping the
left > 0check. Finished tasks would wait in the queue with 0 copies and come back into the heap.
Testing
AAABBBwithn = 2gives8; withn = 0gives6.AAAAAABCDEFGwithn = 2gives16.Awithn = 100gives1.AAwithn = 3gives5.ABCDwithn = 5gives4, andAAABBCwithn = 1gives6.
