5 марта 2023
Тэги: Java, maven, алгоритмы, головоломки, руководство.
Давайте разберёмся, как можно вычислять арифметические выражения. Предположим, на вход нам поступает строка текста, которая содержит корректное арифметическое выражение.
Это выражение состоит из пробелов, чисел, скобок и знаков, обозначающих основные математические действия (плюс, минус, умножить, разделить). Нам нужно разобрать это выражение на отдельные элементы, а затем вычислить результат с учётом приоритетов математических операций. Например, умножение и деление должно выполняться раньше, чем сложение и вычитание. А часть выражения, заключённая в скобки, имеет приоритет над другими частями.
Обработку такого выражения можно разделить на три основных этапа:
Разберём каждую из этих частей подробнее.
Символы, сгруппированные по некоторому признаку, называются токеном. Лексический анализ или токенизация – процесс разбиения входной строки на отдельные токены. В нашем случае каждое число – это отдельный токен. Также токенами являются открывающая скобка, закрывающая и знаки математических операций. Пробелы тоже являются токенами, но на вычисление они никак не влияют, поэтому мы их будем пропускать.
Наличие или отсутствие пробелов никак не должно влиять на корректность разбиения на токены. Если вместе с числом слитно написана скобка или знак плюс, мы должны на выходе всё равно получить два токена.
Такой парсинг можно было бы реализовать и вручную по следующему алгоритму. Проходимся по каждому символу строки и проверяем его:
Однако писать такой код утомительно и можно легко ошибиться. Благо в Java есть стандартный класс для этого – StringTokenizer. Он разбивает исходную строку на подстроки по указанным разделителям и далее мы можем пройтись по ним в цикле.
Давайте создадим класс Lexer и сразу добавим в него константу с разделителями в виде строки:
Обратите внимание, что среди разделителей присутствует пробел.
Теперь давайте создадим перечисление TokenType, представляющее тип токена:
Как видите, токен может быть числом, открывающей скобкой, закрывающей, а также бинарной операцией. Под бинарной операцией здесь понимается любая математическая операция с двумя аргументами.
Сам токен представим в виде интерфейса:
Его реализуют несколько record-классов, по одному на каждый тип. В каждом таком классе может быть свой набор дополнительных полей.
NumberToken – представляет собой число и содержит его значение:
BinaryOperationToken – токен бинарной операции:
Он содержит поле OperationType, которое также является перечислением:
Для скобок создадим ещё одну реализацию интерфейса Token:
Теперь вернёмся к лексеру и добавим в него метод getTokens():
Конструктор класса StringTokenizer принимает на вход исходную строку, разделители и флаг, указывающий на то, что разделители также нужно вернуть как отдельные токены. Это такой небольшой лайфхак, т.к. мы не должны потерять скобки и математические знаки.
Далее в цикле while с помощью методов hasMoreTokens() и nextToken() проходимся по каждому токену:
Если токен содержит пробел – пропускаем его.
Далее пытаемся проверить, не является ли токен числом? Если является, то создаём NumberToken и преобразуем строку токена в число. Саму проверку на принадлежность токена к числу выполняем тривиально:
Проходимся по каждому символу токена и вызываем для него метод Character.isDigit(). Если он вернёт false – сразу говорим, что это НЕ число.
Если же токен не пробел и не число, значит, по условию задачи, он является математической операцией или скобкой. Создаём соответствующий токен:
В итоге в списке tokens у нас окажутся все интересующие нас подстроки, уже имеющие соответствующий тип. И среди них гарантированно нет пробелов.
После лексического анализа приступаем к парсингу. Здесь нам предстоит учесть все правила математики и «перетасовать» наши токены так, чтобы в дальнейшем их было легко вычислить.
По большому счёту для парсинга арифметических выражений есть два способа: абстрактное синтаксическое дерево и обратная польская нотация. Будем использовать последнюю, т.к. она проще в реализации.
Польская нотация (прямая) – это форма записи арифметических выражений, которую придумал польский логик Ян Лукасевич. В ней сначала записывается математическое действие, а затем два аргумента для неё:
Интересной особенностью польской нотации является то, что в ней не требуются скобки (если все операторы бинарные).
Существует также обратная польская нотация, которую разработал Чарльз Хэмблин на основе «прямой» польской нотации. В ней сначала идут аргументы (числа), а затем действие над ними:
Обратите внимание, что в такой записи сначала идёт правый аргумент (делитель), а затем левый (делимое). Так уж получилось, что именно обратная польская нотация идеально подходит для вычислений на стеке благодаря простоте реализации.
Стек реализует принцип LIFO («последним пришёл – первым вышел»). Ближайшая аналогия – это стопка книг. Подробнее про стек можно почитать в статье Коллекции: очередь и стек.
Теперь реализуем алгоритм, который будет конвертировать набор токенов из привычной нам записи в обратную польскую нотацию. Этот алгоритм разработал Эдсгер Дейкстра. Он назвал его «алгоритм сортировочной станции» за сходство алгоритма с процессом сортировки вагонов на железной дороге с использованием третьего пути.
Создадим метод convertToPostfix() и сразу заведём список токенов для обратной польской записи и стек для математических операций:
Затем проходимся по каждому токену в исходной записи и проверяем его тип. Если это число – сразу добавляем в результат:
Если это открывающая скобка – добавляем её в стек операций:
Если закрывающая скобка – извлекаем из стека все операции и добавляем их в результат, пока не дойдём до открывающей скобки. Затем просто удаляем открывающую скобку из стека:
Если же перед нами бинарная операция – извлекаем из стека все операции, у которых более высокий приоритет. Затем заносим текущую операцию в стек:
Вспомогательный метод getPriority() выглядит так:
Как видите, умножение и деление имеют более высокий приоритет, чем сложение и вычитание. У открывающей скобки самый низкий приоритет.
После выхода из цикла добавляем к результату всё, что осталось в стеке:
Теперь нам осталось взять наше выражение в обратной польской нотации и вычислить его на стековой машине.
Как нетрудно догадаться, стековая машина производит вычисления на стеке значений. Она извлекает сначала один аргумент (правый), затем второй (левый), а затем выполняет над ними указанную операцию и заносит результат на вершину стека. Если обратная польская нотация корректна, то после завершения её обработки в стеке будет ровно 1 элемент, который и является результатом вычисления исходного арифметического выражения.
Создадим класс StackMachine и метод evaluate(). В нём создадим стек значений valueStack. Потом последовательно проходимся по токенам в обратной польской нотации. Если токен число – помещаем его на вершину стека.
Иначе если это бинарная операция, то извлекаем из стека два аргумента для неё: сначала правый, затем левый. Обратите внимание, что других типов токенов, кроме числа и операций здесь уже не будет.
Ну а далее выполняем действие, соответствующее типу операции и помещаем результат на вершину стека. При делении важно проверить правый аргумент (делитель), чтобы он не был равен нулю.
После выхода из цикла извлекаем единственный оставшийся элемент из стека – это и есть результат наших вычислений:
Как видите, обратная польская нотация позволяет довольно легко вычислять арифметические выражения.
Теперь вызовем последовательно лексер, конвертер и стековую машину, чтобы обработать исходное выражение целиком:
Теперь протестируем нашу реализацию:
Рассмотренная в данной статье реализация калькулятора доступна на github.
Наш калькулятор на текущий момент может обрабатывать только целые числа, представленные типом int. Если передать более «длинное» число, то при парсинге будет ошибка. Также хотелось бы обрабатывать десятичные дроби. Всё это довольно легко сделать, если заменить int на BigDecimal, а также доработать метод isNumber() в лексере, чтобы он допускал наличие десятичной точки в числе. При делении BigDecimal в стековой машине нужно учесть некоторые особенности округления, о которых я писал в статье Советы по работе с BigDecimal в Java.
Ну а дальше если к этому калькулятору добавить поддержку переменных, функций и управляющие конструкции (хотя бы if, else и while), то вы получите простейший интерпретатор языка программирования.
Kotlin, Java, Spring, Spring Boot, Spring Data, SQL, PostgreSQL, Oracle, Linux, Hibernate, Collections, Stream API, многопоточность, файлы, Nginx, Apache, maven, gradle, JUnit, YouTube, новости, руководство, ООП, алгоритмы, головоломки, rest, GraphQL, Excel, XML, json, yaml.
19.02.2024 21:54 Кот2301
Спасибо, очень помогло