โ† Back to DevBytes

Solving Task Scheduler in Python: Step-by-Step Guide

Solving Task Scheduler in Python: Step-by-Step Guide

The Task Scheduler problem is a classic algorithmic challenge that frequently appears in coding interviews (notably as LeetCode 621). It tests your understanding of greedy algorithms, counting, and mathematical reasoning. In this tutorial, we'll break down the problem, explore multiple approaches, and implement an optimal solution in Python.

What Is the Task Scheduler Problem?

Imagine a CPU that needs to process a list of tasks. Each task is represented by a letter (e.g., 'A', 'B', 'C'), and each task takes exactly one unit of time to complete. The CPU can either execute one task or remain idle during each unit of time. The constraint is that there must be at least n units of cooling time between two executions of the same task. Your goal is to determine the minimum number of time units required to finish all tasks.

For example, given tasks = ['A','A','A','B','B','B'] and n = 2, one valid schedule is A โ†’ B โ†’ idle โ†’ A โ†’ B โ†’ idle โ†’ A โ†’ B, which takes 8 units of time. The answer is 8.

Why It Matters

Understanding the Problem With an Example

Let's walk through a concrete example to build intuition. Suppose tasks = ['A','A','A','A','B','B','B','C','C'] and n = 2. The most frequent task is 'A' with a count of 4. This means we need at least 4 "rounds" where 'A' appears, and between each pair of 'A's, there must be at least 2 other slots (either other tasks or idle time).

Think of the schedule as frames built around the most frequent task:

Frame structure around 'A':
A _ _ | A _ _ | A _ _ | A

Each frame (except the last) has n = 2 slots after 'A'. There are max_count - 1 = 3 full frames, each of size n + 1 = 3, plus one final slot for the last 'A'. We then fill the empty slots with other tasks. If we run out of tasks to fill, the remaining slots become idle.

Approach 1: Simulation With a Max-Heap

A straightforward approach is to simulate the scheduling process. We use a max-heap to always pick the task with the highest remaining count. After executing a task, we place it in a cooldown queue and reinsert it into the heap after n time units have passed.

import heapq
from collections import Counter, deque

def leastInterval_heap(tasks, n):
    # Count frequencies of each task
    counts = Counter(tasks)
    # Python's heapq is a min-heap, so we negate counts
    max_heap = [-cnt for cnt in counts.values()]
    heapq.heapify(max_heap)

    time = 0
    cooldown = deque()  # stores (ready_time, count)

    while max_heap or cooldown:
        # Move any ready tasks from cooldown back to the heap
        if cooldown and cooldown[0][0] == time:
            _, cnt = cooldown.popleft()
            heapq.heappush(max_heap, cnt)

        if max_heap:
            # Execute the most frequent available task
            cnt = heapq.heappop(max_heap)
            cnt += 1  # Decrement (since counts are negative)
            if cnt < 0:
                # Task still has remaining instances, add to cooldown
                cooldown.append((time + n + 1, cnt))

        time += 1

    return time

# Example usage
tasks = ['A', 'A', 'A', 'B', 'B', 'B']
n = 2
print(leastInterval_heap(tasks, n))  # Output: 8

This simulation approach works and is intuitive, but it runs in O(time * log(k)) where k is the number of distinct tasks and time is the total schedule length. In the worst case (many idle slots), this can be slow.

Approach 2: Greedy Mathematical Formula (Optimal)

The key insight is that the total time is determined by the most frequent task. Let max_count be the frequency of the most common task, and let num_max be the number of tasks that share this maximum frequency. The minimum time is:

max(len(tasks), (max_count - 1) * (n + 1) + num_max)

Here's the reasoning:

from collections import Counter

def leastInterval(tasks, n):
    if n == 0:
        return len(tasks)

    freq = Counter(tasks)
    max_count = max(freq.values())

    # Count how many tasks share the maximum frequency
    num_max = sum(1 for cnt in freq.values() if cnt == max_count)

    # Calculate the minimum time using the formula
    min_time = (max_count - 1) * (n + 1) + num_max

    # The answer is the larger of the formula result and total tasks
    return max(len(tasks), min_time)

# Example usage
tasks1 = ['A', 'A', 'A', 'B', 'B', 'B']
n1 = 2
print(leastInterval(tasks1, n1))  # Output: 8

