Леммы о разрастании для регулярных и контекстно-свободных языков
Две леммы о разрастании — утверждения, использующиеся для доказательства ограниченности важных классов формальных языков: регулярных и контекстно-свободных. Важность этих классов для программистов понять легко: регулярные выражения (один из видов описания регулярных языков) в работе используются достаточно часто, а уж языки программирования, синтаксис которых описывается контекстно-свободными грамматиками, и подавно.
Леммы очень похожи одна на другую как формулировками, так и доказательствами. Эта близость показалась мне настолько замечательной, что я решил посвятить этому целую статью.
План такой: разбираемся, что такое регулярные языки и какова связь между регулярными выражениями и конечными автоматами, формулируем и доказываем лемму о разрастании для регулярных языков, используем её для доказательства нерегулярности нескольких языков. Затем похожий трюк проделываем с контекстно-свободными языками, по пути выясняя, как они связаны с регулярными языками и как обходить обнаруженные ограничения при помощи грамматик общего вида. Поехали!

КДПВ иллюстрирует процесс разрастания для КС-грамматик
Формальный язык — произвольное множество строк (то есть, просто последовательностей) в конечном алфавите символов. Алфавит обычно обозначают большой сигмой: . Специальным образом выделяется пустое слово, т.е. слово нулевой длины, не содержащее ни одного символа:
.
Если язык конечный (т.е. содержит некоторое конкретное число различных слов), описать его просто: можно предъявить все входящие в него слова. Поэтому интересно обсуждать описания потецинально бесконечных языков. Таких способов придумано очень много, но сегодня мы обсудим два вполне конкретных.
1. Регулярные языки и регулярные выражения
Регулярные языки строятся индуктивно: вначале описываются языки самого простого вида, а затем правила, при помощи которых из более простых языков можно получать более сложные.
Простейшие языки, относящиеся к классу регулярных:
— язык, не содержащий ни одного слова;
— язык, содержащий только одно, пустое, слово;
— язык, содержащий одно однобуквенное слово.
Теперь можно ввести операции над регулярными языками. Если и
— регулярные языки, регулярными языками также будут следующие:
Замечание: я использую обозначение , чтобы избежать полемики по вопросу о том, является ли ноль натуральным числом
Для простоты записи знак конкатенации часто опускают: .
В программистской реальности регулярные языки возникают при работе с регулярными выражениями. Например, очень востребованным является регулярное выражения для определения строчек, являющихся ссылками. В своё время меня очень впечатлило выражение, используемое в программе PuTTY, а сейчас при помощи любого поисковика можно найти что-нибудь такое:
http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+
Конечно, такое выражение не определит все url’ы и, наоборот, не все строчки, распознаваемые таким регулярным выражением, являются url’ами. Но для многих практических задач такое решение приемлемо.
В языках программирования вы можете встретить дополнительные операции: диапазоны ([0-9]), опциональные символы ([s]?), непустые итерации (a+ — то же, что aa*, т.е. элемент повторяется не менее одного раза) и другие. Они служат лишь для получения более компактных записей, никак не влияя на то, какие в принципе языки можно описывать при помощи регулярных выражений.
Если же обходиться без дополнительных операций, регулярные выражения обычно записываются при помощи символов (в том числе цифр), знаков | (объединение), * (итерация) и скобок. Конкатенация специальным образом не выделяется, она выражается следованием символов друг за другом.
Например, запишем такое регулярное выражение:
Оно описывает язык, состоящий из следующих строк:
abcde
abcdf
abcdef
abcdeef
abcdeeef
abcdeeeef
и так далее.
2. Конечные автоматы
Конечные автоматы являются ещё одним способом представления регулярных языков. Конечный автомат — это ориентированный конечный граф, дуги которого взвешены символами алфавита. Одна из вершин графа считается начальной, при этом несколько вершин могут считаться конечными (терминальными). Вершины этого графа называются состояниями автомата.
В начале обработки строки автомат находится в начальном состоянии. Далее он просматривает символы один за другим, осуществляя переходы между состояниями в соответствии с весами, размещёнными на дугах.
Если в определённый момент из состояния нет исходящей дуги, соответствующей текущему символу входной строки, автомат отклоняет строку. Если строка завершилась, а автомат не достиг терминального состояния, строка также отклоняется. Наконец, если по завершению входной строки автомат оказался в терминальном состоянии — строка считается принятой и принадлежащей языку, который описывается этим автоматом.
Описания языков в виде конечных автоматов эквивалентны описаниям в виде регулярных выражений. Для того, чтобы показать это, временно разрешим записывать на рёбрах конечного автомата регулярные выражения и будем считать, что переход по дуге влечёт за собой вычитывание последовательности символов, принимаемой этим регулярным выражением.

