Pular para o conteúdo

Matemática para Computação · parte 7 de 7

Teoria da Computação e Linguagens Formais: De Autômatos à Máquina Universal de Turing

Hierarquia de Chomsky, autômatos finitos, gramáticas livres de contexto com parser recursivo em Go e o limite da computação com a parada de Turing.

avançadoPublicada em · 26 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Teoria da computação e linguagens formais

Imagine uma fita métrica infinita estendida sobre uma bancada de marceneiro. Em cima dela, um pequeno carrinho de brinquedo com uma câmera desliza para a esquerda e para a direita. O carrinho consegue ler o símbolo gravado na fita onde ele está pisando, consultar uma tabela de regras simples na sua memória interna, apagar o símbolo, desenhar um novo símbolo no lugar e dar um passo para o lado. Essa geringonça mecânica concebida no papel pelo jovem Alan Turing em 1936, sem nenhum chip ou circuito eletrônico, é capaz de rodar exatamente o mesmo algoritmo que o supercomputador mais veloz do planeta executa hoje.

A teoria da computação é o ramo mais filosófico e, ao mesmo tempo, mais prático de toda a ciência da computação. Ela não se preocupa se o seu computador tem processador Intel, Apple Silicon ou um processador quântico: ela responde a duas perguntas universais que valem para qualquer máquina que já existiu ou que venha a ser inventada no futuro: “O que é matematicamente possível computar?” e “Com que eficiência isso pode ser feito?”.

Esta aula ensina os modelos fundamentais da computação com o rigor exigido nos melhores cursos de ciência da computação teórica do mundo. Começaremos com alfabetos e a Hierarquia de Chomsky (seções 1 e 2), construiremos autômatos finitos e desvendaremos o motor de expressões regulares em Go (seções 3 e 4), passaremos pelas linguagens livres de contexto que constroem compiladores (seção 5), mergulharemos na e na prova do (seções 6 e 7), exploraremos as classes de complexidade P versus NP (seção 8), e fecharemos com código em Go, exercícios resolvidos e glossário.

1. Alfabetos, cadeias e linguagens formais

Na matemática pura, uma linguagem não é um conjunto de gírias ou regras de conversação: é um objeto puramente formal definido a partir de três blocos matemáticos elementares:

  • Alfabeto (Σ): Um conjunto finito e não-vazio de símbolos. O alfabeto binário é Σ = {0, 1}; o alfabeto do código ASCII possui 128 caracteres; o alfabeto de nucleotídeos do DNA é Σ = {A, C, G, T}.
  • Cadeia ou Palavra (w): Uma sequência finita de símbolos escolhidos a partir do alfabeto Σ. O comprimento da cadeia é denotado por |w|. A cadeia vazia, que não contém símbolo nenhum, é denotada pela letra grega épsilon (ε), com |ε| = 0.
  • Fecho de Kleene (Σ*): O conjunto de todas as cadeias possíveis de qualquer tamanho que podem ser formadas com os símbolos de Σ, incluindo a cadeia vazia. Se Σ = {0, 1}, então Σ* = {ε, 0, 1, 00, 01, 10, 11, 000, …}.
  • Linguagem Formal (L): Qualquer subconjunto do fecho de Kleene (L ⊆ Σ*). Uma linguagem pode ser finita (como o conjunto de todas as palavras reservadas de Go) ou infinita (como o conjunto de todas as expressões matemáticas bem-formadas).

O trabalho central de qualquer compilador, analisador léxico ou algoritmo de busca é responder ao problema da pertinência: dada uma cadeia arbitrária w e uma linguagem L, decida se w ∈ L.

2. A Hierarquia de Chomsky

Em 1956, o linguista e filósofo Noam Chomsky estabeleceu uma classificação fundamental que conecta gramáticas generativas, linguagens formais e o poder da memória das máquinas necessárias para reconhecê-las.

A hierarquia é dividida em quatro camadas concêntricas, onde cada nível superior engloba estritamente os níveis inferiores:

A Hierarquia de Chomsky e os modelos de computação. Autômato Finito (DFA / NFA) → Autômato com Pilha (Pushdown) (adiciona pilha LIFO)…
figura 1 · A Hierarquia de Chomsky e os modelos de computação

O diagrama acima detalha a escada de poder computacional. O que muda de um nível para o outro não é a velocidade do processador, mas sim o tipo de memória auxiliar que o modelo possui:

Tipo 3: Linguagens Regulares (Autômatos Finitos)

O modelo computacional mais simples e rápido que existe. A máquina possui apenas um registrador com o estado atual. Ela não tem memória externa de pilha nem de fita.

  • Aplicação no software: Expressões regulares simples, analisadores léxicos (lexers) e roteamento de tráfego de rede.

Tipo 2: Linguagens Livres de Contexto (Autômatos com Pilha)

