Facebook Pixel

1723. Find Minimum Time to Finish All Jobs

Problem Description

You have an array jobs where each element jobs[i] represents the time needed to complete the i-th job. You also have k workers available to do these jobs.

The task is to assign all jobs to workers following these rules:

  • Each job must be assigned to exactly one worker
  • A worker's total working time equals the sum of all jobs assigned to them
  • The goal is to minimize the maximum working time among all workers

For example, if you have jobs = [3, 2, 3] and k = 2 workers:

  • One possible assignment: Worker 1 gets jobs [3, 2] (total time = 5), Worker 2 gets job [3] (total time = 3). Maximum working time = 5.
  • Another assignment: Worker 1 gets job [3] (total time = 3), Worker 2 gets jobs [2, 3] (total time = 5). Maximum working time = 5.
  • Optimal assignment: Worker 1 gets jobs [3] (total time = 3), Worker 2 gets jobs [3] (total time = 3), and job [2] to either worker. Maximum working time = 3 or 5.

The solution uses a depth-first search (DFS) approach with backtracking to try all possible job assignments. It maintains an array cnt to track each worker's current workload. For optimization, jobs are sorted in descending order to prune branches early when a worker's load exceeds the current best answer. The algorithm explores assigning each job to each worker, backtracking when necessary, and keeps track of the minimum maximum working time found across all valid assignments.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • No: The problem involves assigning jobs to workers, not traversing nodes and edges in a graph structure.

Need to solve for kth smallest/largest?

  • No: We're looking for the minimum possible maximum working time, not the kth smallest/largest element.

Involves Linked Lists?

  • No: The problem deals with an array of jobs and worker assignments, not linked list operations.

Does the problem have small constraints?

  • Yes: Looking at the problem, we need to try all possible ways to assign jobs to k workers. The number of possible assignments grows exponentially (each job can go to any of k workers), which suggests the constraints must be small for the solution to be feasible. This is typical for problems requiring exhaustive search.

Brute force / Backtracking?

  • Yes: Since we need to explore all possible job assignments to find the optimal one that minimizes the maximum working time, backtracking is the appropriate approach. We try assigning each job to each worker, recursively explore further assignments, and backtrack when needed to try different combinations.

Conclusion: The flowchart correctly leads us to use a backtracking approach. This aligns perfectly with the solution, which uses DFS with backtracking to try all possible job assignments, keeping track of each worker's workload and finding the assignment that minimizes the maximum working time among all workers.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When we need to minimize the maximum working time, we're essentially trying to balance the workload as evenly as possible across all workers. Think of it like distributing books among shelves - we want to avoid having one shelf too full while others are nearly empty.

Since each job must go to exactly one worker and we want the optimal assignment, we need to explore different combinations. With a small number of jobs and workers, we can afford to try all possible assignments. This is where backtracking comes in - it's like trying different arrangements and undoing them if they don't lead to better solutions.

The key insight is that we can optimize our search by sorting jobs in descending order. Why? When we assign larger jobs first, we can quickly identify and prune bad branches. If assigning a large job to a worker already makes their total time exceed our current best answer, we know this path won't lead to a better solution, so we can skip it immediately.

During the search, we maintain an array cnt tracking each worker's current workload. For each job, we try assigning it to each worker, recursively continue with the next job, then backtrack (remove the job from that worker) to try other assignments. This systematic exploration ensures we find the optimal distribution.

An important optimization: if a worker has 0 workload and adding the current job doesn't improve our answer, we can break early. This is because assigning this job to any other empty worker would yield the same result due to symmetry - empty workers are interchangeable.

The algorithm essentially answers: "What's the best way to distribute these tasks so no one is overwhelmed?" by trying all possibilities intelligently and keeping track of the best arrangement found.

Learn more about Dynamic Programming, Backtracking and Bitmask patterns.

Solution Approach

The implementation uses a depth-first search (DFS) with backtracking to explore all possible job assignments. Here's how the solution works:

Data Structures:

  • cnt: An array of size k where cnt[j] tracks the total time assigned to worker j
  • ans: Keeps track of the minimum maximum working time found so far (initialized to infinity)
  • jobs: The input array sorted in descending order for optimization

Algorithm Steps:

  1. Preprocessing: Sort the jobs array in descending order using jobs.sort(reverse=True). This optimization allows us to assign larger jobs first and prune branches early.

  2. DFS Function: The recursive function dfs(i) tries to assign job at index i to each worker:

    • Base Case: When i == len(jobs), all jobs have been assigned. Calculate max(cnt) (the maximum working time among all workers) and update ans if this maximum is smaller.

    • Recursive Case: For the current job jobs[i], try assigning it to each worker j:

      • Pruning: Skip if cnt[j] + jobs[i] >= ans since this assignment won't lead to a better solution
      • Assign the job: cnt[j] += jobs[i]
      • Recursively assign the next job: dfs(i + 1)
      • Backtrack: cnt[j] -= jobs[i] to try other assignments
  3. Symmetry Breaking: If cnt[j] == 0 after backtracking, we break the loop. This is because assigning the current job to any other empty worker would produce identical results (empty workers are interchangeable), so we avoid redundant explorations.

  4. Return Result: After exploring all possibilities, return ans which contains the minimum possible maximum working time.

