Facebook Pixel

3476. Maximize Profit from Task Assignment 🔒

Problem Description

You have a group of workers and a list of tasks. Each worker has a skill level given in the array workers, where workers[i] is the skill level of the i-th worker. Each task has two properties given in the 2D array tasks:

  • tasks[i][0] is the skill requirement needed to complete that task
  • tasks[i][1] is the profit earned from completing that task

The matching rules are:

  • Each worker can complete at most one task
  • A worker can only take a task if their skill level exactly matches the task's skill requirement
  • There's an additional special worker who joins today who can take any task regardless of skill requirement

Your goal is to assign tasks to workers (including the special worker) to maximize the total profit. Each task can only be assigned to one worker, and each worker can only do one task.

For example, if you have workers with skills [2, 3] and tasks [[2, 5], [3, 10], [4, 15]]:

  • Worker with skill 2 can only do the task requiring skill 2 (profit 5)
  • Worker with skill 3 can only do the task requiring skill 3 (profit 10)
  • The special worker can do any task, so they would take the task requiring skill 4 (profit 15)
  • Total maximum profit would be 5 + 10 + 15 = 30

The function should return the maximum total profit possible from optimally assigning tasks to all available workers.

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

Intuition

The key insight is that regular workers have strict constraints - they can only do tasks matching their exact skill level. So we should first assign tasks to regular workers greedily, giving each worker the highest-profit task they're qualified for.

Think of it this way: if a worker with skill level 5 can do multiple tasks requiring skill 5, we want them to take the most profitable one. Since each worker can only do one task, we need to track which tasks are available for each skill level.

We can group tasks by their skill requirements using a hash table. For each skill level, we maintain a list of available profits, sorted in descending order (or use a max heap). When a worker with that skill comes along, we give them the best available task (highest profit) and remove it from the pool.

After all regular workers have been assigned their optimal tasks, we have the special worker who can do ANY remaining task. Since this worker has no restrictions, they should simply take the single most profitable task left across all skill levels.

The approach becomes:

  1. Group tasks by skill requirement in a hash table d[skill] = [list of profits]
  2. For each regular worker, check if there are tasks matching their skill
  3. If yes, assign them the highest-profit task from that skill group
  4. After all regular workers are assigned, find the maximum profit among ALL remaining tasks
  5. Assign that task to the special worker

This greedy strategy works because:

  • Regular workers are limited by skill, so we maximize their individual contributions first
  • The special worker's flexibility is best used to grab whatever single highest-value task remains
  • We're not losing anything by being greedy since each worker-task assignment is independent

Learn more about Greedy, Sorting and Heap (Priority Queue) patterns.

Solution Approach

The implementation uses a hash table combined with sorted lists to efficiently manage and assign tasks.

Data Structure Setup: We use a defaultdict with SortedList as values. The hash table d maps each skill requirement to a sorted list of profits for tasks with that requirement. The SortedList maintains profits in ascending order, allowing us to efficiently access the maximum profit (last element) for each skill level.

d = defaultdict(SortedList)
for skill, profit in tasks:
    d[skill].add(profit)

Assigning Tasks to Regular Workers: We iterate through each worker and check if there are any tasks matching their skill level. If tasks exist for that skill, we take the most profitable one (the last element in the sorted list) and remove it from the list.

ans = 0
for skill in workers:
    if not d[skill]:
        continue
    ans += d[skill].pop()  # pop() removes and returns the last (maximum) element

The pop() operation on SortedList removes the last element, which is the maximum profit task for that skill level. This ensures each regular worker gets their best possible task.

Assigning Task to Special Worker: After regular workers are assigned, we find the single most profitable task remaining across all skill levels. We iterate through all remaining sorted lists and find the maximum profit task.

mx = 0
for ls in d.values():
    if ls:
        mx = max(mx, ls[-1])  # ls[-1] is the maximum in each sorted list
ans += mx

The special worker takes this maximum profit task, regardless of its skill requirement.

Time Complexity:

  • Building the hash table: O(n log n) where n is the number of tasks (due to sorted list insertions)
  • Assigning to regular workers: O(m log n) where m is the number of workers
  • Finding maximum for special worker: O(k) where k is the number of distinct skill levels
  • Overall: O((n + m) log n)

Space Complexity: O(n) to store all tasks in the hash table and sorted lists.

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 concrete example to illustrate the solution approach.

Given:

  • Workers: [2, 3, 5] (3 regular workers)
  • Tasks: [[2, 10], [2, 8], [3, 15], [5, 20], [5, 12], [7, 25]]
  • Plus 1 special worker who can do any task

Step 1: Group tasks by skill requirement

We create a hash table where each skill level maps to a sorted list of profits:

d = {
    2: SortedList([8, 10])     # Two tasks need skill 2
    3: SortedList([15])         # One task needs skill 3
    5: SortedList([12, 20])     # Two tasks need skill 5
    7: SortedList([25])         # One task needs skill 7
}

Step 2: Assign tasks to regular workers

Process each regular worker:

  • Worker with skill 2:

    • Check d[2] → has tasks [8, 10]
    • Take the maximum (10) using pop()
    • Running total: ans = 10
    • d[2] now contains [8]
  • Worker with skill 3:

    • Check d[3] → has task [15]
    • Take the maximum (15) using pop()
    • Running total: ans = 10 + 15 = 25
    • d[3] now empty
  • Worker with skill 5:

    • Check d[5] → has tasks [12, 20]
    • Take the maximum (20) using pop()
    • Running total: ans = 25 + 20 = 45
    • d[5] now contains [12]

Step 3: Find best remaining task for special worker

Remaining tasks in the hash table:

d = {
    2: [8]     # One task left
    3: []      # Empty
    5: [12]    # One task left
    7: [25]    # One task left (untouched)
}

Find the maximum profit among all remaining tasks:

  • Skill 2 has max profit: 8
  • Skill 3 has no tasks
  • Skill 5 has max profit: 12
  • Skill 7 has max profit: 25

The special worker takes the task with skill requirement 7 and profit 25.

Final Result: Total profit = 45 + 25 = 70

This example demonstrates how:

  1. Regular workers are constrained to tasks matching their exact skill level
  2. We greedily assign each regular worker their best available task
  3. The special worker's flexibility is used to grab the single most valuable remaining task across all skill levels

Solution Implementation

1from collections import defaultdict
2from sortedcontainers import SortedList
3from typing import List
4
5class Solution:
6    def maxProfit(self, workers: List[int], tasks: List[List[int]]) -> int:
7        # Create a dictionary mapping skill levels to sorted lists of profits
8        # Each skill level can have multiple tasks with different profits
9        skill_to_profits = defaultdict(SortedList)
10      
11        # Populate the dictionary with tasks grouped by skill level
12        # SortedList maintains profits in ascending order automatically
13        for skill_level, profit in tasks:
14            skill_to_profits[skill_level].add(profit)
15      
16        # Track total profit accumulated
17        total_profit = 0
18      
19        # Assign each worker to their best matching task
20        for worker_skill in workers:
21            # Check if there are any tasks matching this worker's skill level
22            if not skill_to_profits[worker_skill]:
23                continue
24          
25            # Assign the highest profit task for this skill level to the worker
26            # pop() removes and returns the last (highest) element
27            total_profit += skill_to_profits[worker_skill].pop()
28      
29        # Find the maximum remaining profit across all skill levels
30        # This represents one additional task that could be completed
31        max_remaining_profit = 0
32        for profit_list in skill_to_profits.values():
33            if profit_list:
34                # The last element in SortedList is the maximum
35                max_remaining_profit = max(max_remaining_profit, profit_list[-1])
36      
37        # Add the best remaining task's profit to the total
38        total_profit += max_remaining_profit
39      
40        return total_profit
41
1class Solution {
2    public long maxProfit(int[] workers, int[][] tasks) {
3        // Map to store tasks grouped by skill level
4        // Key: skill level, Value: max heap of profits for that skill level
5        Map<Integer, PriorityQueue<Integer>> skillToProfitsMap = new HashMap<>();
6      
7        // Group tasks by skill level and store profits in max heap
8        for (int[] task : tasks) {
9            int skillRequired = task[0];
10            int taskProfit = task[1];
11          
12            // Create max heap if skill level doesn't exist, then add profit
13            skillToProfitsMap.computeIfAbsent(skillRequired, k -> new PriorityQueue<>((a, b) -> b - a))
14                            .offer(taskProfit);
15        }
16      
17        // Total profit accumulated
18        long totalProfit = 0;
19      
20        // Assign each worker to their best matching task (exact skill match)
21        for (int workerSkill : workers) {
22            if (skillToProfitsMap.containsKey(workerSkill)) {
23                PriorityQueue<Integer> profitQueue = skillToProfitsMap.get(workerSkill);
24              
25                // Assign the highest profit task for this skill level
26                totalProfit += profitQueue.poll();
27              
28                // Remove skill level entry if no more tasks available
29                if (profitQueue.isEmpty()) {
30                    skillToProfitsMap.remove(workerSkill);
31                }
32            }
33        }
34      
35        // Find the maximum profit among all remaining unassigned tasks
36        int maxRemainingProfit = 0;
37        for (PriorityQueue<Integer> profitQueue : skillToProfitsMap.values()) {
38            maxRemainingProfit = Math.max(maxRemainingProfit, profitQueue.peek());
39        }
40      
41        // Add the highest remaining profit to total
42        totalProfit += maxRemainingProfit;
43      
44        return totalProfit;
45    }
46}
47
1class Solution {
2public:
3    long long maxProfit(vector<int>& workers, vector<vector<int>>& tasks) {
4        // Map skill level to a max heap of profits for tasks requiring that skill
5        unordered_map<int, priority_queue<int>> skillToProfitHeap;
6      
7        // Group tasks by required skill level, storing profits in max heaps
8        for (const auto& task : tasks) {
9            int requiredSkill = task[0];
10            int taskProfit = task[1];
11            skillToProfitHeap[requiredSkill].push(taskProfit);
12        }
13      
14        long long totalProfit = 0;
15      
16        // Assign each worker to the most profitable task matching their skill
17        for (int workerSkill : workers) {
18            if (skillToProfitHeap.contains(workerSkill)) {
19                // Get the max heap for this skill level
20                auto& profitHeap = skillToProfitHeap[workerSkill];
21              
22                // Add the most profitable task to total
23                totalProfit += profitHeap.top();
24                profitHeap.pop();
25              
26                // Remove empty heap entries to keep map clean
27                if (profitHeap.empty()) {
28                    skillToProfitHeap.erase(workerSkill);
29                }
30            }
31        }
32      
33        // Find the maximum profit among all remaining unassigned tasks
34        int maxRemainingProfit = 0;
35        for (const auto& [skill, profitHeap] : skillToProfitHeap) {
36            maxRemainingProfit = max(maxRemainingProfit, profitHeap.top());
37        }
38      
39        // Add the best remaining task profit to the total
40        totalProfit += maxRemainingProfit;
41      
42        return totalProfit;
43    }
44};
45
1/**
2 * Calculates maximum profit by assigning workers to tasks based on skill levels
3 * @param workers - Array of worker skill levels
4 * @param tasks - 2D array where each element is [skill requirement, profit]
5 * @returns Maximum total profit achievable
6 */
7function maxProfit(workers: number[], tasks: number[][]): number {
8    // Map to store tasks grouped by skill requirement
9    // Key: skill level, Value: MaxPriorityQueue of profits
10    const skillToProfitQueue = new Map<number, any>();
11  
12    // Group tasks by skill requirement and store profits in max priority queues
13    for (const [skillRequirement, profit] of tasks) {
14        if (!skillToProfitQueue.has(skillRequirement)) {
15            skillToProfitQueue.set(skillRequirement, new MaxPriorityQueue());
16        }
17        skillToProfitQueue.get(skillRequirement).enqueue(profit);
18    }
19  
20    // Track total profit accumulated
21    let totalProfit = 0;
22  
23    // Assign each worker to their best matching task
24    for (const workerSkill of workers) {
25        const profitQueue = skillToProfitQueue.get(workerSkill);
26        if (profitQueue) {
27            // Assign worker to highest profit task at their skill level
28            totalProfit += profitQueue.dequeue();
29          
30            // Remove skill entry if no more tasks remain
31            if (profitQueue.size() === 0) {
32                skillToProfitQueue.delete(workerSkill);
33            }
34        }
35    }
36  
37    // Find the maximum profit among remaining unassigned tasks
38    let maxRemainingProfit = 0;
39    for (const profitQueue of skillToProfitQueue.values()) {
40        maxRemainingProfit = Math.max(maxRemainingProfit, profitQueue.front());
41    }
42  
43    // Add the best remaining task profit to total
44    totalProfit += maxRemainingProfit;
45  
46    return totalProfit;
47}
48

Time and Space Complexity

Time Complexity: O((n + m) × log m)

Breaking down the time complexity:

  • Building the dictionary with SortedList: For each task, we add the profit to a SortedList. Adding to a SortedList takes O(log k) where k is the size of that particular list. In the worst case, all tasks have the same skill level, so we perform m insertions with complexity O(log 1) + O(log 2) + ... + O(log m) = O(m log m).
  • Processing workers: For each of the n workers, we check if d[skill] exists (O(1)) and potentially pop from the SortedList, which takes O(log k) where k is the size of that list. In the worst case, this is O(n log m).
  • Finding maximum remaining profit: We iterate through all skill levels in the dictionary (at most m skill levels) and for each non-empty list, we access the last element in O(1) time. This takes O(m).
  • Overall: O(m log m) + O(n log m) + O(m) = O((n + m) × log m)

Space Complexity: O(m)

The space is used for:

  • The dictionary d which stores at most m task profits across all SortedLists
  • Each task is stored exactly once in the data structure
  • Therefore, the total space complexity is O(m)

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

Common Pitfalls

Pitfall 1: Misunderstanding the Special Worker's Role

The Problem: A common misunderstanding is thinking the special worker is optional or that they're just another regular worker. The problem states there's "an additional special worker who joins today" - this worker always exists and should always be assigned a task if any tasks remain.

Incorrect Approach:

# Wrong: Only adding special worker's task if there are unmatched workers
if len(workers) < len(tasks):
    max_remaining = find_max_remaining_task()
    total += max_remaining

Correct Approach: The special worker always exists and takes the best remaining task after regular workers are assigned:

# Correct: Always find and add the best remaining task for special worker
max_remaining_profit = 0
for profit_list in skill_to_profits.values():
    if profit_list:
        max_remaining_profit = max(max_remaining_profit, profit_list[-1])
total_profit += max_remaining_profit  # Always add this, even if it's 0

Pitfall 2: Greedy Global Assignment Instead of Skill-Constrained Assignment

The Problem: You might be tempted to sort all tasks by profit and greedily assign the highest profit tasks first, checking skill compatibility. This doesn't work because regular workers have strict skill requirements.

Incorrect Approach:

# Wrong: Trying to assign highest profit tasks first globally
tasks.sort(key=lambda x: x[1], reverse=True)
assigned_workers = set()
total = 0

for skill_req, profit in tasks:
    for i, worker_skill in enumerate(workers):
        if i not in assigned_workers and worker_skill == skill_req:
            total += profit
            assigned_workers.add(i)
            break

This fails because a high-profit task might "block" a worker from taking their only compatible task, leaving profitable tasks unassigned.

Correct Approach: Each regular worker should take the best task available for their specific skill level:

# Correct: Each worker takes the best task matching their skill
for worker_skill in workers:
    if skill_to_profits[worker_skill]:
        total_profit += skill_to_profits[worker_skill].pop()

Pitfall 3: Not Handling Empty Task Lists

The Problem: If there are no tasks at all, or no remaining tasks after regular workers are assigned, the code should handle this gracefully.

Incorrect Approach:

# Wrong: Assumes there's always at least one task remaining
max_remaining = max(profit_list[-1] for profit_list in skill_to_profits.values() if profit_list)

This raises a ValueError if all profit lists are empty.

Correct Approach: Initialize the maximum to 0 and only update if tasks exist:

# Correct: Handles case where no tasks remain
max_remaining_profit = 0
for profit_list in skill_to_profits.values():
    if profit_list:
        max_remaining_profit = max(max_remaining_profit, profit_list[-1])

Pitfall 4: Modifying Dictionary While Iterating

The Problem: If you try to clean up empty lists from the dictionary while iterating through workers, you might encounter runtime errors.

Incorrect Approach:

# Wrong: Modifying dictionary structure during iteration
for worker_skill in workers:
    if skill_to_profits[worker_skill]:
        total += skill_to_profits[worker_skill].pop()
        if not skill_to_profits[worker_skill]:
            del skill_to_profits[worker_skill]  # Dangerous during iteration

Correct Approach: Leave empty lists in place - they don't affect the final result:

# Correct: Leave empty lists, they're harmless
for worker_skill in workers:
    if skill_to_profits[worker_skill]:
        total_profit += skill_to_profits[worker_skill].pop()
# Empty lists will be skipped when finding max for special worker
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

What are the most two important steps in writing a depth first search function? (Select 2)


Recommended Readings

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

Load More