Введение

Целью данной статьи является продемонстрировать способ вычисления алгебраического выражения, представленного в виде строки, посредством преобразования из инфиксной формы в постфиксную и парсинга (англ. parse - разбор) преобразованной строки.

Перед прочтением рекомендуется прочитать следующее:

  1. Стек;

  2. «Унарный», «бинарный», «операнд»;

  3. C# 10 и .NET 6.


Префиксная, инфиксная и постфиксная формы

Инфиксная форма - самая распространнёная форма, так как человека она проще для представления. Она представляет из себя выражение, в котором операторы располагаются между операндами. Отсюда исходит название данной формы.
Пример инфиксной формы:

2+2=4

Префиксная же форма представляет из себя выражение, в котором операторы находятся перед операндами:

+\ 2\ 2\ = 4

Соответственно, постфиксная форма представляет из себя выражение, в котором оператор находится после операндов:

2\ 2\ +=4

Для вычисления выражения, записанного в инфиксной формы, необходимо его предварительно проанализировать с учётом старшинства операторов и скобок. В свою очередь, префиксная и постфиксная формы такого не требуют, так как операторы записываются в порядке их вычисления и без скобок.

Выражения, записанные в префиксном или постфиксном виде, также называют бесскобочной или польской записью. Их называют польскими в честь автора - польского математика Ян Лукасевича.

Более подробно об представленных формах записи алгебраических выражений можно прочитать в Википедии.

Алгоритм Дейкстра

Для преобразования в постфиксную форму будем использовать улучшенный Эдсгером Вибе Дейкстрой алгоритм.

Принцип работы алгоритма Дейкстра:

  • Проходим исходную строку;

  • При нахождении числа, заносим его в выходную строку;

  • При нахождении оператора, заносим его в стек;

  • Выталкиваем в выходную строку из стека все операторы, имеющие приоритет выше рассматриваемого;

  • При нахождении открывающейся скобки, заносим её в стек;

  • При нахождении закрывающей скобки, выталкиваем из стека все операторы до открывающейся скобки, а открывающуюся скобку удаляем из стека.

Реализация алгоритма Дейкстры

Реализуем класс Mather, в котором определим приватные поля infixExpr для хранения инфиксного выражения, postfixExprдля постфиксного выражения и operationPriority, в котором определим список всех операторов и их приоритет:

public class Mather
{
  	//	Хранит инфиксное выражение
    public string infixExpr {get; private set; }
  	//	Хранит постфиксное выражение
    public string postfixExpr { get; private set; }

  	//	Список и приоритет операторов
    private Dictionary<char, int> operationPriority = new() {
        {'(', 0},
        {'+', 1},
        {'-', 1},
        {'*', 2},
        {'/', 2},
        {'^', 3},
        {'~', 4}	//	Унарный минус
    };

  	//	Конструктор класса
    public Mather(string expression)
    {
      	//	Инициализируем поля
        infixExpr = expression;
        postfixExpr = ToPostfix(infixExpr + "\r");
    }
}

В поле operationPriority скобка ('(') определена лишь для того, чтобы затем не выдавало ошибки при парсинге, а тильда ('~') добавлена для упрощения последующего разбора и представляет собой унарный минус.

Добавим приватный метод GetStringNumber, предназначенный для парсинга целочисленных значений:

/// <summary>
/// Парсинг целочисленных значений
/// </summary>
/// <param name="expr">Строка для парсинга</param>
/// <param name="pos">Позиция</param>
/// <returns>Число в виде строки</returns>
private string GetStringNumber(string expr, ref int pos)
{
  //	Хранит число
  string strNumber = "";
  
  //	Перебираем строку
  for (; pos < expr.Length; pos++)
  {
    //	Разбираемый символ строки
    char num = expr[pos];
	
    //	Проверяем, является символ числом
    if (Char.IsDigit(num))
      //	Если да - прибавляем к строке
      strNumber += num;
    else
    {
      //	Если нет, то перемещаем счётчик к предыдущему символу
      pos--;
      //	И выходим из цикла
      break;
    }
  }

  //	Возвращаем число
  return strNumber;
}

Далее создадим метод ToPostfix , который будет конвентировать в обратную польскую (постфиксную) запись:

private string ToPostfix(string infixExpr)
{
    //	Выходная строка, содержащая постфиксную запись
    string postfixExpr = "";
    //	Инициализация стека, содержащий операторы в виде символов
    Stack<char> stack = new();

		//	Перебираем строку
    for (int i = 0; i < infixExpr.Length; i++)
    {
      //	Текущий символ
      char c = infixExpr[i];
      
      //	Если симовол - цифра
      if (Char.IsDigit(c))
      {
         //	Парсии его, передав строку и текущую позицию, и заносим в выходную строку
       	 postfixExpr += GetStringNumber(infixExpr, ref i) + " ";
      }
      //	Если открывающаяся скобка 
      else if (c == '(')
      {
        	//	Заносим её в стек
        	stack.Push(c);
      }
      //	Если закрывающая скобка
      else if (c == ')')
      {
        	//	Заносим в выходную строку из стека всё вплоть до открывающей скобки
          while (stack.Count > 0 && stack.Peek() != '(')
              postfixExpr += stack.Pop();
        	//	Удаляем открывающуюся скобку из стека
          stack.Pop();
      }
      //	Проверяем, содержится ли символ в списке операторов
      else if (operationPriority.ContainsKey(c))
      {
        //	Если да, то сначала проверяем
        char op = c;
        //	Является ли оператор унарным символом
        if (op == '-' && (i == 0 || (i > 1 && operationPriority.ContainsKey( infixExpr[i-1] ))))
          //	Если да - преобразуем его в тильду
          op = '~';
				
        //	Заносим в выходную строку все операторы из стека, имеющие более высокий приоритет
        while (stack.Count > 0 && ( operationPriority[stack.Peek()] >= operationPriority[op]))
        		postfixExpr += stack.Pop();
				//	Заносим в стек оператор
        stack.Push(op);
      }
    }
  	//	Заносим все оставшиеся операторы из стека в выходную строку
    foreach (char op in stack)
      	postfixExpr += op;

  	//	Возвращаем выражение в постфиксной записи
    return postfixExpr;
}

Алгоритм вычисления постфиксной записи

После получения постфиксной записи, необходимо вычислить её значение. Для этого воспользуемся алгоримом очень похожим на прошлый алгоритмом, только этот будет использовать всего один стек.

Разберём принцип работы данного алгоритма:

  • Проходим постфиксную запись;

  • При нахождении числа, парсим его и заносим в стек;

  • При нахождении бинарного оператора, берём два последних значения из стека в обратном порядке;

  • При нахождении унарного оператора, в данном случае - унарного минуса, берём последнее значение из стека и вычитаем его из нуля, так как унарный минус является правосторонним оператором;

  • Последнее значение, после отработки алгоритма, является решением выражения.

Реализация алгоритма вычисления постфиксной записи

Создадим приватный метод Execute, который будет выполнять операции, соответствующие оператору и возвращать результат:

/// <summary>
/// Вычисляет значения, согласно оператору
/// </summary>
/// <param name="op">Оператор</param>
/// <param name="first">Первый операнд (перед оператором)</param>
/// <param name="second">Второй операнд (после оператора)</param>
private double Execute(char op, double first, double second) => op switch {
    '+' => first + second,					//	Сложение
    '-' => first - second,					//	Вычитание
    '*' => first * second,					//	Умножение
    '/' => first / second,					//	Деление
    '^' => Math.Pow(first, second),	//	Степень
    _ => 0	//	Возвращает, если не был найден подходящий оператор
};

Теперь реализуем сам алгоритм, создав метод Calc, в котором определим следующее:

public double Calc()
{
  	//	Стек для хранения чисел
    Stack<double> locals = new();
  	//	Счётчик действий
    int counter = 0;

  	//	Проходим по строке
    for (int i = 0; i < postfixExpr.Length; i++)
    {
      	//	Текущий символ
        char c = postfixExpr[i];
				
      	//	Если символ число
        if (Char.IsDigit(c))
        {
          	//	Парсим
            string number = GetStringNumber(postfixExpr, ref i);
          	//	Заносим в стек, преобразовав из String в Double-тип
            locals.Push(Convert.ToDouble(number));
        }
      	//	Если символ есть в списке операторов
        else if (operationPriority.ContainsKey(c))
        {
          	//	Прибавляем значение счётчику
            counter += 1;
          	//	Проверяем, является ли данный оператор унарным
            if (c == '~')
            {
              	//	Проверяем, пуст ли стек: если да - задаём нулевое значение,
              	//	еси нет - выталкиваем из стека значение
                double last = locals.Count > 0 ? locals.Pop() : 0;

              	//	Получаем результат операции и заносим в стек
                locals.Push(Execute('-', 0, last));
              	//	Отчитываемся пользователю о проделанной работе
                Console.WriteLine($"{counter}) {c}{last} = {locals.Peek()}");
              	//	Указываем, что нужно перейти к следующей итерации цикла
              	//	 для того, чтобы пропустить остальной код
                continue;
            }
						
          	//	Получаем значения из стека в обратном порядке
            double second = locals.Count > 0 ? locals.Pop() : 0,
            first = locals.Count > 0 ? locals.Pop() : 0;
						
          	//	Получаем результат операции и заносим в стек
            locals.Push(Execute(c, first, second));
          	//	Отчитываемся пользователю о проделанной работе
            Console.WriteLine($"{counter}) {first} {c} {second} = {locals.Peek()}");
        }
    }
		
  	//	По завершению цикла возвращаем результат из стека
    return locals.Pop();
}

Испытание алгоритмов

Попробуем пропустить выражение 15/(7-(1+1))*3-(2+(1+1))*15/(7-(200+1))3-(2+(1+1))(15/(7-(1+1))*3-(2+(1+1))+15/(7-(1+1))*3-(2+(1+1))) через составленный алгоритм и посмотрим, верно ли он работает. Для ориентирования ниже представлен вариант решения от Яндекса.

Вариант решения от Яндекса
Вариант решения от Яндекса

Код программы:

using MatherExecuter;

public class Program
{
    static public void Main()
    {
        string expression = "15/(7-(1+1))*3-(2+(1+1))*15/(7-(200+1))*3-(2+(1+1))*(15/(7-(1+1))*3-(2+(1+1))+15/(7-(1+1))*3-(2+(1+1)))";
        Mather mather = new(expression);

        Console.WriteLine("Ввод: " + mather.infixExpr);
        Console.WriteLine("Постфиксная форма: " + mather.postfixExpr);
        Console.WriteLine("Итого: " + mather.Calc());
    } 
}

Запустив данный код, мы получим следующее:

Результат работы алгоритма
Результат работы алгоритма

Итоги

Хоть реализованные здесь алгоритмы и работают, однако тут не учтены пробелы между символами, дробные значения, проверка на нуль в операции деления, не реализованы функции и тому подобное, поэтому данный код предствален лишь как пример.

Рекомендуется прочитать эту статью. В данной статье автор рассказывает, как реализовать полноценный интерпретатор математических выражений с поддержкой переменных, констант и функций. Однако в этой статье автор строит интерпретатор с использованием анализатора и парсера.

