40. Aplicaciones de relaciones discretas en programación

Las relaciones discretas modelan vínculos reales entre entidades de un programa: usuarios y permisos, productos y categorías, nodos y caminos, datos y transformaciones. Son la base teórica de bases de datos, control de acceso, grafos y algoritmos.

40.1 Introducción

Hasta ahora hemos estudiado las relaciones y funciones discretas desde una perspectiva matemática abstracta. En este tema veremos cómo esos conceptos resuelven problemas concretos de programación.

Cada vez que un programa necesita modelar un vínculo entre dos categorías de objetos—usuarios y roles, artículos y categorías, ciudades y carreteras, datos y procesadores—está usando una relación discreta. La eficiencia, corrección y seguridad del programa dependen de cómo se implemente esa relación.

Este tema recorre las aplicaciones más comunes en el desarrollo de software real.

40.2 Aplicación 1: Control de Acceso (RBAC)

En un sistema multiusuario, la relación entre usuarios y roles define quién puede hacer qué. Esta es una relación binaria que típicamente se guarda en una tabla de base de datos o en memoria.

Escenario: Un portal administrativo donde Ana tiene rol admin, Luis tiene rol editor, y Marta tiene rol viewer. Algunos usuarios pueden tener múltiples roles.

La relación se puede representar como pares ordenados:

usuarioRol = { ("Ana", "admin"), ("Ana", "auditor"), ("Luis", "editor"), ("Marta", "viewer") }

En JavaScript, esto se implementa típicamente con un objeto o un mapa:

// Enfoque 1: Objeto con arrays de roles
const usuarioRoles = {
  "Ana": ["admin", "auditor"],
  "Luis": ["editor"],
  "Marta": ["viewer"]
};

// Enfoque 2: Array de pares
const usuarioRol = [
  ["Ana", "admin"],
  ["Ana", "auditor"],
  ["Luis", "editor"],
  ["Marta", "viewer"]
];

// Verificar si un usuario tiene un rol
function tieneRol(usuario, rol) {
  return usuarioRoles[usuario]?.includes(rol) ?? false;
}

console.log(tieneRol("Ana", "admin"));    // true
console.log(tieneRol("Luis", "admin"));   // false

// Obtener todos los usuarios de un rol
function usuariosConRol(rol) {
  return Object.keys(usuarioRoles).filter(u => 
    usuarioRoles[u].includes(rol)
  );
}

console.log(usuariosConRol("admin"));  // ["Ana"]
console.log(usuariosConRol("viewer")); // ["Marta"]

// Asignar un nuevo rol (agregar un par a la relación)
function asignarRol(usuario, rol) {
  if (!usuarioRoles[usuario]) {
    usuarioRoles[usuario] = [];
  }
  if (!usuarioRoles[usuario].includes(rol)) {
    usuarioRoles[usuario].push(rol);
  }
}

asignarRol("Luis", "admin");
console.log(tieneRol("Luis", "admin")); // true

40.3 Aplicación 2: Bases de Datos Relacionales

Las bases de datos relacionales fundamentan su arquitectura en relaciones. Una tabla es una relación donde cada fila es una tupla y cada columna es un atributo.

Escenario: Una tienda tiene una tabla Productos y una tabla Categorías. Cada producto pertenece a una categoría (función). Múltiples productos pueden estar en la misma categoría (relación no inyectiva).

Representación abstracta:

Productos = {P1, P2, P3, P4} Categorías = {Electrónica, Ropa, Alimentos} Relación pertenece: {(P1, Electrónica), (P2, Electrónica), (P3, Ropa), (P4, Alimentos)}

En JavaScript, modelamos esta relación con arrays y referencias:

// Base de datos en memoria
const productos = [
  { id: "P1", nombre: "Laptop", categoriaId: "C1" },
  { id: "P2", nombre: "Mouse", categoriaId: "C1" },
  { id: "P3", nombre: "Camiseta", categoriaId: "C2" },
  { id: "P4", nombre: "Manzana", categoriaId: "C3" }
];

const categorias = [
  { id: "C1", nombre: "Electrónica" },
  { id: "C2", nombre: "Ropa" },
  { id: "C3", nombre: "Alimentos" }
];

// Proyección: obtener todos los productos de una categoría
function productosDeCategoria(nombreCategoria) {
  const cat = categorias.find(c => c.nombre === nombreCategoria);
  if (!cat) return [];
  return productos.filter(p => p.categoriaId === cat.id);
}