Пример конечного автомата, эквивалентного регулярному выражению bab|aab+a
Теперь будем по одной удалять состояния конечного автомата. Пусть ,
,
— вершины автомата. Из
можно попасть в
непосредственно, соответствующая дуга взвешена языком
. Аналогично, дуга из
в
взвешенна языком
, а из
в
—
. Нельзя забывать, что вершина
может иметь петлю, взвешенную языком
.
Удалим вершину , а вершины
и
свяжем дугой, взвешенной языком
Аналогичным образом установим веса дуг между всеми парами вершин, которые были связаны через . После этого получим конечный автомат, содержащий на одну вершину меньше и при этом, очевидно, эквивалентный исходному.

Удаление вершины с петлёй в конечном автомате из предыдущего примера
Удаляя так вершину за вершиной из исходного автомата, мы получим автомат, содержащий только начальную и несколько конечных вершин. Это не очень удобно, поэтому свяжем все терминальные вершины автомата с какой-нибудь одной из них дугами, взвешенными регулярным выражением . Эта избранная терминальная вершина останется единственной терминальной вершиной модифицированного автомата, и тогда результатом удаления всех возможных вершин будет автомат с одной начальной вериной и одной конечной вершиной. Они будут связаны дугой, вес которой и будет искомым регулярным выражением.
Обратно, можно построить конечный автомат по регулярному выражению. Конечные автоматы для тривиальных регулярных выражений построить просто: для пустого языка это будет конечный автомат без дуг; для языка, содержащего только пустое слово, это будет граф с одной вершиной, которая считается и начальной, и терминальной; для языка, содержащего одно однобуквенное слово, это будет граф, в котором единственная начальная вершина соединена с единственной терминальной вершиной дугой, взвешенной этим однобуквенным словом.
Далее, необходимо построить правила преобразования операций над регулярными языками к операциям над конечными автоматами. Эти правила могут выглядеть, например, так:

После применения этих правил останется только технично избавиться от дуг, взвешенных пустыми строками.
3. Лемма о разрастании для регулярных языков
Рассмотрим язык . Он содержит строки
,
,
,
и так далее. Является ли он регулярным? Представим себе, что да.
В таком случае соответствующий конечный автомат, очевидно, содержит хотя бы один цикл. В действительности понадобится, по всей видимости, два цикла: один для того, чтобы генерировать сколь угодно длинные последовательности из букв и другой — для последовательностей из букв
. Как гарантировать, что циклы будут совершать равное число итераций? Ведь у конечного автомата нет памяти… Может быть, соответствующего конечного автомата не существует, а язык не является регулярным?
И действительно, не является! Интуитивное соображение, приведённое выше, можно перестроить строго математически. Результатом этого будет
Лемма о разрастании регулярных языков. Для любого регулярного языкасуществует число
такое, что
найдутся такие три слова
,
,
, что:
;
;
;
.
Пусть — регулярный язык, которому соответствует конечный автомат с числом вершин
, а
— слово длины не менее
.
Рассмотрим последовательность вершин конечного автомата в порядке их посещения при разборе слова :
,
,
, …,
. Последовательность, очевидно, содержит
вершину, и
.
Так как всего в графе только вершин, хотя бы одна из них повторяется в последовательности. Пусть
— первая вершина последовательности из числа повторяющихся, причём второй раз она встретилась в позиции
. Тогда
— первые
символов строки
,
— отрезок
, соответствующий перемещению из
в
, а
— отрезок
, соответствующий перемещению из
в
.
Так как переход от к
образует цикл в конечном автомате, по этому циклу можно пройти произвольное (в том числе и нулевое!) число раз, и всякий раз будет получаться строчка, принимаемая автоматом, поэтому
.
При этом пара состояний ,
— первый повтор в рассматриваемой последовательности. Значит, все состояния
,
, …,
являются различными. Раз так, то их не больше
. Отсюда получаем, что
и
, что и требовалось.
Другими словами: в любом достаточно длинном слове найдётся непустая, но не совпадающая со всем словом часть, которую можно повторять произвольное (в т.ч. нулевое) число раз, оставаясь при этом в рамках того же регулярного языка, что и исходное слово.
Попробуем применить эту лемму к языку . Пусть
— то самое число из леммы для соответствующего регулярного языка. Тогда строку
можно представить в виде
,
причём , а, значит, строка
содержит только символы
. Строка
поэтому тоже содержит только символы
, да ещё и является непустой согласно утверждению леммы. Тогда строки вида
для
, чем символов
и, стало быть, не принадлежат языку
. Но их принадлежность языку следует из леммы и условия регулярности
. Следовательно,
не является регулярным языком!
Аналогично можно доказать нерегулярность языка , который является подмножеством множества всех правильных скобочных последовательностей. А вслед за ним нерегулярным оказывается и весь язык правильных скобочных последовательностей.
4. Контекстно-свободные грамматики
Грамматики — способ определения языков, основанный на операции постановки строки вместо подстроки.
Алфавит в грамматиках разбит на две части: терминальные символы (терминалы) и нетерминальные символы (нетерминалы)
;
. Среди нетерминалов выделен специальный символ
— аксиома грамматики.
Ключевой частью грамматики являются правила вывода . Правила вывода можно описать как бинарное отношение
на множестве строк в алфавите
: если
, то правилами грамматики разрешено заменять любое вхождение строки
на строку
. Это бинарное отношение можно воспринимать как многозначную функцию: для каждой левой части может быть определено несколько различных правых частей. Обычно, если
, пишут просто
.
Строка выводится за один шаг из строки
, если эти строки допускают представление в виде
,
и при этом
. То есть, вывод за один шаг — это замена подстроки в соответствии с правилами вывода. Будем это отношение обозначать так:
.
Скажем, что строка выводится из строки
(за произвольное число шагов), если существует последовательнось строк
,
,
, …,
, в которой каджая строка выводится за один шаг из предыдущей:
. В таком случае будем писать
.
Строка допускается грамматикой, если она состоит только из терминальных символов и при этом выводится из аксиомы:
,
. Язык, определяемый грамматикой, состоит из всех допускаемых ею строк.
Осталось добавить, что грамматика называется контекстно-свободной (КС-грамматикой), если все левые части правил вывода — единичные нетерминалы. Языки, описываемые контекстно-свободными грамматиками, называются контекстно-свободными языками.
Например, такая КС-грамматика описывает правильные скобочные последовательности:
И действительно, всякая правильная скобочная последовательность либо является пустой строкой, либо начинается с открывающей скобки. Если последовательность непустая, то где-то в ней встречается закрывающая скобка, парная первой открывающей. Между этими скобками находится правильная скобочная последовательность; после закрывающей скобки также находится правильная скобочная последовательность.
Чтобы лучше понять, как это работает, рассмотрим «наивную» реализацию поиска вывода строки в грамматике через поиск в ширину:
def BuildPath(queue, parents, parent):
path = []
while parents[parent] != parent:
path += [queue[parent]]
parent = parents[parent]
return path[::-1]
def Solve(rules, target):
queue = ['S']
parents = [0]
idx = 0
while idx < len(queue):
current = queue[idx]
for rule in rules:
entryIdx = current.find(rule[0])
while entryIdx != -1:
new = current[:entryIdx] + rule[1] + current[entryIdx + len(rule[0]):]
if new == target:
path = [queue[0]] + BuildPath(queue, parents, idx) + [new]
return path
queue.append(new)
parents.append(idx)
entryIdx = current.find(rule[0], entryIdx + 1)
idx += 1
Такая реализация работает, конечно, очень медленно и, кроме того, вообще не завершает работу, если строка не выводится грамматикой; однако эта реализация вполне подходит для демонстрационных целей! Вот и в данном случае:
rules = [
("S", "(S)S"),
("S", ""),
]
target = "(()())()"
print('\n'.join(Solve(rules, target)))
S
(S)S
((S)S)S
((S)(S)S)S
((S)(S)S)(S)S
(()(S)S)(S)S
(()()S)(S)S
(()())(S)S
(()())()S
(()())()
Можно построить КС-грамматику и для языка :
rules = [
("S", "aSb"),
("S", ""),
]
target = "aaabbb"
print('\n'.join(Solve(rules, target)))
S
aSb
aaSbb
aaaSbbb
aaabbb
Мы только что построили КС-грамматики для языков, которые ранее не смогли описать регулярными выражениями. Оказывается, регулярные языки эквивалентны подмножеству КС-грамматик, которое называется автоматными грамматиками. Таким образом, КС-грамматики выразительнее регулярных выражений: они позволяет описывать любые языки, являющиеся регулярными, но также и некоторые языки, регулярными не являющиеся.
Тем не менее, и КС-грамматики описывают не всё.
5. Лемма о разрастании для КС-грамматик
Рассмотрим язык . Мы уже знаем, что контекстно-свободные грамматики позволяют строить строки вида
, так что, видимо, надо будет сделать как-то так:
Такая грамматика построит язык . Как же добиться, чтобы
всегда совпадало с
? Ведь подстановки действуют локально и ничего «не знают» ни о строке в целом, ни о том, сколько раз были применены другие подстановки. И, действительно, добиться этого невозможно. Данный язык не является контекстно-свободным, и установить это позволяет
Лемма о разрастании контекстно-свободных грамматик. Для любого контекстно-свободного языкасуществует число
такое, что
найдутся такие слова
,
,
,
,
, что:
;
;
;
.
Для доказательства нам потребуется вначале привести грамматику к нормальной форме Хомского, воспользовавшись соответствующим алгоритмом.
В этой форме в правых частях правил вывода могут находиться либо два нетерминала, либо один терминал. Пустая строка может находиться в правой части, только если в левой находится аксиома. Итак, разрешены правила следующих трёх видов:
Для каждой строки, выводимой в грамматике, можно построить дерево разбора согласно применяемым правилам: внутренними вершинами дерева являются нетерминалы, листьями — терминалы. Корнем деревая является аксиома. Ребра расставляются в соответствии с применяемыми правилами вывода.

