Перейти до основного вмісту

Розбір виразів

Задано рядок, що містить математичний вираз із числами та різними операторами. Нам потрібно обчислити його значення за O(n)O(n), де nn — довжина рядка.

Алгоритм, який ми тут розглянемо, переводить вираз у так званий зворотний польський запис (явно або неявно) і обчислює цей вираз.

Коли підходить цей алгоритм?
  • Потрібно обчислити математичний вираз із рядка з урахуванням пріоритетів операторів і дужок за O(n)O(n)?
  • Граматика проста (бінарні/унарні оператори, дужки), тож достатньо алгоритму на стеку, а не повноцінного парсера?
  • Достатньо одного проходу зі стеками операндів і операторів, без побудови дерева розбору?

Зворотний польський запис

Зворотний польський запис — це форма запису математичних виразів, у якій оператори розташовані після своїх операндів. Наприклад, наступний вираз

a+bcd+(ef)(gh+i)a + b * c * d + (e - f) * (g * h + i)

можна записати у зворотному польському записі так:

abcd+efghi++a b c * d * + e f - g h * i + * +

Зворотний польський запис розробив австралійський філософ і фахівець з інформатики Charles Hamblin у середині 1950-х років на основі польського запису, який 1920 року запропонував польський математик Jan Łukasiewicz.

Зручність зворотного польського запису полягає в тому, що вирази в такій формі дуже легко обчислювати за лінійний час. Ми використовуємо стек, який спочатку порожній. Ми будемо проходити по операндах і операторах виразу у зворотному польському записі. Якщо поточний елемент — число, то ми кладемо його значення на вершину стека; якщо поточний елемент — оператор, то ми беремо два верхні елементи стека, виконуємо операцію і кладемо результат назад на вершину стека. Наприкінці в стеку залишиться рівно один елемент, який і буде значенням виразу.

Очевидно, що це просте обчислення працює за час O(n)O(n).

Розбір простих виразів

Поки що ми розглянемо лише спрощену задачу: вважаємо, що всі оператори бінарні (тобто беруть два аргументи) і всі ліво-асоціативні (якщо пріоритети рівні, вони виконуються зліва направо). Дужки дозволені.

Ми заведемо два стеки: один для чисел і один для операторів та дужок. Спочатку обидва стеки порожні. Для другого стека ми будемо підтримувати умову, що всі операції впорядковані за строго спадним пріоритетом. Якщо в стеку є дужки, то впорядкованим є кожен блок операторів (що відповідає одній парі дужок), а весь стек упорядкованим бути не обов'язково.

Ми будемо проходити по символах виразу зліва направо. Якщо поточний символ — цифра, то ми кладемо значення цього числа на стек. Якщо поточний символ — відкривна дужка, то ми кладемо її на стек. Якщо поточний символ — закривна дужка, то ми виконуємо всі оператори зі стека, доки не дійдемо до відкривної дужки (іншими словами, виконуємо всі операції всередині дужок). Нарешті, якщо поточний символ — оператор, то поки на вершині стека стоїть оператор з таким самим або вищим пріоритетом, ми виконуємо цю операцію, а потім кладемо новий оператор на стек.

Після того як ми обробили весь рядок, у стеку можуть залишитися деякі оператори, тож ми їх виконуємо.

Ось реалізація цього методу для чотирьох операторів ++ - * //:

bool delim(char c) {
return c == ' ';
}

bool is_op(char c) {
return c == '+' || c == '-' || c == '*' || c == '/';
}

int priority (char op) {
if (op == '+' || op == '-')
return 1;
if (op == '*' || op == '/')
return 2;
return -1;
}

void process_op(stack<int>& st, char op) {
int r = st.top(); st.pop();
int l = st.top(); st.pop();
switch (op) {
case '+': st.push(l + r); break;
case '-': st.push(l - r); break;
case '*': st.push(l * r); break;
case '/': st.push(l / r); break;
}
}

int evaluate(string& s) {
stack<int> st;
stack<char> op;
for (int i = 0; i < (int)s.size(); i++) {
if (delim(s[i]))
continue;

if (s[i] == '(') {
op.push('(');
} else if (s[i] == ')') {
while (op.top() != '(') {
process_op(st, op.top());
op.pop();
}
op.pop();
} else if (is_op(s[i])) {
char cur_op = s[i];
while (!op.empty() && priority(op.top()) >= priority(cur_op)) {
process_op(st, op.top());
op.pop();
}
op.push(cur_op);
} else {
int number = 0;
while (i < (int)s.size() && isalnum(s[i]))
number = number * 10 + s[i++] - '0';
--i;
st.push(number);
}
}

while (!op.empty()) {
process_op(st, op.top());
op.pop();
}
return st.top();
}