A máquina ganha uma pilha LIFO ilimitada. Isso permite que ela conte e emparelhe símbolos simétricos (como parênteses que abrem e fecham).

  • Aplicação no software: Gramáticas de linguagens de programação (Go, Rust, Python), formatos de serialização (JSON, XML) e árvores de sintaxe abstrata (AST).

Tipo 1: Linguagens Sensíveis ao Contexto (Autômatos Linearmente Limitados)

A máquina ganha uma fita de memória cujo tamanho é limitado pelo tamanho da entrada multiplicada por uma constante. As regras de produção dependem dos vizinhos ao redor dos símbolos.

  • Aplicação no software: Análise semântica e verificação de tipos dependentes em compiladores.

Tipo 0: Linguagens Recursivamente Enumeráveis (Máquinas de Turing)

A máquina ganha acesso a uma fita de leitura e escrita infinita e irrestrita. Representa o limite absoluto da computabilidade universal. Qualquer algoritmo executável em hardware real pertence a esta classe.

3. Autômatos Finitos Determinísticos (DFA)

Um Determinístico (Deterministic Finite Automaton ou DFA) é um modelo matemático composto por um número finito de estados e transições estritas. A cada símbolo lido da cadeia de entrada, a máquina salta determinísticamente para exatamente um novo estado.

Formalmente, um DFA é uma 5-tupla M = (Q, Σ, δ, q₀, F):

  1. Q: conjunto finito de estados.
  2. Σ: alfabeto de entrada.
  3. δ: função de transição δ: Q × Σ → Q, que recebe um estado e um símbolo e devolve o próximo estado.
  4. q₀: estado inicial (q₀ ∈ Q).
  5. F: conjunto de estados de aceitação ou finais (F ⊆ Q).
Autômato Finito Determinístico (DFA) reconhecendo strings terminadas em '01'. Fita de Entrada → q₀ (inicia); q₀ → q₁ (lê '0'); q₁ → q₂ (lê '1')…
figura 2 · Autômato Finito Determinístico (DFA) reconhecendo strings terminadas em '01'

O diagrama acima ilustra um DFA projetado para reconhecer qualquer sequência binária que termine exatamente no padrão “01” (como 1001 ou 001).

A cadeia de bits chega pela esquerda. Partindo do estado inicial q₀, se o leitor receber o bit 0, o fluxo transita para q₁, indicando que o primeiro caractere do padrão foi visto. Se o próximo bit for 1, a transição avança para o estado duplo q₂, que pertence ao conjunto de aceitação F. Se novos bits chegarem, o autômato navega pelos atalhos de degrau para manter o estado atualizado com os últimos bits processados. Se a fita de entrada terminar enquanto a máquina repousa em q₂, a cadeia é aceita como membro da linguagem; se terminar em q₀ ou q₁, ela é rejeitada.

Autômatos Finitos Não-Determinísticos (NFA) e o Teorema de Rabin-Scott

Em um DFA, para cada estado e cada símbolo existe exatamente uma transição possível. Mas e se permitirmos que a máquina tome múltiplos caminhos simultâneos para o mesmo símbolo, ou até salte de estado sem ler nenhum caractere (a chamada transição vazia ou ε-transição)? Esse modelo é o Autômato Finito Não-Determinístico (NFA).

Um NFA não prevê o futuro nem faz mágica: você pode imaginá-lo como um processo que cria clones de si mesmo (como um fork() de sistema operacional) sempre que encontra uma bifurcação, explorando todos os caminhos em paralelo. Uma cadeia é aceita se pelo menos uma das cópias terminar em um estado final.

Em 1959, Michael Rabin e Dana Scott publicaram um teorema que rendeu a ambos o Prêmio Turing: Para todo NFA, existe um DFA equivalente que aceita exatamente a mesma linguagem.

O método para transformar um NFA em DFA chama-se Construção de Subconjuntos (Subset Construction ou Powerset Construction):

Construção de subconjuntos: convertendo NFA em DFA determinístico. q₀ → q₁ (lê 'a' ou ε); q₁ → q₂ (lê 'b'); {q₀} → {q₀, q₁} (lê 'a')…
figura 3 · Construção de subconjuntos: convertendo NFA em DFA determinístico

O diagrama acima revela a engrenagem dessa conversão. No NFA da esquerda, a leitura do símbolo ‘a’ pode manter o autômato em q₀ ou avançá-lo para q₁. Para transformar isso em uma máquina estritamente determinística, o DFA da direita cria um macro-estado que representa o conjunto de todos os estados que poderiam estar ativos simultaneamente: o estado {q₀, q₁}.

Se o NFA original possui n estados, o conjunto das partes (visto na aula 3) prova que o DFA resultante pode ter até 2ⁿ estados. Essa explosão exponencial é o motivo pelo qual compiladores de alta velocidade (como o pacote regexp de Go) utilizam avaliação preguiçosa (lazy evaluation): eles não geram todos os 2ⁿ estados com antecedência; eles criam e armazenam em cache apenas os estados do DFA que forem efetivamente visitados durante a leitura do texto.

