๐ŸŽ“ A1Learner
๐Ÿ”

DSA Basic Calculator

A basic calculator evaluates a string containing parentheses, unary minus and all four operators. A stack of frames plus a running sign handles nesting, and multiplication and division resolve before addition.

The problem

A basic calculator evaluates an arithmetic string. The string holds digits, the four operators, parentheses, unary minus and spaces. The answer is one integer.

Multiplication and division bind tighter than addition and subtraction. Parentheses override that order. Division truncates toward zero, so 10 / 4 gives 2 and -7 / 2 gives -3.

The cases used in this article

tests = [
    ("1 + 1", 2),
    ("2-(1+2)", -1),
    ("2+3*4", 14),
    ("14-3/2", 13),
    ("(1+(4+5+2)-3)+(6+8)", 23),
    ("-(2+3)*4+10/4", -18),
    (" 42 - 3 * (8 - 5) ", 33),
]
for text, expected in tests:
    print(repr(text), "=", expected)

The fifth case nests parentheses three levels deep. The sixth opens with a unary minus on a whole bracket. The last case pads everything with spaces.

Intuition: work strictly left to right

The first idea reads the string once and applies each operator as it arrives. Parentheses are skipped as decoration. The idea is simple, fast and wrong.

Left to right, ignoring precedence and brackets

def trunc_div(a, b):
    whole = abs(a) // abs(b)
    if (a < 0) == (b < 0):
        return whole
    return -whole


def left_to_right(text):
    tokens = []
    num = None
    for ch in text:
        if ch.isdigit():
            num = (num or 0) * 10 + int(ch)
        elif ch in "+-*/":
            if num is not None:
                tokens.append(num)
                num = None
            tokens.append(ch)
    if num is not None:
        tokens.append(num)
    total = tokens[0]
    i = 1
    while i < len(tokens):
        op = tokens[i]
        value = tokens[i + 1]
        if op == "+":
            total = total + value
        elif op == "-":
            total = total - value
        elif op == "*":
            total = total * value
        else:
            total = trunc_div(total, value)
        i += 2
    return total

print("2+3*4 left to right:", left_to_right("2+3*4"), " correct:", 14)
print("2-(1+2) left to right:", left_to_right("2-(1+2)"), " correct:", -1)

Both answers are wrong. The first adds before multiplying. The second subtracts before the bracket groups its values. Order is the whole problem, and a single left-to-right pass has nowhere to keep it.

Brute force: collapse the brackets, then fold in two passes

The brute force removes one pair of parentheses at a time. It finds the innermost bracket, evaluates the flat text inside it, and writes the number back into the string. The work repeats until no brackets remain.

A flat string is evaluated in two passes. The first pass folds multiplication and division into single values. The second pass folds addition and subtraction. A sign in front of a number folds into that number, which is how unary minus is handled.

The collapsing evaluator, counted

def trunc_div(a, b):
    whole = abs(a) // abs(b)
    if (a < 0) == (b < 0):
        return whole
    return -whole


def flat_eval(text, counter):
    counter[0] += len(text)
    tokens = []
    i = 0
    expect_num = True
    while i < len(text):
        ch = text[i]
        if ch == " ":
            i += 1
        elif ch.isdigit():
            j = i
            while j < len(text) and text[j].isdigit():
                j += 1
            tokens.append(int(text[i:j]))
            i = j
            expect_num = False
        elif ch in "+-" and expect_num:
            sign = 1
            while i < len(text) and text[i] in "+-":
                if text[i] == "-":
                    sign = -sign
                i += 1
            while i < len(text) and text[i] == " ":
                i += 1
            j = i
            while j < len(text) and text[j].isdigit():
                j += 1
            tokens.append(sign * int(text[i:j]))
            i = j
            expect_num = False
        else:
            tokens.append(ch)
            i += 1
            expect_num = True
    folded = [tokens[0]]
    i = 1
    while i < len(tokens):
        op = tokens[i]
        value = tokens[i + 1]
        if op == "*":
            folded[-1] = folded[-1] * value
        elif op == "/":
            folded[-1] = trunc_div(folded[-1], value)
        else:
            folded.extend([op, value])
        i += 2
    total = folded[0]
    i = 1
    while i < len(folded):
        if folded[i] == "+":
            total = total + folded[i + 1]
        else:
            total = total - folded[i + 1]
        i += 2
    return total


def evaluate_slow(text):
    counter = [0]
    rounds = 0
    while "(" in text:
        rounds += 1
        counter[0] += len(text)
        opening = text.rfind("(")
        closing = text.index(")", opening)
        inner = text[opening + 1:closing]
        value = flat_eval(inner, counter)
        text = text[:opening] + str(value) + text[closing + 1:]
    return flat_eval(text, counter), rounds, counter[0]

