Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

XVYggbpFw2

.pdf
Скачиваний:
2
Добавлен:
15.04.2023
Размер:
2.31 Mб
Скачать

2.Находим регулярное выражение для Linv (см. доказательство тео-

ремы 15.1):

Мы воспользовались очевидным aa*a+a = aa* = a*a . Итак, r = aa*bb*+cc*.

3.rinv = bb*aa* +cc*.

4.Автомат Ainv есть:

Избавляясь в нем от переходов без меток преобразуем его в:

5. Наконец, искомая грамматика:

S

bB | cH

B

bB | aD

D

aD |

H

cH |

17. ПРОГРАММИРОВАНИЕ

Основное приложение формальных языков и автоматов в программировании – это, несомненно, создание компиляторов. В этом деле роль конечных автоматов и им соответствующих регулярных грамматик весьма скромная. Они обеспечивают этап так называемого лексического анализа. На этом этапе текст программы разбивается на маленькие кусочки, которые невозможно без потери смысла разделить на что либо еще. К таким кусочкам-лексемам относятся ключевые слова, идентификаторы, разные другие конструкции вроде оператора присвоения := в Паскале, просто разделители – точки, запятые, точки с запятой и пр. Основную же роль при создании компилятора играют некоторые классы КС-грамматик и им соответствующие стековые автоматы. При этом привлекаются математические понятия и конструкции, гораздо более серьезные, чем рассмотренные в настоящем пособии. Алгоритмы создания компиляторов составляют содержание отдельной дисциплины – теории языков программирования.

Однако теоретические сведения из теории автоматов и вообще регулярных языков, о которых шла речь, могут пригодиться любому программисту иногда в самом неожиданном месте. Ниже будет рассмотрено решение нескольких чисто программистских задач, иллюстрирующих этот тезис. Как итоговое решение такой задачи, мы будем приводить отрывочные листинги на языке Си. Будем надеяться, что это не сильно усложнит чтение тем, кому этот язык мало знаком.

Пример 17.1. Требуется написать программу, которая в имеющемся тексте ищет подслова aba или abba и выдает количество и тех и других.

В примере 14.3 построен минимальный детерминированный автомат, распознающий цепочки с aba или abba. Чтобы далеко не ходить, нарисуем его еще раз. Автомат имеет единственное заключительное состояние J:

Представим себе, что терминал c обозначает любую букву исходного текста, отличную от a и от b. Нетрудно видеть, что начиная с состояния S, автомат первый раз попадает в J либо из K - когда он только что прочел

abba, либо из I - когда он только что прочел aba. Пишем теперь на Си функцию, моделирующую работу этого автомата:

enum Terminal {a,b,c};

//

Список терминалов

enum Condition {S,H,I,K,J};

//

Список состояний

Terminal GetFirstSymbol(); //

Эта

функция возвращает первый символ текста.

//

Это

может быть файл, буфер памяти – что угодно.

Terminal GetNextSymbol(); // Эта функция возвращает очередной символ текста. bool EndOfStream(); //Сигнализирует об окончании текста

//Функции GetFirstSymbol(); GetNextSymbol(); EndOfStream();определяются в

//зависимости от ситуации. Нас они не интересуют.

//Следующая функция, отправляющая в abaCount и abbaCount

//количество слов aba и abba, основная.

void CountOurWords(int &abaCount, int &abbaCount)

{

Terminal x; Condition T;

if(EndOfStream()) return; abaCount=0;

abbaCount=0; T = S;

x = GetFirstSymbol(); do

{

switch(T)

{

case S: switch(x)

{

case a: T=H; break;

}

break; case H: switch(x)

{

case b: T=I; break; case c: T=S; break;

}

break; case K: switch(x)

{

case a: T=J;

abbaCount++; // Только что прочитана abba break;

case b:

case c: T=S; break;

}

break; case I: switch(x)

{

case a: T=J;

abaCount++; // Только что прочитана aba break;

case b: T=K; break; case c: T=S; break;

}

break;

case J: T=S; // “Сброс”. Со следующего символа автомат будет // работать, как от начала.

break;

}

x = GetNextSymbol();

}

while(!EndOfStream());

}

Разумеется, можно возразить: “Да что здесь программировать! В цикле просматриваем последние три или четыре символа, сравниваем их с

aba или abba и при совпадении увеличиваем соответствующие счетчики”. Однако ценность нашего решения в том, что по этой схеме можно программировать множество различных задач. И код для этого пишется чуть ли не автоматически. К тому же он легко модифицируется при изменении постановки задачи.

