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.
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 sizek
wherecnt[j]
tracks the total time assigned to workerj
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:
-
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. -
DFS Function: The recursive function
dfs(i)
tries to assign job at indexi
to each worker:-
Base Case: When
i == len(jobs)
, all jobs have been assigned. Calculatemax(cnt)
(the maximum working time among all workers) and updateans
if this maximum is smaller. -
Recursive Case: For the current job
jobs[i]
, try assigning it to each workerj
:- 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
- Pruning: Skip if
-
-
Symmetry Breaking: If
cnt[j] == 0
after backtracking, webreak
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. -
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 EvaluatorExample 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)
- assigningjobs[1]=3
:- Sub-branch 1.1: Assign to Worker 0 →
cnt = [6, 0, 0]
- Call
dfs(2)
- assignjobs[2]=2
to Worker 0 →cnt = [8, 0, 0]
- All jobs assigned,
max(cnt) = 8
, updateans = 8
- Backtrack:
cnt = [6, 0, 0]
- Call
- Sub-branch 1.2: Assign to Worker 1 →
cnt = [3, 3, 0]
- Call
dfs(2)
- try assigningjobs[2]=2
:- Assign to Worker 0 →
cnt = [5, 3, 0]
,max = 5
, updateans = 5
- Assign to Worker 1 →
cnt = [3, 5, 0]
,max = 5
,ans
stays 5 - Assign to Worker 2 →
cnt = [3, 3, 2]
,max = 3
, updateans = 3
- Assign to Worker 0 →
- Backtrack:
cnt = [3, 0, 0]
- Call
- Sub-branch 1.3: Assign to Worker 2 →
cnt = [3, 0, 3]
- Similar exploration yields same results due to symmetry
- Sub-branch 1.1: Assign to Worker 0 →
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 thek
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:
- Early termination when
cnt[j] + jobs[i] >= ans
(branch pruning) - Breaking when
cnt[j] == 0
(symmetry breaking to avoid duplicate states) - 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 ofn
(number of jobs)O(k)
for thecnt
array that tracks the workload of each workerO(1)
for other variables likeans
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
How does merge sort divide the problem into subproblems?
Recommended Readings
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Backtracking Template Prereq DFS with States problems dfs_with_states Combinatorial search problems Combinatorial search problems involve finding groupings and assignments of objects that satisfy certain conditions Finding all permutations combinations subsets and solving Sudoku are classic combinatorial problems The time complexity of combinatorial problems often grows rapidly with the size of
Bitmask and Dynamic Programming Bit manipulation is a crucial aspect of computer programming and one of the most powerful tools for bit manipulation is bitmasks Let's first understand what a bit is A bit is a binary digit It's the smallest piece of data in a computer and can be
Want a Structured Path to Master System Design Too? Don’t Miss This!