241. Different Ways to Add Parentheses
Problem Description
You are given a string expression
containing numbers and operators (+
, -
, *
). Your task is to compute all possible results that can be obtained by grouping the numbers and operators in different ways using parentheses.
For example, if the expression is "2-1-1"
, you can group it as:
(2-1)-1 = 0
2-(1-1) = 2
So the output would be [0, 2]
.
The key points to understand:
- You need to find ALL possible ways to add parentheses to the expression
- Each different way of grouping will potentially give a different result
- The operators maintain their standard mathematical precedence within each grouping
- You can return the results in any order
- The expression will only contain single or multi-digit numbers and the operators
+
,-
,*
The solution uses a divide-and-conquer approach with memoization. For each operator in the expression, it splits the expression into left and right parts, recursively computes all possible results for each part, and then combines them using the operator. The base case is when the expression contains only a number (no operators), in which case it returns that number as the only result.
The @cache
decorator helps avoid recomputing results for the same sub-expressions, improving efficiency. The algorithm systematically explores all possible ways to split the expression at each operator position, ensuring all possible groupings are considered.
Intuition
When we see an expression like "2*3-4*5"
, we need to think about all the different ways we can evaluate it by adding parentheses. The key insight is that every way of fully parenthesizing the expression corresponds to choosing which operator to evaluate last.
Think about it this way: in any fully parenthesized expression, there's always one operator that gets evaluated last. For example:
- In
((2*3)-(4*5))
, the-
is evaluated last - In
(2*(3-(4*5)))
, the first*
is evaluated last - In
((2*(3-4))*5)
, the last*
is evaluated last
This naturally leads to a recursive approach: for each operator in our expression, we can treat it as the "last" operator to be evaluated. This splits our expression into two parts - everything to the left of this operator and everything to the right.
For example, if we choose the -
in "2*3-4*5"
as our last operator:
- Left part:
"2*3"
- Right part:
"4*5"
Now here's the recursive insight: we need to find all possible results for the left part and all possible results for the right part. Then, we combine each result from the left with each result from the right using our chosen operator.
The recursion continues until we hit a base case - when our expression is just a number with no operators. At that point, the only possible result is the number itself.
This approach ensures we explore all possible ways of parenthesizing because:
- We try every operator as a potential "last" operator
- For each split, we recursively explore all possibilities for both sub-expressions
- We combine all possible left results with all possible right results
The memoization (@cache
) is a natural optimization since we might encounter the same sub-expression multiple times through different recursive paths.
Solution Approach
The solution implements a divide-and-conquer algorithm with memoization using Python's @cache
decorator. Let's walk through the implementation step by step:
1. Main Function Structure:
The solution defines a nested recursive function dfs(exp)
that processes any expression string and returns a list of all possible results.
2. Base Case:
if exp.isdigit():
return [int(exp)]
When the expression contains only digits (no operators), we've reached our base case. We simply return a list containing that single number.
3. Recursive Case: For expressions containing operators, we iterate through each character:
for i, c in enumerate(exp):
if c in '-+*':
When we find an operator, we treat it as the potential "last" operator to be evaluated.
4. Divide Step:
left, right = dfs(exp[:i]), dfs(exp[i + 1 :])
We split the expression at the operator position:
exp[:i]
gives us everything before the operatorexp[i + 1:]
gives us everything after the operator Both sub-expressions are recursively evaluated to get all their possible results.
5. Conquer Step:
for a in left: for b in right: if c == '-': ans.append(a - b) elif c == '+': ans.append(a + b) else: ans.append(a * b)
We combine every result from the left sub-expression with every result from the right sub-expression using the current operator. This ensures we capture all possible combinations.
6. Memoization:
The @cache
decorator automatically memoizes the results. If dfs
is called with the same expression string multiple times, it returns the cached result instead of recomputing it. This is crucial for efficiency since the same sub-expression can appear in multiple recursive branches.
Time Complexity Analysis:
The number of possible results follows the Catalan number sequence, which grows exponentially. For an expression with n
operators, the time complexity is approximately O(2^n)
in the worst case, though memoization helps avoid redundant calculations.
Space Complexity:
The space complexity includes the recursion stack depth O(n)
and the space to store all possible results, which can be up to O(2^n)
in total.
Ready to land your dream job?
Unlock your dream job with a 3-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's trace through the expression "2*3-4"
to see how the algorithm finds all possible results.
Initial Call: dfs("2*3-4")
The expression contains operators, so we iterate through each character:
-
When i=1, c='*' (first operator):
- Split: left =
dfs("2")
, right =dfs("3-4")
dfs("2")
is just a number, returns[2]
dfs("3-4")
needs further processing:- At i=1, c='-': Split into
dfs("3")
anddfs("4")
dfs("3")
returns[3]
dfs("4")
returns[4]
- Combine: 3 - 4 = -1
- Returns
[-1]
- At i=1, c='-': Split into
- Now combine results from "2" and "3-4" using '*':
- 2 * (-1) = -2
- This grouping represents:
2 * (3 - 4) = -2
- Split: left =
-
When i=3, c='-' (second operator):
- Split: left =
dfs("2*3")
, right =dfs("4")
dfs("4")
is just a number, returns[4]
dfs("2*3")
needs processing:- At i=1, c='*': Split into
dfs("2")
anddfs("3")
dfs("2")
returns[2]
dfs("3")
returns[3]
- Combine: 2 * 3 = 6
- Returns
[6]
- At i=1, c='*': Split into
- Now combine results from "2*3" and "4" using '-':
- 6 - 4 = 2
- This grouping represents:
(2 * 3) - 4 = 2
- Split: left =
Final Result: [-2, 2]
The algorithm found both possible ways to parenthesize:
2 * (3 - 4) = 2 * (-1) = -2
(2 * 3) - 4 = 6 - 4 = 2
Note how memoization helps: when we call dfs("2")
multiple times in different recursive branches, the cached result is returned instantly after the first computation.
Solution Implementation
1from typing import List
2from functools import cache
3
4class Solution:
5 def diffWaysToCompute(self, expression: str) -> List[int]:
6 """
7 Computes all possible results from different ways of grouping numbers and operators.
8 Uses divide-and-conquer approach by splitting at each operator.
9
10 Args:
11 expression: A string containing numbers and operators (+, -, *)
12
13 Returns:
14 List of all possible results from different parenthesizations
15 """
16
17 @cache
18 def compute_ways(expr: str) -> List[int]:
19 """
20 Recursively computes all possible results for a given expression.
21
22 Args:
23 expr: Substring of the original expression
24
25 Returns:
26 List of possible computation results
27 """
28 # Base case: if the expression is just a number, return it as a list
29 if expr.isdigit():
30 return [int(expr)]
31
32 results = []
33
34 # Try splitting the expression at each operator
35 for index, char in enumerate(expr):
36 if char in '-+*':
37 # Recursively compute all possible results for left and right parts
38 left_results = compute_ways(expr[:index])
39 right_results = compute_ways(expr[index + 1:])
40
41 # Combine results from left and right using the current operator
42 for left_value in left_results:
43 for right_value in right_results:
44 if char == '-':
45 results.append(left_value - right_value)
46 elif char == '+':
47 results.append(left_value + right_value)
48 else: # char == '*'
49 results.append(left_value * right_value)
50
51 return results
52
53 return compute_ways(expression)
54
1class Solution {
2 // Cache to store previously computed results for subexpressions
3 private static Map<String, List<Integer>> memo = new HashMap<>();
4
5 /**
6 * Computes all possible results from different ways of grouping operators in the expression
7 * @param expression - String containing numbers and operators (+, -, *)
8 * @return List of all possible results
9 */
10 public List<Integer> diffWaysToCompute(String expression) {
11 return dfs(expression);
12 }
13
14 /**
15 * Recursive helper function to compute all possible results for a given expression
16 * @param expression - Current expression or subexpression to evaluate
17 * @return List of all possible results for this expression
18 */
19 private List<Integer> dfs(String expression) {
20 // Check if result for this expression is already cached
21 if (memo.containsKey(expression)) {
22 return memo.get(expression);
23 }
24
25 List<Integer> results = new ArrayList<>();
26
27 // Base case: if expression is just a number (length < 3 means no operators)
28 if (expression.length() < 3) {
29 results.add(Integer.parseInt(expression));
30 return results;
31 }
32
33 // Try splitting the expression at each operator position
34 for (int i = 0; i < expression.length(); i++) {
35 char currentChar = expression.charAt(i);
36
37 // If current character is an operator, split the expression
38 if (currentChar == '-' || currentChar == '+' || currentChar == '*') {
39 // Recursively compute all possible results for left and right subexpressions
40 List<Integer> leftResults = dfs(expression.substring(0, i));
41 List<Integer> rightResults = dfs(expression.substring(i + 1));
42
43 // Combine results from left and right subexpressions using the current operator
44 for (int leftValue : leftResults) {
45 for (int rightValue : rightResults) {
46 if (currentChar == '-') {
47 results.add(leftValue - rightValue);
48 } else if (currentChar == '+') {
49 results.add(leftValue + rightValue);
50 } else { // currentChar == '*'
51 results.add(leftValue * rightValue);
52 }
53 }
54 }
55 }
56 }
57
58 // Cache the results for this expression before returning
59 memo.put(expression, results);
60 return results;
61 }
62}
63
1class Solution {
2public:
3 vector<int> diffWaysToCompute(string expression) {
4 return dfs(expression);
5 }
6
7private:
8 // Memoization cache to store computed results for subexpressions
9 unordered_map<string, vector<int>> memo;
10
11 // Recursively compute all possible results for the given expression
12 // by splitting at each operator and combining results from left and right subexpressions
13 vector<int> dfs(string exp) {
14 // Check if result is already computed and cached
15 if (memo.count(exp)) {
16 return memo[exp];
17 }
18
19 // Base case: if expression is a single number (length < 3), return it as integer
20 if (exp.size() < 3) {
21 return {stoi(exp)};
22 }
23
24 vector<int> results;
25 int n = exp.size();
26
27 // Try splitting the expression at each operator
28 for (int i = 0; i < n; ++i) {
29 char currentChar = exp[i];
30
31 // If current character is an operator, split the expression
32 if (currentChar == '-' || currentChar == '+' || currentChar == '*') {
33 // Recursively compute all possible results for left substring
34 vector<int> leftResults = dfs(exp.substr(0, i));
35 // Recursively compute all possible results for right substring
36 vector<int> rightResults = dfs(exp.substr(i + 1, n - i - 1));
37
38 // Combine each result from left with each result from right
39 // using the current operator
40 for (int& leftVal : leftResults) {
41 for (int& rightVal : rightResults) {
42 if (currentChar == '-') {
43 results.push_back(leftVal - rightVal);
44 } else if (currentChar == '+') {
45 results.push_back(leftVal + rightVal);
46 } else { // currentChar == '*'
47 results.push_back(leftVal * rightVal);
48 }
49 }
50 }
51 }
52 }
53
54 // Cache the computed results before returning
55 memo[exp] = results;
56 return results;
57 }
58};
59
1// Memoization cache to store computed results for subexpressions
2const memo: Map<string, number[]> = new Map();
3
4/**
5 * Main function to compute all possible results from different ways of adding parentheses
6 * @param expression - The input expression containing numbers and operators
7 * @returns Array of all possible results
8 */
9function diffWaysToCompute(expression: string): number[] {
10 // Clear memo for each new problem instance
11 memo.clear();
12 return dfs(expression);
13}
14
15/**
16 * Recursively compute all possible results for the given expression
17 * by splitting at each operator and combining results from left and right subexpressions
18 * @param exp - The expression or subexpression to evaluate
19 * @returns Array of all possible results for this expression
20 */
21function dfs(exp: string): number[] {
22 // Check if result is already computed and cached
23 if (memo.has(exp)) {
24 return memo.get(exp)!;
25 }
26
27 // Base case: if expression is a single number (length < 3), return it as integer
28 if (exp.length < 3) {
29 return [parseInt(exp)];
30 }
31
32 const results: number[] = [];
33 const n = exp.length;
34
35 // Try splitting the expression at each operator
36 for (let i = 0; i < n; i++) {
37 const currentChar = exp[i];
38
39 // If current character is an operator, split the expression
40 if (currentChar === '-' || currentChar === '+' || currentChar === '*') {
41 // Recursively compute all possible results for left substring
42 const leftResults = dfs(exp.substring(0, i));
43 // Recursively compute all possible results for right substring
44 const rightResults = dfs(exp.substring(i + 1));
45
46 // Combine each result from left with each result from right
47 // using the current operator
48 for (const leftVal of leftResults) {
49 for (const rightVal of rightResults) {
50 if (currentChar === '-') {
51 results.push(leftVal - rightVal);
52 } else if (currentChar === '+') {
53 results.push(leftVal + rightVal);
54 } else { // currentChar === '*'
55 results.push(leftVal * rightVal);
56 }
57 }
58 }
59 }
60 }
61
62 // Cache the computed results before returning
63 memo.set(exp, results);
64 return results;
65}
66
Time and Space Complexity
Time Complexity: O(n * 2^n)
The algorithm uses divide-and-conquer with memoization. For each operator in the expression, we split it into left and right subexpressions and recursively compute all possible results. In the worst case:
- There are
n
operators in the expression - Each way of parenthesizing corresponds to a unique binary tree structure
- The number of ways to parenthesize an expression with
n
operators is then
-th Catalan number, which is approximatelyO(4^n / n^(3/2))
- However, with memoization (
@cache
), each unique substring is computed only once - There are
O(n^2)
possible substrings of the input expression - For each substring with
k
operators, we iterate through all operators and combine results from left and right subproblems - The combination step for each split point takes
O(left_results * right_results)
time - In the worst case, this leads to
O(n * 2^n)
time complexity, wheren
is the length of the expression
Space Complexity: O(n^2 * 2^n)
The space complexity consists of:
- Memoization cache:
O(n^2)
unique substrings, and each substring can produce up toO(2^n)
different results (Catalan number bound), giving usO(n^2 * 2^n)
for storing all cached results - Recursion stack depth:
O(n)
in the worst case when the expression is parsed linearly - Temporary storage for results: Each recursive call stores an
ans
list that can contain up toO(2^n)
results
The dominant factor is the memoization cache, making the overall space complexity O(n^2 * 2^n)
.
Common Pitfalls
1. Incorrect Handling of Multi-Digit Numbers
One of the most common mistakes is treating each digit as a separate number instead of recognizing multi-digit numbers. For example, in the expression "23-4*5"
, the 23
should be treated as a single number, not as 2
and 3
.
Incorrect Approach:
# Wrong: This would treat "23" as separate digits
for i, c in enumerate(expression):
if c.isdigit():
# Process each digit separately - WRONG!
results.append(int(c))
Correct Solution: The provided code handles this correctly by:
- Using
exp.isdigit()
to check if the entire substring is a number - Only splitting at operator positions, preserving multi-digit numbers intact
- The recursive calls with
exp[:i]
andexp[i+1:]
maintain number integrity
2. Missing Base Case for Multi-Digit Numbers
Another pitfall is having an incomplete base case that only checks for single digits:
Incorrect Base Case:
# Wrong: Only handles single digits
if len(exp) == 1 and exp.isdigit():
return [int(exp)]
Correct Solution:
# Correct: Handles any length number
if exp.isdigit():
return [int(exp)]
This correctly identifies strings like "123"
as base cases and converts them to integers.
3. Forgetting to Cache or Using Incorrect Memoization
Without proper memoization, the algorithm becomes extremely inefficient due to redundant calculations:
Inefficient Approach:
def compute_ways(expr: str) -> List[int]:
# No @cache decorator - will recompute same subproblems
if expr.isdigit():
return [int(expr)]
# ... rest of the code
Issues with Manual Memoization:
# Wrong: Using mutable default argument
def compute_ways(expr: str, memo={}) -> List[int]:
if expr in memo:
return memo[expr]
# ... computation
memo[expr] = results
return results # Dangerous: memo persists across function calls
Correct Solution:
Using @cache
decorator ensures proper memoization without the pitfalls of manual implementation.
4. Incorrect Operator Detection
A subtle but critical mistake is incorrectly identifying operator positions:
Wrong Approach:
# This might miss operators or include wrong characters
operators = []
for i in range(len(expression)):
if not expression[i].isdigit():
operators.append(i)
Correct Solution:
for i, c in enumerate(exp):
if c in '-+*': # Explicitly check for valid operators
5. Modifying the Results List Instead of Creating New Ones
When dealing with recursive calls and memoization, modifying shared lists can lead to incorrect results:
Problematic Code:
@cache
def compute_ways(expr: str) -> List[int]:
results = [] # This is fine
# ... computation
return results # Returning a new list each time is correct
What to Avoid:
# Wrong: Modifying a shared list
global_results = []
def compute_ways(expr: str):
global_results.clear() # Don't do this!
# ... append to global_results
return global_results
The correct approach creates a fresh list for each recursive call, ensuring independence between different branches of computation.
What is an advantages of top-down dynamic programming vs bottom-up dynamic programming?
Recommended Readings
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Memoization Prereq Backtracking problems backtracking Memoization is a fancy word for a simple concept so is the case for a lot of things we learn in school It means saving the previous function call result in a dictionary and reading from it when we do the exact same call again
Math for Technical Interviews How much math do I need to know for technical interviews The short answer is about high school level math Computer science is often associated with math and some universities even place their computer science department under the math faculty However the reality is that you
Want a Structured Path to Master System Design Too? Donβt Miss This!