2025, Dec 18 18:02

Разделение выражения по операторам верхнего уровня без разрыва скобок

Как корректно разделить математическое выражение по операторам верхнего уровня, сохраняя скобки. Простой токенайзер на Python и почему regex здесь не работает.

Разделите математическое выражение по операторам верхнего уровня, не нарушая скобки

Разбивать математическую строку по операторам кажется простым, пока не нужно сохранить целостность всего, что находится внутри согласованных скобок. Задача — делить по +, -, *, / только тогда, когда они стоят на верхнем уровне, а вложенные подвыражения и имена вроде math.sqrt или символы вроде π оставлять нетронутыми внутри своих скобок.

Демонстрация задачи

Рассмотрим эти выражения и желаемое разбиение. Операторы вне скобок должны выступать разделителями; всё, что находится внутри сбалансированных скобок, остаётся одним куском.

import math
s1 = "5+5*10"
expected1 = ["5", "+", "5", "*", "10"]
s2 = "(2*2)-5*(math.sqrt(9)+2)"
expected2 = ["(2*2)", "-", "5", "*", "(math.sqrt(9)+2)"]
s3 = "(((5-3)/2)*0.5)+((2*2))*(((math.log(5)+2)-2))"
expected3 = ["(((5-3)/2)*0.5)", "+", "((2*2))", "*", "(((math.log(5)+2)-2))"]

Заманчиво сделать прямой split по регулярному выражению, например:

import re
expr = "(((5-3)/2)*0.5)+((2*2))*(((math.log(5)+2)-2))"
parts = re.split(r"([\+|\-|\*|\/]|\(.*\))", expr)

Но это не учитывает баланс скобок и ломается на вложенных структурах.

Почему наивный regex не работает

Совпадение по операторам само по себе несложно, но чтобы сохранить подвыражения с произвольной вложенностью скобок, нужно отслеживать текущую глубину внутри скобок. Простые шаблоны вроде (.*) жадные и не понимают балансировки, а обычный split не видит вложенность. В итоге либо происходит избыточное разбиение внутри скобок, либо, наоборот, захватывается слишком много.

Простой токенайзер с отслеживанием состояния

Небольшой самописный токенайзер решает задачу аккуратно. Идея в том, чтобы пройти строку потоком, вести счётчик текущей глубины скобок и делить по операторам только тогда, когда глубина равна нулю. Если предполагается синтаксически корректный ввод, всё довольно просто.

OPS_SET = set("+-*/")
PAREN_SHIFT = {"(": 1, ")": -1}
def split_top_level(expr: str) -> list[str]:
    tokens = [""]
    depth = 0
    for ch in expr:
        depth += PAREN_SHIFT.get(ch, 0)
        if ch in OPS_SET:
            if depth == 0:
                tokens.extend([ch, ""])
                continue
        tokens[-1] += ch
    return tokens
samples = [
    "5+5*10",
    "(2*2)-5*(math.sqrt(9)+2)",
    "(((5-3)/2)*0.5)+((2*2))*(((math.log(5)+2)-2))",
]
for item in samples:
    print(split_top_level(item))

Это даёт ожидаемые токены, сохраняя внутреннюю структуру и имена внутри скобок.

['5', '+', '5', '*', '10']
['(2*2)', '-', '5', '*', '(math.sqrt(9)+2)']
['(((5-3)/2)*0.5)', '+', '((2*2))', '*', '(((math.log(5)+2)-2))']

Зачем это нужно

Когда вы можете надёжно делить по операторам верхнего уровня, на этом легко строить более высокий разбор или интерпретацию, избегая ловушек regex-подходов, которые не понимают вложенность. Если конечная цель — вычислять выражения, стоит посмотреть на алгоритм «сортировочной станции». Кроме того, такой подход с отслеживанием состояния для этой задачи заметно быстрее, чем split на основе re.

Итог

Когда нужно сегментировать математические строки, сохраняя целостность фрагментов с балансом скобок, не используйте split по регулярному выражению — лучше явно отслеживайте глубину скобок. Пройдите вход один раз, делите только при нулевой глубине и сохраняйте всё внутри согласованных скобок как единое целое, включая буквенные токены вроде math.sqrt или π. Это даёт предсказуемые токены и надёжную основу для дальнейшей обработки или вычисления.