Например, предыдущая функция вычисляет количество непересекающихся подслов. А если считать, что слов aba в строке ababa два, а не одно? Как модифицировать код, чтобы он это учел? Лучше всего в случаях

case K: case I:

после abbaCount++; и abaCount++; добавить команду возврата считанного символа x в поток (т.е. читать его дважды).

Следующие примеры – это в некотором смысле синтаксические анализаторы. Они производят “разбор” некоторой строки, решая задачу распознавания, т.е. определяя - будет ли данная строка в определенном смысле правильной. Получаемые при этом программы затем могут быть без труда модифицированы на какую-то конкретную работу с данными в этой строке (например, считывание записанных в ней чисел в массив).

Пример 17.2. Имеется строка символов. Программа должна определить, записаны ли в этой строке целые числа. Разделителями между числами могут служить пробелы, запятая и точка с запятой. При этом пробелов может быть несколько, они могут быть в начале и конце строки а также рядом с “,” или с “;” . Например строка “ -12,22 -123 ; 42;+4 ” - правильная и содержит 5 чисел: -12, 22, -123, 42, 4. А строки “3 7;;6” или “4 , 4;” - неправильные.

Начнем с конструирования автомата, распознающего правильные строки. Прежде всего надо ввести список терминалов. Будем обозначать любую цифру буквой a. Любой из знаков “-” или “+” буквой z. Пробел – буквой p, любой из разделителей “,” или “;” - буквой d. Все остальные символы недопустимы и для них будем использовать терминал n. Вводим

enum Terminal {a,z,p,d,n};

// Список термина-

лов

 

 

Как и раньше, будем использовать функции

Terminal GetNextSymbol();

// Возвращает очередной символ текста.

bool EndOfStream();

//Сигнализирует об окончании текста

на кодировании которых останавливаться не будем.

Сочинить автомат, - это, конечно, процесс творческий и требует некоторого навыка. И все же он гораздо проще, чем голое программирование этой задачи. В автомате будет в чистом виде сконцентрирована суть алгоритма, без лишних деталей. Можно, конечно, начать с регулярного выра-

жения, которое находится еще проще. Но к автомату обращаться все равно придется. После некоторого размышления получается автомат:

Автомат, очевидно, правильный, но не детерминированный. К тому же содержит переход без метки. То, что в нем нет ни одного перехода с меткой n, тоже нормально. Ведь цепочка с n не должна распознаваться. Избавляемся от перехода без метки и получаем:

Процесс вычисления эквивалентного детерминированного автомата неожиданно дает:

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

enum Condition {S,A,B,C};

// Список состояний

bool DetectString()

{

Terminal x; Condition T;

if(EndOfStream()) return false; //Пустая строка считается неправильной

T = S; do

{

x = GetNextSymbol();

if(x==n) return false; //Сразу покончим с недопустимыми символами switch(T)

{

case S: switch(x)

{

 

case a: T=B; break;

 

case z: T=A; break;

 

case p: break;

// Состояние не меняется

default: return false;

 

}

 

break;

 

case A: switch(x)

 

{

 

case a: T=B; break;

 

default: return false;

 

}

 

break;

 

case B: switch(x)

 

{

 

case a: break;

// Состояние не меняется

case d: T=S; break;

 

case p: T=C; break;

 

default: return false;

 

}

 

break;

 

case C: switch(x)

 

{

 

case a: T=B; break;

 

case d: T=S; break;

 

case p: break;

// Состояние не меняется

case z: T=A; break;

 

default: return false;

 

}

 

break;

 

}

 

} while(!EndOfStream());

 

switch(T)

 

{

 

case B:

 

case C: return true;

 

default: return false;

 

}

 

}

 

Этот код не только красив и краток. Он достаточно легко модифицируется под разные нужды. Например, если захочется изменить разделители, то надо всего лишь поправить GetNextSymbol(). Основная функция вообще не изменится.

Или такая задача. Надо все прочитанные числа отправить в какой-то числовой массив. Посмотрим на автомат и легко поймем, что он прекращает читать очередное число в момент, когда уходит из состояния B по дуге с меткой p или по дуге с меткой d (или вообще строка заканчивается). Теперь надо завести какой-то локальный буфер, где копится очередное число и в момент, когда считывание числа завершается, перевести содержимое буфера из строки в число и отправить его в массив. Вот примерный код.

