Find Minimum Time to Finish All Jobs
HardPrompt
You are given an integer array jobs, where jobs[i] is the amount of time it takes to complete the ith job.
There are k workers that you can assign jobs to. Each job should be assigned to exactly one worker. The working time of a worker is the sum of the time it takes to complete all jobs assigned to them. Your goal is to devise an optimal assignment such that the maximum working time of any worker is minimized.
Return the minimum possible maximum working time of any assignment.
Example 1:
Input: jobs = [3,2,3], k = 3
Output: 3
Explanation: By assigning each person one job, the maximum time is 3.Example 2:
Input: jobs = [1,2,4,7,8], k = 2
Output: 11
Explanation: Assign the jobs the following way:
Worker 1: 1, 2, 8 (working time = 1 + 2 + 8 = 11)
Worker 2: 4, 7 (working time = 4 + 7 = 11)
The maximum working time is 11.
Constraints:
1 <= k <= jobs.length <= 121 <= jobs[i] <= 107
Approaches
4 approaches with complexity analysis and trade-offs.
This approach explores all possible assignments of jobs to workers using recursion. For each job, we try to assign it to every worker. To make this feasible, we apply several key optimizations.
Algorithm
- The core idea is a recursive function,
backtrack(jobIndex, workerTimes), which tries to assignjobs[jobIndex]to one of thekworkers. - Algorithm Steps:
- Sort the
jobsarray in descending order. This helps in pruning more effectively, as assigning larger jobs first will quickly lead to exceeding any potential optimal maximum time. - Initialize an array
workerTimesof sizekto all zeros, representing the current total time for each worker. - Initialize a global variable
minMaxTimetoInteger.MAX_VALUEto store the minimum of the maximum worker times found so far. - Start the recursion with
backtrack(0, workerTimes). - Inside
backtrack(jobIndex, workerTimes):- Base Case: If
jobIndexequals the number of jobs, all jobs have been assigned. We find the maximum time inworkerTimesand updateminMaxTime. - Pruning: Before assigning a job to a worker, check if their new time would exceed the current
minMaxTime. IfworkerTimes[i] + jobs[jobIndex] >= minMaxTime, there's no need to explore this path further, so we skip this worker. - Recursive Step: Iterate through each worker
ifrom0tok-1.- Assign
jobs[jobIndex]to workeri. - Recursively call
backtrack(jobIndex + 1, workerTimes). - Undo the assignment (backtrack) to explore other possibilities.
- Assign
- Symmetry Breaking: Since all workers are identical, assigning a job to any empty worker is equivalent. To avoid redundant computations, if we assign a job to a worker with
0current time, we can stop trying other empty workers for this job. Webreakthe loop after the recursive call for the first empty worker.
- Base Case: If
- Sort the
Walkthrough
The core idea is a recursive function, backtrack(jobIndex, workerTimes), which tries to assign jobs[jobIndex] to one of the k workers. We apply several optimizations to make this feasible.
First, we sort the jobs array in descending order. This is a heuristic that often helps pruning work more effectively. Assigning larger jobs first means we are more likely to quickly create a high workerTime, which helps in pruning subsequent branches of the search tree that would exceed this time.
Second, we use pruning. We maintain a global variable minMaxTime that stores the minimum of the maximum worker times found so far. During the recursion, if assigning a job to a worker would make that worker's time greater than or equal to the current minMaxTime, we know this path cannot lead to a better solution, so we prune it.
Third, we break symmetry. Since the workers are indistinguishable, the assignment {W1:[j1], W2:[j2]} is the same as {W1:[j2], W2:[j1]}. We can avoid exploring these symmetric states by enforcing an order. A simple way is: when assigning a job, if we assign it to an empty worker (time = 0), we don't need to try assigning it to any other empty workers. This is because any assignment to another empty worker would lead to a state that is symmetric to one we are already exploring.
class Solution { int minMaxTime = Integer.MAX_VALUE; public int minimumTimeRequired(int[] jobs, int k) { // Sort jobs in descending order for better pruning Arrays.sort(jobs); int[] reversedJobs = new int[jobs.length]; for (int i = 0; i < jobs.length; i++) { reversedJobs[i] = jobs[jobs.length - 1 - i]; } backtrack(reversedJobs, 0, new int[k]); return minMaxTime; } private void backtrack(int[] jobs, int jobIndex, int[] workerTimes) { if (jobIndex == jobs.length) { int currentMax = 0; for (int time : workerTimes) { currentMax = Math.max(currentMax, time); } minMaxTime = Math.min(minMaxTime, currentMax); return; } for (int i = 0; i < workerTimes.length; i++) { if (workerTimes[i] + jobs[jobIndex] >= minMaxTime) { continue; } workerTimes[i] += jobs[jobIndex]; backtrack(jobs, jobIndex + 1, workerTimes); workerTimes[i] -= jobs[jobIndex]; // Symmetry breaking: if this worker was empty, // no need to try other empty workers. if (workerTimes[i] == 0) { break; } } }}Complexity
Time
O(k^n) in the worst case. While optimizations significantly reduce the search space, the upper bound remains exponential. For n=12, k=12, this can be slow but might pass due to the small constraints and effective pruning.
Space
O(n + k) for the recursion stack depth (n) and the workerTimes array (k).
Trade-offs
Pros
Relatively simple to understand and implement compared to more advanced techniques.
Cons
The worst-case time complexity is very high, making it unsuitable for larger constraints.
This approach reframes the problem from an optimization problem ('find the minimum maximum') to a decision problem ('is it possible to achieve a maximum time of X?'). We can then binary search for the smallest X for which the answer is 'yes'.
Algorithm
- The possible values for the minimum maximum working time are within a specific range. The lower bound is the largest single job time, and the upper bound is the sum of all job times.
- We perform a binary search on this range of possible answers. For each
midvalue, we check if it's possible to assign all jobs such that no worker's time exceedsmid. - Algorithm Steps:
- Define the search range:
low = max(jobs),high = sum(jobs). - Perform binary search:
- While
low <= high, pickmid = low + (high - low) / 2. - Call a helper function
isPossible(jobs, k, mid)to check if an assignment is feasible withmidas the time limit. - If
isPossibleis true, it meansmidis a potential answer. We try for a better (smaller) one:ans = mid,high = mid - 1. - If
isPossibleis false,midis too small:low = mid + 1.
- While
- The
isPossiblefunction is implemented using a backtracking/DFS approach, similar to the first method.
- Define the search range:
isPossible(jobs, k, limit)function:- Sort
jobsdescendingly for better pruning. - Use a recursive function
canAssign(jobIndex, workerTimes, limit). - Base Case: If
jobIndexreaches the end, all jobs are assigned within thelimit. Returntrue. - Recursive Step: For the current
jobs[jobIndex], try to assign it to each workeri. IfworkerTimes[i] + jobs[jobIndex] <= limit, assign the job and recurse. If the recursive call returnstrue, we've found a valid assignment, so returntrueup the call stack. Backtrack if it fails. - Apply the same symmetry-breaking optimization as in the pure backtracking approach.
- Sort
Walkthrough
This approach reframes the problem from an optimization problem ('find the minimum maximum') to a decision problem ('is it possible to achieve a maximum time of X?'). We can then binary search for the smallest X for which the answer is 'yes'.
The possible values for the minimum maximum working time are within a specific range. The lower bound is the largest single job time (as one worker must take it), and the upper bound is the sum of all job times (the case with one worker).
We perform a binary search on this range of possible answers. For each mid value in the binary search, we check if it's possible to assign all jobs such that no worker's time exceeds mid. This check is done by a helper function, isPossible, which itself uses a backtracking search. The key difference from the first approach is that the pruning condition is now a fixed limit (mid), which can make the search faster.
class Solution { public int minimumTimeRequired(int[] jobs, int k) { Arrays.sort(jobs); int[] reversedJobs = new int[jobs.length]; int sum = 0; int maxJob = 0; for (int i = 0; i < jobs.length; i++) { reversedJobs[i] = jobs[jobs.length - 1 - i]; sum += jobs[i]; maxJob = Math.max(maxJob, jobs[i]); } int low = maxJob, high = sum; int ans = high; while (low <= high) { int mid = low + (high - low) / 2; if (isPossible(reversedJobs, k, mid)) { ans = mid; high = mid - 1; } else { low = mid + 1; } } return ans; } private boolean isPossible(int[] jobs, int k, int limit) { return canAssign(jobs, 0, new int[k], limit); } private boolean canAssign(int[] jobs, int jobIndex, int[] workerTimes, int limit) { if (jobIndex == jobs.length) { return true; } for (int i = 0; i < workerTimes.length; i++) { if (workerTimes[i] + jobs[jobIndex] <= limit) { workerTimes[i] += jobs[jobIndex]; if (canAssign(jobs, jobIndex + 1, workerTimes, limit)) { return true; } workerTimes[i] -= jobs[jobIndex]; } // Symmetry breaking if (workerTimes[i] == 0) { break; } } return false; }}Complexity
Time
O(k^n * log(S)), where S is the sum of all job times. The log(S) factor comes from the binary search. The k^n is the worst-case complexity of the `isPossible` check. In practice, this is often faster than pure backtracking because the `limit` provides a very strong and fixed pruning condition.
Space
O(n + k) for the recursion stack and `workerTimes` array.
Trade-offs
Pros
More structured than pure backtracking.
The fixed
limitin the check function allows for aggressive pruning.
Cons
The worst-case time complexity is still high, although it performs well in practice.
Given the small constraint on the number of jobs (n <= 12), we can use dynamic programming with bitmasking to represent subsets of jobs. This approach directly calculates the minimum maximum time without binary search.
Algorithm
- DP State:
dp[i][mask]represents the minimum possible maximum working time if we useiworkers to complete the set of jobs represented bymask. A1at thej-th position inmaskmeans jobjis included. - Precomputation: First, we precompute an array
sum[mask]that stores the total time for each subset of jobs. This can be done in O(n * 2^n) or O(2^n). - Algorithm Steps:
- Precompute
sum[mask]for all2^nmasks. - Initialize a DP table
dp[k+1][1<<n]. - Base Case: For one worker (
i=1), the max time is just the sum of jobs in the subset:dp[1][mask] = sum[mask]. - Transition: We iterate from
i = 2tokworkers. To computedp[i][mask], we consider all possible ways to form thei-th worker's assignment. We can give a subset of jobssub(a submask ofmask) to thei-th worker. The remainingi-1workers must handle the jobs inmask \ sub. The maximum time for this specific split ismax(sum[sub], dp[i-1][mask ^ sub]). We want to find thesubthat minimizes this value.dp[i][mask] = min( max(dp[i-1][mask ^ sub], sum[sub]) )for all non-empty proper submaskssubofmask. - The final answer is
dp[k][(1<<n) - 1].
- Precompute
Walkthrough
Given the small constraint on the number of jobs (n <= 12), we can use dynamic programming with bitmasking to represent subsets of jobs. This approach directly calculates the minimum maximum time without binary search.
We build the solution worker by worker. The state of our DP will involve the number of workers used and the subset of jobs completed.
DP State: dp[i][mask] represents the minimum possible maximum working time if we use i workers to complete the set of jobs represented by mask. A 1 at the j-th position in mask means job j is included.
First, we precompute an array sum[mask] that stores the total time for each subset of jobs. Then, we build our dp table. The base case is for i=1 worker, where dp[1][mask] is simply sum[mask]. For i > 1, we compute dp[i][mask] by iterating through all submasks sub of mask. We assign the jobs in sub to the i-th worker and the jobs in mask ^ sub to the previous i-1 workers. The maximum time for this split is max(sum[sub], dp[i-1][mask ^ sub]). We take the minimum over all possible submasks sub.
class Solution { public int minimumTimeRequired(int[] jobs, int k) { int n = jobs.length; int[] sum = new int[1 << n]; for (int mask = 1; mask < (1 << n); mask++) { // Find the lowest set bit (a job in the mask) int j = Integer.numberOfTrailingZeros(mask); int prevMask = mask ^ (1 << j); sum[mask] = sum[prevMask] + jobs[j]; } int[][] dp = new int[k + 1][1 << n]; // Base case: 1 worker for (int mask = 0; mask < (1 << n); mask++) { dp[1][mask] = sum[mask]; } // Fill DP table for i = 2 to k workers for (int i = 2; i <= k; i++) { for (int mask = 1; mask < (1 << n); mask++) { dp[i][mask] = Integer.MAX_VALUE; // Iterate through all submasks of mask for (int sub = mask; sub > 0; sub = (sub - 1) & mask) { int remainingMask = mask ^ sub; int currentMax = Math.max(dp[i - 1][remainingMask], sum[sub]); dp[i][mask] = Math.min(dp[i][mask], currentMax); } } } return dp[k][(1 << n) - 1]; }}Complexity
Time
O(k * 3^n). The outer loops run k and 2^n times. The inner loop iterates through all submasks. The total number of (sub, mask) pairs where sub is a submask of mask is 3^n.
Space
O(k * 2^n) for the DP table. This can be optimized to O(2^n) because dp[i] only depends on dp[i-1].
Trade-offs
Pros
Provides a guaranteed polynomial-time solution with respect to
k(but exponential inn).It's more systematic than backtracking.
Cons
The 3^n complexity can be high, and the space complexity is also significant.
This is the most efficient approach, combining the binary search strategy with a fast DP-based check for the decision problem.
Algorithm
- Similar to the second approach, we binary search for the minimum possible maximum time,
limit. - Decision Problem: 'Is it possible to partition the
jobsarray intokor fewer subsets, such that the sum of each subset does not exceedlimit?' - DP State for
isPossible: We use a DP tabledp[mask]to store information about partitioning the jobs inmask. A simple and effective state isdp[mask] = (workers_needed, time_of_last_worker). We aim to minimizeworkers_neededfirst, and thentime_of_last_workeras a tie-breaker. - Algorithm Steps:
- Perform binary search on the answer
limitin the range[max(jobs), sum(jobs)]. - For each
limit, callisPossible(jobs, k, limit).
- Perform binary search on the answer
isPossible(jobs, k, limit)function:- Define
dp[mask]as a pair{num_workers, last_worker_time}. - Initialize
dp[0] = {1, 0}. All otherdp[mask]are initialized to an invalid state like{k+1, 0}. - Iterate
maskfrom1to(1<<n) - 1. - For each
mask, iterate through each jobjthat is part of themask. - Let
prev_mask = mask ^ (1 << j). Get the statedp[prev_mask]. - Consider adding job
jto the partition forprev_mask. We can either add it to the last worker if time permits, or start a new worker. - Update
dp[mask]with the lexicographically smaller of the new state and its current value. - After filling the DP table, if
dp[(1<<n) - 1].num_workers <= k, returntrue.
- Define
Walkthrough
This is the most efficient approach, combining the binary search strategy with a fast DP-based check for the decision problem.
Similar to the second approach, we binary search for the minimum possible maximum time, limit. The core of this method is a highly efficient isPossible(limit) check using dynamic programming.
The decision problem is: 'Is it possible to partition the jobs array into k or fewer subsets, such that the sum of each subset does not exceed limit?'
We can solve this with DP. Let dp[mask] store the time taken by the last worker for an optimal partition of jobs in mask, and workers[mask] store the number of workers needed. We want to minimize workers, and then dp as a tie-breaker.
We iterate through each mask from 1 to (1<<n)-1. For each job j in the mask, we look at the state for mask ^ (1<<j). We try to add job j to this previous state. If we can add it to the last worker without exceeding limit, we do so. Otherwise, we must start a new worker. We update the state for mask if we find a better partition (fewer workers, or same workers with less time on the last one).
After computing the DP table, we check if workers[(1<<n)-1] is at most k. If so, the limit is achievable.
class Solution { public int minimumTimeRequired(int[] jobs, int k) { int n = jobs.length; int sum = 0; int maxJob = 0; for (int job : jobs) { sum += job; maxJob = Math.max(maxJob, job); } int low = maxJob, high = sum; int ans = high; while (low <= high) { int mid = low + (high - low) / 2; if (isPossible(jobs, k, mid)) { ans = mid; high = mid - 1; } else { low = mid + 1; } } return ans; } private boolean isPossible(int[] jobs, int k, int limit) { int n = jobs.length; int[] dp = new int[1 << n]; // Stores time of last worker int[] workers = new int[1 << n]; // Stores num of workers Arrays.fill(dp, Integer.MAX_VALUE); Arrays.fill(workers, Integer.MAX_VALUE); dp[0] = 0; workers[0] = 1; for (int mask = 1; mask < (1 << n); mask++) { for (int j = 0; j < n; j++) { if ((mask & (1 << j)) != 0) { int prevMask = mask ^ (1 << j); if (workers[prevMask] == Integer.MAX_VALUE) continue; // Option 1: Add to the last worker of the previous state if (dp[prevMask] + jobs[j] <= limit) { if (workers[prevMask] < workers[mask] || (workers[prevMask] == workers[mask] && dp[prevMask] + jobs[j] < dp[mask])) { workers[mask] = workers[prevMask]; dp[mask] = dp[prevMask] + jobs[j]; } } // Option 2: Start a new worker else { if (workers[prevMask] + 1 < workers[mask] || (workers[prevMask] + 1 == workers[mask] && jobs[j] < dp[mask])) { workers[mask] = workers[prevMask] + 1; dp[mask] = jobs[j]; } } } } } return workers[(1 << n) - 1] <= k; }}Complexity
Time
O(n * 2^n * log(S)), where S is the sum of job times. The `isPossible` check takes O(n * 2^n) and it's called log(S) times.
Space
O(2^n) for the DP table used in the `isPossible` check.
Trade-offs
Pros
The most efficient known algorithm for this problem's constraints.
It combines two powerful techniques to achieve a much better time complexity.
Cons
More complex to conceptualize and implement correctly compared to the other approaches.
Solutions
Solution
class Solution { private int [] cnt ; private int ans ; private int [] jobs ; private int k ; public int minimumTimeRequired ( int [] jobs , int k ) { this . k = k ; Arrays . sort ( jobs ); for ( int i = 0 , j = jobs . length - 1 ; i < j ; ++ i , -- j ) { int t = jobs [ i ]; jobs [ i ] = jobs [ j ]; jobs [ j ] = t ; } this . jobs = jobs ; cnt = new int [ k ]; ans = 0x3f3f3f3f ; dfs ( 0 ); return ans ; } private void dfs ( int i ) { if ( i == jobs . length ) { int mx = 0 ; for ( int v : cnt ) { mx = Math . max ( mx , v ); } ans = Math . min ( ans , mx ); return ; } for ( int j = 0 ; j < k ; ++ j ) { if ( cnt [ j ] + jobs [ i ] >= ans ) { continue ; } cnt [ j ] += jobs [ i ]; dfs ( i + 1 ); cnt [ j ] -= jobs [ i ]; if ( cnt [ j ] == 0 ) { break ; } } } }class Solution { public: int ans ; int minimumTimeRequired ( vector < int >& jobs , int k ) { vector < int > cnt ( k ); ans = 0x3f3f3f3f ; sort ( jobs . begin (), jobs . end (), greater < int > ()); dfs ( 0 , k , jobs , cnt ); return ans ; } void dfs ( int i , int k , vector < int >& jobs , vector < int >& cnt ) { if ( i == jobs . size ()) { ans = min ( ans , * max_element ( cnt . begin (), cnt . end ())); return ; } for ( int j = 0 ; j < k ; ++ j ) { if ( cnt [ j ] + jobs [ i ] >= ans ) continue ; cnt [ j ] += jobs [ i ]; dfs ( i + 1 , k , jobs , cnt ); cnt [ j ] -= jobs [ i ]; if ( cnt [ j ] == 0 ) break ; } } };class Solution : def minimumTimeRequired ( self , jobs : List [ int ], k : int ) -> int : def dfs ( i ): nonlocal ans if i == len ( jobs ): ans = min ( ans , max ( cnt )) return for j in range ( k ): if cnt [ j ] + jobs [ i ] >= ans : continue cnt [ j ] += jobs [ i ] dfs ( i + 1 ) cnt [ j ] -= jobs [ i ] if cnt [ j ] == 0 : break cnt = [ 0 ] * k jobs . sort ( reverse = True ) ans = inf dfs ( 0 ) return ansVideo walkthrough
Newsletter
One sharp idea, every week
System design and interview prep — short enough to finish.
No spam. Unsubscribe anytime.
Practice
Same difficulty — related problems to reinforce the pattern.