Деревья выражений

Модуль "Выражения" • урок 2 из 7 • ~8 минут

Суть урока. Любое выражение (например, A + B * C) естественно представимо в виде двоичного дерева: листья - операнды, внутренние узлы - операции. Приоритет операций однозначно задается структурой (то, что глубже, вычисляется раньше), поэтому скобки дереву не нужны.

Три классических обхода такого дерева порождают три формы записи из прошлого урока: КЛП - префиксную, ЛКП - инфиксную, ЛПК - постфиксную (ОПЗ).

Что такое дерево выражения

Дерево выражения - это двоичное дерево. Главное правило его построения:

Дерево автоматически хранит приоритет операций своей структурой: операции на нижних ярусах дерева (глубже) должны быть вычислены в первую очередь! Это позволяет полностью отказаться от использования скобок при вычислениях.

Почему дерево именно двоичное? Это наследие архитектуры: базовые машинные операции двуместны - процессор складывает, вычитает и умножает ровно два числа. У любой операции не больше двух операндов, поэтому и у любого узла не больше двух сыновей.

Деревья выражений: дерево A + (B * C) - в корне сложение, умножение лежит глубже, в листьях операнды
Дерево выражения: листья - операнды, внутренние узлы - операции, приоритет задан глубиной

Обходы дерева дают формы записи

В прошлом уроке мы познакомились с тремя формами записи. Теперь видно, откуда они берутся: это три классических обхода дерева выражения.

Деревья выражений: три обхода дерева A + B * C - КЛП дает префиксную запись, ЛКП инфиксную, ЛПК постфиксную A B C * +
Три обхода одного дерева дают три формы записи; номера - порядок, в котором обход берет узлы

Деревья выражений в коде

Опишем узел дерева на языке C. В узле-операции заполнено поле op, в узле-листе - поле value:

#include <stdio.h>

// Узел дерева выражения
typedef struct Node {
    char op;       // Операция ('+', '-', '*') или '\0', если это операнд
    int value;     // Значение числа (если это лист)
    struct Node *left, *right;
} Node;

Насколько тесно обходы связаны с формами записи, лучше всего видно по коду. Чтобы перевести дерево в ОПЗ, достаточно распечатать его концевым обходом (ЛПК) - вся "трансляция" умещается в несколько строк:

// Концевой обход (ЛПК) печатает выражение в обратной польской записи
void PrintPostfix(Node* root) {
    if (root == NULL) return;
    PrintPostfix(root->left);        // сначала левое поддерево
    PrintPostfix(root->right);       // затем правое
    if (root->left == NULL && root->right == NULL)
        printf("%d ", root->value);  // лист - печатаем операнд
    else
        printf("%c ", root->op);     // внутренний узел - операцию
}

Вычисляется готовое дерево так же просто - рекурсивно "снизу вверх". По сути это тот же концевой обход (ЛПК): сначала узнаем значения поддеревьев, затем применяем операцию корня.

// Рекурсивное вычисление (снизу вверх)
int Evaluate(Node* root) {
    if (root->left == NULL && root->right == NULL) {
        return root->value; // Если лист - просто возвращаем число
    }

    // Сначала считаем левое и правое поддеревья (спускаемся в глубину)
    int left_val = Evaluate(root->left);
    int right_val = Evaluate(root->right);

    // Затем применяем операцию корня к результатам
    switch(root->op) {
        case '+': return left_val + right_val;
        case '*': return left_val * right_val;
        case '-': return left_val - right_val;
        case '/': return left_val / right_val;
    }
    return 0;
}
А как в Python и C++? В Python есть стандартный модуль ast (Abstract Syntax Tree). При запуске любого скрипта интерпретатор Python сначала парсит текстовый код и строит из него огромное дерево абстрактного синтаксиса. Используя import ast, вы можете программно "походить" по дереву своего же кода, чтобы написать линтер (поиск ошибок) или изменить логику выполнения скрипта на лету. В C++ для обхода деревьев выражений часто применяют паттерн "Посетитель" (Visitor): узлы разных типов наследуются от абстрактного класса Expr, но сама логика рекурсивного обхода полностью повторяет наш код на C.

Как получить дерево из строки?

Мы научились обходить и вычислять готовое дерево. Но компилятор получает выражение обычной строкой в инфиксной форме - и должен разобрать ее сам. Этому посвящена вся вторая половина модуля: алгоритм Рутисхаузера (урок 3), алгоритм Бауэра-Замельзона (урок 4) и сортировочная станция Дейкстры (урок 5).

