Las cadenas de Markov son un concepto bastante viejo (de principios del siglo XX) que se ha usado como una forma simple de generar texto o predecir secuencias, y son la base conceptual de algunas cosas que después evolucionaron hacia lo que hoy llamamos IA generativa.
Una cadena de Markov es un modelo matemático que dice: “el siguiente estado depende solo del estado actual, no de todo el historial pasado”. Esto se llama la “propiedad de Markov” o “falta de memoria”.
Aplicado a texto, funciona así:
Tu resumen está muy bien. Vamos a la implementación práctica en ambos lenguajes. La idea técnica clave es usar un modelo de orden N (no solo la palabra actual, sino las últimas N palabras) porque con orden 1 el texto generado suele ser bastante caótico. Con orden 2 o 3 ya se ve mucho más coherente.
import random
import re
from collections import defaultdict
def construir_modelo(texto, orden=2):
palabras = texto.split()
modelo = defaultdict(list)
for i in range(len(palabras) - orden):
clave = tuple(palabras[i:i+orden]) # ej: ("el", "perro")
siguiente = palabras[i+orden]
modelo[clave].append(siguiente)
return modelo
def generar_texto(modelo, orden=2, num_palabras=50):
# arrancamos con una clave al azar del modelo
clave = random.choice(list(modelo.keys()))
resultado = list(clave)
for _ in range(num_palabras - orden):
siguientes = modelo.get(clave)
if not siguientes:
clave = random.choice(list(modelo.keys()))
siguientes = modelo[clave]
palabra = random.choice(siguientes)
resultado.append(palabra)
clave = tuple(resultado[-orden:]) # desplazamos la ventana
return " ".join(resultado)
# --- uso ---
texto = """
El perro corre en el parque. El gato duerme en la casa.
El perro ladra fuerte y el gato lo ignora. En el parque
el perro juega con otro perro mientras el gato observa desde la ventana.
"""
modelo = construir_modelo(texto, orden=2)
print(generar_texto(modelo, orden=2, num_palabras=30))
Puntos clave del código:
defaultdict(list) guarda, para cada tupla de N palabras, la lista de palabras que las siguieron (con repeticiones, así se conserva la probabilidad de forma implícita — si “perro” aparece 3 veces como siguiente, tiene 3x más chances de salir sorteado).orden palabras, no una sola. Eso es lo que hace que el orden 2 o 3 dé resultados más razonables que el orden 1.if not siguientes: reiniciamos.Para un modelo de letras en vez de palabras (más “creativo” / lovecraftiano) el mismo código sirve, solo cambiás texto.split() por list(texto).
En C no hay diccionarios nativos, así que la parte interesante es implementar la tabla hash a mano (o usar una estructura simple si el vocabulario es chico). Acá una versión simplificada con orden 1 (palabra → lista de palabras siguientes), usando una tabla hash básica:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define TABLE_SIZE 1000
#define MAX_WORD 64
#define MAX_SIGUIENTES 200
typedef struct Nodo {
char palabra[MAX_WORD];
char siguientes[MAX_SIGUIENTES][MAX_WORD];
int num_siguientes;
struct Nodo *next; // encadenamiento para colisiones
} Nodo;
Nodo *tabla[TABLE_SIZE];
unsigned int hash(const char *str) {
unsigned int h = 5381;
while (*str) h = ((h << 5) + h) + (unsigned char)(*str++);
return h % TABLE_SIZE;
}
Nodo* buscar_o_crear(const char *palabra) {
unsigned int idx = hash(palabra);
Nodo *actual = tabla[idx];
while (actual) {
if (strcmp(actual->palabra, palabra) == 0) return actual;
actual = actual->next;
}
Nodo *nuevo = calloc(1, sizeof(Nodo));
strcpy(nuevo->palabra, palabra);
nuevo->next = tabla[idx];
tabla[idx] = nuevo;
return nuevo;
}
void agregar_transicion(const char *actual, const char *siguiente) {
Nodo *nodo = buscar_o_crear(actual);
if (nodo->num_siguientes < MAX_SIGUIENTES) {
strcpy(nodo->siguientes[nodo->num_siguientes], siguiente);
nodo->num_siguientes++;
}
}
Nodo* obtener_nodo(const char *palabra) {
unsigned int idx = hash(palabra);
Nodo *actual = tabla[idx];
while (actual) {
if (strcmp(actual->palabra, palabra) == 0) return actual;
actual = actual->next;
}
return NULL;
}
int main() {
srand(time(NULL));
// texto de entrenamiento de ejemplo
char texto[] = "el perro corre en el parque el gato duerme en la casa "
"el perro ladra fuerte y el gato lo ignora en el parque "
"el perro juega con otro perro mientras el gato observa";
char copia[1024];
strcpy(copia, texto);
char *palabras[200];
int n = 0;
char *tok = strtok(copia, " ");
while (tok) {
palabras[n++] = tok;
tok = strtok(NULL, " ");
}
// construir el modelo (orden 1)
for (int i = 0; i < n - 1; i++) {
agregar_transicion(palabras[i], palabras[i+1]);
}
// generar texto
const char *palabra_actual = palabras[0];
printf("%s", palabra_actual);
for (int i = 0; i < 30; i++) {
Nodo *nodo = obtener_nodo(palabra_actual);
if (!nodo || nodo->num_siguientes == 0) break;
int idx_rand = rand() % nodo->num_siguientes;
palabra_actual = nodo->siguientes[idx_rand];
printf(" %s", palabra_actual);
}
printf("\n");
return 0;
}
Compilás con gcc markov.c -o markov y corrés ./markov.
Diferencias clave frente al Python:
Nodo *next) porque C no trae diccionarios."el_perro" si quisieras orden 2 — totalmente posible, solo hay que armar el string compuesto antes de hashear).calloc), en un programa real conviene agregar una función liberar_tabla().Con textos de entrenamiento chicos como los de mis ejemplos, vas a ver que el generador cae en loops o frases idénticas al original rápido — necesita un corpus bastante grande (miles o decenas de miles de palabras) para que las probabilidades den variedad real y no termine repitiendo el texto fuente casi literal.
Vamos a construir el concepto de a poco, desde la probabilidad pura hasta cómo se traduce en estructuras de datos.
Un proceso de Markov se define sobre un espacio de estados $S = {s_1, s_2, …, s_n}$. En cada paso, el sistema está en algún estado, y pasa al siguiente según una probabilidad que depende solo del estado actual:
$$P(X_{t+1} = s_j \mid X_t = s_i)$$
Esto se llama propiedad de Markov: el futuro depende del presente, no del pasado completo. Formalmente:
$$P(X_{t+1} \mid X_t, X_{t-1}, …, X_0) = P(X_{t+1} \mid X_t)$$
Aplicado a texto, cada estado es una palabra (o una secuencia corta de palabras, ya volvemos a esto). La “transición” es simplemente: qué palabra viene después.
Si tenés $n$ palabras distintas en tu vocabulario, en teoría existe una matriz $n \times n$ donde la celda $(i,j)$ guarda:
$$P(\text{palabra}_j \mid \text{palabra}_i)$$
En la práctica nunca construís esta matriz explícitamente, porque el vocabulario puede tener miles de palabras y la matriz sería enorme y casi toda ceros (la mayoría de los pares de palabras nunca aparecen juntos). Por eso se usa una estructura dispersa: un diccionario/mapa donde la clave es el estado actual y el valor es la lista (o distribución) de estados posibles siguientes.
Esto es clave para tu implementación: no necesitás álgebra de matrices, necesitás un mapa de “estado → lista de siguientes posibles”.
Acá está el corazón del algoritmo, y es puro conteo, nada sofisticado:
Si “el” fue seguido de “perro” 3 veces y de “gato” 1 vez en todo el corpus, entonces:
$$P(\text{perro} \mid \text{el}) = 3/4, \quad P(\text{gato} \mid \text{el}) = 1/4$$
Truco de implementación importante: no hace falta calcular estas probabilidades explícitamente como fracciones. Si simplemente guardás una lista con repeticiones (["perro", "perro", "perro", "gato"]) y después elegís un elemento al azar de esa lista con distribución uniforme, estás logrando exactamente el mismo efecto que si hubieras calculado las probabilidades reales. Es la manera más simple de “samplear” de una distribución categórica sin hacer matemática explícita.
Con orden 1 (el estado es una sola palabra), el modelo pierde demasiado contexto. Por ejemplo, “banco” podría seguir a “el” tanto en “el banco de plaza” como en “el banco central” — el modelo mezcla todo y pierde coherencia rápido.
La solución es usar cadenas de orden N: el estado no es una palabra, sino una tupla de N palabras consecutivas. Con orden 2, el estado sería ("el", "banco") y ahí las transiciones posibles ya son mucho más específicas y coherentes.
Esto sigue siendo matemáticamente una cadena de Markov de orden 1, solo que redefiniste qué es un “estado”: en vez de que el estado sea una palabra, es una ventana deslizante de N palabras. Es un truco muy común: subir el orden de contexto convirtiendo secuencias en estados atómicos.
Trade-off que tenés que entender:
Este es el trade-off fundamental que vas a tener que ajustar empíricamente según el tamaño de tu corpus.
Una vez que tenés el mapa “estado → lista de posibles siguientes”, generar texto es:
Si tu estado actual nunca fue visto seguido de nada en el corpus (por ejemplo, es la última palabra del texto de entrenamiento), no hay transición posible. Tenés que decidir una estrategia:
Vale la pena que veas el paralelismo, porque es justo lo que mencionaste al principio:
| Cadena de Markov | LLM moderno (GPT, Claude, etc.) |
|---|---|
| Estado = últimas N palabras | Contexto = últimos miles de tokens |
| Probabilidades = conteo de frecuencias | Probabilidades = red neuronal entrenada |
| Transición fija por corpus | Transición aprendida y generalizable a secuencias nunca vistas |
| Sampling categórico simple | Sampling con temperatura, top-k, top-p, etc. |
La diferencia enorme es que un LLM no memoriza conteos exactos de secuencias vistas: aprende una función continua que generaliza a secuencias nunca vistas antes, y puede mantener coherencia en contextos de miles de palabras, no solo 2 o 3. Pero la lógica de fondo — “dado el contexto, sampleo la palabra siguiente según una distribución de probabilidad” — es conceptualmente la misma línea evolutiva.
Con esto ya tenés todos los ladrillos teóricos: estados, transiciones, conteo de frecuencias, orden N, sampling, y manejo de callejones sin salida. ¿Querés que profundice en alguna parte en particular, como el tema del back-off o cómo pensar la estructura de datos antes de programarla vos mismo?