O Lema do Bombeamento (Pumping Lemma) e os limites do DFA

Por que uma expressão regular não consegue validar se um trecho de código tem o número correto de parênteses balanceados?

Considere a linguagem clássica L = {0ⁿ 1ⁿ | n ≥ 0}, isto é, cadeias com uma sequência de n zeros seguida por exatamente a mesma quantidade de n uns (como 000111).

Para verificar se a quantidade de uns é idêntica à quantidade de zeros, a máquina precisaria contar quantos zeros entraram. Mas um autômato finito possui, por definição, um número finito de estados (digamos k estados). Se você passar uma cadeia com k + 1 zeros, pelo Princípio da Casa dos Pombos a máquina será forçada a visitar o mesmo estado duas vezes antes de ver o primeiro 1. Ao entrar nesse laço fechado, ela perde a capacidade de contar e aceita cadeias inválidas (como 000000111).

O Pumping Lemma para linguagens regulares formaliza esse fato: qualquer linguagem que exija contagem de profundidade arbitrária não é regular e jamais poderá ser processada por um DFA.

4. Expressões regulares no mundo real: Go vs linguagens dinâmicas

Muitos desenvolvedores já ouviram a frase: “tínhamos um problema, resolvemos usar Regex, agora temos dois problemas”. Essa anedota nasceu de uma diferença matemática profunda entre dois tipos de motores de expressão regular:

  1. Motores baseados em Backtracking (Python, JavaScript, Ruby, Java): Usam autômatos não-determinísticos com retrocesso ingênuo. Em padrões com repetições ambíguas (como (a+)+b), ao receber uma entrada como aaaaaaaaaaaaac, o motor testa todas as combinações exponenciais de parênteses. O tempo de resposta explode de milissegundos para horas, travando a CPU no infame ataque de negação de serviço por Regex (ReDoS).
  2. Motores baseados em Autômatos Lineares (Go e a biblioteca RE2 de Ken Thompson): O pacote regexp de Go compila a expressão regular convertendo-a diretamente em um NFA e depois em um DFA simulado em tempo linear. A busca percorre a string uma única vez, garantindo execução em tempo estritamente O(n), imune a qualquer travamento por backtracking.

5. Linguagens Livres de Contexto e Compiladores

Como linguagens de programação contêm estruturas aninhadas com blocos que abrem e fecham (if { while { ... } }), precisamos subir para o Tipo 2 da hierarquia: as Linguagens Livres de Contexto (Context-Free Languages ou CFL).

Uma (CFG) é definida por um conjunto de regras de substituição onde o lado esquerdo contém sempre uma única variável não-terminal: V → w, onde V é uma variável e w é uma cadeia de variáveis e símbolos terminais.

Veja uma gramática simples que gera expressões aritméticas com parênteses balanceados:

  • Expr → Expr + Termo | Termo
  • Termo → Termo * Fator | Fator
  • Fator → ( Expr ) | id

A máquina que reconhece essa linguagem é o Autômato com Pilha (Pushdown Automaton ou PDA). Ao encontrar um parêntese abrindo (, o autômato empilha um símbolo; ao encontrar o parêntese fechando ), ele desempilha. Se a entrada terminar e a pilha estiver vazia, os blocos foram perfeitamente balanceados.

Essa é a primeira metade de todo compilador moderno:

  1. Analisador Léxico (Lexer / DFA): lê os caracteres brutos do arquivo fonte e agrupa em tokens (identificadores, números, operadores).
  2. Analisador Sintático (Parser / PDA): lê os tokens e aplica as regras da gramática para construir a Árvore Sintática Abstrata (AST). Se o código violar a gramática, o compilador emite o clássico “Syntax Error”.

Por essa razão matemática elementar, você nunca deve tentar extrair dados de documentos HTML ou JSON usando expressões regulares: como HTML e JSON contêm tags aninhadas em profundidade arbitrária, eles pertencem ao Tipo 2 (livres de contexto), enquanto expressões regulares pertencem ao Tipo 3. Tentar fazer isso é exigir que uma máquina sem memória execute o trabalho de uma máquina com pilha.

A assimetria dos autômatos com pilha: DPDA vs NPDA

Aqui reside uma das surpresas mais fascinantes da teoria da computação:

  • No Tipo 3 (autômatos finitos), vimos pelo teorema de Rabin-Scott que DFA e NFA têm exatamente o mesmo poder: qualquer coisa que um NFA faz, um DFA também faz.
  • No Tipo 2 (autômatos com pilha), isso deixa de ser verdade: os Autômatos com Pilha Determinísticos (DPDA) são estritamente menos poderosos do que os Não-Determinísticos (NPDA).