Есть и еще один путь, любимый современными компиляторами, - разбор по формальной грамматике. Он выходит за рамки экзаменационных билетов, поэтому спрятан под кат.

Теория глубже: разбор по грамматике (рекурсивный спуск)

Компиляторы описывают выражения формальной грамматикой, а парсер строится прямо по ее правилам: каждому правилу соответствует своя функция.

Показать теорию

Простейшая грамматика арифметических выражений выглядит так:

expr -> term   | expr + term | expr - term
term -> fact   | term * fact | term / fact
fact -> (expr) | цифра | буква

Читается это так: выражение (expr) - цепочка слагаемых (term), слагаемое - цепочка множителей (fact), а множитель - это операнд или целое подвыражение в скобках. Обратите внимание: приоритет операций зашит прямо в грамматику. Умножение "живет" на уровень глубже сложения, поэтому в дереве оно автоматически окажется ниже и выполнится раньше.

По такой грамматике парсер пишется почти механически: каждому правилу - своя функция, а вместо явного стека работает обычная рекурсия. Функции вызывают друг друга по кругу expr -> term -> fact -> expr - это косвенная рекурсия, поэтому в C нужен прототип (а в Паскале - опережающее объявление forward). Для простоты узел здесь хранит одну литеру, а диагностику ошибок мы опускаем:

typedef struct TNode {
    char data;                        // литера: операнд или знак операции
    struct TNode *left, *right;
} TNode;

TNode* makenode(char data, TNode* l, TNode* r) {
    TNode* t = malloc(sizeof(TNode));
    t->data = data;
    t->left = l;
    t->right = r;
    return t;
}

int ch;                               // текущая литера выражения
void next(void) { ch = getchar(); }

TNode* expr(void);                    // прототип: expr -> term -> fact -> expr

TNode* fact(void) {                   // множитель: операнд или (expr)
    TNode* t;
    if (ch == '(') {
        next();                       // пропускаем '('
        t = expr();                   // внутри скобок - целое подвыражение
        next();                       // пропускаем ')'
    } else {
        t = makenode(ch, NULL, NULL); // лист: цифра или переменная
        next();
    }
    return t;
}

TNode* term(void) {                   // слагаемое: цепочка множителей
    TNode* t = fact();
    while (ch == '*' || ch == '/') {
        char op = ch;
        next();
        t = makenode(op, t, fact());  // накопленное уходит в левое поддерево
    }
    return t;
}

TNode* expr(void) {                   // выражение: цепочка слагаемых
    TNode* t = term();
    while (ch == '+' || ch == '-') {
        char op = ch;
        next();
        t = makenode(op, t, term());
    }
    return t;
}

TNode* parse(void) { next(); return expr(); }

Такой парсер называют полурекурсивным: цепочки операций одного приоритета он обрабатывает итеративно (циклы while в term и expr - именно они дают левую ассоциативность: a-b-c собирается как (a-b)-c), а вглубь, в скобки, спускается рекурсивно. Тот же гибридный прием применяют к печати дерева (рекурсия по правой ветке, итерация по левой) и к его удалению (рекурсивный спуск влево внутри итеративного прохода вправо). Полурекурсия сокращает глубину вложенных вызовов, а значит и расход системного стека, о котором мы говорили в уроке о рекурсии.

Коротко: главное

  • Дерево выражений - это двоичное дерево, где листья содержат операнды, а внутренние узлы - операторы. Двоичное оно потому, что базовые машинные операции двуместны.
  • Дерево автоматически учитывает приоритет операций через свою топологию (то, что глубже - выполняется раньше). Скобки не нужны.
  • Три формы обхода генерируют три формы записи: КЛП (Префиксная), ЛКП (Инфиксная), ЛПК (Постфиксная / ОПЗ).
  • Вычисление построенного дерева всегда выполняется рекурсивно "снизу вверх".
  • Дерево строят из инфиксной строки алгоритмы Рутисхаузера, Бауэра-Замельзона и Дейкстры - им посвящены следующие уроки модуля.

Проверьте себя

Какой результат даст концевой обход (ЛПК) дерева, в корне которого находится +, в левом сыне A, а в правом сыне B?

Как вам урок? Спасибо за отзыв!