console.log(productosDeCategoria("Electrónica"));
// [{ id: "P1", nombre: "Laptop", categoriaId: "C1" },
//  { id: "P2", nombre: "Mouse", categoriaId: "C1" }]

// Join: obtener productos con sus categorías
function productosConCategoria() {
  return productos.map(p => {
    const cat = categorias.find(c => c.id === p.categoriaId);
    return { ...p, categoria: cat.nombre };
  });
}

console.log(productosConCategoria());
// [{ id: "P1", ..., categoria: "Electrónica" },
//  { id: "P2", ..., categoria: "Electrónica" },
//  ... ]

// Conteo: cuántos productos hay en cada categoría
function productosPorCategoria() {
  const resultado = {};
  for (const cat of categorias) {
    resultado[cat.nombre] = productos.filter(
      p => p.categoriaId === cat.id
    ).length;
  }
  return resultado;
}

console.log(productosPorCategoria());
// { Electrónica: 2, Ropa: 1, Alimentos: 1 }

40.4 Aplicación 3: Grafos y Redes

Un grafo dirigido es una relación binaria sobre un conjunto de nodos. La relación "hay arista de X a Y" define la estructura del grafo.

Escenario: Una red social modela usuarios y relaciones de amistad. Una relación simétrica indica amigos mutuos. Una relación dirigida indica "sigue".

Relación de amistad bidireccional:

amigos = { ("Ana", "Luis"), ("Luis", "Ana"), ("Luis", "Marta"), ("Marta", "Luis"), ("Ana", "Marta"), ("Marta", "Ana") }

Implementación en JavaScript (lista de adyacencia):

// Grafo como lista de adyacencia
const grafo = {
  "Ana": ["Luis", "Marta"],
  "Luis": ["Ana", "Marta"],
  "Marta": ["Ana", "Luis", "Carlos"],
  "Carlos": ["Marta"]
};

// ¿Están conectados directamente?
function sonAmigos(u1, u2) {
  return grafo[u1]?.includes(u2) ?? false;
}

console.log(sonAmigos("Ana", "Luis"));  // true
console.log(sonAmigos("Ana", "Carlos")); // false

// Amigos en común
function amigosEnComun(u1, u2) {
  if (!grafo[u1] || !grafo[u2]) return [];
  return grafo[u1].filter(a => grafo[u2].includes(a));
}

console.log(amigosEnComun("Ana", "Carlos"));
// ["Marta"]

// Vecinos de distancia 2 (amigos de mis amigos)
function amigosDeAmigos(usuario) {
  const amigos = grafo[usuario] || [];
  const aDA = new Set();
  for (const amigo of amigos) {
    for (const amigoDelAmigo of grafo[amigo] || []) {
      if (amigoDelAmigo !== usuario && !amigos.includes(amigoDelAmigo)) {
        aDA.add(amigoDelAmigo);
      }
    }
  }
  return Array.from(aDA);
}

console.log(amigosDeAmigos("Ana"));
// ["Carlos"]

// Recorrido en profundidad (DFS) desde un nodo
function dfs(inicio, visitados = new Set()) {
  if (visitados.has(inicio)) return [];
  visitados.add(inicio);
  const resultado = [inicio];
  for (const vecino of grafo[inicio] || []) {
    resultado.push(...dfs(vecino, visitados));
  }
  return resultado;
}

console.log(dfs("Ana")); 
// ["Ana", "Luis", "Marta", "Carlos"]

40.5 Aplicación 4: Mapeo de Datos y Transformaciones

Las funciones discretas se usan para mapear datos de un conjunto a otro. Un diccionario de traducciones, un índice invertido o una tabla de símbolos son funciones.

Escenario: Un compilador mantiene una tabla de símbolos donde cada identificador (variable) se mapea a su tipo y ubicación en memoria.
// Tabla de símbolos: identificador → {tipo, dirección}
const tablaSímbolos = {
  "x": { tipo: "int", dirección: 1000 },
  "nombre": { tipo: "string", dirección: 1008 },
  "valores": { tipo: "array[int]", dirección: 1016 }
};

// Consultar tipo de una variable
function obtenerTipo(id) {
  return tablaSímbolos[id]?.tipo ?? "desconocido";
}

console.log(obtenerTipo("x"));       // "int"
console.log(obtenerTipo("nombre"));  // "string"

// Índice invertido: palabra → lista de documentos que la contienen
const indiceInvertido = {
  "javascript": [0, 2, 4],
  "python": [1, 3],
  "programación": [0, 1, 2, 3, 4],
  "web": [0, 2]
};