Terminal GetNextSymbol(char &y);

// Возвращает очередной символ строки

 

// в виде терминала.

 

// Сам символ записывает в y.

void ClearBuffer();

// Очищает локальный буфер

void AddToBuffer(char y);

// Добавляет к локальному буферу символ y

void AddToMassivFromBuffer();

// Если локальный буфер не пуст, переводит

 

// его содержимое из строки в число,

 

// добавляет это число к массиву и

 

// очищает локальный буфер.

bool DetectString()

 

{

 

Terminal x;

 

Condition T;

 

char y;

 

if(EndOfStream()) return false;

//Пустая строка считается неправильной

T = S;

 

ClearBuffer();

 

do

 

{

 

x = GetNextSymbol(y);

 

if(x==n) return false;

//Сразу покончим с недопустимыми символами

switch(T)

 

 

{

 

 

case S: switch(x)

 

 

{

 

 

case a: T=B; AddToBuffer(y); break;

 

case z: T=A; AddToBuffer(y); break;

 

case p: break;

// Состояние не меняется

default: return false;

 

 

}

 

 

break;

 

 

case A: switch(x)

 

 

{

 

 

case a: T=B; AddToBuffer(y); break;

 

default: return false;

 

 

}

 

 

break;

 

 

case B: switch(x)

 

 

{

 

 

case a: AddToBuffer(y); break;

// Состояние не меняется

case d: T=S; AddToMassivFromBuffer(); break; case p: T=C; AddToMassivFromBuffer(); break; default: return false;

}

break; case C: switch(x)

{

case a: T=B; AddToBuffer(y); break;

case d: T=S; break;

 

case p: break;

// Состояние не меняется

case z: T=A; break;

 

default: return false;

 

}

 

break;

 

}

 

} while(!EndOfStream());

 

switch(T)

 

{

 

case B:

 

case C: return true;

 

default: return false;

 

}

 

}

 

Пример 17.3. Этот пример из реальной практики программирования. Требовалось написать обучающую программу, которая с помощью алгоритма Блейка вычисляла бы минимальную ДНФ. Исходными данными для программы служит произвольная формула исчисления высказываний в дизъюнктивной нормальной форме. Такую ДНФ можно понимать как

множество конъюнктов вида xi

1

1xi

2

xi

n, где i1,i2,…,in – различные на-

 

 

2

 

 

n

туральные числа, а каждое из 1,

2,…,

n – либо пустое место, либо штрих,

означающий отрицание в логике высказываний. Например, x1x3 x13 x21x22 . Возникает вопрос, каким образом пользователь должен сообщить

компьютеру о имеющейся у него ДНФ? Самым простым (а, значит, и надежным) представляется подавать эту ДНФ в виде текстового файла, где в каждой строке записан один конъюнкт. Как именно? Ведь индексы в текстовом файле указывать затруднительно. Привлекать синтаксис ТЕХа также не целесообразно – слишком много добавочных символов. Поэтому просто перечисляем индексы переменных, взяв их в скобки, и там, где надо добавляем символ ‘ (верхний апостроф). К примеру, указанный выше конъюнкт запишется как (1)(3)’(13)’(21)(22)’ . Для того, чтобы облегчить пользователю ввод данных, разрешим опускать скобки в тех случаях, когда

вних заключена только одна цифра. К тому же позволим в начале строки,

вконце строки а также между переменными ставить сколько угодно пробелов. Так что выше указанный конъюнкт можно будет записать как 1 3’

(13)’(21)(22)’ или как 13’(13)’(21) (22)’ .

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

MassivMinus, – с отрицаниями.

Начинаем с автомата. Вот его вариант, который ищется без труда, но который не является детерминированным:

Здесь используется множество терминалов: a,s,l,r,p, где a – любая цифра от 0 до 9, s - штрих, l, r - левая и правая скобки, p - пробел. Запишем автомат в таблицу:

 

 

 

 

 

a

 

s

l

r

p

 

fin

 

 

 

 

 

 

S

 

S,A

 

 

B

 

 

S

 

+

 

 

 

 

 

 

A

 

 

 

S

 

 

 

 

 

 

+

 

 

 

 

 

 

B

 

C

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

C

 

C

 

 

 

 

D,S

 

 

 

 

 

 

 

 

 

D

 

 

 

S

 

 

 

 

 

 

 

 

 

 

Вычислив соответствующий детерминированный полный автомат,

