wandres.dev
TEORÍA DE AUTÓMATAS · la base formal

La quíntupla: la definición formal sin misticismo

Un autómata finito determinista es una quíntupla formada por un conjunto finito de estados, un alfabeto finito de entradas, una función de transición, un estado inicial distinguido y un subconjunto de estados de aceptación. Esta lección interroga a cada componente por separado, distingue la función de transición total de la parcial y explica el papel del estado sumidero, define la extensión de la transición a cadenas para dar una noción precisa de aceptación, y aterriza toda la definición en un tipo de TypeScript donde el compilador exige exactamente la totalidad que la teoría pedía.

⏱ 18 min

La quíntupla tiene fama de trámite: cinco letras que se recitan al principio de un capítulo y se olvidan en la página siguiente. Esa fama es injusta y sale cara. Cada uno de los cinco componentes responde a una pregunta distinta que cualquier modelo de cómputo está obligado a responder —qué situaciones existen, qué puede llegar, qué se sigue de qué, dónde se empieza y qué cuenta como éxito— y ninguno de los cinco puede quitarse sin perder algo concreto. Leerlos despacio una sola vez es lo que convierte la definición en una herramienta de diseño en lugar de un ornamento académico. Aquí la leemos despacio, sin misticismo, y sin saltarnos los matices que los libros dan por sabidos.

🎯 Al terminar esta lección sabrás
  • Enunciar la quíntupla M = (Q, Σ, δ, q0, F) y justificar por qué cada componente es necesario.
  • Distinguir la función de transición total de la parcial y entender el papel del estado sumidero.
  • Definir la extensión δ* sobre cadenas y con ella la noción precisa de aceptación.
  • Traducir la definición a un tipo de TypeScript donde el compilador exija la totalidad.

Cinco componentes y ni uno de adorno

Un autómata finito determinista se define como la quíntupla M = (Q, Σ, δ, q0, F). La expresión es tan compacta que invita a pasar de largo, así que conviene interrogar a cada componente y preguntarle qué aporta y qué se rompería sin él.

Q es un conjunto finito y no vacío de estados. No es una lista ordenada ni una jerarquía: es un conjunto, y por tanto sus elementos no tienen más estructura que su identidad. Que sea finito es la restricción que da nombre al modelo y la que vuelve decidible casi todo lo que querríamos preguntarle. Σ es el alfabeto: un conjunto finito y no vacío de símbolos de entrada, letras en teoría de lenguajes y nombres de eventos en una interfaz. Su finitud pesa tanto como la de Q, porque si el alfabeto fuese infinito la tabla de transiciones dejaría de ser un objeto escribible.

δ es la función de transición, con firma δ: Q × Σ → Q, y es el único componente que codifica comportamiento; los otros cuatro son inventarios. Que se la llame función y no relación es la afirmación entera del determinismo, y su peso es tal que la lección siguiente se dedica por completo a lo que ocurre cuando se relaja. q0 pertenece a Q y es el estado inicial: un elemento distinguido, no un valor por defecto, porque cambiarlo produce una máquina distinta que puede reconocer un lenguaje distinto con exactamente las mismas transiciones. Y F, subconjunto de Q, reúne los estados de aceptación.

🔵

Q · el inventario de situaciones

Conjunto finito y no vacío. Sin orden ni estructura interna: sus elementos solo se distinguen por identidad.

🔤

Σ · el alfabeto cerrado

Los símbolos que la máquina sabe leer. Cerrado y finito: lo que no está en Σ sencillamente no existe para M.

➡️

δ · la única regla

δ: Q × Σ → Q. Todo el comportamiento vive aquí; su carácter de función es, literalmente, el determinismo.

🏁

q0 y F · la interpretación

Dónde arranca el cómputo y qué desenlaces cuentan como aceptación. Cambiarlos cambia el lenguaje sin tocar una sola arista.

Sobre F conviene deshacer tres supersticiones. Puede ser vacío, y entonces M no acepta ninguna cadena. Puede ser todo Q, y entonces acepta todas. Y puede contener a q0, en cuyo caso la cadena vacía pertenece al lenguaje. Los tres casos son legítimos y aparecen sin drama en construcciones reales, por ejemplo al complementar una máquina.