// Buscar documentos que contienen una palabra
function buscar(palabra) {
  return indiceInvertido[palabra] ?? [];
}

console.log(buscar("javascript")); 
// [0, 2, 4]

// Búsqueda AND: documentos que contienen todas las palabras
function buscarAND(...palabras) {
  const resultado = indiceInvertido[palabras[0]] ?? [];
  return resultado.filter(doc => 
    palabras.every(pal => 
      (indiceInvertido[pal] ?? []).includes(doc)
    )
  );
}

console.log(buscarAND("javascript", "web"));
// [0, 2]

// Aplicación: Conversión de unidades (función discreta)
const conversiones = {
  "km_a_m": (x) => x * 1000,
  "m_a_km": (x) => x / 1000,
  "kg_a_g": (x) => x * 1000,
  "g_a_kg": (x) => x / 1000
};

function convertir(cantidad, conversión) {
  const fn = conversiones[conversión];
  return fn ? fn(cantidad) : null;
}

console.log(convertir(5, "km_a_m"));  // 5000
console.log(convertir(3000, "m_a_km")); // 3

40.6 Aplicación 5: Estado y Máquinas de Estados Finitos

Una máquina de estados finitos define una relación entre estados y transiciones. Dado un estado actual y una entrada, la transición determina el nuevo estado.

Escenario: Un reproductor de música tiene estados: detenido, reproduciendo, pausado. Las acciones son: play, pause, stop. La transición depende del estado actual y la acción.
// Máquina de estados para un reproductor
const transiciones = {
  "detenido": {
    "play": "reproduciendo",
    "pause": "detenido",
    "stop": "detenido"
  },
  "reproduciendo": {
    "play": "reproduciendo",
    "pause": "pausado",
    "stop": "detenido"
  },
  "pausado": {
    "play": "reproduciendo",
    "pause": "pausado",
    "stop": "detenido"
  }
};

class Reproductor {
  constructor() {
    this.estado = "detenido";
  }

  transicionar(acción) {
    const siguiente = transiciones[this.estado]?.[acción];
    if (siguiente) {
      this.estado = siguiente;
      return true;
    }
    return false;
  }

  obtenerEstado() {
    return this.estado;
  }
}

const player = new Reproductor();
console.log(player.obtenerEstado());     // "detenido"
player.transicionar("play");
console.log(player.obtenerEstado());     // "reproduciendo"
player.transicionar("pause");
console.log(player.obtenerEstado());     // "pausado"
player.transicionar("play");
console.log(player.obtenerEstado());     // "reproduciendo"
player.transicionar("stop");
console.log(player.obtenerEstado());     // "detenido"

40.7 Errores Comunes

  • Confundir la dirección de la relación: En una relación usuarios-roles, es importante saber si consultamos "qué roles tiene el usuario" o "qué usuarios tienen este rol". La estructura de datos debe soportar ambas consultas eficientemente.
  • Olvidar la simetría: En grafos no dirigidos (como amistades), la relación debe ser simétrica. Si agregas la arista (A, B), también debes agregar (B, A).
  • Asumir funciones donde hay relaciones: Si un usuario puede tener múltiples roles, la relación usuario-rol no es una función. Usar una función provocará pérdida de datos.
  • Ineficiencia en consultas frecuentes: Si constantemente necesitas "todos los productos de una categoría", una lista de adyacencia inversa acelera las búsquedas.
  • No validar transiciones de estado: Una máquina de estados debe rechazar transiciones inválidas. No hacerlo causa bugs sutiles.

40.8 Qué debes recordar de este tema

  • Las relaciones discretas modelan vínculos reales entre entidades de un programa.
  • El control de acceso (RBAC) es una relación usuario-rol que define permisos.
  • Las bases de datos relacionales fundamentan su estructura en relaciones binarias.
  • Los grafos son relaciones binarias dirigidas o no dirigidas sobre nodos.
  • Las funciones discretas sirven para mapeos, transformaciones y tablas de símbolos.
  • Las máquinas de estados usan relaciones entre estados y acciones.
  • La elección de estructura (objeto, array, matriz de adyacencia) depende de las consultas más frecuentes.

40.9 Conclusión

Las relaciones y funciones discretas no son conceptos abstractos sin utilidad. Son la base teórica de prácticamente toda la programación real: desde controlar quién accede a qué, hasta navegar redes sociales, procesar consultas de bases de datos y construir compiladores.

En los próximos temas exploraremos aplicaciones más especializadas en estructuras de datos, bases de datos, algoritmos, grafos e inteligencia artificial, siempre con relaciones discretas como fundamento conceptual.