Existe uma classe de linguagens livres de contexto que exige que a máquina “adivinhe” o momento exato de mudar de comportamento. O exemplo clássico é a linguagem de palíndromos de tamanho par: L = {w wᴿ | w ∈ {0, 1}*}, como a cadeia “0110”. A máquina precisa empilhar a primeira metade e desempilhar a segunda metade; mas como não há nenhum marcador no meio indicando onde termina w e onde começa a sua versão invertida wᴿ, um autômato determinístico não sabe quando parar de empilhar. Ele precisa do não-determinismo para bifurcar hipóteses.

Por que essa assimetria importa para você? Porque nenhum compilador do mundo pode se dar ao luxo de rodar com não-determinismo (o que exigiria testar árvores exponenciais de sintaxe). As linguagens de programação do mundo real (como Go, C, Rust e Java) são intencionalmente projetadas para pertencerem à fatia estritamente determinística das linguagens livres de contexto, usando subclasses de gramáticas conhecidas como LL(k) ou LR(1). Isso garante que o compilador consiga decidir exatamente qual regra da gramática aplicar olhando apenas um ou dois tokens à frente (o lookahead), processando milhões de linhas de código em tempo linear O(n).

Ambiguidade em gramáticas e o dilema do dangling else

Uma gramática é dita ambígua se existir pelo menos uma cadeia que possa ser gerada por duas árvores sintáticas (ASTs) diferentes. Se o compilador puder montar duas árvores distintas para o mesmo trecho de código, ele não saberá qual instrução de máquina gerar.

O exemplo clássico que atormentou os criadores das primeiras linguagens de programação é o problema do “dangling else” (o else pendente):

if (condicaoA) if (condicaoB) executarX(); else executarY();

A qual dos dois if a cláusula else pertence?

  • Interpretação 1: O else pertence ao segundo if. Se condicaoA for verdadeira e condicaoB for falsa, executa Y.
  • Interpretação 2: O else pertence ao primeiro if. Se condicaoA for falsa, executa Y.

Na gramática pura, ambas as árvores são perfeitamente válidas. Para resolver essa ambiguidade sem quebrar o determinismo do parser, os projetistas de linguagens adotam uma regra de desempate semântica: uma cláusula else é sempre associada ao if mais próximo e não-fechado anterior. Em linguagens modernas como Go e Rust, a ambiguidade foi erradicada de forma ainda mais elegante: o uso de chaves {} é estritamente obrigatório em blocos de controle, tornando impossível qualquer confusão estrutural na árvore sintática.

6. A Máquina de Turing e a computabilidade

Em 1936, para resolver o Entscheidungsproblem (o problema da decidibilidade formulado por David Hilbert em 1928), Alan Turing concebeu a Máquina de Turing.

A máquina consiste em:

  1. Uma fita infinita dividida em células, onde cada célula pode conter um símbolo de um alfabeto de fita Γ (que inclui o símbolo de espaço em branco B).
  2. Um cabeçote de leitura e escrita que aponta para uma célula de cada vez e pode se mover para a esquerda (L) ou para a direita (R).
  3. Uma unidade de controle com um conjunto finito de estados Q e uma função de transição determinística: δ: Q × Γ → Q × Γ × {L, R} Dado o estado atual e o símbolo sob o cabeçote, a função diz: qual o novo estado, qual símbolo gravar por cima e para qual lado mover o cabeçote.

A Tese de Church-Turing afirma que qualquer função matemática que possa ser calculada por um procedimento mecânico ou algoritmo em qualquer computador do universo físico pode ser calculada por uma Máquina de Turing.

Se algo não pode ser resolvido por uma Máquina de Turing, não pode ser resolvido por nenhum computador que a humanidade venha a construir.

A Máquina de Turing Universal (UTM) e a certidão de nascimento do software

Antes do artigo de Turing em 1936, todas as máquinas de calcular eram dispositivos de hardware com propósito fixo: existia uma máquina mecânica para somar inteiros, outra máquina para calcular tabelas de navegação marítima e teares com cartões perfurados para tecer tecidos. Para mudar a tarefa, você precisava reconstruir as engrenagens físicas.

O insight mais revolucionário de Alan Turing foi a concepção da Máquina de Turing Universal (Universal Turing Machine ou UTM).

Turing percebeu que a descrição das regras e estados de qualquer Máquina de Turing específica M nada mais era do que uma sequência finita de símbolos, que podia ser codificada como um texto ⟨M⟩. Ele então projetou uma única máquina universal U que recebe na sua fita duas coisas:

  1. O código da máquina que se deseja simular: ⟨M⟩.
  2. Os dados de entrada daquela máquina: w.

A Máquina Universal lê o código ⟨M⟩ gravado na fita e simula passo a passo cada transição que a máquina M faria sobre a entrada w: U(⟨M⟩, w) = M(w)