Так ми навчилися обчислювати значення виразу за O(n)O(n), при цьому неявно скориставшись зворотним польським записом. Трохи змінивши наведену вище реалізацію, можна також отримати вираз у зворотному польському записі в явному вигляді.

Унарні оператори

Тепер припустимо, що вираз містить також унарні оператори (оператори, що беруть один аргумент). Унарний плюс і унарний мінус — поширені приклади таких операторів.

Одна з відмінностей у цьому випадку полягає в тому, що нам потрібно визначати, є поточний оператор унарним чи бінарним.

Можна помітити, що перед унарним оператором завжди стоїть інший оператор, або відкривна дужка, або взагалі нічого (якщо він стоїть на самому початку виразу). Навпаки, перед бінарним оператором завжди буде операнд (число) або закривна дужка. Тож легко позначити, чи може наступний оператор бути унарним.

Крім того, унарний і бінарний оператори ми маємо виконувати по-різному. А ще ми маємо обрати пріоритет унарного оператора вищим, ніж у всіх бінарних операторів.

Окрім того, варто зауважити, що деякі унарні оператори (наприклад, унарний плюс і унарний мінус) насправді право-асоціативні.

Право-асоціативність

Право-асоціативність означає, що щоразу, коли пріоритети рівні, оператори мають обчислюватися справа наліво.

Як зазначено вище, унарні оператори зазвичай право-асоціативні. Іще один приклад право-асоціативного оператора — оператор піднесення до степеня (abca \wedge b \wedge c зазвичай сприймається як abca^{b^c}, а не як (ab)c(a^b)^c).

Яку зміну нам треба зробити, щоб коректно обробляти право-асоціативні оператори? Виявляється, що зміни дуже мінімальні. Єдина відмінність буде в тому, що при рівних пріоритетах ми відкладатимемо виконання право-асоціативної операції.

Єдиний рядок, який потрібно замінити, — це

while (!op.empty() && priority(op.top()) >= priority(cur_op))

на

while (!op.empty() && (
(left_assoc(cur_op) && priority(op.top()) >= priority(cur_op)) ||
(!left_assoc(cur_op) && priority(op.top()) > priority(cur_op))
))

де left_assoc — функція, яка визначає, чи є оператор ліво-асоціативним.

Ось реалізація для бінарних операторів ++ - * // та унарних операторів ++ і -.

bool delim(char c) {
return c == ' ';
}

bool is_op(char c) {
return c == '+' || c == '-' || c == '*' || c == '/';
}

bool is_unary(char c) {
return c == '+' || c=='-';
}

int priority (char op) {
if (op < 0) // унарний оператор
return 3;
if (op == '+' || op == '-')
return 1;
if (op == '*' || op == '/')
return 2;
return -1;
}

void process_op(stack<int>& st, char op) {
if (op < 0) {
int l = st.top(); st.pop();
switch (-op) {
case '+': st.push(l); break;
case '-': st.push(-l); break;
}
} else {
int r = st.top(); st.pop();
int l = st.top(); st.pop();
switch (op) {
case '+': st.push(l + r); break;
case '-': st.push(l - r); break;
case '*': st.push(l * r); break;
case '/': st.push(l / r); break;
}
}
}

int evaluate(string& s) {
stack<int> st;
stack<char> op;
bool may_be_unary = true;
for (int i = 0; i < (int)s.size(); i++) {
if (delim(s[i]))
continue;

if (s[i] == '(') {
op.push('(');
may_be_unary = true;
} else if (s[i] == ')') {
while (op.top() != '(') {
process_op(st, op.top());
op.pop();
}
op.pop();
may_be_unary = false;
} else if (is_op(s[i])) {
char cur_op = s[i];
if (may_be_unary && is_unary(cur_op))
cur_op = -cur_op;
while (!op.empty() && (
(cur_op >= 0 && priority(op.top()) >= priority(cur_op)) ||
(cur_op < 0 && priority(op.top()) > priority(cur_op))
)) {
process_op(st, op.top());
op.pop();
}
op.push(cur_op);
may_be_unary = true;
} else {
int number = 0;
while (i < (int)s.size() && isalnum(s[i]))
number = number * 10 + s[i++] - '0';
--i;
st.push(number);
may_be_unary = false;
}
}

while (!op.empty()) {
process_op(st, op.top());
op.pop();
}
return st.top();
}

Відеоматеріали