for text in ["2+3*4", "(1+(4+5+2)-3)+(6+8)", "-(2+3)*4+10/4"]:
    result, rounds, scanned = evaluate_slow(text)
    print(repr(text), "=", result, " collapse rounds:", rounds, " characters scanned:", scanned)

The answers are right. The cost is the rescanning. Every collapse round reads the whole string again, and the flat evaluation reads its piece again on top of that.

The optimal solution: one pass with a stack of frames

A frame is one level of parentheses with its own stack, its own running sign and its own pending operator, and a single left-to-right pass over a stack of frames evaluates the whole string. That one structure is the whole method.

The running sign is the plus or minus waiting for the next value. A minus with no value before it is unary, and it flips the running sign instead of starting a subtraction. Multiplication and division fold into the top stack value at once, which is what makes them bind tighter.

An opening bracket pushes the current frame and starts a fresh one. A closing bracket sums its frame and feeds that total into the parent frame as one value, under the parent's waiting sign and operator.

The frame evaluator on every case

def evaluate(text):
    def feed(frame, value):
        stack, sign, op = frame
        factor = sign * value
        if op == "*":
            stack[-1] = stack[-1] * factor
        elif op == "/":
            stack[-1] = int(stack[-1] / factor)
        else:
            stack.append(factor)
        frame[1] = 1
        frame[2] = None

    frames = []
    frame = [[], 1, None]
    num = 0
    has_num = False
    operand_done = False
    for ch in text + "=":
        if ch.isdigit():
            num = num * 10 + int(ch)
            has_num = True
        elif ch in "+-*/()= ":
            if ch == " ":
                continue
            if has_num:
                feed(frame, num)
                num = 0
                has_num = False
                operand_done = True
            if ch == "+":
                if operand_done:
                    frame[1] = 1
                operand_done = False
            elif ch == "-":
                if operand_done:
                    frame[1] = -1
                else:
                    frame[1] = -frame[1]
                operand_done = False
            elif ch in "*/":
                frame[2] = ch
                operand_done = False
            elif ch == "(":
                frames.append(frame)
                frame = [[], 1, None]
                operand_done = False
            elif ch == ")":
                value = sum(frame[0])
                frame = frames.pop()
                feed(frame, value)
                operand_done = True
    return sum(frame[0])

tests = [
    ("1 + 1", 2),
    ("2-(1+2)", -1),
    ("2+3*4", 14),
    ("14-3/2", 13),
    ("(1+(4+5+2)-3)+(6+8)", 23),
    ("-(2+3)*4+10/4", -18),
    (" 42 - 3 * (8 - 5) ", 33),
]
for text, expected in tests:
    result = evaluate(text)
    print(repr(text), "=", result, "expected:", expected, "match:", result == expected)

All seven cases pass in one pass each. A sentinel equals sign at the end flushes the final number through the same feed path as every other value.

Walkthrough: evaluating 2-(1+2)

The trace prints each feed and each bracket event. A feed writes one signed value into the current frame's stack, or folds it into the top value when an operator waits.

Feeds and bracket events, traced

def traced_evaluate(text):
    def feed(frame, value):
        stack, sign, op = frame
        factor = sign * value
        if op == "*":
            stack[-1] = stack[-1] * factor
        elif op == "/":
            stack[-1] = int(stack[-1] / factor)
        else:
            stack.append(factor)
        frame[1] = 1
        frame[2] = None
        print("feed", value, "with sign", sign, "-> stack:", stack)

    frames = []
    frame = [[], 1, None]
    num = 0
    has_num = False
    operand_done = False
    for ch in text + "=":
        if ch.isdigit():
            num = num * 10 + int(ch)
            has_num = True
        elif ch in "+-*/()= ":
            if ch == " ":
                continue
            if has_num:
                feed(frame, num)
                num = 0
                has_num = False
                operand_done = True
            if ch == "+":
                if operand_done:
                    frame[1] = 1
                operand_done = False
            elif ch == "-":
                if operand_done:
                    frame[1] = -1
                    print("minus after a value: running sign is now -1")
                else:
                    frame[1] = -frame[1]
                operand_done = False
            elif ch in "*/":
                frame[2] = ch
                operand_done = False
            elif ch == "(":
                frames.append(frame)
                frame = [[], 1, None]
                operand_done = False
                print("open bracket: fresh frame pushed")
            elif ch == ")":
                value = sum(frame[0])
                frame = frames.pop()
                print("close bracket: frame total", value, "returns to the parent")
                feed(frame, value)
                operand_done = True
    return sum(frame[0])

print("answer:", traced_evaluate("2-(1+2)"))

The bracket total of 3 returns to a parent whose running sign is -1, so it lands as -3 beside the 2. The final sum reads -1 straight off the parent stack. Nesting is just more frames on the same pile.

Complexity