La separación en cinco piezas no es capricho notacional: aísla el inventario —Q y Σ—, del comportamiento —δ—, de la interpretación —q0 y F—. Dos autómatas pueden compartir los tres primeros componentes y diferir solo en los dos últimos reconociendo lenguajes distintos, y esa independencia es justo lo que explotan la minimización, el complemento y la construcción del autómata inverso.

ℹ️
La quíntupla no es la máquina en ejecución

La definición describe un objeto estático; el cómputo es otra cosa. Para hablar de ejecución se usa la descripción instantánea: el par formado por el estado actual y la parte de la entrada que queda por leer. La quíntupla no cambia nunca —es el programa—, mientras que la descripción instantánea avanza paso a paso —es la ejecución—. Confundir ambas es lo que lleva a preguntarse dónde se guarda el estado dentro de la definición: no se guarda, porque la definición no tiene estado. Solo la ejecución lo tiene, y consiste exactamente en un elemento de Q.

Total, parcial y el estado sumidero

Un detalle que los textos resuelven en una nota al pie y que en la práctica decide diseños: ¿está δ definida para todos los pares de Q × Σ? Si lo está, la función es total y la máquina es completa. Si algunos pares quedan sin destino, la función es parcial y la máquina se queda atascada al recibir uno de ellos. Ambos convenios circulan en la literatura y la equivalencia entre ellos es constructiva y barata.

Para totalizar una máquina parcial se añade un estado sumidero —también llamado trampa o estado muerto— que no pertenece a F, se dirigen a él todos los pares que faltaban, y se le dan bucles sobre sí mismo para cada símbolo del alfabeto. Una vez dentro no se sale, y como no acepta, cualquier cadena que llegue allí queda rechazada para siempre. El lenguaje reconocido no cambia en absoluto: solo se ha hecho explícito el rechazo que antes era un atasco.

stateDiagram-v2
[*] --> q0
q0 --> q1 : a
q0 --> sumidero : b
q1 --> q1 : a
q1 --> q1 : b
sumidero --> sumidero : a
sumidero --> sumidero : b

Esa máquina acepta exactamente las cadenas sobre a, b que empiezan por a, con F igual a q1. Su tabla completa cabe en cuatro casillas visibles y ninguna está vacía, que es justo el punto de totalizar.

estado símbolo a símbolo b
q0 inicial q1 sumidero
q1 aceptador q1 q1
sumidero sumidero sumidero
⚠️
El sumidero y el bucle no son la misma decisión

Hay dos formas de totalizar una máquina parcial y significan cosas opuestas. Mandar los pares faltantes a un sumidero declara que ese símbolo es un error irrecuperable. Convertirlos en bucles sobre el propio estado —δ(q, a) = q— declara que el símbolo es irrelevante y se ignora. En teoría de lenguajes casi siempre se quiere el sumidero, porque una cadena con un símbolo inesperado debe rechazarse. En un sistema reactivo casi siempre se quiere el bucle, porque un evento inesperado no debe romper la interfaz. Es la misma operación técnica con dos semánticas incompatibles, y elegir sin darse cuenta es la fuente del clásico bug en el que una máquina de UI queda muerta tras un evento espurio.

Las dos totalizaciones se distinguen en el código con la misma claridad con la que se distinguen en la pizarra, y verlas juntas evita elegir por descuido.

type Par = { estado: Estado; simbolo: Simbolo }

// Totalizacion por sumidero: lo no declarado es un rechazo irrecuperable.
const conSumidero = (p: Par): Estado => delta[p.estado]?.[p.simbolo] ?? "sumidero"

// Totalizacion por bucle: lo no declarado es un evento que se ignora.
const conBucle = (p: Par): Estado => delta[p.estado]?.[p.simbolo] ?? p.estado

La totalidad tiene además una consecuencia técnica que reaparecerá en la lección 3: complementar un autómata consiste en intercambiar F por Q menos F, y esa operación solo es correcta si la máquina es completa. Sobre una máquina parcial, intercambiar los finales produce un autómata que acepta un lenguaje equivocado, porque las cadenas atascadas no estaban siendo rechazadas por ningún estado concreto al que pudiéramos darle la vuelta.

Un tercer convenio, menos frecuente pero legítimo, es dejar δ parcial y tratar el atasco como un error de ejecución que se propaga hacia arriba. Tiene sentido cuando la entrada procede de un sistema del que se espera corrección y un símbolo inesperado indica un fallo de integración que conviene ver cuanto antes. Lo que nunca es aceptable es no haber elegido: una máquina cuyo comportamiento ante lo no declarado depende del lenguaje de implementación, del tipo de acceso a un objeto o de si alguien puso un ?. es una máquina cuya quíntupla no está realmente definida.

Aceptar una cadena: la extensión δ*

δ consume un símbolo. Para hablar de cadenas se define su extensión δ*: Q × Σ* → Q por inducción sobre la longitud: δ*(q, ε) = q para la cadena vacía, y δ*(q, wa) = δ(δ*(q, w), a) para una cadena w seguida de un símbolo a. Es decir, procesa w desde q, y desde donde acabes aplica δ una vez más.

Con δ* la aceptación deja de ser una intuición y se vuelve una definición de una línea: el lenguaje de M es el conjunto de cadenas w sobre Σ* tales que δ*(q0, w) pertenece a F. Nada más. No hay backtracking, no hay elección, no hay estados intermedios que reinterpretar: se pliega la cadena desde q0 y se mira si el estado en el que se cae está en F.

Esa inducción es, palabra por palabra, un pliegue por la izquierda. δ* no es una construcción exótica: es reduce con δ como operador y q0 como acumulador inicial, y esa identidad es la razón por la que el estado de una máquina siempre puede reconstruirse desde su historial de eventos.

Merece la pena detenerse en una propiedad de δ* que se demuestra en tres líneas por inducción y que se usa constantemente sin nombrarla: δ*(q, uv) = δ*(δ*(q, u), v). Es decir, procesar una cadena entera equivale a procesar la primera mitad y continuar con la segunda desde donde se quedó. Esa asociatividad es lo que legitima persistir el estado en lugar del historial, partir una traza en fragmentos para depurarla, o reanudar una sesión sin volver a reproducir todos los eventos desde el principio. Y es también lo que falla en cuanto la máquina consulta algo externo a δ: si el destino depende de un reloj o de una variable global, la traza deja de ser troceable porque procesar la segunda mitad más tarde ya no da el mismo resultado.

Conviene también no confundir el lenguaje de una máquina con la máquina misma. L(M) es un conjunto de cadenas y nada más; máquinas muy distintas pueden definir el mismo lenguaje, y de hecho hay infinitas máquinas por lenguaje regular, todas obtenibles añadiendo estados redundantes. Esa distinción entre el objeto sintáctico —la quíntupla— y el objeto semántico —el conjunto de cadenas— es la que abre la puerta a la minimización, y la que hace posible preguntar si dos definiciones distintas significan lo mismo. La lección 3 recoge esa pregunta y la responde con un algoritmo.

La definición convertida en tipo

Todo lo anterior cabe en un archivo de TypeScript sin perder ni una garantía, y el ejercicio de escribirlo es más instructivo de lo que parece: obliga a decidir explícitamente cosas que la notación matemática deja implícitas.

type Estado = "q0" | "q1" | "sumidero"
type Simbolo = "a" | "b"

// delta total: el Record obliga a declarar TODOS los pares estado-simbolo
const delta: Record<Estado, Record<Simbolo, Estado>> = {
  q0:       { a: "q1",       b: "sumidero" },
  q1:       { a: "q1",       b: "q1" },
  sumidero: { a: "sumidero", b: "sumidero" },
}

const inicial: Estado = "q0"
const finales: ReadonlySet<Estado> = new Set<Estado>(["q1"])

// delta* como pliegue por la izquierda sobre la cadena
const deltaEstrella = (w: Simbolo[]): Estado => w.reduce((q, s) => delta[q][s], inicial)

const acepta = (w: Simbolo[]): boolean => finales.has(deltaEstrella(w))

El tipo Record<Estado, Record<Simbolo, Estado>> no es decoración: es la totalidad de δ convertida en obligación del compilador. Si olvidas un par, el código no compila; si añades un estado a Q, el error aparece exactamente en la tabla que hay que ampliar. La definición formal, expresada así, deja de ser un recordatorio de buenas intenciones y pasa a ser una restricción mecánica, que es la única clase de restricción que sobrevive a un equipo y a un año de cambios.