Esse parágrafo de Turing em 1936 é o momento exato em que o software foi inventado. O computador deixou de ser um circuito elétrico engessado para se tornar um executor de texto. Em 1945, o matemático John von Neumann pegou a ideia da Máquina Universal de Turing e a materializou na arquitetura de computadores moderna (a Arquitetura de von Neumann): uma CPU central conectada a uma memória comum onde programas e dados residem lado a lado como meros bytes. Toda vez que você abre o VS Code, compila um programa em Go e o executa no seu sistema operacional, você está rodando uma implementação física da Máquina de Turing Universal.

7. O Problema da Parada (Halting Problem)

Existe algum algoritmo universal capaz de ler o código-fonte de qualquer programa de computador e dizer com 100% de certeza se esse programa vai terminar sua execução ou vai ficar travado em um loop infinito?

Em 1936, Turing provou que esse algoritmo é uma impossibilidade matemática absoluta. O Problema da Parada é formalmente indecidível.

A prova de Turing utiliza a técnica de redução ao absurdo e a diagonalização de Cantor vista na aula 3:

A Máquina de Turing e a contradição do Problema da Parada. Cabeçote L/R → [ 1 ] (lê e grava); H(Programa P, Entrada I) → Máquina Inversa D(D) (alimenta análise)…
figura 4 · A Máquina de Turing e a contradição do Problema da Parada

O diagrama acima resume o mecanismo da contradição:

  1. Suponha que exista um programa perfeito chamado H(P, I) que recebe o código de um programa P e uma entrada I. A função H sempre termina e responde:
    • Verdadeiro, se P(I) para.
    • Falso, se P(I) entra em loop infinito.
  2. Como programas de computador são apenas texto, um programa pode receber o código de outro programa como entrada, ou até receber o seu próprio código-fonte.
  3. Agora construímos uma nova máquina astuta, chamada D(P), que usa H como sub-rotina interna e faz o oposto do que H prevê:
    func D(P Program) {
        if H(P, P) == true {
            // Se H disse que o programa para, D entra em loop infinito de proposito
            for { }
        } else {
            // Se H disse que o programa entra em loop, D termina imediatamente
            return
        }
    }
  4. Agora fazemos a pergunta fatal: o que acontece se passarmos o próprio programa D como entrada para ele mesmo, executando D(D)?
    • Se H(D, D) disser que D(D) vai parar: o if entra no loop infinito e D(D) NUNCA para! Contradição.
    • Se H(D, D) disser que D(D) entra em loop: o if pula para o else e D(D) PARA imediatamente! Contradição.

Ambas as hipóteses levam a um colapso lógico inescapável. A única premissa que gerou o absurdo foi supor que o detector universal H existia. Logo, a função H não existe nem nunca poderá existir.

O : a consequência para linters e segurança

O lógico Henry Gordon Rice generalizou a prova de Turing em 1953: Qualquer propriedade semântica não-trivial sobre o comportamento em tempo de execução de um programa é indecidível.

Isso significa que você nunca conseguirá escrever um programa estático que responda com 100% de precisão (sem falsos positivos e sem falsos negativos) perguntas como:

  • “Este software tem algum vazamento de memória?”
  • “Existe alguma condição de corrida entre goroutines neste código?”
  • “Este arquivo binário é um vírus que vai apagar o disco?”

Toda ferramenta real de análise estática (linters, compiladores, antivírus) é forçada a adotar aproximações conservadoras: ela prefere alertar sobre um bug que não existe (falso positivo) ou deixar passar anomalias complexas, porque prever o comportamento exato sem rodar a máquina é matematicamente proibido pelas leis da computação.

8. Classes de complexidade: P versus NP

Até agora falamos sobre o que pode ou não ser resolvido (computabilidade). A teoria da complexidade computacional estuda o custo em tempo e memória dos problemas que podem ser resolvidos.

Os problemas de decisão (que respondem SIM ou NÃO) são agrupados em classes de complexidade:

  • (Polinomial): Problemas que podem ser resolvidos por um algoritmo determinístico em tempo polinomial O(nᵏ), como ordenação de listas, busca em grafos com Dijkstra e multiplicação de matrizes. São os problemas considerados tratáveis ou fáceis na prática.
  • (Nondeterministic Polynomial): Problemas cuja solução, se alguém lhe entregar de bandeja, pode ser verificada em tempo polinomial. Resolver o problema do zero pode exigir testar bilhões de permutações, mas conferir se a resposta está certa leva microssegundos.
    • Exemplo: O Sudoku. Encontrar a solução de um tabuleiro gigante 100x100 por tentativa e erro é absurdamente difícil; mas se alguém lhe der o tabuleiro preenchido, você confere linha por linha em tempo linear se há números repetidos.
  • Classe : Os problemas mais difíceis de toda a classe NP. Em 1971, Stephen Cook e Leonid Levin provaram que o problema da satisfatibilidade booleana (SAT) é NP-Completo. Mais importante: se alguém encontrar um algoritmo polinomial para resolver um único problema NP-Completo (como o problema do caixeiro viajante ou a partição de conjuntos), todos os milhares de problemas NP do universo serão automaticamente resolvidos em tempo polinomial, provando que P = NP.