tasks2 = ['A', 'A', 'A', 'B', 'B', 'B']
n2 = 0
print(leastInterval(tasks2, n2))  # Output: 6

tasks3 = ['A', 'A', 'A', 'A', 'A', 'A', 'B', 'C', 'D', 'E', 'F', 'G']
n3 = 2
print(leastInterval(tasks3, n3))  # Output: 16

Breaking Down the Formula

Let's trace through the first example to verify the formula. With tasks = ['A','A','A','B','B','B'] and n = 2:

The schedule looks like: A B idle | A B idle | A B. The two full frames each have 3 slots, and the final frame has 2 slots (for the last 'A' and 'B'), totaling 8.

Edge Cases to Consider

def leastInterval_robust(tasks, n):
    if not tasks:
        return 0
    if n == 0:
        return len(tasks)

    freq = Counter(tasks)
    max_count = max(freq.values())
    num_max = sum(1 for cnt in freq.values() if cnt == max_count)

    return max(len(tasks), (max_count - 1) * (n + 1) + num_max)

Complexity Analysis

For the optimal greedy approach:

For the heap simulation approach:

Best Practices

Complete Solution With Tests

from collections import Counter

class Solution:
    def leastInterval(self, tasks, n):
        """
        Calculate the minimum time to complete all tasks with cooldown.

        Args:
            tasks: List[str] - list of task labels
            n: int - cooldown period between same tasks

        Returns:
            int - minimum number of time units
        """
        if not tasks:
            return 0
        if n == 0:
            return len(tasks)

        # Count the frequency of each task
        freq = Counter(tasks)

        # Find the maximum frequency
        max_count = max(freq.values())

        # Count how many tasks have the maximum frequency
        num_max = sum(1 for count in freq.values() if count == max_count)

        # The minimum time is determined by the most frequent task(s)
        # We need (max_count - 1) full cycles of size (n + 1),
        # plus one final cycle containing all tasks with max frequency
        formula_result = (max_count - 1) * (n + 1) + num_max

        # If there are enough tasks to fill all idle slots,
        # the answer is simply the total number of tasks
        return max(len(tasks), formula_result)


# Comprehensive test cases
if __name__ == "__main__":
    sol = Solution()

    # Test 1: Basic example with idle time
    assert sol.leastInterval(['A', 'A', 'A', 'B', 'B', 'B'], 2) == 8
    print("Test 1 passed: Basic example with idle time")

    # Test 2: No cooldown needed
    assert sol.leastInterval(['A', 'A', 'A', 'B', 'B', 'B'], 0) == 6
    print("Test 2 passed: No cooldown needed")

    # Test 3: Single task type
    assert sol.leastInterval(['A', 'A', 'A', 'A'], 3) == 13
    print("Test 3 passed: Single task type")

    # Test 4: Enough tasks to fill all slots
    assert sol.leastInterval(['A', 'A', 'B', 'B', 'C', 'C', 'D', 'D'], 2) == 8
    print("Test 4 passed: Enough tasks, no idle time")

    # Test 5: Empty task list
    assert sol.leastInterval([], 2) == 0
    print("Test 5 passed: Empty task list")

    # Test 6: Multiple tasks with max frequency
    assert sol.leastInterval(['A', 'A', 'B', 'B', 'C', 'C'], 2) == 6
    print("Test 6 passed: Multiple tasks with max frequency")

    # Test 7: Large cooldown
    assert sol.leastInterval(['A', 'A', 'A', 'B'], 5) == 11
    print("Test 7 passed: Large cooldown")

    print("\nAll tests passed!")

Common Mistakes to Avoid

Conclusion

The Task Scheduler problem is a beautiful example of how mathematical reasoning can transform a seemingly complex simulation into a simple formula. By recognizing that the most frequent task dictates the schedule structure, we can compute the answer in linear time without any simulation overhead. The key takeaway is to always look for patterns and mathematical relationships before jumping into brute-force or simulation approaches. Mastering this problem not only prepares you for interviews but also deepens your understanding of greedy algorithms and scheduling theory. Practice implementing both the simulation and mathematical solutions, and make sure you can explain the reasoning behind the formula โ€” that's often what interviewers care about most.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles