Taro Logo

Find the Minimum Amount of Time to Brew Potions

#1442 Most AskedMedium
25 views
Topics:
ArraysDynamic Programming

You are given two integer arrays, skill and mana, of length n and m, respectively.

In a laboratory, n wizards must brew m potions in order. Each potion has a mana capacity mana[j] and must pass through all the wizards sequentially to be brewed properly. The time taken by the ith wizard on the jth potion is timeij = skill[i] * mana[j].

Since the brewing process is delicate, a potion must be passed to the next wizard immediately after the current wizard completes their work. This means the timing must be synchronized so that each wizard begins working on a potion exactly when it arrives. ​

Return the minimum amount of time required for the potions to be brewed properly.

Example 1:

Input: skill = [1,5,2,4], mana = [5,1,4,2]

Output: 110

Explanation:

Potion Number Start time Wizard 0 done by Wizard 1 done by Wizard 2 done by Wizard 3 done by
0 0 5 30 40 60
1 52 53 58 60 64
2 54 58 78 86 102
3 86 88 98 102 110

As an example for why wizard 0 cannot start working on the 1st potion before time t = 52, consider the case where the wizards started preparing the 1st potion at time t = 50. At time t = 58, wizard 2 is done with the 1st potion, but wizard 3 will still be working on the 0th potion till time t = 60.

Example 2:

Input: skill = [1,1,1], mana = [1,1,1]

Output: 5

Explanation:

  1. Preparation of the 0th potion begins at time t = 0, and is completed by time t = 3.
  2. Preparation of the 1st potion begins at time t = 1, and is completed by time t = 4.
  3. Preparation of the 2nd potion begins at time t = 2, and is completed by time t = 5.

Example 3:

Input: skill = [1,2,3,4], mana = [1,2]

Output: 21

Constraints:

  • n == skill.length
  • m == mana.length
  • 1 <= n, m <= 5000
  • 1 <= mana[i], skill[i] <= 5000

Solution


Clarifying Questions

When you get asked this question in a real-life environment, it will often be ambiguous (especially at FAANG). Make sure to ask these questions in that case:

  1. What are the specific constraints on the time it takes to brew each potion, such as the range of possible values and the data type (e.g., integer, float)?
  2. If it is impossible to brew all potions, what should the function return (e.g., -1, null, throw an exception)?
  3. Can multiple potions be brewed concurrently, or must they be brewed sequentially?
  4. Is the input array guaranteed to be non-empty, and what should happen if the input is null or empty?
  5. If there are dependencies between potions, are these dependencies guaranteed to be acyclic (i.e., no circular dependencies)?

Brute Force Solution

Approach

The brute force approach involves exploring every possible way to combine the ingredients to brew potions. It's like trying every single combination of ingredients until we find the fastest way to brew all the potions. This is done without any clever tricks, just trying everything.

Here's how the algorithm would work step-by-step:

  1. Consider all possible orders in which you could brew the potions. For example, brew potion 1 first, then potion 2, then potion 3, and so on.
  2. For each possible ordering, calculate the total time it would take to brew all the potions in that order.
  3. To calculate the time for a particular order, simply add up the individual brewing times for each potion, while respecting any cool-down periods.
  4. After calculating the total time for one specific order, move on to the next possible order and repeat the calculation.
  5. Continue evaluating every single possible ordering of the potions.
  6. Once you've calculated the brewing time for every possible order, compare all the total times.
  7. The shortest total time across all the possible orderings is the minimum amount of time to brew all the potions.

Code Implementation

import itertools

def find_minimum_brewing_time_brute_force(brewing_times, cool_down_times):
    number_of_potions = len(brewing_times)
    all_possible_orders = list(itertools.permutations(range(number_of_potions)))
    minimum_total_time = float('inf')

    for potion_order in all_possible_orders:
        total_time_for_order = 0
        current_time = 0

        for i in range(number_of_potions):
            potion_index = potion_order[i]
            # Add cool down if not the first potion
            if i > 0:

                previous_potion_index = potion_order[i - 1]
                current_time += cool_down_times[previous_potion_index]

            # Calculate the time to complete the current potion.
            total_time_for_order += brewing_times[potion_index] + cool_down_times[potion_index]
            current_time += brewing_times[potion_index]

        # Update minimum time if the current order is faster.
        if total_time_for_order < minimum_total_time:
            minimum_total_time = total_time_for_order

    return minimum_total_time

Big(O) Analysis

Time Complexity
O(n!) – The algorithm explores all possible orderings of the potions. With n potions, there are n! (n factorial) possible permutations or orderings. For each of these n! orderings, the algorithm calculates the total time, which involves iterating through the n potions in the specific order to calculate the time taken including any cooldown periods. Therefore, the dominant factor determining the time complexity is the generation and processing of all n! permutations, resulting in a time complexity of O(n!).
Space Complexity
O(N) – The algorithm explores all possible orderings of potions, implying the use of a data structure to store the current permutation being explored. A typical implementation for generating permutations uses recursion, which results in a call stack. In the worst case, the recursion depth can reach N, where N is the number of potions, leading to a call stack of size O(N). Therefore, the auxiliary space complexity is O(N) due to the recursive call stack used to generate permutations.