A maioria dos cientistas da computação acredita que P ≠ NP: verificar uma resposta é intrinsecamente mais fácil do que criá-la do nada. Esse é um dos sete Problemas do Prêmio Millennium do Clay Mathematics Institute, valendo 1 milhão de dólares para quem apresentar a demonstração formal definitiva.

9. Implementação prática: um DFA lexer em Go

Para ver a elegância dos autômatos em ação, veja a implementação em Go de um Autômato Finito Determinístico puro (sem bibliotecas de regex) que reconhece se uma cadeia é um número de ponto flutuante válido (como 42, -3.14, +0.5 ou .25):

package main

import (
	"fmt"
	"unicode"
)

// Estado representa os nos do nosso DFA
type Estado int

const (
	EstadoInicial Estado = iota
	EstadoSinal
	EstadoDigitoInteiro
	EstadoPontoSemDigito
	EstadoDigitoFracionario
	EstadoInvalido
)

// ValidarNumeroFloat executa a maquina de estados finita sobre a string.
func ValidarNumeroFloat(entrada string) bool {
	estado := EstadoInicial

	for _, char := range entrada {
		switch estado {
		case EstadoInicial:
			if char == '+' || char == '-' {
				estado = EstadoSinal
			} else if unicode.IsDigit(char) {
				estado = EstadoDigitoInteiro
			} else if char == '.' {
				estado = EstadoPontoSemDigito
			} else {
				return false
			}

		case EstadoSinal:
			if unicode.IsDigit(char) {
				estado = EstadoDigitoInteiro
			} else if char == '.' {
				estado = EstadoPontoSemDigito
			} else {
				return false
			}

		case EstadoDigitoInteiro:
			if unicode.IsDigit(char) {
				estado = EstadoDigitoInteiro // permanece no loop
			} else if char == '.' {
				estado = EstadoDigitoFracionario
			} else {
				return false
			}

		case EstadoPontoSemDigito:
			// Se comecou com ponto (ex: ".5"), precisa obrigatoriamente de um digito a seguir
			if unicode.IsDigit(char) {
				estado = EstadoDigitoFracionario
			} else {
				return false
			}

		case EstadoDigitoFracionario:
			if unicode.IsDigit(char) {
				estado = EstadoDigitoFracionario // permanece no loop
			} else {
				return false
			}

		default:
			return false
		}
	}

	// Estados de aceitacao (F): onde e permitido terminar
	return estado == EstadoDigitoInteiro || estado == EstadoDigitoFracionario
}

func main() {
	exemplos := []string{
		"123",     // true
		"-3.1415", // true
		"+0.5",    // true
		".25",     // true
		"abc",     // false
		"1.2.3",   // false
		"-.",      // false
		"+",       // false
	}

	fmt.Println("Validacao de numeros de ponto flutuante via DFA:")
	for _, ex := range exemplos {
		fmt.Printf("  '%s' -> %t\n", ex, ValidarNumeroFloat(ex))
	}
}

Esse código em Go roda em tempo estritamente linear O(n) e consome zero alocações de memória no heap, a exata técnica utilizada no analisador léxico oficial do compilador de Go (go/scanner).

Um parser descendente recursivo em Go (construindo a AST)

Agora que vimos o autômato finito (Tipo 3) transformando caracteres em tokens, vamos subir para o Tipo 2 da hierarquia de Chomsky e construir o analisador sintático (Parser).

A técnica mais utilizada na construção de compiladores reais chama-se Parser Descendente Recursivo (Recursive Descent Parser). Cada regra não-terminal da Gramática Livre de Contexto vira uma função em Go, e a pilha de recursão da linguagem atua naturalmente como a memória do autômato com pilha (PDA).

Veja a implementação completa que analisa expressões matemáticas, respeita a precedência de multiplicação sobre adição, processa parênteses aninhados e constrói uma Árvore Sintática Abstrata (AST) que pode ser avaliada recursivamente:

package main

import (
	"fmt"
	"strconv"
	"strings"
	"unicode"
)

// --- 1. A ESTRUTURA DA ARVORE SINTATICA ABSTRATA (AST) ---

// NoAST representa qualquer no da nossa arvore sintatica
type NoAST interface {
	Avaliar() int
	String() string
}

// NoNumero representa um operando folha (um numero inteiro)
type NoNumero struct {
	Valor int
}

func (n *NoNumero) Avaliar() int    { return n.Valor }
func (n *NoNumero) String() string { return strconv.Itoa(n.Valor) }

// NoBinario representa uma operacao entre duas subarvores (+ ou *)
type NoBinario struct {
	Operador byte
	Esq      NoAST
	Dir      NoAST
}

func (b *NoBinario) Avaliar() int {
	switch b.Operador {
	case '+':
		return b.Esq.Avaliar() + b.Dir.Avaliar()
	case '*':
		return b.Esq.Avaliar() * b.Dir.Avaliar()
	default:
		return 0
	}
}

func (b *NoBinario) String() string {
	return fmt.Sprintf("(%s %c %s)", b.Esq.String(), b.Operador, b.Dir.String())
}

// --- 2. O PARSER QUE APLICA A GRAMATICA LIVRE DE CONTEXTO ---

type Parser struct {
	tokens []string
	pos    int
}

func NovoParser(expressao string) *Parser {
	// Tokenizador simples separando numeros e simbolos
	var tokens []string
	var buffer strings.Builder

	for i := 0; i < len(expressao); i++ {
		char := expressao[i]
		if unicode.IsSpace(rune(char)) {
			continue
		}
		if unicode.IsDigit(rune(char)) {
			buffer.WriteByte(char)
			for i+1 < len(expressao) && unicode.IsDigit(rune(expressao[i+1])) {
				i++
				buffer.WriteByte(expressao[i])
			}
			tokens = append(tokens, buffer.String())
			buffer.Reset()
		} else {
			tokens = append(tokens, string(char))
		}
	}
	return &Parser{tokens: tokens, pos: 0}
}

func (p *Parser) atual() string {
	if p.pos < len(p.tokens) {
		return p.tokens[p.pos]
	}
	return ""
}

func (p *Parser) consumir(tokenEsperado string) {
	if p.atual() == tokenEsperado {
		p.pos++
	} else {
		panic(fmt.Sprintf("Erro de sintaxe: esperado '%s', mas encontrou '%s'", tokenEsperado, p.atual()))
	}
}

// Expr -> Termo ( '+' Termo )*
func (p *Parser) parseExpressao() NoAST {
	nodo := p.parseTermo()

	for p.atual() == "+" {
		p.consumir("+")
		direita := p.parseTermo()
		nodo = &NoBinario{Operador: '+', Esq: nodo, Dir: direita}
	}
	return nodo
}

// Termo -> Fator ( '*' Fator )*  (Garante precedencia de '*' sobre '+')
func (p *Parser) parseTermo() NoAST {
	nodo := p.parseFator()

	for p.atual() == "*" {
		p.consumir("*")
		direita := p.parseFator()
		nodo = &NoBinario{Operador: '*', Esq: nodo, Dir: direita}
	}
	return nodo
}

// Fator -> '(' Expr ')' | Numero
func (p *Parser) parseFator() NoAST {
	token := p.atual()

	if token == "(" {
		p.consumir("(")
		nodo := p.parseExpressao() // Chamada recursiva: a pilha guarda o contexto
		p.consumir(")")
		return nodo
	}

	// Senao, deve ser um numero
	val, err := strconv.Atoi(token)
	if err != nil {
		panic(fmt.Sprintf("Numero invalido: %s", token))
	}
	p.consumir(token)
	return &NoNumero{Valor: val}
}

func main() {
	entrada := "3 + 4 * (2 + 1)"
	parser := NovoParser(entrada)
	ast := parser.parseExpressao()

	fmt.Printf("Expressao original: %s\n", entrada)
	fmt.Printf("Arvore AST construida: %s\n", ast.String()) // Saida: (3 + (4 * (2 + 1)))
	fmt.Printf("Resultado avaliado: %d\n", ast.Avaliar())    // Saida: 15
}

Observe a beleza matemática desse código: a precedência de operadores não exigiu nenhum if artificial. Ela decorre puramente da estrutura hierárquica das funções da gramática: parseExpressao chama parseTermo, e parseTermo chama parseFator. A multiplicação fica naturalmente mais profunda na árvore do que a adição, sendo avaliada primeiro durante a descida da árvore.

10. Erros comuns e boas práticas

  1. Tentar fazer parse de gramáticas aninhadas com expressões regulares. Usar Regex para tentar balancear tags HTML, validar JSONs recursivos ou interpretar equações matemáticas é um erro de categorização teórica de Chomsky. Linguagens de Tipo 3 não têm memória de pilha. Para dados hierárquicos, use sempre parsers com gramáticas Livres de Contexto (como encoding/json ou geradores de parser como ANTLR).
  2. Não prever vulnerabilidades de ReDoS em linguagens dinâmicas. Se o seu backend recebe expressões regulares parametrizadas por usuários e roda em runtimes com backtracking (como Node.js ou Python), uma regex mal-formada com quantificadores aninhados pode prender a thread em 100% de CPU indefinidamente. Em Go, o motor RE2 é imune por construção linear, mas em outras linguagens proteja a chamada com timeouts rígidos.
  3. Acreditar que inteligência artificial resolve o Problema da Parada. Modelos de linguagem (LLMs) são Máquinas de Turing estatísticas. Dizer que uma IA conseguirá provar se qualquer código arbitrário vai terminar sem erros esbarra frontalmente no Teorema de Rice. A análise pode acertar 99% dos casos do dia a dia, mas o limite teórico de indecidibilidade permanece intransponível.
  4. Confundir problemas insolúveis com problemas intratáveis. Um problema indecidível (como o Problema da Parada) não pode ser resolvido por nenhum algoritmo em nenhum tempo. Um problema intratável (como problemas NP-Completos com entradas gigantes) possui algoritmo de resolução exato, mas seu custo computacional cresce tão depressa que precisamos nos contentar com algoritmos de aproximação ou heurísticas.