Time Complexity: O(k^n) in the worst case, where n is the number of jobs, as each job can be assigned to any of k workers. The pruning and optimizations significantly reduce the actual search space.

Space Complexity: O(n) for the recursion stack depth plus O(k) for the cnt array.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a small example with jobs = [3, 2, 3] and k = 3 workers.

Step 1: Preprocessing

  • Sort jobs in descending order: jobs = [3, 3, 2]
  • Initialize cnt = [0, 0, 0] (tracking each worker's workload)
  • Initialize ans = infinity

Step 2: DFS Exploration

Starting with dfs(0) - assigning job at index 0 (value = 3):

Branch 1: Assign jobs[0]=3 to Worker 0

  • cnt = [3, 0, 0]
  • Call dfs(1) - assigning jobs[1]=3:
    • Sub-branch 1.1: Assign to Worker 0 → cnt = [6, 0, 0]
      • Call dfs(2) - assign jobs[2]=2 to Worker 0 → cnt = [8, 0, 0]
      • All jobs assigned, max(cnt) = 8, update ans = 8
      • Backtrack: cnt = [6, 0, 0]
    • Sub-branch 1.2: Assign to Worker 1 → cnt = [3, 3, 0]
      • Call dfs(2) - try assigning jobs[2]=2:
        • Assign to Worker 0 → cnt = [5, 3, 0], max = 5, update ans = 5
        • Assign to Worker 1 → cnt = [3, 5, 0], max = 5, ans stays 5
        • Assign to Worker 2 → cnt = [3, 3, 2], max = 3, update ans = 3
      • Backtrack: cnt = [3, 0, 0]
    • Sub-branch 1.3: Assign to Worker 2 → cnt = [3, 0, 3]
      • Similar exploration yields same results due to symmetry

Pruning in Action: After finding ans = 3, when we explore other branches:

  • If we try cnt[j] + jobs[i] >= 3, we skip that assignment
  • For example, assigning both jobs of value 3 to the same worker would give total ≥ 6, which exceeds our current best of 3, so we prune

Symmetry Breaking: When backtracking from Worker 1 with cnt[1] = 0, we break instead of trying Worker 2, since both are empty workers and would yield identical results.

Final Result: The minimum maximum working time is 3, achieved when each worker gets exactly one job.

Solution Implementation

1from typing import List
2from math import inf
3
4class Solution:
5    def minimumTimeRequired(self, jobs: List[int], k: int) -> int:
6        """
7        Find the minimum possible maximum working time when distributing jobs among k workers.
8      
9        Args:
10            jobs: List of job durations
11            k: Number of workers
12          
13        Returns:
14            Minimum possible maximum working time among all workers
15        """
16      
17        def backtrack(job_index: int) -> None:
18            """
19            Use backtracking to try all possible job assignments.
20          
21            Args:
22                job_index: Current job index being assigned
23            """
24            nonlocal min_max_time
25          
26            # Base case: all jobs have been assigned
27            if job_index == len(jobs):
28                # Update the minimum maximum time if current assignment is better
29                min_max_time = min(min_max_time, max(worker_times))
30                return
31          
32            # Try assigning current job to each worker
33            for worker_id in range(k):
34                # Pruning: skip if this assignment would exceed current best answer
35                if worker_times[worker_id] + jobs[job_index] >= min_max_time:
36                    continue
37              
38                # Assign job to current worker
39                worker_times[worker_id] += jobs[job_index]
40              
41                # Recursively assign remaining jobs
42                backtrack(job_index + 1)
43              
44                # Backtrack: remove job from current worker
45                worker_times[worker_id] -= jobs[job_index]
46              
47                # Optimization: if worker had no jobs before, no need to try other empty workers
48                # (they would produce identical results due to symmetry)
49                if worker_times[worker_id] == 0:
50                    break
51      
52        # Initialize worker time tracking array
53        worker_times = [0] * k
54      
55        # Sort jobs in descending order for better pruning efficiency
56        # (larger jobs assigned first lead to earlier pruning)
57        jobs.sort(reverse=True)
58      
59        # Initialize minimum maximum time to infinity
60        min_max_time = inf
61      
62        # Start backtracking from the first job
63        backtrack(0)
64      
65        return min_max_time
66
1class Solution {
2    // Array to track the current workload for each worker
3    private int[] workerLoads;
4    // Global variable to store the minimum maximum workload found
5    private int minMaxWorkload;
6    // Array of job times
7    private int[] jobs;
8    // Number of workers
9    private int k;
10
11    public int minimumTimeRequired(int[] jobs, int k) {
12        this.k = k;
13        this.jobs = jobs;
14      
15        // Sort jobs in ascending order first
16        Arrays.sort(jobs);
17      
18        // Reverse the sorted array to get descending order
19        // Processing larger jobs first helps with pruning
20        for (int left = 0, right = jobs.length - 1; left < right; left++, right--) {
21            int temp = jobs[left];
22            jobs[left] = jobs[right];
23            jobs[right] = temp;
24        }
25      
26        // Initialize worker loads array
27        workerLoads = new int[k];
28      
29        // Initialize minimum maximum workload to a large value
30        minMaxWorkload = Integer.MAX_VALUE;
31      
32        // Start backtracking from the first job
33        backtrack(0);
34      
35        return minMaxWorkload;
36    }
37
38    private void backtrack(int jobIndex) {
39        // Base case: all jobs have been assigned
40        if (jobIndex == jobs.length) {
41            // Find the maximum workload among all workers
42            int maxWorkload = 0;
43            for (int load : workerLoads) {
44                maxWorkload = Math.max(maxWorkload, load);
45            }
46            // Update the global minimum if current maximum is smaller
47            minMaxWorkload = Math.min(minMaxWorkload, maxWorkload);
48            return;
49        }
50      
51        // Try assigning current job to each worker
52        for (int workerIndex = 0; workerIndex < k; workerIndex++) {
53            // Pruning: skip if assigning this job would exceed current best answer
54            if (workerLoads[workerIndex] + jobs[jobIndex] >= minMaxWorkload) {
55                continue;
56            }
57          
58            // Assign current job to this worker
59            workerLoads[workerIndex] += jobs[jobIndex];
60          
61            // Recursively assign remaining jobs
62            backtrack(jobIndex + 1);
63          
64            // Backtrack: remove the job from this worker
65            workerLoads[workerIndex] -= jobs[jobIndex];
66          
67            // Optimization: if this worker had no jobs before, 
68            // no need to try other empty workers (they're equivalent)
69            if (workerLoads[workerIndex] == 0) {
70                break;
71            }
72        }
73    }
74}
75
1class Solution {
2public:
3    int ans;  // Global variable to store the minimum maximum working time
4
5    int minimumTimeRequired(vector<int>& jobs, int k) {
6        vector<int> workerTime(k);  // Track working time for each worker
7        ans = INT_MAX;  // Initialize answer to maximum possible value
8      
9        // Sort jobs in descending order for optimization (assign larger jobs first)
10        sort(jobs.begin(), jobs.end(), greater<int>());
11      
12        // Start DFS to try all possible job assignments
13        dfs(0, k, jobs, workerTime);
14        return ans;
15    }
16
17    void dfs(int jobIndex, int k, vector<int>& jobs, vector<int>& workerTime) {
18        // Base case: all jobs have been assigned
19        if (jobIndex == jobs.size()) {
20            // Find the maximum working time among all workers
21            ans = min(ans, *max_element(workerTime.begin(), workerTime.end()));
22            return;
23        }
24      
25        // Try assigning current job to each worker
26        for (int workerIndex = 0; workerIndex < k; ++workerIndex) {
27            // Pruning: skip if assigning this job would exceed current best answer
28            if (workerTime[workerIndex] + jobs[jobIndex] >= ans) {
29                continue;
30            }
31          
32            // Assign current job to this worker
33            workerTime[workerIndex] += jobs[jobIndex];
34          
35            // Recursively assign remaining jobs
36            dfs(jobIndex + 1, k, jobs, workerTime);
37          
38            // Backtrack: remove job from this worker
39            workerTime[workerIndex] -= jobs[jobIndex];
40          
41            // Optimization: if worker had no jobs before, no need to try other empty workers
42            // (they would produce identical results due to symmetry)
43            if (workerTime[workerIndex] == 0) {
44                break;
45            }
46        }
47    }
48};
49
1let ans: number;  // Global variable to store the minimum maximum working time
2
3function minimumTimeRequired(jobs: number[], k: number): number {
4    const workerTime: number[] = new Array(k).fill(0);  // Track working time for each worker
5    ans = Number.MAX_SAFE_INTEGER;  // Initialize answer to maximum possible value
6  
7    // Sort jobs in descending order for optimization (assign larger jobs first)
8    jobs.sort((a, b) => b - a);
9  
10    // Start DFS to try all possible job assignments
11    dfs(0, k, jobs, workerTime);
12    return ans;
13}
14
15function dfs(jobIndex: number, k: number, jobs: number[], workerTime: number[]): void {
16    // Base case: all jobs have been assigned
17    if (jobIndex === jobs.length) {
18        // Find the maximum working time among all workers
19        ans = Math.min(ans, Math.max(...workerTime));
20        return;
21    }
22  
23    // Try assigning current job to each worker
24    for (let workerIndex = 0; workerIndex < k; workerIndex++) {
25        // Pruning: skip if assigning this job would exceed current best answer
26        if (workerTime[workerIndex] + jobs[jobIndex] >= ans) {
27            continue;
28        }
29      
30        // Assign current job to this worker
31        workerTime[workerIndex] += jobs[jobIndex];
32      
33        // Recursively assign remaining jobs
34        dfs(jobIndex + 1, k, jobs, workerTime);
35      
36        // Backtrack: remove job from this worker
37        workerTime[workerIndex] -= jobs[jobIndex];
38      
39        // Optimization: if worker had no jobs before, no need to try other empty workers
40        // (they would produce identical results due to symmetry)
41        if (workerTime[workerIndex] === 0) {
42            break;
43        }
44    }
45}
46

Time and Space Complexity

Time Complexity: O(k^n) where n is the number of jobs and k is the number of workers.

The algorithm uses backtracking with pruning to explore all possible job assignments. In the worst case:

  • For the first job, we have at most k choices (assigning to any of the k workers)
  • For the second job, we again have at most k choices
  • This continues for all n jobs

Therefore, the total number of states explored is bounded by k^n. Although the code includes several pruning strategies:

  1. Early termination when cnt[j] + jobs[i] >= ans (branch pruning)
  2. Breaking when cnt[j] == 0 (symmetry breaking to avoid duplicate states)
  3. Sorting jobs in descending order (to fail faster on infeasible branches)

These optimizations significantly reduce the practical runtime but don't change the worst-case complexity.

Space Complexity: O(n + k)

The space complexity consists of:

  • O(n) for the recursion stack depth, as the DFS goes to a maximum depth of n (number of jobs)
  • O(k) for the cnt array that tracks the workload of each worker
  • O(1) for other variables like ans and loop counters

The total space complexity is O(n + k).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Forgetting the Symmetry Breaking Optimization

Pitfall: Without the if worker_times[worker_id] == 0: break optimization, the algorithm explores redundant branches when assigning a job to empty workers.

Problem: If multiple workers have 0 workload, assigning a job to worker 0 vs worker 1 vs worker 2 (all empty) produces identical subproblems. This leads to exponential redundant computation.

Example: With 3 empty workers and job value 5:

  • Assigning to worker 0: [5, 0, 0]
  • Assigning to worker 1: [0, 5, 0]
  • Assigning to worker 2: [0, 0, 5]

All three lead to the same set of future possibilities, just with different worker labels.

Solution: After backtracking from an empty worker, break the loop to avoid trying other empty workers:

if worker_times[worker_id] == 0:
    break  # Don't try other empty workers

2. Not Sorting Jobs in Descending Order

Pitfall: Processing jobs in their original order or ascending order significantly reduces pruning effectiveness.

Problem: When smaller jobs are assigned first, the pruning condition worker_times[worker_id] + jobs[job_index] >= min_max_time is less likely to trigger early in the search, leading to more branches being explored.

Example:

  • Jobs = [1, 2, 10], k = 2
  • Without sorting: Assigning 1 and 2 first rarely triggers pruning
  • With descending sort [10, 2, 1]: Assigning 10 first immediately limits future choices

Solution: Always sort jobs in descending order before starting the backtracking:

jobs.sort(reverse=True)

3. Incorrect Pruning Condition

Pitfall: Using > instead of >= in the pruning condition, or forgetting the pruning entirely.

Problem:

  • Using > instead of >=: Allows exploration of branches that can only match (not improve) the current best, wasting computation
  • No pruning: Explores all k^n possibilities without early termination

Solution: Use the correct pruning condition:

if worker_times[worker_id] + jobs[job_index] >= min_max_time:
    continue  # Skip this assignment

4. Not Using nonlocal for min_max_time

Pitfall: Forgetting to declare min_max_time as nonlocal in the nested function.

Problem: Python will treat min_max_time as a local variable when you try to modify it, causing an UnboundLocalError.

Solution: Declare the variable as nonlocal at the beginning of the nested function:

def backtrack(job_index: int) -> None:
    nonlocal min_max_time  # Essential for modifying outer scope variable

5. Integer Overflow with Initial Value

Pitfall: Initializing min_max_time with a finite but arbitrary large number like 10**9 or sys.maxsize.

Problem: If the actual answer exceeds your arbitrary maximum, the algorithm will fail to find the correct solution. While sys.maxsize is usually safe, using inf is cleaner and guaranteed to work.

Solution: Use float('inf') or from math import inf:

min_max_time = inf  # Guaranteed to be larger than any possible answer
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

How does merge sort divide the problem into subproblems?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More