получим:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

a

 

s

 

l

 

r

 

p

 

 

fin

 

 

S

 

E

 

Err

 

B

 

Err

 

S

S

+

 

 

E

 

E

 

S

 

B

 

Err

 

S

A,S

+

 

 

B

 

C

 

Err

 

Err

 

Err

 

Err

B

 

 

 

C

 

C

 

Err

 

Err

 

G

 

Err

C

 

 

 

G

 

E

 

S

 

B

 

Err

 

S

D,S

+

 

 

Err

Err

 

Err

 

Err

 

Err

 

Err

Err

 

 

Проделав процедуру минимизации будем иметь:

 

a

s

l

r

p

0

Спектр0

1

Спектр1

2

Спектр2

3

S

E

Err

B

Err

S

1

12221

1

 

1

 

1

E

E

S

B

Err

S

1

11221

3

31221

3

31251

3

B

C

Err

Err

Err

Err

2

22222

2

42222

2

 

2

C

C

Err

Err

G

Err

2

22212

4

 

4

 

4

G

E

S

B

Err

S

1

11221

3

31221

3

31251

3

Err

Err

Err

Err

Err

Err

2

22222

2

22222

5

 

5

Видим, что состояния E,G сливаются в одно, а состояние Err, как непродуктивное, выкидывается. Окончательно:

 

a

s

l

r

p

fin

S

E

 

B

 

S

+

E

E

S

B

 

S

+

B

C

 

 

 

 

 

C

C

 

 

E

 

 

Граф автомата:

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

enum Terminal{a,s,l,r,p,n}; enum Condition{S,B,C,E};

bool EndOfStream(); // Возвращает true, когда строка заканчивается Terminal GetNextSymbol(); // Возвращает очередной символ строки в виде терминала

bool ReadString()

{

Terminal x; Condition T;

if(EndOfStream()) return false; T = S;

do

{

x = GetNextSymbol();

if(x==n) return false; //Недопустимый символ switch(T)

{

case S: switch(x)

{

case

p:

break;

// Состояние не меняется

case

a:

T=E; break;

 

case l: T=B; break;

 

default: return false;

 

}

 

break;

 

case B: switch(x)

 

{

 

case a: T=C; break;

 

default: return false;

 

}

 

break;

 

case C: switch(x)

 

{

 

case a: break;

// Состояние не меняется

case r: T=E; break;

 

default: return false;

 

}

 

break;

 

case E: switch(x)

 

{

 

case a: break;

// Состояние не меняется

case l: T=B; break;

 

case p: T=S; break;

 

case s: T=S; break;

 

default: return false;

 

}

 

break;

 

}

 

} while(!EndOfStream());

 

switch(T)

 

{

 

case S:

 

case E: return true;

 

default: return false;

 

}

 

}

Глядя на граф автомата, нетрудно видеть, при каких именно переходах заканчивается считывание очередной переменной конъюнкта. Это происходит тогда, когда начинается считывание очередной переменной – при переходах из S в E с меткой a, из E в E по петле с меткой а и из S в B с меткой l. Если результат считывания каждой переменной накапливать в специальном буфере, то именно в эти моменты этот локальный буфер, если он не пуст, надо освобождать, отправляя его содержимое после перевода в число в соответствующий массив. В какой именно массив Plus или Minus, будет говорить последний символ локального буфера.

Запись в локальный буфер только что считанного символа происходит при переходах S в E с меткой a, из E в E по петле с меткой а, из B в C с меткой а, из С в С по петле с меткой а и из E в S с меткой s . Делаем соответствующую модификацию нашей функции ReadString().

Terminal GetNextSymbol(char &y); // Возвращает очередной символ строки в виде // терминала, а сам символ записывает в y.

void ClearBuffer();

// Очищает локальный буфер.

void AddToBuffer(char y);

// Добавляет к локальному буферу символ y.

bool BufferIsEmpty();

// Пуст или нет промежуточный буфер.

int GetFromBuffer(int &PM);

// Если локальный буфер не пуст,

 

// переводит его содержимое из строки

 

// в число и его возвращает. При этом в PM

 

// записывает 0, когда буфер завершается

 

// символом “штрих”

 

// и записывает 1 иначе.

void AddToMassivPlus(int z);

// Добавляет z к массиву MassivPlus.

void AddToMassivMinus(int z);

// Добавляет z к массиву MassivMinus.

bool ReadString()

 

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]