1 Módulo 05: Projeto do Professor — Mil Duzentos e Noventa e Seis Estados Viram Vinte
Este é o projeto de referência resolvido pelo professor: a mesma atividade que cada grupo vai executar neste módulo, feita por inteiro, com as decisões justificadas uma a uma. Os números da sua linguagem serão outros, mas a forma de conferi-los é a mesma.
1.1 Visão Geral do Módulo 05
O módulo anterior terminou com um constrangimento numérico. Os autômatos gerados pela construção de Thompson para as seis categorias léxicas da Peneira somavam 1296 estados — trezentos e noventa e dois só para reconhecer texto entre aspas. Nenhum deles foi escolhido por alguém; todos vieram das seis regras da construção, aplicadas sem exceção. Este módulo cobra essa conta.
A atividade pede a ferramenta completa: expressão regular entra, autômato determinístico mínimo sai. São três etapas encadeadas — determinizar, minimizar e exportar para visualização — mais a tabela de contagem de estados nas três, e a verificação de que o resultado continua reconhecendo o que o autômato desenhado à mão no módulo 3 reconhecia.
O que torna este módulo diferente dos anteriores é que ele entrega, além de capacidade, uma decisão. Até aqui o projeto sabia construir autômatos e executá-los. A partir da minimização, ele sabe responder se duas especificações descrevem a mesma linguagem — pergunta que o módulo 2 deixou explicitamente em aberto, quando a comparação de linguagens até um comprimento foi apresentada como evidência e não como prova.
1.2 Tarefa 1: A determinização
A atividade — converter o autômato não determinístico em determinístico, tratando corretamente as transições vazias.
A construção de subconjuntos cabe em uma frase: cada estado do autômato resultante é um conjunto de estados do original. E é exatamente o conjunto que a simulação do módulo 4 carregava durante a execução. A diferença é quando o cálculo acontece — lá, a cada entrada processada; aqui, uma vez só, na construção.
05_determinizacao.h
#ifndef PENEIRA_05_DETERMINIZACAO_H
#define PENEIRA_05_DETERMINIZACAO_H
#include <cstddef>
#include <string>
#include "03_afd.h"
#include "04_afn.h"
namespace peneira {
// Construção de subconjuntos: converte o autômato não determinístico no
// determinístico equivalente.
//
// A ideia inteira cabe numa frase: cada estado do autômato resultante é um
// CONJUNTO de estados do original. É exatamente o conjunto que a simulação do
// módulo 4 carregava durante a execução — a diferença é que aqui o cálculo é
// feito uma vez, ainda na construção, e não a cada entrada processada.
Afd determinizar(const Afn& afn, std::string nome);
// Remove os estados que não são alcançáveis a partir do inicial. A construção
// de subconjuntos já produz só estados alcançáveis, mas um autômato escrito à
// mão pode ter estados órfãos, e a minimização precisa deles fora antes de
// começar.
Afd removerInalcancaveis(const Afd& original);
// Quantos estados do autômato dado são inalcançáveis. Usado nos relatórios.
std::size_t contarInalcancaveis(const Afd& original);
} // namespace peneira
#endif // PENEIRA_05_DETERMINIZACAO_H05_determinizacao.cpp
#include "05_determinizacao.h"
#include <map>
#include <set>
#include <utility>
#include <vector>
namespace peneira {
namespace {
// Percorre o autômato a partir do inicial e devolve os estados alcançados.
std::set<Estado> alcancaveis(const Afd& a) {
std::set<Estado> vistos;
if (a.inicial() == kSemEstado) {
return vistos;
}
const std::vector<Simbolo> alfabeto = a.simbolosUsados();
std::vector<Estado> pilha{a.inicial()};
vistos.insert(a.inicial());
while (!pilha.empty()) {
const Estado atual = pilha.back();
pilha.pop_back();
for (const Simbolo s : alfabeto) {
const Estado destino = a.transicao(atual, s);
if (destino != kSemEstado && vistos.insert(destino).second) {
pilha.push_back(destino);
}
}
}
return vistos;
}
} // namespace
Afd determinizar(const Afn& afn, std::string nome) {
Afd resultado(std::move(nome));
if (afn.inicial() == kSemEstado) {
return resultado;
}
const std::vector<Simbolo> alfabeto = afn.simbolosUsados();
// O mapa é a peça central: leva cada conjunto de estados do original ao
// índice do estado que o representa no resultado. É ele que faz dois
// caminhos diferentes que chegam ao mesmo conjunto reutilizarem o mesmo
// estado, em vez de duplicá-lo — e é por isso que o resultado costuma ser
// muito menor que o pior caso teórico.
std::map<std::set<Estado>, Estado> conhecidos;
std::vector<std::set<Estado>> fila;
const std::set<Estado> inicial = afn.fechoVazio(std::set<Estado>{afn.inicial()});
conhecidos[inicial] = resultado.novoEstado(inicial.count(afn.final()) > 0);
resultado.definirInicial(conhecidos[inicial]);
fila.push_back(inicial);
for (std::size_t i = 0; i < fila.size(); ++i) {
const std::set<Estado> atual = fila[i];
const Estado origem = conhecidos[atual];
for (const Simbolo s : alfabeto) {
const std::set<Estado> destino =
afn.fechoVazio(afn.mover(atual, s));
// Conjunto vazio significa que não há para onde ir com este
// símbolo. Não criamos estado para ele: fica como transição
// indefinida, que é o estado de erro implícito da representação
// esparsa adotada no módulo 3.
if (destino.empty()) {
continue;
}
const auto it = conhecidos.find(destino);
Estado indice;
if (it == conhecidos.end()) {
indice = resultado.novoEstado(destino.count(afn.final()) > 0);
conhecidos[destino] = indice;
fila.push_back(destino);
} else {
indice = it->second;
}
resultado.adicionarTransicao(origem, s, indice);
}
}
return resultado;
}
std::size_t contarInalcancaveis(const Afd& original) {
return original.quantidadeDeEstados() - alcancaveis(original).size();
}
Afd removerInalcancaveis(const Afd& original) {
const std::set<Estado> vivos = alcancaveis(original);
Afd resultado(original.nome());
std::map<Estado, Estado> novoIndice;
for (const Estado e : vivos) {
novoIndice[e] = resultado.novoEstado(original.ehFinal(e));
}
if (novoIndice.count(original.inicial()) > 0) {
resultado.definirInicial(novoIndice[original.inicial()]);
}
const std::vector<Simbolo> alfabeto = original.simbolosUsados();
for (const Estado e : vivos) {
for (const Simbolo s : alfabeto) {
const Estado destino = original.transicao(e, s);
if (destino != kSemEstado && novoIndice.count(destino) > 0) {
resultado.adicionarTransicao(novoIndice[e], s,
novoIndice[destino]);
}
}
}
return resultado;
}
} // namespace peneiraRepare que o algoritmo reutiliza sem alteração as duas funções escritas no módulo 4, o fecho vazio e o movimento por símbolo. Não foi sorte: elas foram escritas naquele módulo já sabendo que seriam consumidas aqui, e o comentário no cabeçalho delas dizia isso. Quem escreveu o fecho como peça independente, em vez de enterrá-lo dentro da simulação, ganha este módulo de presente.
A peça central da implementação é o mapa que leva cada conjunto de estados ao índice do estado que o representa. É ele que faz dois caminhos diferentes que chegam ao mesmo conjunto reutilizarem o mesmo estado, em vez de duplicá-lo — e é essa reutilização que explica por que o resultado prático fica tão longe do pior caso teórico.
Uma decisão de representação: quando o conjunto destino é vazio, não crio estado para ele. A transição fica indefinida, o que na representação esparsa adotada no módulo 3 já significa rejeição. Poderia criar um estado morto explícito e teria a função de transição total da definição formal; preferi manter a coerência com o que já existia, e a conversão para a forma completa continua disponível de um lado e de outro.
Onde é fácil errar aqui. Esquecer o fecho vazio depois do movimento. A sequência correta é mover por símbolo e então fechar sobre as transições vazias do resultado; quem só fecha no estado inicial produz um autômato que rejeita cadeias válidas, e o defeito só aparece em expressões com estrela ou opcional no meio — que são justamente as que a especificação léxica usa.
Como verificar. A determinização derruba as seis categorias de 1296 para 332 estados, e nenhum dos autômatos resultantes pode mudar de veredicto sobre os corpora do módulo 2. As duas coisas são conferidas na tabela da tarefa 3.
1.3 Tarefa 2: A minimização
A atividade — reduzir o autômato determinístico ao menor equivalente, por refinamento de partições.
A determinização reorganiza; a minimização funde. São operações de naturezas diferentes e vale ter isso claro: a primeira agrupa em um estado cada conjunto que a simulação carregaria junto; a segunda junta estados que ninguém consegue distinguir pelo que aceitam dali para a frente.
05_minimizacao.h
#ifndef PENEIRA_05_MINIMIZACAO_H
#define PENEIRA_05_MINIMIZACAO_H
#include <string>
#include "03_afd.h"
namespace peneira {
// Minimização por refinamento de partições (algoritmo de Moore).
//
// A ideia: começar supondo que só existem duas classes de estados — finais e
// não finais — e ir separando sempre que dois estados da mesma classe levarem,
// com o mesmo símbolo, a classes diferentes. Quando nenhuma separação nova
// aparece, cada classe vira um estado do autômato mínimo.
//
// O resultado é ÚNICO a menos de renomeação dos estados, e é esse resultado
// que transforma a pergunta "estas duas especificações descrevem a mesma
// linguagem?" num procedimento mecânico: minimize as duas e compare.
Afd minimizar(const Afd& original, std::string nome);
// Registro passo a passo do refinamento: quais estados estavam juntos em cada
// rodada e o que provocou cada separação.
std::string tracarMinimizacao(const Afd& original);
// Compara dois autômatos por estrutura, ignorando os nomes dos estados.
// Numera os dois canonicamente por percurso em largura a partir do inicial,
// visitando os símbolos em ordem, e exige que a numeração resultante case.
//
// É com esta função que a unicidade do autômato mínimo deixa de ser um
// enunciado e vira verificação: dois autômatos mínimos da mesma linguagem
// precisam sair isomorfos, venham de onde vierem.
bool isomorfos(const Afd& a, const Afd& b);
} // namespace peneira
#endif // PENEIRA_05_MINIMIZACAO_H05_minimizacao.cpp
#include "05_minimizacao.h"
#include <map>
#include <queue>
#include <set>
#include <sstream>
#include <utility>
#include <vector>
#include "05_determinizacao.h"
namespace peneira {
namespace {
// Calcula a partição estável dos estados. Devolve, para cada estado, o índice
// do bloco a que ele pertence.
std::vector<std::size_t> particionar(const Afd& a,
const std::vector<Simbolo>& alfabeto,
std::ostringstream* trilha) {
const std::size_t n = a.quantidadeDeEstados();
std::vector<std::size_t> bloco(n, 0);
// Partição inicial: finais de um lado, não finais do outro. Se todos os
// estados forem do mesmo tipo, já começamos com um bloco só.
for (std::size_t e = 0; e < n; ++e) {
bloco[e] = a.ehFinal(e) ? 1u : 0u;
}
std::size_t rodada = 0;
for (;;) {
if (trilha != nullptr) {
std::map<std::size_t, std::vector<Estado>> porBloco;
for (std::size_t e = 0; e < n; ++e) {
porBloco[bloco[e]].push_back(e);
}
*trilha << " rodada " << rodada << ": " << porBloco.size()
<< " bloco(s)\n";
for (const auto& par : porBloco) {
*trilha << " {";
for (std::size_t i = 0; i < par.second.size(); ++i) {
if (i > 0) *trilha << ", ";
*trilha << "q" << par.second[i];
}
*trilha << "}" << (a.ehFinal(par.second.front()) ? " final" : "")
<< '\n';
}
}
// A assinatura de um estado é o bloco dele mais o bloco de destino de
// cada símbolo. Dois estados no mesmo bloco com assinaturas diferentes
// são distinguíveis e precisam ser separados.
std::map<std::vector<long long>, std::size_t> novoIndice;
std::vector<std::size_t> novoBloco(n, 0);
for (std::size_t e = 0; e < n; ++e) {
std::vector<long long> assinatura;
assinatura.reserve(alfabeto.size() + 1);
assinatura.push_back(static_cast<long long>(bloco[e]));
for (const Simbolo s : alfabeto) {
const Estado destino = a.transicao(e, s);
assinatura.push_back(
destino == kSemEstado
? -1
: static_cast<long long>(bloco[destino]));
}
const auto it = novoIndice.find(assinatura);
if (it == novoIndice.end()) {
const std::size_t indice = novoIndice.size();
novoIndice[assinatura] = indice;
novoBloco[e] = indice;
} else {
novoBloco[e] = it->second;
}
}
if (novoBloco == bloco) {
if (trilha != nullptr) {
*trilha << " nenhuma separacao nova: ponto fixo alcancado\n";
}
return bloco;
}
bloco = novoBloco;
++rodada;
}
}
// Um bloco é morto quando não tem estado final e todas as suas transições
// voltam para ele mesmo: entrar nele significa nunca mais aceitar.
bool blocoEhMorto(const Afd& a, const std::vector<std::size_t>& bloco,
std::size_t alvo, const std::vector<Simbolo>& alfabeto) {
bool temEstado = false;
for (std::size_t e = 0; e < a.quantidadeDeEstados(); ++e) {
if (bloco[e] != alvo) {
continue;
}
temEstado = true;
if (a.ehFinal(e)) {
return false;
}
for (const Simbolo s : alfabeto) {
const Estado destino = a.transicao(e, s);
if (destino != kSemEstado && bloco[destino] != alvo) {
return false;
}
}
}
return temEstado;
}
} // namespace
Afd minimizar(const Afd& original, std::string nome) {
// Ordem obrigatória: primeiro tirar os inalcançáveis. Um estado que
// ninguém alcança pode ficar sozinho num bloco e inflar o resultado sem
// que isso signifique nada sobre a linguagem.
const Afd limpo = removerInalcancaveis(original);
const std::vector<Simbolo> alfabeto = limpo.simbolosUsados();
if (limpo.quantidadeDeEstados() == 0) {
return Afd(std::move(nome));
}
const std::vector<std::size_t> bloco =
particionar(limpo, alfabeto, nullptr);
std::size_t totalDeBlocos = 0;
for (const std::size_t b : bloco) {
totalDeBlocos = (b + 1 > totalDeBlocos) ? b + 1 : totalDeBlocos;
}
// O bloco morto é descartado: voltamos à representação esparsa do módulo 3,
// em que a transição ausente já significa rejeição. O autômato mínimo da
// teoria, com função total, é este mais o estado morto de volta — mesma
// linguagem, uma escolha de representação de diferença.
std::size_t blocoMorto = totalDeBlocos;
for (std::size_t b = 0; b < totalDeBlocos; ++b) {
if (blocoEhMorto(limpo, bloco, b, alfabeto)) {
blocoMorto = b;
break;
}
}
Afd resultado(std::move(nome));
std::map<std::size_t, Estado> indiceDoBloco;
for (std::size_t b = 0; b < totalDeBlocos; ++b) {
if (b == blocoMorto) {
continue;
}
bool final = false;
for (std::size_t e = 0; e < limpo.quantidadeDeEstados(); ++e) {
if (bloco[e] == b) {
final = limpo.ehFinal(e);
break;
}
}
indiceDoBloco[b] = resultado.novoEstado(final);
}
if (indiceDoBloco.count(bloco[limpo.inicial()]) > 0) {
resultado.definirInicial(indiceDoBloco[bloco[limpo.inicial()]]);
}
// Uma transição por bloco e símbolo: como todos os estados do bloco têm a
// mesma assinatura, basta consultar o primeiro deles.
std::set<std::size_t> jaEmitidos;
for (std::size_t e = 0; e < limpo.quantidadeDeEstados(); ++e) {
const std::size_t b = bloco[e];
if (b == blocoMorto || !jaEmitidos.insert(b).second) {
continue;
}
for (const Simbolo s : alfabeto) {
const Estado destino = limpo.transicao(e, s);
if (destino == kSemEstado) {
continue;
}
const std::size_t destinoBloco = bloco[destino];
if (destinoBloco == blocoMorto) {
continue;
}
resultado.adicionarTransicao(indiceDoBloco[b], s,
indiceDoBloco[destinoBloco]);
}
}
return resultado;
}
std::string tracarMinimizacao(const Afd& original) {
const Afd limpo = removerInalcancaveis(original);
std::ostringstream trilha;
trilha << "refinamento de particoes de " << original.nome() << " ("
<< limpo.quantidadeDeEstados() << " estados alcancaveis)\n";
if (limpo.quantidadeDeEstados() > 0) {
particionar(limpo, limpo.simbolosUsados(), &trilha);
}
return trilha.str();
}
bool isomorfos(const Afd& a, const Afd& b) {
if (a.quantidadeDeEstados() != b.quantidadeDeEstados()) {
return false;
}
if (a.simbolosUsados() != b.simbolosUsados()) {
return false;
}
if (a.quantidadeDeEstados() == 0) {
return true;
}
if (a.inicial() == kSemEstado || b.inicial() == kSemEstado) {
return a.inicial() == b.inicial();
}
// Percurso em largura simultâneo nos dois autômatos, visitando os símbolos
// em ordem. Se em algum ponto um tem transição e o outro não, ou as
// finalidades divergem, não há isomorfismo.
const std::vector<Simbolo> alfabeto = a.simbolosUsados();
std::map<Estado, Estado> par;
std::queue<std::pair<Estado, Estado>> fila;
par[a.inicial()] = b.inicial();
fila.push({a.inicial(), b.inicial()});
while (!fila.empty()) {
const std::pair<Estado, Estado> atual = fila.front();
fila.pop();
if (a.ehFinal(atual.first) != b.ehFinal(atual.second)) {
return false;
}
for (const Simbolo s : alfabeto) {
const Estado da = a.transicao(atual.first, s);
const Estado db = b.transicao(atual.second, s);
if ((da == kSemEstado) != (db == kSemEstado)) {
return false;
}
if (da == kSemEstado) {
continue;
}
const auto it = par.find(da);
if (it == par.end()) {
par[da] = db;
fila.push({da, db});
} else if (it->second != db) {
return false;
}
}
}
return par.size() == a.quantidadeDeEstados();
}
} // namespace peneiraO algoritmo de Moore parte da suposição mais otimista possível — só existem duas classes de estados, finais e não finais — e vai separando enquanto encontrar motivo. O motivo é a assinatura: dois estados no mesmo bloco que, com o mesmo símbolo, levam a blocos diferentes são distinguíveis e precisam ser separados. Quando uma rodada inteira não produz separação nova, chegamos ao ponto fixo.
Vale ver o refinamento acontecendo sobre o autômato determinizado do número, que tem quarenta e três estados:
rodada 0: 2 bloco(s)
{q0, q1, q12}
{q2, ..., q42} final
rodada 1: 4 bloco(s)
{q0}
{q1, q12}
{q2, ..., q22} final
{q23, ..., q42} final
rodada 2: 5 bloco(s)
{q0}
{q1}
{q2, ..., q22} final
{q12}
{q23, ..., q42} final
nenhuma separacao nova: ponto fixo alcancado
Três rodadas, quarenta e três estados, cinco blocos. A rodada 1 é a mais instrutiva: ela separa os finais em dois grupos, e o que os distingue é que um aceita ponto adiante e o outro não — são a parte inteira e a parte fracionária, que o desenho à mão do módulo 3 já tinha separado por intuição. O algoritmo redescobre a distinção sem saber nada sobre números.
Duas decisões de implementação merecem defesa.
A ordem importa: inalcançáveis saem antes. Um estado que ninguém alcança pode ficar sozinho num bloco e inflar o resultado sem que isso signifique nada sobre a linguagem. A construção de subconjuntos já produz só estados alcançáveis, mas um autômato escrito à mão pode ter órfãos, e a função é escrita para servir aos dois casos.
O bloco morto é descartado no fim. O refinamento trabalha sobre a forma completa, com estado de erro explícito, porque a assinatura precisa de um destino definido para cada símbolo. Ao reconstruir, descarto o bloco morto e volto à representação esparsa. Isso merece uma ressalva honesta: o autômato mínimo da teoria, com função de transição total, é este mais o estado morto de volta. Mesma linguagem, uma escolha de representação de diferença — e a unicidade vale nas duas formas.
Onde é fácil errar aqui. Comparar estados apenas pela finalidade e pelos símbolos que aceitam, sem olhar para onde levam. Dois estados finais que aceitam os mesmos símbolos podem ser distinguíveis se um deles leva a um estado morto e o outro não. A assinatura precisa incluir o bloco de destino, não o símbolo.
Como verificar. O menor autômato encontrado precisa aceitar exatamente as mesmas cadeias que o de partida. A verificação está na tarefa 3, e ela é mais forte do que parece.
1.4 Tarefa 3: A verificação, e o teorema em ação
A atividade — a tabela de contagem nas três etapas e a confirmação de que o resultado equivale ao autômato manual do módulo 3.
A tabela das seis categorias, com a redução de cada uma:
categoria Thompson det. minimo reducao
IDENTIFICADOR 250 64 2 99.2%
NUMERO 168 43 5 97.0%
TEXTO 392 100 3 99.2%
PADRAO 392 100 3 99.2%
PONTUACAO 64 16 5 92.2%
ESPACO 30 9 2 93.3%
TOTAL 1296 332 20
Mil duzentos e noventa e seis estados viram vinte. O identificador, que a construção de Thompson expandiu para duzentos e cinquenta estados porque [a-z] vira vinte e seis uniões, reduz-se aos dois estados que qualquer pessoa desenharia à mão. O texto entre aspas, com seus trezentos e noventa e dois, cabe em três.
Vale entender por que a queda é tão grande, porque o número sozinho não ensina. Pegue o identificador. A expressão [a-z][a-z0-9_]* tem duas classes, e Thompson expande cada uma em uma cadeia de uniões: vinte e seis fragmentos para a primeira, trinta e sete para a segunda, cada um com dois estados, mais dois estados por operador de união. Daí os duzentos e cinquenta.
Só que esses vinte e seis caminhos paralelos são indistinguíveis para o autômato. Depois de ler um a ou um q, o conjunto de estados alcançados é o mesmo conjunto — o fecho vazio das saídas dos respectivos fragmentos converge para a entrada do fragmento seguinte. A construção de subconjuntos percebe isso naturalmente: consulta o mapa, encontra o conjunto já registrado e reutiliza o estado em vez de criar outro. Sessenta e quatro estados sobrevivem, e a minimização depois observa que quase todos aceitam exatamente o mesmo daí para a frente, fundindo tudo em dois.
Essa é a lição prática por trás dos números: a expansão de Thompson é redundante por construção, e as duas etapas seguintes existem para desfazer redundância que ninguém quis criar. A construção não é ingênua — é uniforme, e a uniformidade cobra em tamanho o que entrega em simplicidade.
Agora a parte que vale o módulo inteiro. Temos dois caminhos completamente independentes até o autômato do número: o desenho que fiz à mão no módulo 3, e a expressão regular passada por Thompson, determinização e minimização. Nenhum dos dois sabe da existência do outro. O teorema da unicidade diz que os dois mínimos têm de ser o mesmo autômato, a menos de renomeação dos estados.
caminho 1 — desenhado a mao no modulo 3: 5 estados, minimizado para 5
caminho 2 — expressao regular via Thompson: 168 estados,
determinizado para 43, minimizado para 5
os dois minimos sao isomorfos? SIM
Duas coisas nesse resultado. A primeira é que o desenho à mão já era mínimo — os cinco estados que projetei no módulo 3 não tinham gordura, e o algoritmo confirma. A segunda, mais importante, é o isomorfismo: partindo de lugares diferentes, por processos diferentes, chegamos ao mesmo objeto.
O teste de isomorfismo não é comparação de contagem. Ele numera os dois autômatos canonicamente, por percurso em largura a partir do inicial visitando os símbolos em ordem, e exige que finalidade e estrutura de transições casem em cada passo. Dois autômatos com cinco estados cada podem ter contagens iguais e formas diferentes; este teste recusaria.
Além da estrutura, confiro o comportamento sobre o corpus do módulo 2, e os dois mínimos passam nos treze casos. São verificações independentes: uma olha a forma, outra olha o que o autômato faz. Passar nas duas é bem mais convincente que passar em qualquer uma.
O que isso destrava. No módulo 2, comparei expressões regulares gerando as linguagens que elas denotam até um comprimento dado, e registrei que aquilo era evidência e não demonstração — duas expressões podem coincidir até o comprimento dez e divergir no onze. Agora existe o procedimento de verdade: converta as duas em autômatos, minimize, teste isomorfismo. A resposta é exata e vale para cadeias de qualquer comprimento. Foi para poder dizer isto que o módulo 2 deixou a lacuna explícita.
Onde é fácil errar aqui. Aceitar a igualdade de contagem como prova de equivalência. Dois autômatos mínimos com o mesmo número de estados podem reconhecer linguagens diferentes; é preciso comparar a estrutura, e é por isso que a função de isomorfismo existe em vez de um simples confronto de tamanhos.
1.5 Tarefa 4: A exportação para visualização
A atividade — exportar o autômato para um formato de visualização gráfica e salvar os diagramas.
Esta é a tarefa que os grupos apressados tratam como acessória. Não é. A partir deste módulo os autômatos deixam de ser desenhados por pessoas e passam a ser produzidos por algoritmo, com dezenas ou centenas de estados. Conferir um resultado desses lendo tabela de transição é inviável; olhando o desenho, é imediato.
05_dot.cpp
#include "05_dot.h"
#include <fstream>
#include <map>
#include <sstream>
#include <vector>
namespace peneira {
namespace {
// Escapa o que o DOT trata como especial dentro de um rótulo entre aspas.
std::string escaparDot(Simbolo s) {
switch (s) {
case '"':
return "\\\"";
case '\\':
return "\\\\";
case ' ':
return "esp";
case '\t':
return "tab";
case '\n':
return "nl";
case '\r':
return "cr";
default:
break;
}
if (s >= 33 && s <= 126) {
return std::string(1, static_cast<char>(s));
}
std::ostringstream saida;
saida << '#' << static_cast<unsigned int>(s);
return saida.str();
}
} // namespace
std::string exportarDot(const Afd& a) {
std::ostringstream saida;
saida << "digraph automato {\n";
saida << " rankdir=LR;\n";
saida << " labelloc=\"t\";\n";
saida << " label=\"" << a.nome() << " (" << a.quantidadeDeEstados()
<< " estados)\";\n";
// Nó invisível apontando para o inicial: é a convenção usual para marcar
// o estado de partida num diagrama de autômato.
saida << " inicio [shape=point];\n";
if (a.inicial() != kSemEstado) {
saida << " inicio -> q" << a.inicial() << ";\n";
}
for (std::size_t e = 0; e < a.quantidadeDeEstados(); ++e) {
saida << " q" << e << " [shape="
<< (a.ehFinal(e) ? "doublecircle" : "circle") << "];\n";
}
// Agrupa por destino e, dentro do destino, colapsa símbolos consecutivos
// em faixa, para que o rótulo da aresta caiba no desenho.
const std::vector<Simbolo> alfabeto = a.simbolosUsados();
for (std::size_t e = 0; e < a.quantidadeDeEstados(); ++e) {
std::map<Estado, std::vector<Simbolo>> porDestino;
for (const Simbolo s : alfabeto) {
const Estado destino = a.transicao(e, s);
if (destino != kSemEstado) {
porDestino[destino].push_back(s);
}
}
for (const auto& par : porDestino) {
std::ostringstream rotulo;
const std::vector<Simbolo>& simbolos = par.second;
std::size_t i = 0;
bool primeiro = true;
while (i < simbolos.size()) {
std::size_t j = i;
while (j + 1 < simbolos.size() &&
simbolos[j + 1] == static_cast<Simbolo>(simbolos[j] + 1)) {
++j;
}
if (!primeiro) rotulo << ",";
if (i == j) {
rotulo << escaparDot(simbolos[i]);
} else {
rotulo << escaparDot(simbolos[i]) << "-"
<< escaparDot(simbolos[j]);
}
primeiro = false;
i = j + 1;
}
saida << " q" << e << " -> q" << par.first << " [label=\""
<< rotulo.str() << "\"];\n";
}
}
saida << "}\n";
return saida.str();
}
bool salvarDot(const Afd& a, const std::string& caminho, std::string& erro) {
std::ofstream arquivo(caminho, std::ios::binary);
if (!arquivo) {
erro = "nao foi possivel abrir para escrita: " + caminho;
return false;
}
arquivo << exportarDot(a);
if (!arquivo) {
erro = "falha ao escrever: " + caminho;
return false;
}
erro.clear();
return true;
}
} // namespace peneiraO formato adotado é o DOT, do Graphviz, por ser texto simples e ter ferramenta madura de desenho. O arquivo gerado para o autômato mínimo do número sai assim:
digraph automato {
rankdir=LR;
label="NUMERO (5 estados)";
inicio [shape=point];
inicio -> q0;
q0 [shape=circle];
q2 [shape=doublecircle];
q0 -> q1 [label="-"];
q0 -> q2 [label="0-9"];
q2 -> q2 [label="0-9"];
q2 -> q3 [label="."];
...
}
Duas convenções que valem seguir: o estado final é círculo duplo, e um nó invisível aponta para o inicial. São as convenções dos livros, e usá-las faz o diagrama gerado pela ferramenta parecer com o do material teórico.
Os símbolos consecutivos com o mesmo destino são agrupados em faixa, pelo mesmo motivo que a tabela do módulo 3 agrupa: sem isso, o autômato do identificador teria trinta e sete arestas paralelas entre os mesmos dois estados, e o desenho ficaria ilegível.
Um defeito real que só o diagrama revelou. Ao olhar o arquivo gerado para a categoria do espaço em branco, o rótulo da aresta saiu como esp,n,r,t — o autômato estava reconhecendo as letras n, r e t, além do espaço. A causa estava no analisador da notação, escrito no módulo 4: diante de \t, ele removia o significado especial da barra invertida e devolvia a letra t, em vez de traduzir para o caractere de tabulação.
O defeito é do tipo pior: o autômato ficava bem formado, a determinização e a minimização funcionavam sobre ele, os testes de estrutura passavam — e a linguagem reconhecida estava errada. Nenhuma verificação anterior o teria pego, porque o corpus da categoria de espaço não distingue tabulação de letra t sem que alguém olhe. O diagrama olhou.
A correção foi acrescentar a tradução das três sequências de controle no ponto em que a barra invertida é consumida, nos três lugares onde ela aparece: no átomo, no membro de classe e no extremo de faixa. Depois dela, o rótulo sai como tab-nl,cr,esp. Vale como argumento a favor de tratar a visualização como ferramenta de trabalho, e não como entrega decorativa.
Como verificar. Gerar os seis diagramas e abrir pelo menos um. Se o Graphviz não estiver instalado, o arquivo DOT continua sendo texto legível e a conferência pode ser feita nele mesmo — que foi exatamente como o defeito acima apareceu.
1.6 Referência teórica: o pior caso da determinização
O módulo enuncia que a construção de subconjuntos pode produzir um autômato com número de estados exponencial no do original. Enunciar isso e mostrar a tabela do projeto, em que os estados caem, deixa o resultado parecendo teoria desconectada. Implementei a família que exibe o crescimento.
A linguagem é a das cadeias sobre a e b cujo k-ésimo símbolo antes do fim é a. Um autômato não determinístico a reconhece adivinhando qual ocorrência de a é a certa. Um determinístico não pode adivinhar: precisa lembrar os últimos k+1 símbolos lidos, e há 2^{k+1} combinações possíveis.
k Thompson determinizado minimo
1 16 5 4
2 22 9 8
3 28 17 16
4 34 33 32
5 40 65 64
6 46 129 128
7 52 257 256
A coluna do determinizado dobra a cada incremento de k, e vale exatamente 2^{k+1} + 1; a do mínimo vale 2^{k+1}. O expoente é k+1, e não k, porque a família implementada aqui conta o k-ésimo símbolo antes do fim, o que obriga o determinístico a lembrar k+1 símbolos — a formulação canônica do teorema, que numera o último símbolo como o primeiro, chega a 2^k pela mesma razão. A minimização não ajuda — o autômato já é mínimo. Não há desperdício a cortar: a linguagem exige mesmo essa memória, e nenhum algoritmo poderia fazer melhor.
O contraste com a tabela das categorias reais é o ponto pedagógico. Lá, a determinização encolhe; aqui, explode. O pior caso é real, é atingível, e expressões de uso prático raramente se parecem com ele. Saber os dois lados é o que separa quem entende o algoritmo de quem decorou que “pode ser exponencial”.
1.7 Referência teórica: o refinamento de Hopcroft
O módulo menciona o algoritmo de Hopcroft como refinamento do de Moore, com complexidade menor. Não o implementei, e registro o motivo.
O de Moore, que implementei, recalcula a assinatura de todos os estados a cada rodada; seu custo é da ordem de O(k n^2) para n estados e k símbolos. O de Hopcroft trabalha com uma fila de blocos a processar e escolhe sempre o menor lado de cada separação, chegando a O(k n \log n). Para os quarenta e três estados do maior autômato deste projeto, a diferença é imperceptível; ela passa a importar em ordens de grandeza que este compilador não vai alcançar.
Implementar Hopcroft aqui custaria complexidade de código sem ganho observável, e obscureceria justamente o que a versão de Moore deixa visível: a partição refinando rodada a rodada, que é o conceito do módulo. A troca é consciente e está comentada no código.
1.8 Verificação da entrega
| Item | Como conferir | Estado nesta referência |
|---|---|---|
| Determinização | Trata transições vazias; reduz 1296 para 332 estados | Atende |
| Eliminação de inalcançáveis | Executada antes do refinamento | Atende |
| Minimização | Refinamento até ponto fixo; 332 para 20 estados | Atende |
| Contagem nas três etapas | Tabela por categoria, com redução percentual | Atende |
| Equivalência com o manual | Isomorfismo com o autômato do módulo 3 | Confirmado, e os corpora passam nos dois |
| Exportação visual | Seis arquivos DOT gerados | Atende |
| Pior caso demonstrado | Família com crescimento exponencial medido | 2^{k+1}+1 determinizado, 2^{k+1} mínimo |
| Código compila limpo | Nenhum aviso sob o modo estrito | Atende, verificado por compilação e execução |
O que quero deixar registrado sobre esta entrega é que ela fecha o primeiro bloco da disciplina com uma ferramenta que funciona: expressão regular entra, autômato mínimo sai, e há como provar que o resultado está certo. O módulo 6 vai mostrar o que essa ferramenta não consegue fazer, e o módulo 7 a transformará em analisador léxico. Mas o motor está pronto, e ele tem vinte estados onde a construção ingênua tinha mil duzentos e noventa e seis.