Minimum Cost to Change the Final Value of Expression
HardPrompt
You are given a valid boolean expression as a string expression consisting of the characters '1','0','&' (bitwise AND operator),'|' (bitwise OR operator),'(', and ')'.
- For example,
"()1|1"and"(1)&()"are not valid while"1","(((1))|(0))", and"1|(0&(1))"are valid expressions.
Return the minimum cost to change the final value of the expression.
- For example, if
expression = "1|1|(0&0)&1", its value is1|1|(0&0)&1 = 1|1|0&1 = 1|0&1 = 1&1 = 1. We want to apply operations so that the new expression evaluates to0.
The cost of changing the final value of an expression is the number of operations performed on the expression. The types of operations are described as follows:
- Turn a
'1'into a'0'. - Turn a
'0'into a'1'. - Turn a
'&'into a'|'. - Turn a
'|'into a'&'.
Note: '&' does not take precedence over '|' in the order of calculation. Evaluate parentheses first, then in left-to-right order.
Example 1:
Input: expression = "1&(0|1)"
Output: 1
Explanation: We can turn "1&(0|1)" into "1&(0&1)" by changing the '|' to a '&' using 1 operation.
The new expression evaluates to 0. Example 2:
Input: expression = "(0&0)&(0&0&0)"
Output: 3
Explanation: We can turn "(0&0)&(0&0&0)" into "(0|1)|(0&0&0)" using 3 operations.
The new expression evaluates to 1.Example 3:
Input: expression = "(0|(1|0&1))"
Output: 1
Explanation: We can turn "(0|(1|0&1))" into "(0|(0|0&1))" using 1 operation.
The new expression evaluates to 0.
Constraints:
1 <= expression.length <= 105expressiononly contains'1','0','&','|','(', and')'- All parentheses are properly matched.
- There will be no empty parentheses (i.e:
"()"is not a substring ofexpression).
Approaches
2 approaches with complexity analysis and trade-offs.
This approach uses recursion to solve the problem, mirroring the recursive nature of the expression itself. We define a function that takes a subexpression and returns the minimum cost to make it evaluate to 0 and the minimum cost to make it evaluate to 1. To avoid recomputing the same subproblem, we use memoization.
Algorithm
- The core idea is to use recursion to break down the expression into smaller subproblems. We define a function, say
solve(subExpression), that computes the minimum costs. - The state for our recursion/dynamic programming will be a pair of values:
{cost_to_evaluate_to_0, cost_to_evaluate_to_1}. - Base Cases:
- If
subExpressionis'0', the cost to make it0is0, and to make it1is1. So we return{0, 1}. - If
subExpressionis'1', the cost to make it0is1, and to make it1is0. So we return{1, 0}.
- If
- Recursive Step:
- For a complex
subExpression, we first need to find its main operator. Due to the left-to-right evaluation rule (with parentheses taking precedence), the main operator is the last&or|that is not enclosed in parentheses. - We can find this by scanning the subexpression from right to left, keeping track of the parenthesis nesting level. The first operator found at level 0 is our split point.
- If no such operator is found, the expression must be fully enclosed in parentheses (e.g.,
(1&0)). In this case, we strip the outer parentheses and recurse on the inner part. - Once we split the expression
EintoE1 op E2, we recursively callsolve(E1)andsolve(E2)to get their respective cost pairs, say{c1_0, c1_1}and{c2_0, c2_1}. - We then combine these results using predefined formulas to calculate the costs for
E.
- For a complex
- Combining Results: For an expression
E1 op E2:- If
opis&:cost(E, 0) = min(min(c1_0, c2_0), 1 + c1_0 + c2_0)cost(E, 1) = min(c1_1 + c2_1, 1 + min(c1_1, c2_1))
- If
opis|:cost(E, 0) = min(c1_0 + c2_0, 1 + min(c1_0, c2_0))cost(E, 1) = min(min(c1_1, c2_1), 1 + c1_1 + c2_1)
- If
- Memoization: To avoid recomputing results for the same subexpressions, we use a hash map or a 2D array to store the results of
solve(subExpression).
Walkthrough
We can define a recursive function, for instance solve(expression_string), which computes a pair of values: the minimum cost to make the expression evaluate to 0, and the minimum cost to make it evaluate to 1. The base cases for the recursion are single-digit strings '0' and '1'. For any other expression, we find the main operator (the last one evaluated according to the rules) and split the expression into two operands. We then make recursive calls on these operands. The results from the recursive calls are combined based on the operator. For example, to make E1 & E2 evaluate to 1, we must make both E1 and E2 evaluate to 1, so the cost is the sum of their costs to become 1. We also consider the option of changing the operator itself (e.g., & to |) for an additional cost of 1. Since the same subexpression can appear multiple times, we use memoization to store and reuse the results, effectively turning the recursion into dynamic programming.
class Solution { private Map<String, int[]> memo; public int minOperationsToFlip(String expression) { memo = new HashMap<>(); int[] costs = solve(expression); // The cost to achieve the original value is 0. // The cost to flip is the other value in the pair. // So, the answer is the maximum of the two costs. return Math.max(costs[0], costs[1]); } private int[] solve(String expr) { if (memo.containsKey(expr)) { return memo.get(expr); } if (expr.equals("0")) { return new int[]{0, 1}; // {cost_for_0, cost_for_1} } if (expr.equals("1")) { return new int[]{1, 0}; } int balance = 0; int splitIndex = -1; // Find the last operator at parenthesis level 0 for (int i = expr.length() - 1; i >= 0; i--) { char c = expr.charAt(i); if (c == ')') balance++; else if (c == '(') balance--; else if (balance == 0 && (c == '&' || c == '|')) { splitIndex = i; break; } } int[] result; if (splitIndex == -1) { // Expression is wrapped in parentheses result = solve(expr.substring(1, expr.length() - 1)); } else { char op = expr.charAt(splitIndex); String leftExpr = expr.substring(0, splitIndex); String rightExpr = expr.substring(splitIndex + 1); int[] leftCosts = solve(leftExpr); int[] rightCosts = solve(rightExpr); int c1_0 = leftCosts[0], c1_1 = leftCosts[1]; int c2_0 = rightCosts[0], c2_1 = rightCosts[1]; result = new int[2]; if (op == '&') { result[0] = Math.min(Math.min(c1_0, c2_0), 1 + c1_0 + c2_0); result[1] = Math.min(c1_1 + c2_1, 1 + Math.min(c1_1, c2_1)); } else { // op == '|' result[0] = Math.min(c1_0 + c2_0, 1 + Math.min(c1_0, c2_0)); result[1] = Math.min(Math.min(c1_1, c2_1), 1 + c1_1 + c2_1); } } memo.put(expr, result); return result; }}Complexity
Time
O(N^2), where N is the length of the expression. For each subproblem of length `k`, we scan it in O(k) time to find the split point. The sum of work across all subproblems leads to an O(N^2) complexity in the worst case (e.g., for expressions like `1&1&1...&1`).
Space
O(N^2), where N is the length of the expression. The memoization can store up to O(N^2) subproblems, and the sum of the lengths of these subproblem strings can also be O(N^2). The recursion depth can go up to O(N).
Trade-offs
Pros
The recursive structure of the solution closely matches the recursive structure of the problem, making it conceptually straightforward.
Cons
The time complexity of O(N^2) is too slow for the given constraints (N up to 10^5) and will result in a Time Limit Exceeded error.
The space complexity of O(N^2) can be very high.
Repeatedly creating substrings is inefficient in Java.
A more efficient approach is to use a method similar to the standard algorithm for evaluating arithmetic expressions: a two-stack approach. This allows us to parse and compute the costs in a single pass over the expression, achieving linear time complexity.
Algorithm
- This approach uses two stacks: one for values (
valuesStack) and one for operators (opsStack). ThevaluesStackwill store pairs of{cost_to_make_0, cost_to_make_1}. - We iterate through the expression character by character from left to right.
- If the character is
'0'or'1', we push the corresponding base cost pair ({0, 1}for'0',{1, 0}for'1') onto thevaluesStack. - If the character is
'(', we push it onto theopsStack. - If the character is
')', we evaluate operations from theopsStackuntil we find the matching'('. We pop the'('but do not use it in calculations. - If the character is an operator (
'&'or'|'), we first check the top of theopsStack. Because&and|have the same precedence and are evaluated left-to-right, we must evaluate any pending operator at the top of the stack (as long as it's not a'('). After evaluating, we push the current operator ontoopsStack. - The evaluation step involves popping one operator from
opsStackand two cost pairs fromvaluesStack. We then compute the new cost pair using the combination formulas and push the result back ontovaluesStack. - After iterating through the entire expression, we evaluate any remaining operators in the
opsStack. - The final result will be the single cost pair left on the
valuesStack. The cost to flip the expression's value is the maximum of the two values in this pair, as one will be 0 (the cost to obtain the original value) and the other will be the minimum cost to change it.
Walkthrough
We can process the expression iteratively in one pass. We use a stack for operands (which in our case are the {cost_to_0, cost_to_1} pairs) and another stack for operators. As we scan the expression string:
- Numbers (
'0','1') are converted to their base cost pairs and pushed onto the value stack. '('is pushed onto the operator stack.- An operator (
'&','|') triggers the evaluation of the previous operator on the stack (if it's not a'('), due to the left-to-right evaluation rule. Then, the current operator is pushed. ')'triggers evaluation of all operators until the matching'('is found.
This process builds up the costs for larger and larger subexpressions from the bottom up. After the entire string is processed, any remaining operators on the stack are evaluated. The final answer is on top of the value stack. If the final cost pair is {c0, c1}, the original value of the expression corresponds to the one with zero cost. For instance, if c1 is 0, the expression evaluates to 1, and the cost to flip it to 0 is c0. A simple way to get the answer is max(c0, c1).
class Solution { public int minOperationsToFlip(String expression) { Stack<int[]> valuesStack = new Stack<>(); // {cost_to_0, cost_to_1} Stack<Character> opsStack = new Stack<>(); for (char c : expression.toCharArray()) { if (c == '0') { valuesStack.push(new int[]{0, 1}); } else if (c == '1') { valuesStack.push(new int[]{1, 0}); } else if (c == '(') { opsStack.push(c); } else if (c == ')') { while (!opsStack.isEmpty() && opsStack.peek() != '(') { evaluate(valuesStack, opsStack); } opsStack.pop(); // Pop '(' } else { // Operator '&' or '|' while (!opsStack.isEmpty() && opsStack.peek() != '(') { evaluate(valuesStack, opsStack); } opsStack.push(c); } } while (!opsStack.isEmpty()) { evaluate(valuesStack, opsStack); } int[] finalCosts = valuesStack.pop(); return Math.max(finalCosts[0], finalCosts[1]); } private void evaluate(Stack<int[]> valuesStack, Stack<Character> opsStack) { char op = opsStack.pop(); int[] rightCosts = valuesStack.pop(); int[] leftCosts = valuesStack.pop(); int c1_0 = leftCosts[0], c1_1 = leftCosts[1]; int c2_0 = rightCosts[0], c2_1 = rightCosts[1]; int[] result = new int[2]; if (op == '&') { // Cost to get 0: min(make one operand 0, change op to | and make both 0) result[0] = Math.min(Math.min(c1_0, c2_0), 1 + c1_0 + c2_0); // Cost to get 1: min(make both operands 1, change op to | and make one 1) result[1] = Math.min(c1_1 + c2_1, 1 + Math.min(c1_1, c2_1)); } else { // op == '|' // Cost to get 0: min(make both operands 0, change op to & and make one 0) result[0] = Math.min(c1_0 + c2_0, 1 + Math.min(c1_0, c2_0)); // Cost to get 1: min(make one operand 1, change op to & and make both 1) result[1] = Math.min(Math.min(c1_1, c2_1), 1 + c1_1 + c2_1); } valuesStack.push(result); }}Complexity
Time
O(N), where N is the length of the expression. Each character, value, and operator is pushed onto and popped from the stacks at most once.
Space
O(N), where N is the length of the expression. In the worst-case scenario (e.g., `1&1&...&1` or `((...))`), the stacks can grow to a size proportional to N.
Trade-offs
Pros
Optimal time complexity of O(N), which passes for the given constraints.
Optimal space complexity of O(N).
It's an iterative solution, so it avoids potential stack overflow issues with deep recursion on very long expressions.
Cons
The implementation can be more complex than a direct recursive solution, requiring careful handling of stacks and operator precedence rules.
Solutions
Solution
class Solution {public int minOperationsToFlip(String expression) { Deque<Integer> numStack = new LinkedList<Integer>(); Deque<Integer> opStack = new LinkedList<Integer>(); Deque<Character> signStack = new LinkedList<Character>(); int length = expression.length(); for (int i = 0; i < length; i++) { char c = expression.charAt(i); if (Character.isDigit(c)) { numStack.push((int)(c - '0')); opStack.push(1); } else if (c == ')') signStack.pop(); else { signStack.push(c); continue; } if (numStack.size() > 1 && signStack.peek() != '(') { int num2 = numStack.pop(); int num1 = numStack.pop(); int op2 = opStack.pop(); int op1 = opStack.pop(); char sign = signStack.pop(); int[] ops = minOp(num1, num2, op1, op2, sign); numStack.push(ops[0]); opStack.push(ops[1]); } } return opStack.pop(); }public int[] minOp(int num1, int num2, int op1, int op2, char sign) { if (sign == '&') { if (num1 == 1 && num2 == 1) return new int[]{1, Math.min(op1, op2)}; else if (num1 == 0 && num2 == 0) return new int[]{0, Math.min(op1, op2) + 1}; else return new int[]{0, 1}; } else { if (num1 == 0 && num2 == 0) return new int[]{0, Math.min(op1, op2)}; else if (num1 == 1 && num2 == 1) return new int[]{1, Math.min(op1, op2) + 1}; else return new int[]{1, 1}; } }}// OJ: https://leetcode.com/problems/minimum-cost-to-change-the-final-value-of-expression/ // Time: O(N) // Space: O(N) // Ref: https://www.bilibili.com/video/BV1yU4y1V79x class Solution { stack < vector < int >> num ; stack < char > op ; void eval () { auto b = num . top (); num . pop (); auto a = num . top (); num . pop (); char c = op . top (); op . pop (); if ( c == '&' ) { num . push ({ min ({ a [ 0 ] + b [ 0 ], a [ 1 ] + b [ 0 ], a [ 0 ] + b [ 1 ] }), min ({ a [ 1 ] + b [ 1 ], a [ 1 ] + b [ 0 ] + 1 , a [ 0 ] + b [ 1 ] + 1 , a [ 1 ] + b [ 1 ] + 1 }) }); } else { num . push ({ min ({ a [ 0 ] + b [ 0 ], a [ 0 ] + b [ 1 ] + 1 , a [ 1 ] + b [ 0 ] + 1 , a [ 0 ] + b [ 0 ] + 1 }), min ({ a [ 1 ] + b [ 1 ], a [ 1 ] + b [ 0 ], a [ 0 ] + b [ 1 ] }) }); } } public: int minOperationsToFlip ( string s ) { for ( char c : s ) { if ( isdigit ( c )) { if ( c == '0' ) num . push ({ 0 , 1 }); else num . push ({ 1 , 0 }); } else if ( c == '(' ) { op . push ( c ); } else if ( c == ')' ) { while ( op . top () != '(' ) eval (); op . pop (); } else { while ( op . size () && op . top () != '(' ) eval (); op . push ( c ); } } while ( op . size ()) eval (); return max ( num . top ()[ 0 ], num . top ()[ 1 ]); } };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.