Compárese con la alternativa habitual, un switch sobre el estado con un default que devuelve el estado actual. Funciona, es más corto y parece equivalente, pero ha renunciado a lo esencial: el default absorbe silenciosamente todos los pares que se olviden, de modo que añadir un estado nuevo no produce ningún error y la máquina simplemente ignora eventos que debería atender. La tabla exhaustiva convierte ese olvido en un fallo de compilación; el switch permisivo lo convierte en un comportamiento raro que aparecerá en producción. La diferencia entre ambos no es de estilo: es si el modelo formal sigue vigente o si solo queda su recuerdo.

Nótese además que ese objeto no es solo ejecutable, es inspeccionable. Se puede recorrer para listar los estados inalcanzables desde q0, para detectar estados sin salida, para generar el diagrama o para producir automáticamente un caso de prueba por cada casilla. Nada de eso es posible cuando la transición está repartida por condicionales dentro de manejadores de eventos. La quíntupla como dato, y no como código, es lo que habilita todo el utillaje que rodea a las máquinas serias: visualizadores, comprobadores de cobertura y generadores de tests basados en el modelo.

💡
Si Σ no está cerrado, has perdido el modelo

La tentación más común al llevar esto a una aplicación es tipar el evento como una cadena arbitraria. En cuanto lo haces, Σ deja de ser finito y con él se evaporan las dos propiedades que hacían valiosa la definición: la tabla ya no es escribible y la exhaustividad ya no es comprobable. Un alfabeto cerrado —una unión de literales, un enum— es lo que permite al compilador decirte que falta una casilla. La finitud de Σ no es una limitación heredada de los años cincuenta: es el requisito que compra el análisis.

La definición no describe la máquina: la restringe

Es tentador leer la quíntupla como una notación para apuntar lo que la máquina hace, un formato de documentación con letras griegas. Es al revés, y entenderlo cambia cómo se diseña. Cada componente es una prohibición disfrazada de declaración. Que Q sea finito prohíbe que el sistema tenga memoria acumulativa: la situación actual es todo lo que se sabe del pasado. Que Σ sea finito prohíbe que llegue nada que no se haya previsto por nombre. Que δ sea una función —y no una relación— prohíbe que el mismo par estado-símbolo tenga dos futuros, y con ello garantiza que una sesión grabada se reproduzca idéntica. Que q0 sea un elemento distinguido prohíbe arrancar en un estado inventado. Y que F sea un subconjunto fijado de antemano prohíbe decidir a posteriori qué contaba como éxito. Ninguna de esas cinco garantías la obtienes por escribir código con cuidado; las obtienes por construcción, y solo mientras respetes la forma. Por eso el error más caro no es equivocarse en una transición, sino romper la quíntupla sin notarlo: aceptar eventos con nombre libre rompe Σ, guardar un contador junto al estado rompe la finitud de Q, resolver un destino consultando una variable externa rompe que δ sea función. Cada una de esas tres traiciones se comete a diario y ninguna produce un error visible el primer día; lo que produce es una máquina que ya no demuestra nada. La definición formal no está ahí para que la recites: está ahí para que sepas exactamente qué garantía pierdes cada vez que la incumples.

⚔️ Interroga la quíntupla
  1. Escribe la quíntupla completa de un autómata sobre a, b que acepte exactamente las cadenas de longitud par. Comprueba que δ es total y que q0 pertenece a F.
  2. Toma esa máquina y cambia solo q0. Describe con precisión qué lenguaje reconoce ahora y por qué las aristas no han cambiado.
  3. Parte de una máquina parcial de tu elección y totalízala dos veces: una con sumidero y otra con bucles. Enuncia qué lenguaje reconoce cada resultado y en cuál de los dos casos coinciden.
  4. Demuestra en tres líneas, por inducción sobre la longitud, que δ*(q, uv) = δ*(δ*(q, u), v). Es la propiedad que legitima partir una traza en dos.
  5. Implementa acepta en TypeScript para tu máquina y añade un estado nuevo a Q sin tocar la tabla: comprueba que el compilador te señala exactamente las casillas que faltan.
  6. Complementa tu autómata intercambiando F por su complemento. Hazlo primero sobre la versión parcial y después sobre la total, y explica por qué solo una de las dos da el resultado correcto.