Комментарии (16)


  1. zede
    21.12.2021 14:06
    +7

    Ну почему по первым же строкам становится видно, что это статья студента?

    Отослать в википедию для чтения о алгербраических нотациях, где указана лишь польская нотация, которая, вообще не использована в статье. Ну и если уже расписывать, то лучше было бы упомянуть, что постфиксная нотация - это не просто польская нотация, а обратная польская нотация(ОПН). Для примеров, почему-то, в инфиксной нотации использовались пробелы, в то время как "22+=4" вызывает смятение, пока не дойдет, что 2 числа просто слепили вместе. С каких пор вначале статьи на хабре рекомендуют читать самоучитель по С# перед прочтением статьи?


    1. Ertanic Автор
      21.12.2021 15:02

      Информация про постфиксную, инфиксную и префиксную нотации была приведена для справки, не было целью расписывать данные моменты, а лишь показать наглядную реализацию алгоритмов.

      Префиксная и постфиксная нотации - являются польскими записями, постфиксная нотация также называется обратной польской записью, поэтому они взаимозаменяемые.

      На счёт пробелов в примерах все вопросы к Хабру, так как редактор формул не позволяет ставить пробелы.

      Ссылка на самоучитель была привидена для людей, плохо знакомых с C#.


      1. rahmaevao
        22.12.2021 07:19

        2\;\;\;2+=22

        \; -толстый пробел; \: средний; \, тонкий; \! - "отрицательный" пробел


        1. Ertanic Автор
          23.12.2021 16:30

          Спасибо, поправил.


  1. Rsa97
    21.12.2021 14:06
    +1

    Остаётся вопрос, а зачем вообще нужна строка с обратной записью? Почему вместо этого сразу не создать второй стек и не вычислить выражение?


    1. Ertanic Автор
      21.12.2021 15:02

      Для наглядности работы алгоритмов.


  1. GorchilinD
    21.12.2021 14:50
    +2

    На этом принципе работали советские программируемые микрокалькуляторы, Б3-34, МК-61, МК-52. Там чтобы сложить 2 + 2 надо было набрать 2, стрелочку вверх, 2 а после +. Сначала крайне неудобно, потом привыкаешь. После с "техникой молодежи" на Луну сажаешь космические корабли :)


  1. belch84
    21.12.2021 15:11
    +2

    Инфиксная, префиксная и постфиксная нотация соответствуют трем способам обхода вершин дерева выражения: Left-Node-Right (центрированный обход), Node-Left-Right (прямой обход), Left-Right-Node (обратный обход). Пришлось когда-то написать парсер выражений, преобразовывавший их в форму, удобную для (многократного) вычисления значений и других манипуляций (вроде аналитического дифференцирования). Только я использовал рекурсию, программа получается несколько проще, но могут возникнуть проблемы с динамическим распределением памяти

    Для наглядности работы алгоритмов
    Не только. Обратная польская запись позволяет записать выражение без скобок. Есть (был?) такой язык — Forth, в нем обратная польская запись была главным способом задания выражений


    1. forthuser
      21.12.2021 17:13
      +3

      Есть (был?) такой язык — Forth, в нем обратная польская запись была главным способом задания выражений

      Не совсем обратная т.к. в понимании Форт всё есть слова и цикл их трансляции во внутренности Форт системы достаточно примитивен по классике его построения.

      Хаб Хабра по Forth языку

      The Top 999 Forth Open Source Projects on Github, но их, конечно, больше и не только размещены в рамках Github.

      По версии IEEE Spectrum Top Programming Languages Форт входит в топ 50-ти языков.

      P.S. Действующий рускоязычный форум по Форт языку ????


  1. kolu4iy
    21.12.2021 16:57
    +1

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

    А интерпретатор я таки сделал, но потом. Не поверите, на visual foxpro. Да, не лучшее средство для подобных экспериментов, но мне надо было реализовать существующий алгоритм функционального тестирования некоего прибора с подробной визуализацией, интерпретатор использовался как скриптовый язык - для программирования поведения "вот этой кнопочки" и её воздействия на весь прибор. Потом и в диплом пошло :)

    Это все было лет 20 назад, да ещё и студент, так что не ругайтесь пожалста за микроскоп и гвозди.


  1. thegriglat
    22.12.2021 02:23

    Когда-то писал такую же штуку на lex и yacc (нынче bison), там парсинг, приоритеты и дерево операций строится тривиально, потом просто обходишь дерево как хочешь и вуаля


  1. belch84
    22.12.2021 10:51

    А интерпретатор я таки сделал, но потом. Не поверите, на visual foxpro
    На FoxPro (даже и не Visual, а на более древних версиях) все сильно упрощалось наличием макроподстановок (&), позволявших выполнять компиляцию прямо во время выполнения программы. Особенно просто это работает для числовых выражений, написав
    X = 0.345
    Y = "SIN(X)"
    ?&Y
    можно вывести на экран значение SIN(0.345), причем это работает, даже если строка для выражения будет прочитана из файла, будучи неизвестной во время компиляции программы. Конечно, здесь будут сложности с обработкой ошибок, поскольку при наличии синтаксической ошибки в выражении такой способ не сможет показать, в какой позиции выражения она возникла.


  1. BerkutEagle
    22.12.2021 12:55

    Оператор ~ в Execute лишний.

    locals.Push(Execute("-", 0, last));


    1. Ertanic Автор
      23.12.2021 16:35

      Спасибо за подсказку, не заметил. Поправил.


  1. shai_hulud
    22.12.2021 21:36

    Странно что нет ссылки на исползованный алгоритм:
    Shunting-yard algorithm

    Плюс пропущена фаза токенизации.


    1. Ertanic Автор
      23.12.2021 16:31

      Спасибо за ссылку, добавлю.

      Мне было лениво делать ещё и токенизацию, раз всё и без неё прекрасно работает :)