The collapsing evaluator scans the whole string once per bracket round and again per flat evaluation, so its character visits grow with the product of length and nesting depth. The frame evaluator visits each character once, so its time is O(n) in the string length.

The frame stack holds one frame per open bracket, so its extra space is O(d) in the nesting depth. The counts below build strings of growing depth and compare characters scanned against characters in the string.

Counting scanned characters against string length

def trunc_div(a, b):
    whole = abs(a) // abs(b)
    if (a < 0) == (b < 0):
        return whole
    return -whole


def flat_eval(text, counter):
    counter[0] += len(text)
    tokens = []
    i = 0
    expect_num = True
    while i < len(text):
        ch = text[i]
        if ch == " ":
            i += 1
        elif ch.isdigit():
            j = i
            while j < len(text) and text[j].isdigit():
                j += 1
            tokens.append(int(text[i:j]))
            i = j
            expect_num = False
        elif ch in "+-" and expect_num:
            sign = 1
            while i < len(text) and text[i] in "+-":
                if text[i] == "-":
                    sign = -sign
                i += 1
            while i < len(text) and text[i] == " ":
                i += 1
            j = i
            while j < len(text) and text[j].isdigit():
                j += 1
            tokens.append(sign * int(text[i:j]))
            i = j
            expect_num = False
        else:
            tokens.append(ch)
            i += 1
            expect_num = True
    folded = [tokens[0]]
    i = 1
    while i < len(tokens):
        op = tokens[i]
        value = tokens[i + 1]
        if op == "*":
            folded[-1] = folded[-1] * value
        elif op == "/":
            folded[-1] = trunc_div(folded[-1], value)
        else:
            folded.extend([op, value])
        i += 2
    total = folded[0]
    i = 1
    while i < len(folded):
        if folded[i] == "+":
            total = total + folded[i + 1]
        else:
            total = total - folded[i + 1]
        i += 2
    return total


def slow_scans(text):
    counter = [0]
    while "(" in text:
        counter[0] += len(text)
        opening = text.rfind("(")
        closing = text.index(")", opening)
        value = flat_eval(text[opening + 1:closing], counter)
        text = text[:opening] + str(value) + text[closing + 1:]
    flat_eval(text, counter)
    return counter[0]

for depth in [1, 2, 4, 8, 16]:
    text = "1+(" * depth + "2" + ")" * depth
    print("nesting depth:", depth, "characters:", len(text), "slow scans:", slow_scans(text), " one pass visits:", len(text))

At depth 16 the collapsing evaluator scans about ten characters for every character in the string. The ratio keeps growing with depth. One pass never revisits anything.

Edge cases and pitfalls

A bare number evaluates to itself. Doubled minuses cancel, because each unary minus flips the sign once. Deep nesting is only more frames, and spaces may appear anywhere.

Edge cases and the division pitfall, run

def evaluate(text):
    def feed(frame, value):
        stack, sign, op = frame
        factor = sign * value
        if op == "*":
            stack[-1] = stack[-1] * factor
        elif op == "/":
            stack[-1] = int(stack[-1] / factor)
        else:
            stack.append(factor)
        frame[1] = 1
        frame[2] = None

    frames = []
    frame = [[], 1, None]
    num = 0
    has_num = False
    operand_done = False
    for ch in text + "=":
        if ch.isdigit():
            num = num * 10 + int(ch)
            has_num = True
        elif ch in "+-*/()= ":
            if ch == " ":
                continue
            if has_num:
                feed(frame, num)
                num = 0
                has_num = False
                operand_done = True
            if ch == "+":
                if operand_done:
                    frame[1] = 1
                operand_done = False
            elif ch == "-":
                if operand_done:
                    frame[1] = -1
                else:
                    frame[1] = -frame[1]
                operand_done = False
            elif ch in "*/":
                frame[2] = ch
                operand_done = False
            elif ch == "(":
                frames.append(frame)
                frame = [[], 1, None]
                operand_done = False
            elif ch == ")":
                value = sum(frame[0])
                frame = frames.pop()
                feed(frame, value)
                operand_done = True
    return sum(frame[0])

print("bare number:", evaluate("42"))
print("zero:", evaluate("0"))
print("doubled minus:", evaluate("--5"))
print("negated bracket of a negative:", evaluate("-(-3)"))
print("deep nesting:", evaluate("(((((1)))))"))
print("spaces everywhere:", evaluate("  12  +  8 "))
print("calculator division -7 / 2:", evaluate("-7/2"))
print("python floor division -7 // 2:", -7 // 2)

The last two lines show the division pitfall. Python's // operator floors, which sends -7 // 2 down to -4. The calculator's rule truncates toward zero and gives -3. Any evaluator built on // alone fails exactly the negative divisions.

Exercise

What happens in this evaluator when a closing parenthesis is read?