2998. Minimum Number of Operations to Make X and Y Equal


Problem Description

You are presented with a task of converting one positive integer (x) into another (y) by performing a series of operations. During each operation, you are allowed to:

  1. Divide x by 11 if x is divisible by 11.
  2. Divide x by 5 if x is divisible by 5.
  3. Decrease the value of x by 1.
  4. Increase the value of x by 1.

The goal is to determine the minimum number of these operations required to make the two integers equal.

Intuition

When tackling this problem, there is an element of choice at each step, suggesting a recursive solution or dynamic programming approach. The key intuition lies in understanding that larger adjustments can be made when x is significantly greater than y. This involves prioritizing dividing by 11 or 5 before resorting to incrementing or decrementing, which are less "efficient" operations since they only change x by 1.

Recursive Depth-First Search (DFS)

Since we can perform multiple types of operations, we can use recursive depth-first search (DFS) to explore these possibilities. At each step, there are four options, and this is where the problem becomes complex as we need to decide the best option to minimize the number of operations. To optimize the process, we can do the following:

  • If x is greater than or equal to y, the simplest approach is to decrement x until it equals y, with the number of operations being x - y.

  • If x is less than y, we need to consider:

    • Dividing x by 11 if it's a multiple of 11, or getting x as close to a multiple of 11 as we can, then dividing.
    • Dividing x by 5 if it's a multiple of 5, or getting x as close to a multiple of 5 as we can, then dividing.
    • Decrementing x by 1.
    • Incrementing x by 1.

Caching Intermediate Results

To avoid recalculating the minimum operations for the same values of x in the recursive process, we can cache the results. This caching mechanism is employed by decorating the recursive function with Python's @cache decorator, which automatically stores the return values of the function for each unique input.

Recursive Approach

The provided solution function defines a recursive DFS helper function. The recursion acts on the value of x and computes the minimum operations based on the four available operations. At each recursive call, it calculates the optimal solution through DFS and comparison of the different operation costs, keeping track of the minimum operations needed. The process requires checking for the remainder of x after division by 5 and 11 to approach the target y closely, efficiently navigating the choices.

Overall, the recursive DFS with caching is a top-down approach to solve the problem step by step, taking the optimal decision at each state to minimize the total number of operations.

Learn more about Breadth-First Search, Memoization and Dynamic Programming patterns.

Not Sure What to Study? Take the 2-min Quiz to Find Your Missing Piece

Which of these properties could exist for a graph but not a tree?

Solution Approach

