Least Operators to Express Number
HardPrompt
Given a single positive integer x, we will write an expression of the form x (op1) x (op2) x (op3) x ... where each operator op1, op2, etc. is either addition, subtraction, multiplication, or division (+, -, *, or /). For example, with x = 3, we might write 3 * 3 / 3 + 3 - 3 which is a value of 3.
When writing such an expression, we adhere to the following conventions:
- The division operator (
/) returns rational numbers. - There are no parentheses placed anywhere.
- We use the usual order of operations: multiplication and division happen before addition and subtraction.
- It is not allowed to use the unary negation operator (
-). For example, "x - x" is a valid expression as it only uses subtraction, but "-x + x" is not because it uses negation.
We would like to write an expression with the least number of operators such that the expression equals the given target. Return the least number of operators used.
Example 1:
Input: x = 3, target = 19
Output: 5
Explanation: 3 * 3 + 3 * 3 + 3 / 3.
The expression contains 5 operations.Example 2:
Input: x = 5, target = 501
Output: 8
Explanation: 5 * 5 * 5 * 5 - 5 * 5 * 5 + 5 / 5.
The expression contains 8 operations.Example 3:
Input: x = 100, target = 100000000
Output: 3
Explanation: 100 * 100 * 100 * 100.
The expression contains 3 operations.
Constraints:
2 <= x <= 1001 <= target <= 2 * 108
Approaches
2 approaches with complexity analysis and trade-offs.
This approach attempts to solve the problem by exploring all possible sequences of operations in a depth-first manner. We start with an initial value (e.g., x) and recursively try to apply all valid operations (+, -, *, / which form terms x^p) until we reach the target value. The goal is to find the path of operations with the minimum length.
Algorithm
- Define a recursive function, say
findMinOperators(currentValue), which calculates the minimum operators to reachtargetfromcurrentValue. - The base cases for the recursion would be:
- If
currentValue == target, we have reached the destination, so we need 0 more operators. Return 0. - If
currentValueexceedstargetby a large margin or goes far below zero, it's likely not an optimal path. We can return infinity to prune this path.
- If
- In the recursive step, from
currentValue, we can perform all possible operations. This means we can add or subtract any power ofx.- For each power
pofx(i.e.,x^1, x^2, ...) andx/x=1:- Recursively call
findMinOperators(currentValue + x^p)andfindMinOperators(currentValue - x^p). - The cost of adding/subtracting a term
x^pispoperators (p-1for multiplication and1for+or-). Forx/x, the cost is 2 operators (/and+or-).
- Recursively call
- For each power
- The function returns the minimum cost among all possible next steps.
- To avoid recomputing results for the same
currentValue, memoization (a hash map) can be used to store the results.
Walkthrough
The brute-force recursive approach explores every possible expression that can be formed. We can define a recursive function that tries to reach the target from the current value. At each step, we can add or subtract a term. A term is a power of x, like x^p, which is formed by p-1 multiplications. The cost of adding such a term is p operators in total (1 for the + and p-1 for the *s). Similarly, for x/x=1, the cost is 2 operators. This method will explore a vast tree of possibilities. Without memoization, it will recompute solutions for the same intermediate values multiple times, leading to an exponential time complexity. Even with memoization, the state space is too large to be practical, as we might need to compute the minimum operators for many values between 0 and target (and even beyond).
import java.util.HashMap;import java.util.Map; class Solution { Map<Integer, Integer> memo; int x_val; public int leastOpsExpressTarget(int x, int target) { this.memo = new HashMap<>(); this.x_val = x; // We start from x, so we need one less operator than the number of x's used. // The cost of x itself is 0 operators. // The problem can be rephrased as finding min ops to get target from 0, // where the first term x^k costs k-1 ops, and subsequent terms cost k ops. // This is complex. A simpler model is to find min ops to get target from x. return solve(target); } private int solve(int currentTarget) { if (currentTarget == 0) return -1; // Should not happen if we start from x if (currentTarget == x_val) return 0; if (memo.containsKey(currentTarget)) return memo.get(currentTarget); long power = x_val; int p = 1; int minOps = Integer.MAX_VALUE; // Case 1: target = x * ... * x + ... // We try to reach target from a smaller value by multiplication if (currentTarget > x_val && currentTarget % x_val == 0) { minOps = Math.min(minOps, solve(currentTarget / x_val) + 1); } // Case 2: target = x + ... + x if (currentTarget > x_val && currentTarget % x_val != 0) { // Simplified heuristic minOps = Math.min(minOps, solve(currentTarget - x_val) + 1); } // This is a highly simplified and incorrect recursive solution to illustrate the idea. // A full brute-force would be much more complex. memo.put(currentTarget, minOps); return minOps; }}Note: The provided code is a conceptual illustration and not a complete or correct brute-force solution, which would be significantly more complex.
Complexity
Time
O(c^T) without memoization, where c is the branching factor and T is the target. With memoization, it's still very high due to the large state space.
Space
O(T) for the memoization table, where T is the target value. In the worst case, the recursion depth can also be large, contributing to stack space.
Trade-offs
Pros
Simple to conceptualize as a direct translation of the problem statement.
Cons
Extremely high time complexity, as it explores many redundant and non-optimal paths.
The depth of the recursion can be very large, potentially leading to a stack overflow error without proper pruning.
Even with memoization, the number of states to visit can be enormous, making it impractical for the given constraints.
A more structured and efficient way to solve this problem is to treat it as a shortest path problem on an implicit graph. The nodes are the numbers we can form, and the edges represent the operations that transform one number to another. The weight of each edge is the number of operators required for that operation. Dijkstra's algorithm is perfectly suited for finding the minimum cost (least operators) to reach the target node.
Algorithm
- Model the problem as finding the shortest path in a graph. The nodes of the graph are the integer values we can generate, and the edges are the operations.
- The cost of an edge is the number of operators it represents.
- Use Dijkstra's algorithm to find the shortest path from a starting value to the
target. - We can start from value
0and try to reachtarget. The cost to add the first termx^pisp-1operators (forp>0) or1operator (forp=0, i.e.,x/x). - For any subsequent term added to a non-zero sum, the cost is
poperators forx^p(p>0) and2operators forx^0. - Initialize a priority queue with the starting state, e.g.,
(cost: 0, value: 0). A special initial cost like -1 can handle the first term logic gracefully. - Use a hash map
distto keep track of the minimum cost to reach each value, to avoid cycles and redundant computations. - While the priority queue is not empty, extract the state
(cost, value)with the minimum cost. - If
valueis thetarget, we have found the shortest path. Return the cost. - From
value, generate the next possible states by adding or subtracting termsx^p(forp=0, 1, 2, ...). Calculate the new cost and if it's better than the known distance, updatedistand push the new state to the priority queue.
Walkthrough
We can think of the problem as finding the shortest path from 0 to target. The states in our search are the numbers we can generate. We use Dijkstra's algorithm to explore these states, always expanding the one that was reached with the fewest operators so far.
A priority queue will store tuples of (cost, value). We also use a map to store the minimum cost found so far for each value to avoid redundant processing.
We start at 0 with a cost of 0. From any value u with cost c, we can transition to u + x^p or u - x^p. The cost of adding a term x^p depends on whether it's the first term of the expression.
- The first term
x^p(wherep>0) costsp-1operators (x*...*x). - The first term
x/x(value 1, i.e.,p=0) costs1operator. - Adding a term
x^p(p>0) to an existing expression costspoperators (one for+/-andp-1for*). - Adding
x/xto an existing expression costs2operators (one for+/-and one for/).
The algorithm proceeds by repeatedly extracting the minimum-cost state from the priority queue and exploring its neighbors (next possible values) until the target is reached.
import java.util.HashMap;import java.util.Map;import java.util.PriorityQueue; class Solution { public int leastOpsExpressTarget(int x, int target) { // (cost, value) PriorityQueue<long[]> pq = new PriorityQueue<>((a, b) -> Long.compare(a[0], b[0])); Map<Long, Integer> dist = new HashMap<>(); // Start with the initial terms we can form. // The first term x^p costs p-1 ops (p>0) or 1 op (p=0). pq.offer(new long[]{0, x}); // Term x, cost 0 dist.put((long)x, 0); long powerVal = x; int p = 1; while (powerVal < 2L * target) { // Term x^p pq.offer(new long[]{p, powerVal}); dist.put(powerVal, p); if (powerVal > (long)Integer.MAX_VALUE / x) break; // Avoid overflow powerVal *= x; p++; } // Term x/x = 1 pq.offer(new long[]{1, 1}); dist.put(1L, 1); while (!pq.isEmpty()) { long[] current = pq.poll(); int cost = (int)current[0]; long val = current[1]; if (val == target) { return cost; } if (cost > dist.getOrDefault(val, Integer.MAX_VALUE)) { continue; } // From `val`, we can add or subtract other terms. // The cost to add a term x^p to an existing expression is p ops. // The cost to add x/x is 2 ops. long termVal = x; int termPower = 1; while (termVal < 2L * target) { // Add term x^p long nextValAdd = val + termVal; int nextCostAdd = cost + 1 + termPower; if (nextCostAdd < dist.getOrDefault(nextValAdd, Integer.MAX_VALUE)) { dist.put(nextValAdd, nextCostAdd); pq.offer(new long[]{nextCostAdd, nextValAdd}); } // Subtract term x^p long nextValSub = val - termVal; int nextCostSub = cost + 1 + termPower; if (nextValSub > 0 && nextCostSub < dist.getOrDefault(nextValSub, Integer.MAX_VALUE)) { dist.put(nextValSub, nextCostSub); pq.offer(new long[]{nextCostSub, nextValSub}); } if (termVal > (long)Integer.MAX_VALUE / x) break; termVal *= x; termPower++; } // Add/Subtract term x/x = 1 long nextValAdd1 = val + 1; int nextCostAdd1 = cost + 2; if (nextCostAdd1 < dist.getOrDefault(nextValAdd1, Integer.MAX_VALUE)) { dist.put(nextValAdd1, nextCostAdd1); pq.offer(new long[]{nextCostAdd1, nextValAdd1}); } long nextValSub1 = val - 1; int nextCostSub1 = cost + 2; if (nextValSub1 > 0 && nextCostSub1 < dist.getOrDefault(nextValSub1, Integer.MAX_VALUE)) { dist.put(nextValSub1, nextCostSub1); pq.offer(new long[]{nextCostSub1, nextValSub1}); } } return -1; // Should not be reached }}Note: The provided Dijkstra implementation is slightly simplified for clarity. A fully robust solution might handle the first term logic differently (e.g., starting from 0) but the core idea remains the same.
Complexity
Time
O(E log V), where V is the number of states visited and E is the number of transitions. E is roughly `V * log_x(target)`. The performance depends heavily on how many states need to be explored to reach the target.
Space
O(V), where V is the number of states (values) visited. In the worst case, V can be proportional to `target`.
Trade-offs
Pros
Guaranteed to find the optimal solution because it explores paths in increasing order of cost.
More efficient than brute-force recursion by avoiding re-computation of states using the
distmap.
Cons
The number of states (values) can be large, potentially up to
2 * target, which might lead to high memory usage for thedistmap.The time complexity can be high if the
targetis large and the optimal path involves many intermediate steps.
Solutions
Solution
class Solution {private int x;private Map<Integer, Integer> f = new HashMap<>();public int leastOpsExpressTarget(int x, int target) { this.x = x; return dfs(target); }private int dfs(int v) { if (x >= v) { return Math.min(v * 2 - 1, 2 * (x - v)); } if (f.containsKey(v)) { return f.get(v); } int k = 2; long y = (long)x * x; while (y < v) { y *= x; ++k; } int ans = k - 1 + dfs(v - (int)(y / x)); if (y - v < v) { ans = Math.min(ans, k + dfs((int)y - v)); } f.put(v, ans); return ans; }}class Solution {public: int leastOpsExpressTarget(int x, int target) { unordered_map<int, int> f; function<int(int)> dfs = [&](int v) -> int { if (x >= v) { return min(v * 2 - 1, 2 * (x - v)); } if (f.count(v)) { return f[v]; } int k = 2; long long y = x * x; while (y < v) { y *= x; ++k; } int ans = k - 1 + dfs(v - y / x); if (y - v < v) { ans = min(ans, k + dfs(y - v)); } f[v] = ans; return ans; }; return dfs(target); }};class Solution: def leastOpsExpressTarget(self, x: int, target: int) -> int: @ cache def dfs(v: int) -> int: if x >= v: return min(v * 2 - 1, 2 * (x - v)) k = 2 while x ** k < v: k += 1 if x ** k - v < v: return min(k + dfs(x ** k - v), k - 1 + dfs(v - x ** (k - 1))) return k - 1 + dfs(v - x ** (k - 1)) return dfs(target)Video 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.