11. Exercícios de fixação

Exercício 1: Aplicação do Pumping Lemma

Prove que a linguagem L = {w ∈ {0, 1}* | w possui número igual de zeros e uns} não pode ser reconhecida por um Autômato Finito Determinístico (DFA).

Resolução por redução ao absurdo:

  1. Suponha, por contradição, que L seja uma linguagem regular reconhecida por um DFA.
  2. Sabemos que a linguagem L₁ = {0* 1*} (qualquer número de zeros seguido de qualquer número de uns) é trivialmente regular, pois sua expressão regular é direta: 0*1*.
  3. Pela propriedade de fechamento das linguagens regulares, a interseção de duas linguagens regulares é obrigatoriamente regular.
  4. Calculamos a interseção: L ∩ L₁ = {0ⁿ 1ⁿ | n ≥ 0}.
  5. Porém, já demonstramos formalmente pelo Pumping Lemma na seção 3 que a linguagem {0ⁿ 1ⁿ | n ≥ 0} não é regular.
  6. A interseção de uma linguagem supostamente regular com uma linguagem regular produziu uma linguagem não-regular, o que quebra o teorema de fechamento.
  7. Conclusão: Nossa suposição inicial era falsa. A linguagem L não é regular e exige obrigatoriamente um autômato com pilha (PDA) para ser reconhecida.

Exercício 2: O oráculo de paradas e a auto-referência

Por que a estratégia de adicionar um temporizador (timeout) de 10 segundos a uma função não resolve o Problema da Parada na prática?

Resolução: Um temporizador resolve a conveniência de engenharia (o processo não fica travado para sempre), mas não resolve o problema de decisão matemática. Se o programa for interrompido no segundo 10, você não sabe se ele estava preso em um loop infinito ou se ele levaria 10 segundos e meio para concluir um cálculo perfeitamente correto e legítimo. O timeout transforma uma resposta de certeza lógica em uma renúncia heurística.

12. Glossário

  • Autômato Finito (DFA / NFA): Modelo computacional com estados finitos e sem memória auxiliar que reconhece linguagens regulares.
  • Gramática Livre de Contexto (CFG): Conjunto de regras de produção recursivas que geram linguagens aninhadas e estruturadas em árvores.
  • Máquina de Turing: Modelo formal universal de computação composto por um controle de estados finitos e uma fita infinita de memória.
  • Problema da Parada (Halting Problem): O teorema que estabelece que é indecidível determinar se um programa arbitrário vai terminar sua execução para uma dada entrada.
  • Teorema de Rice: Teorema que generaliza a indecidibilidade para qualquer propriedade semântica não-trivial de programas de computador.
  • Classe P: Conjunto de problemas de decisão que podem ser resolvidos em tempo polinomial por uma Máquina de Turing determinística.
  • Classe NP: Conjunto de problemas de decisão cujas soluções podem ser verificadas em tempo polinomial.
  • NP-Completo: Os problemas mais difíceis da classe NP; se um for resolvido em tempo polinomial, todos os problemas de NP também o serão.

13. Fontes e referências

  • Sipser, Michael. Introduction to the Theory of Computation, 3ª edição, Cengage Learning, 2013 (Capítulos 1 a 7: Regular Languages, Context-Free Languages, The Church-Turing Thesis, Decidability, Reducibility, and Time Complexity).
  • Hopcroft, John E.; Motwani, Rajeev; Ullman, Jeffrey D. Introduction to Automata Theory, Languages, and Computation, 3ª edição, Pearson, 2006.
  • Turing, Alan M. On Computable Numbers, with an Application to the Entscheidungsproblem, Proceedings of the London Mathematical Society, 1936.
  • Cox, Russ. Regular Expression Matching Can Be Simple And Fast (but is slow in Java, Perl, PHP, Python, Ruby…), 2007. Consulta em novembro de 2026.

Relacionados

  • Lógica proposicional e de primeira ordem
  • Indução matemática
  • Teoria dos conjuntos, relações e funções
  • Análise combinatória e contagem
  • Teoria dos grafos e algoritmos de busca
  • Big O

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?