Формальные языки
Регулярные выражения
Когда вы пишете в терминале `grep -E '(error|warn).*timeout' server.log`, вы используете нотацию, изобретённую математиком Стивеном Клини в 1956 году. Он доказал, что ЛЮБОЙ регулярный язык можно описать комбинацией трёх операций: объединения, конкатенации и звезды. Сегодня мы построим эту нотацию формально - и увидим, где заканчиваются возможности «настоящих» regex и начинаются расширения grep/PCRE.
- **grep/sed/awk:** каждый regex в командной строке - это формальное регулярное выражение (плюс синтаксический сахар)
- **Лексический анализ:** компиляторы описывают токены (числа, идентификаторы, ключевые слова) через regex и компилируют их в DFA
- **Сетевые IDS (Snort, Suricata):** сигнатуры атак записываются как regex и матчатся на скорости 10 Gbit/s благодаря конечным автоматам
Синтаксис формальных regex
Вы уже знаете три операции над языками: объединение (∪), конкатенацию (·) и звезду Клини (*). **Регулярное выражение** - это компактная текстовая запись, которая описывает язык через эти три операции. Точно так же, как арифметическое выражение `2 + 3 × 4` компактно описывает число 14.
**Формальное определение.** Регулярные выражения над алфавитом Σ строятся индуктивно: 1. **∅** - regex, описывающий пустой язык {}. 2. **ε** - regex, описывающий {ε}. 3. **a** (для любого a ∈ Σ) - regex, описывающий {a}. 4. Если r и s - regex, то **(r|s)** - regex для L(r) ∪ L(s). 5. Если r и s - regex, то **(r·s)** или **(rs)** - regex для L(r)·L(s). 6. Если r - regex, то **(r*)** - regex для L(r)*.
**Приоритет операторов** позволяет опускать скобки - точно как в арифметике, где умножение выполняется раньше сложения. В regex: ***** (звезда) > **·** (конкатенация) > **|** (объединение). Поэтому `ab*|c` означает `(a(b*))|c`, а не `(ab)*|c` или `a(b*|c)`.
| Приоритет | Оператор | Пример | Аналогия из арифметики |
|---|---|---|---|
| 1 (высший) | * (звезда Клини) | a* = (a)* | возведение в степень: a³ |
| 2 | · (конкатенация) | ab = a·b | умножение: a × b |
| 3 (низший) | | (объединение) | a|b | сложение: a + b |
**Мнемоника:** приоритеты regex совпадают с арифметикой. Звезда (*) = степень (высший), конкатенация (·) = умножение (средний), объединение (|) = сложение (низший). Если помните `2+3×4²`, вы помните `a|bc*`.
Какой язык описывает regex `(a|b)*c`?
Построение regex для реальных задач
Знать синтаксис - мало. Нужно уметь **переводить** словесное описание языка в regex и обратно. Это навык, который развивается практикой. Начнём с простых примеров и постепенно усложним задачу.
Пример 1: строки, содержащие подстроку «01»
Пример 2: строки с чётным числом нулей
Пример 3: строки, НЕ содержащие «11»
**Паттерн построения regex:** 1. Определите «хорошие блоки» - части строки, которые точно допустимы. 2. Скомбинируйте блоки через конкатенацию и звезду Клини. 3. Проверьте граничные случаи: пустая строка, один символ, самая короткая допустимая строка.
| Словесное описание | Формальный regex | Примеры строк |
|---|---|---|
| Все двоичные строки | (0|1)* | ε, 0, 1, 00, 01, 10, 11, ... |
| Начинается с a, кончается на b | a(a|b)*b | ab, aab, abb, aabb, ... |
| Содержит подстроку 01 | (0|1)*01(0|1)* | 01, 001, 010, 1101, ... |
| Чётное число нулей | (1|01*0)* | ε, 1, 00, 11, 100, 001, ... |
| Не содержит 11 | (0|10)*(1|ε) | ε, 0, 1, 00, 01, 10, 010, ... |
| Длина кратна 3 | ((0|1)(0|1)(0|1))* | ε, 000, 001, ..., 111, 000000, ... |
**Типичная ошибка:** regex `0*1*` описывает НЕ «строки из нулей и единиц», а «ноль или более нулей, затем ноль или более единиц». Это {ε, 0, 1, 00, 01, 11, 001, 011, ...} - подмножество Σ*, а не Σ*. Строка '10' ∉ L(0*1*).
Какой regex описывает язык «двоичные строки, оканчивающиеся на 00»?
Алгебра регулярных выражений
Два regex могут выглядеть по-разному, но описывать один и тот же язык. Например, `a|b` и `b|a` - это один язык {a, b}. Существует набор **алгебраических законов**, позволяющих упрощать regex - аналогично тому, как `x + 0 = x` упрощает арифметику.
**Два regex r и s эквивалентны** (записывается r ≡ s), если L(r) = L(s) - они описывают один и тот же язык. Эквивалентность можно проверить алгебраически (применяя законы) или конструктивно (построив минимальный DFA для каждого regex).
| Закон | Формула | Аналогия |
|---|---|---|
| Коммутативность | | r|s ≡ s|r | a + b = b + a |
| Ассоциативность | | (r|s)|t ≡ r|(s|t) | (a+b)+c = a+(b+c) |
| Идемпотентность | | r|r ≡ r | - |
| Нейтральный ∅ | r|∅ ≡ r | a + 0 = a |
| Ассоциативность · | (rs)t ≡ r(st) | (ab)c = a(bc) |
| Нейтральный ε | rε ≡ εr ≡ r | a × 1 = a |
| Аннулятор ∅ | r∅ ≡ ∅r ≡ ∅ | a × 0 = 0 |
| Дистрибутивность | r(s|t) ≡ rs|rt | a(b+c) = ab+ac |
| Звезда пустого | ∅* ≡ ε | - |
| Двойная звезда | (r*)* ≡ r* | - |
| Идемпотентность * | ε* ≡ ε | - |
| Разложение * | r* ≡ ε|rr* | - |
**Упрощение regex на практике.** Закон `r* ≡ ε|rr*` позволяет «раскрывать» звезду: первая итерация выделяется явно. Закон `r(s|t) ≡ rs|rt` выносит общий префикс. А закон `(r*)* ≡ r*` убирает лишнюю вложенность.
**Ключевое тождество: r* ≡ ε | r·r*.** Звезда Клини - это рекурсивное определение: «или ничего, или один r плюс ещё сколько угодно r». Это основа конструкции NFA по regex (алгоритм Томпсона).
Какое из тождеств НЕВЕРНО?
Формальные regex vs grep/PCRE
Если вы писали `\d+\.\d+` или `(\w+)\s+\1`, вы использовали **практические** регулярные выражения (PCRE, POSIX). Они выглядят похоже на формальные regex, но есть принципиальная разница: практические regex **выходят за пределы** регулярных языков. Это одно из самых частых заблуждений в CS.
| Возможность | Формальные regex | grep/PCRE |
|---|---|---|
| Базовые: a, b, c | ✓ | ✓ |
| Объединение: r|s | ✓ | ✓ |
| Конкатенация: rs | ✓ | ✓ |
| Звезда Клини: r* | ✓ | ✓ |
| Плюс: r⁺ | = rr* | ✓ (r+) |
| Опциональность: r? | = r|ε | ✓ (r?) |
| Классы символов: [a-z] | = a|b|...|z | ✓ |
| Обратные ссылки: \1 | ✗ НЕТ | ✓ |
| Lookahead: (?=r) | ✗ НЕТ | ✓ |
| Lookbehind: (?<=r) | ✗ НЕТ | ✓ |
| Рекурсия: (?R) | ✗ НЕТ | ✓ (PCRE) |
Первые шесть строк таблицы - просто **синтаксический сахар**: `r+` = `rr*`, `r?` = `r|ε`, `[a-z]` = `a|b|...|z`. Язык описания не расширяется - любой regex с `+`, `?` и `[]` можно переписать с помощью трёх базовых операций. Но **обратные ссылки** - это качественно другой уровень.
**Теорема:** формальные regex описывают ровно класс **регулярных языков** (тип 3 по Хомскому). Ни больше, ни меньше. PCRE с обратными ссылками описывают строго больший класс - некоторые контекстно-свободные и даже контекстно-зависимые языки. Задача проверки PCRE-regex NP-трудна, тогда как формальных regex - O(n).
Теорема Клини (1956)
В 1956 году Стивен Клини доказал одну из самых красивых теорем теории вычислений: класс языков, описываемых регулярными выражениями, в точности совпадает с классом языков, распознаваемых конечными автоматами. Для каждого regex можно построить NFA (конструкция Томпсона), и для каждого DFA можно написать regex (алгоритм устранения состояний). Эта эквивалентность - основа того, что `grep` внутри строит автомат по вашему regex.
**Когда говорят «regex» в индустрии - имеют в виду PCRE/POSIX, а не формальные regex.** В курсе теории вычислений «regex» = формальные регулярные выражения (только |, ·, *). Если преподаватель спрашивает «можно ли описать regex», он имеет в виду формальные - без backreferences, lookahead и прочих расширений.
Ключевые идеи
- **Формальный regex** - индуктивное определение: базовые (∅, ε, символ) + три операции (|, ·, *)
- **Приоритет:** * > · > | - как степень > умножение > сложение в арифметике
- **Алгебраические законы** позволяют упрощать regex: r|r ≡ r, (r*)* ≡ r*, r∅ ≡ ∅, r* ≡ ε|rr*
- **Формальные regex ≠ PCRE:** обратные ссылки (\1) и lookahead выходят за пределы регулярных языков; matching PCRE NP-труден
Связанные темы
Регулярные выражения связывают формальные языки с автоматами и практическими инструментами:
- Конечные автоматы (DFA/NFA) — Теорема Клини: regex описывают ровно те языки, что распознаются конечными автоматами
- Конструкция Томпсона — Алгоритм преобразования regex → NFA: каждый оператор (|, ·, *) становится фрагментом автомата
Вопросы для размышления
- Вернёмся к grep-команде из начала: `(error|warn).*timeout`. Перепишите её как формальный regex (используя только |, · и *) над алфавитом ASCII.
- Regex `a*` и `(a|ε)*` описывают один и тот же язык. Докажите это через алгебраические законы.
- Google RE2 (движок regex) намеренно НЕ поддерживает обратные ссылки. Используя знания об NP-трудности PCRE-matching, объясните, почему это разумное инженерное решение.
Связанные уроки
- fl-04-operations — Closure operations are the basis of regex operators
- fl-06-dfa — Every regex has an equivalent DFA
- fl-07-nfa — Regex is most naturally compiled to NFA first
- fl-09-regex-to-automata — Thompson construction converts regex to NFA step by step
- comp-07-regex-in-lexer — Regex used practically in lexer rule specifications