The reference solution provided earlier implements a recursive depth-first search (DFS) approach. Its key elements are:

  • We define a recursive helper function dfs which aims to find the minimum number of operations to convert x to y.

  • The dfs function is decorated with the @cache decorator from Python's functools module to memoize or cache the results of function calls. This means that results of previous computations are stored, and if the function is called again with the same arguments, it returns the cached result instead of recalculating.

  • Inside the dfs function, the base case handles the scenario where y is greater than or equal to x. In such a case, since incrementing or decrementing is the only choice, the number of operations is simply the difference y - x.

  • When x is greater than y, the function explores four possible operations:

    • Directly decrement x by 1 until it equals y, which costs x - y operations.
    • If x is divisible by 5, divide it by 5. If it's not, first make x divisible by 5 (adjusting by the remainder when dividing by 5) and then divide. This takes x % 5 + 1 + dfs(x // 5) operations, where % is the modulus operator and // is the integer division operator.
    • If x is divisible by 11 in a similar fashion, divide it by 11. Otherwise, adjust x to be divisible by 11 and then divide. It involves x % 11 + 1 + dfs(x // 11) operations.
    • Considering the edge case, sometimes it's quicker to first increment x so that it is divisible by 5 or 11 before dividing. This is handled by the two lines: 5 - x % 5 + 1 + dfs(x // 5 + 1) and 11 - x % 11 + 1 + dfs(x // 11 + 1).
  • The recursive calls are made, and the minimum number of operations calculated from each recursion is determined using the min function.

  • The optimal minimum number of operations from the current state is returned from each recursive call until we get back to the original call from the minimumOperationsToMakeEqual method.

In terms of algorithm design, the solution leverages recursion with memoization—an optimization technique to avoid redundant computations and improve performance, widely applied in dynamic programming. The cache allows the algorithms to keep track of and reuse previous results, minimizing the search space and eliminating the need to recompute the cost of operations for the same values of x.

The data structure used implicitly is the call stack through recursion and the dictionary used by the @cache decorator to store computation results.

This approach dynamically computes the solutions to sub-problems and efficiently finds the minimum number of operations required to make x and y equal.

Discover Your Strengths and Weaknesses: Take Our 2-Minute Quiz to Tailor Your Study Plan:

Which of the tree traversal order can be used to obtain elements in a binary search tree in sorted order?

Example Walkthrough

Let's walk through the recursive depth-first search (DFS) approach with an example where x = 50 and y = 8.

  1. The goal is to minimize the number of operations to transform x to equal y.
  2. We call the dfs function with x = 50.

Since x is greater than y, we have four paths to explore. Let's look at these paths:

  • Path 1: Decrementing x by 1 until x equals y. This would take 50 - 8 = 42 operations.

  • Path 2: Since x is divisible by 5, we could divide x by 5. However, 50 divided by 5 is 10, which is still greater than y. So, we would perform one division and then recurse:

    • 1 + dfs(10): One division operation plus whatever the recursive call returns for 10.
  • Path 3: x is not divisible by 11 but we want to divide by 11. So, we could adjust x to be closer to a multiple of 11 and then divide:

    • Since 50 % 11 is 6, we need to decrement x by 6 first to make it exactly divisible by 11, then divide x by 11, which leaves us with 50 / 11 = 4, leaving x at 4.
    • The recursive call would then be 6 + 1 + dfs(4): Six decrement operations, one division operation, plus whatever the recursive call returns for 4.
  • Path 4: In some cases, it would be better to first increment x to make it divisible by 5 or 11. Since 50 is already divisible by 5, this doesn't apply, but for 11, we could consider incrementing x by 5 to make it 55, then dividing by 11 to get 5. But in this example, since x = 50 is already divisible by 5, this doesn't offer any benefit, so we wouldn't consider this option.

Now, we need to recursively invoke dfs for the cases where x is greater than y. Let's detail the recursion for dfs(10) from Path 2:

  • We repeat the process for x = 10 and y = 8.
  • Optimal sub-path: As 10 is greater than 8, we can decrement twice to reach 8. So, dfs(10) would return 10 - 8 = 2.

Substituting back into Path 2, we get 1 + 2 = 3 operations to go from 50 to 8 via dividing by 5 and decrementing twice.

After evaluating these options and paths, we can determine the minimum number of steps. In our case, the minimum operations can be found in Path 2 with 3 steps: one division by 5 and two decrements.

The cached results prevent redundant computations. If another call to dfs(10) occurs, the previously stored result (2) is used instead of recalculating.

This example demonstrates how the DFS with caching efficiently computes the minimum number of steps. It explores different operations and leverages past results to avoid unnecessary work, thereby finding the optimal solution.

Solution Implementation

1from functools import lru_cache  # Import lru_cache for memoization
2
3class Solution:
4    def minimumOperationsToMakeEqual(self, x: int, y: int) -> int:
5        # This function uses dynamic programming to calculate the minimum number
6        # of operations to make x equal to y using allowed operations.
7        @lru_cache(maxsize=None)  # Memoize results of the dfs function
8        def dfs(current_value: int) -> int:
9            # Base case: if the current value is greater than or equal to y,
10            # the operation needed is the difference between them.
11            if current_value >= y:
12                return current_value - y
13          
14            # Initialize the minimum operations to the difference (subtracting operations).
15            minimum_operations = current_value - y
16          
17            # Operation: multiply by 5 and add 1 to the answer, then call dfs recursively.
18            operations_to_five_multiple = current_value % 5
19            minimum_operations = min(minimum_operations, operations_to_five_multiple + 1 + dfs(current_value // 5))
20          
21            # Operation: get to the next multiple of 5, add 1 to the answer, and call dfs recursively.
22            operations_to_next_five_multiple = 5 - current_value % 5
23            minimum_operations = min(minimum_operations, operations_to_next_five_multiple + 1 + dfs(current_value // 5 + 1))
24          
25            # Operation: multiply by 11 and add 1 to the answer, then call dfs recursively.
26            operations_to_eleven_multiple = current_value % 11
27            minimum_operations = min(minimum_operations, operations_to_eleven_multiple + 1 + dfs(current_value // 11))
28          
29            # Operation: get to the next multiple of 11, add 1 to the answer, and call dfs recursively.
30            operations_to_next_eleven_multiple = 11 - current_value % 11
31            minimum_operations = min(minimum_operations, operations_to_next_eleven_multiple + 1 + dfs(current_value // 11 + 1))
32          
33            return minimum_operations
34
35        # Start the recursive process from the initial value x.
36        return dfs(x)
37
1import java.util.Map;
2import java.util.HashMap;
3
4public class Solution {
5    // Store the computed values to avoid redundant calculations
6    private Map<Integer, Integer> computedValues = new HashMap<>();
7
8    // This variable holds the target value 'y' that we want 'x' to equal after operations
9    private int targetValue;
10
11    // Calculate the minimum number of operations required to make 'x' equal to 'y'
12    public int minimumOperationsToMakeEqual(int x, int y) {
13        this.targetValue = y;
14        return findMinimumOperations(x);
15    }
16
17    // A recursive method to compute the minimum number of operations using dynamic programming (memoization)
18    private int findMinimumOperations(int currentValue) {
19        // Base case: if currentValue is already greater than or equal to targetValue, return the difference
20        if (targetValue >= currentValue) {
21            return targetValue - currentValue;
22        }
23
24        // Check if the result has been computed before to avoid redundant calculations
25        if (computedValues.containsKey(currentValue)) {
26            return computedValues.get(currentValue);
27        }
28
29        // Operations involve increments or decrements by a fixed step, or division by 5 or 11
30        // Initialize 'ans' to some maximum value: typically, the difference between current and target value
31        int ans = currentValue - targetValue;
32
33        // Operation set when 'currentValue' is divisible by 5
34        int addStepsWhenDivBy5 = currentValue % 5 + 1;
35        int operationsWhenDivBy5 = addStepsWhenDivBy5 + findMinimumOperations(currentValue / 5);
36
37        // Operation set when 'currentValue' is not divisible by 5 (Move toward divisibility)
38        int addStepsToNextDivBy5 = 5 - currentValue % 5 + 1;
39        int operationsToNextDivBy5 = addStepsToNextDivBy5 + findMinimumOperations(currentValue / 5 + 1);
40
41        // Operation set when 'currentValue' is divisible by 11
42        int addStepsWhenDivBy11 = currentValue % 11 + 1;
43        int operationsWhenDivBy11 = addStepsWhenDivBy11 + findMinimumOperations(currentValue / 11);
44
45        // Operation set when 'currentValue' is not divisible by 11 (Move toward divisibility)
46        int addStepsToNextDivBy11 = 11 - currentValue % 11 + 1;
47        int operationsToNextDivBy11 = addStepsToNextDivBy11 + findMinimumOperations(currentValue / 11 + 1);
48
49        // Finding the minimum of all operation sets
50        ans = Math.min(ans, Math.min(operationsWhenDivBy5, Math.min(operationsToNextDivBy5, 
51                 Math.min(operationsWhenDivBy11, operationsToNextDivBy11))));
52
53        // Store the result in the map to avoid redundant future calculations
54        computedValues.put(currentValue, ans);
55
56        return ans;
57    }
58}
59
1#include <unordered_map>
2#include <functional>
3#include <algorithm>
4
5class Solution {
6public:
7    int minimumOperationsToMakeEqual(int x, int y) {
8        // Using an unordered_map to memoize the results of subproblems.
9        std::unordered_map<int, int> memo;
10
11        // Defining the recursive function using std::function.
12        std::function<int(int)> dfs = [&](int current) {
13            // Base case: if current value is greater or equal to y, 
14            // return the difference (the number of operations needed to decrease x to y).
15            if (current <= y) {
16                return y - current;
17            }
18
19            // If the result for this value of current has already been computed,
20            // return it to avoid redundant calculations.
21            if (memo.count(current)) {
22                return memo[current];
23            }
24          
25            // Recursive case: Calculate the operations needed if we do a divide by 5 or 11
26            // and potentially add or subtract to get to a multiple of 5 or 11, respectively.
27            int a = current % 5 + 1 + dfs(current / 5);
28            int b = 5 - current % 5 + 1 + dfs(current / 5 + 1);
29            int c = current % 11 + 1 + dfs(current / 11);
30            int d = 11 - current % 11 + 1 + dfs(current / 11 + 1);
31
32            // The result for current is the minimum of subtracting y from current,
33            // or the computed values from the four scenarios (a, b, c, and d).
34            // We save the minimum result in our memoization map to use later.
35            memo[current] = std::min({current - y, a, b, c, d});
36
37            // Return the computed minimum for the current value.
38            return memo[current];
39        };
40
41        // Start the depth-first search from value x.
42        return dfs(x);
43    }
44};
45
1// Define a type for the frequency map to hold calculated operations for certain values.
2type FrequencyMap = Map<number, number>;
3
4// Global frequency map to store the number of operations needed for given values.
5const frequencyMap: FrequencyMap = new Map();
6
7// The depth-first search function to find the minimum number of operations
8// to make x equal to y.
9function dfs(x: number): number {
10    // Base case: if x is already greater or equal to y, return the difference.
11    if (y >= x) {
12        return y - x;
13    }
14    // If the number of operations for x is already calculated, return it.
15    if (frequencyMap.has(x)) {
16        return frequencyMap.get(x)!;
17    }
18
19    // Calculate the number of operations if we decrease x by x modulo 5.
20    const decreaseByMod5 = (x % 5) + 1 + dfs((x / 5) | 0);
21    // Calculate the number of operations if we increase x to be divisible by 5.
22    const increaseToNextMultipleOf5 = 5 - (x % 5) + 1 + dfs(((x / 5) | 0) + 1);
23    // Calculate the number of operations if we decrease x by x modulo 11.
24    const decreaseByMod11 = (x % 11) + 1 + dfs((x / 11) | 0);
25    // Calculate the number of operations if we increase x to be divisible by 11.
26    const increaseToNextMultipleOf11 = 11 - (x % 11) + 1 + dfs(((x / 11) | 0) + 1);
27
28    // Determine the minimum number of operations from all possibilities.
29    const minOperations = Math.min(
30        x - y,
31        decreaseByMod5,
32        increaseToNextMultipleOf5,
33        decreaseByMod11,
34        increaseToNextMultipleOf11
35    );
36
37    // Store the computed number of operations in the map for memoization.
38    frequencyMap.set(x, minOperations);
39    return minOperations;
40}
41
42// The main function to initiate the process of making x equal to y
43// using the minimum number of operations.
44function minimumOperationsToMakeEqual(x: number, y: number): number {
45    return dfs(x);
46}
47
Not Sure What to Study? Take the 2-min Quiz

What is an advantages of top-down dynamic programming vs bottom-up dynamic programming?

Time and Space Complexity

The given Python code defines a function minimumOperationsToMakeEqual that calculates the minimum number of operations needed to make x equal to y. The dfs (depth-first search) function is a helper function that uses memoization via the @cache decorator to store previously computed results and avoid redundant computations.

Time Complexity

The time complexity of the code is influenced by:

  • The number of different states that need to be computed, which correspond to different values of x during the recursion.
  • The number of recursive calls made at each step.

Here, the number of unique states can be determined by the range of possible values from x to y and the different operations applied to x. The operations include:

  1. Subtracting x by y (base case).
  2. Dividing by 5 and adjusting (two cases: adding a step if x is not a multiple of 5 and adjusting the result accordingly).
  3. Dividing by 11 and adjusting (similar to dividing by 5, with two cases).

Despite having multiple recursive calls per function call, the use of memoization ensures that each unique state of x is calculated only once. Given that x can only be halved a logarithmic number of times before reaching y, the time complexity would be O(log(x - y)) for each possible division operation (dividing by 5 or 11). Additionally, the constant operations within each recursive call don't add to the asymptotic complexity.

Hence, the overall time complexity of the function is O(log(x - y)).

Space Complexity

The space complexity of the code involves the following considerations:

  • The space used by the memoization cache.
  • The space used by the recursion call stack.

The memoization cache will store results for each unique value of x encountered during the recursive calls. Since x can decrease by a factor of 5 or 11 at each step or by one of the smaller steps (from the % operations), the space used by the memoization cache would also be related to the logarithm of the difference x - y.

The call stack depth is also bound by the number of recursive calls before x reaches y, again related to the logarithm of the difference between x and y.

Therefore, the space complexity of the function is O(log(x - y)) accounting for both the memoization cache and the call stack depth.

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

Fast Track Your Learning with Our Quick Skills Quiz:

What data structure does Breadth-first search typically uses to store intermediate states?


Recommended Readings


Got a question? Ask the Teaching Assistant anything you don't understand.

Still not clear? Ask in the Forum,  Discord or Submit the part you don't understand to our editors.

←
↑TA đŸ‘šâ€đŸ«