Optimal Solution

Approach

We want to find the quickest time to brew all potions given some dependencies. The key idea is to brew potions as soon as all their ingredients are ready, prioritizing potions that unlock many others. This is done by thinking about potions that don't require other ingredients first.

Here's how the algorithm would work step-by-step:

  1. First, identify the potions that don't need any other potions to be brewed first. These are our starting points.
  2. Brew these initial potions and note the time it takes to brew each of them.
  3. Now, look at potions that depend on the potions we just brewed. For each of these new potions, check if all their required ingredients are now available.
  4. If all ingredients for a potion are available, we can start brewing it. The time we can start brewing is the latest time any of its ingredients became available.
  5. Keep track of the earliest time each potion can be finished brewing, which is when we start brewing plus the potion's brewing time.
  6. Repeat steps 3-5 until all potions have been brewed. Each time, prioritize brewing potions that unlock the most other potions to avoid unnecessary delays.
  7. The minimum time to brew all potions is the latest finish time among all potions.

Code Implementation

def find_minimum_time_to_brew_potions(number_of_potions, times, ingredients):
    in_degree = [0] * number_of_potions
    adjacency_list = [[] for _ in range(number_of_potions)]

    for potion_index in range(number_of_potions):
        for ingredient_index in ingredients[potion_index]:
            adjacency_list[ingredient_index].append(potion_index)
            in_degree[potion_index] += 1

    queue = []
    start_times = [0] * number_of_potions
    finish_times = [0] * number_of_potions

    for potion_index in range(number_of_potions):
        if in_degree[potion_index] == 0:
            queue.append(potion_index)
            finish_times[potion_index] = times[potion_index]

    while queue:
        current_potion = queue.pop(0)

        # Iterate through the potions that depend on the current potion.
        for next_potion in adjacency_list[current_potion]:
            in_degree[next_potion] -= 1

            # Update the start time for the next potion.
            start_times[next_potion] = max(start_times[next_potion], finish_times[current_potion])

            # If all ingredients are available, add to queue.
            if in_degree[next_potion] == 0:
                finish_times[next_potion] = start_times[next_potion] + times[next_potion]
                queue.append(next_potion)

    # Find the maximum finish time to determine completion time.
    maximum_finish_time = 0
    for finish_time in finish_times:
        maximum_finish_time = max(maximum_finish_time, finish_time)

    return maximum_finish_time

Big(O) Analysis

Time Complexity
O(n^2) – Let n be the number of potions. Identifying initial potions (step 1) involves iterating through all potions and their dependencies, taking O(n*m) time where m is the average number of dependencies per potion. Subsequent steps involve iterating through potions (outer loop - n times) and checking dependencies for each (inner loop - up to n times in the worst case). Prioritizing potions that unlock more potions could be implemented using sorting which takes O(n log n) in each iteration of the outer loop. Since the number of iterations of the outer loop is dependent on the number of potions, this leads to a time complexity dominated by O(n * n log n). This simplifies to O(n^2 log n). However, if prioritizing unlocks is not done using sorting but other linear scan, the complexity is simplified to O(n^2). The time complexity to determine the latest finish time is O(n). Therefore, the overall time complexity of the described solution is O(n^2).
Space Complexity
O(N) – The algorithm uses several auxiliary data structures. We need to keep track of potions that don't require other potions, which can potentially include all N potions in the worst case. We also store the earliest finish time for each potion, requiring an array of size N. Additionally, we need to potentially track the dependencies of each potion. Therefore, the space complexity is primarily driven by auxiliary arrays and lists that can grow linearly with the number of potions N, resulting in O(N) space complexity.

Edge Cases

Null or empty potions array
How to Handle:
Return 0 immediately, as no brewing is needed with no potions.
Potions array with a single element
How to Handle:
Return the time required to brew the single potion.
Potions array with all brewing times equal to zero
How to Handle:
Return 0 immediately, as all potions can be brewed instantly.
Very large potions array (performance considerations)
How to Handle:
Consider using a heap-based approach to efficiently find the next shortest brew time, optimizing for large n.
Potions array with very large brewing times (integer overflow)
How to Handle:
Use 64-bit integers (long) to handle potentially large sum of brewing times to prevent integer overflow.
Potions array where some brew times are significantly larger than others (skewed distribution)
How to Handle:
Heap or priority queue based approaches are robust to skewed distributions as they efficiently track the shortest remaining brew times.
No dependencies exist between potions.
How to Handle:
If the problem states dependencies exist, and none are present, the problem simplifies to finding the sum of all potion brewing times.
Cyclic dependencies between potions exist.
How to Handle:
Detect cycles using topological sort (e.g., Kahn's algorithm) and return an error or handle based on problem specifics (e.g., break the cycle arbitrarily).
0/1916 completed