Пример КС-грамматики и дерева разбора для строки acd
Итак, пусть грамматика находится в нормальной форме. Выберем , где
— общее число нетерминалов в грамматике, и пусть
. Тогда для этого слова можно построить дерево разбора. Дерево разбора — бинарное, т.к. грамматика находится в нормальной форме. Значит, высота дерева
не может быть меньше, чем
. Эта высота является числом нетерминалов, которые встречаются на пути из корня дерева до одного из листов-терминалов. Раз число нетерминалов в этом пути превосходит число различных нетерминалов в грамматике, как минимум один из них повторяется.
Выберем в графе вершину с нетерминалом таким образом, чтобы в поддереве этой вершины все нетерминалы были различны и не повторялись, но при этом в нём встречался бы нетерминал
. Такую вершину обязательно можно будет выбрать: хотя бы один повторяющийся нетерминал в дереве есть, а из всех повторяющихся нетерминалов можно выбрать тот, что находится на наибольшем возможном расстоянии от корня.
Нетерминал встречается в дереве разбора, поэтому существует вывод
.
также встречается в поддереве, в корне которого записан сам нетерминал
, поэтому существует вывод
, причём
, т.к. в нормальной форме нетерминал порождает как минимум два нетерминала, каждый из которых затем превращается как минимум в один терминал. Последнее вхождение
порождает некоторую строку
.

Итого:
Значит, , причём
.
Осталось лишь получить ограничение на длину строки . Т.к. эта строка выводится из поддерева нетерминала
, а в этом дереве нет повторяющихся нетерминалов, высота дерева ограничена общим числом нетерминалов
. Значит, полученная строка не может иметь длину больше чем
. Так что
.
Применим эту лемму к языку . Предположим, что он является контекстно-свободным. Тогда выполнена лемма о разрастании, и пусть
— то самое число из леммы. Рассмотрим слово
.
Для него выполнено утверждение леммы, так что , причём
и
. Значит, строка
не является пустой и при этом не содержит либо символ
, либо символ
, ведь эти символы разделяются последовательностью из
символов
.
Тогда все строки вида также принадлежат рассматриваемому языку. Для
и
, и, следовательно, не будут относиться к языку
. Следовательно, он не является контекстно-свободным!
6. Грамматики общего вида
Что же, теперь терпеливый читатель умеет доказывать ограниченность регулярных и контекстно-свободных языков. В качестве завершения продемонстрирую, как построить язык , используя грамматику общего вида.
Начнём с правил для аксиомы грамматики:
Нетерминал будет указывать, где находится граница между группами из символов
и
. Поэтому в конечном счёте его достаточно превращать в пустую строку:
Нетерминал обеспечивает строительство строк неограниченной длины:
Наконец, нужно обеспечить, чтобы нетерминалы перемещались в правый участок строки и там превращались в терминалы
. Тут-то и пригодятся правила со сложными левыми частями!
Вот и всё! Применяя программу из пункта 5 к этим правилам вывода, получим:
rules = [
("S", "aHbCE"),
("H", ""),
("H", "aHbC"),
("Cb", "bC"),
("CE", "Ec"),
("E", ""),
]
target = "aaabbbccc"
print('\n'.join(Solve(rules, target)))
S
aHbCE
aaHbCbCE
aaaHbCbCbCE
aaaHbbCCbCE
aaaHbbCbCCE
aaaHbbbCCCE
aaaHbbbCCEc
aaaHbbbCEcc
aaaHbbbEccc
aaaHbbbccc
aaabbbccc
У получившегося алгоритма есть интересная особенность: правила со сложной левой частью позволяют как бы «перемещать» группы символов; в данном случае нетерминалы перемещаются в правую часть строки. Похожий паттерн часто встречается в алгоритмах для машин Тьюринга.
Он же используется при доказательстве эквивалентности (в некотором не самом простом смысле) машин Тьюринга и ассоциативных исчислений, основанных на грамматиках. Об этом расскажу как-нибудь в другой раз.
