Los algoritmos resuelven problemas usando secuencias de operaciones. La complejidad computacional cuantifica cuántos recursos (tiempo y memoria) se necesitan. Las relaciones discretas son fundamentales para modelar ordenamientos, búsquedas, grafos y la estructura matemática de los algoritmos.
Un algoritmo es un procedimiento finito de pasos para resolver un problema. Su eficiencia se mide por complejidad: cuántas operaciones ejecuta en función del tamaño de la entrada.
Las relaciones discretas subyacen en todos los algoritmos fundamentales:
Este tema explora cómo la teoría de relaciones discretas explica la estructura de los algoritmos clásicos.
La complejidad Big O define una relación de orden entre funciones. Decimos que f(n) es O(g(n)) si existe una constante c tal que f(n) ≤ c·g(n) para todo n suficientemente grande.
// Análisis de complejidad mediante relaciones de orden
// O(1) - Acceso directo a array
function accederElemento(arr, indice) {
return arr[indice]; // 1 operación
}
// O(n) - Búsqueda lineal
function busquedaLineal(arr, objetivo) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === objetivo) return i;
}
return -1; // n operaciones en el peor caso
}
// O(log n) - Búsqueda binaria (requiere array ordenado)
function busquedaBinaria(arr, objetivo) {
let izq = 0, der = arr.length - 1;
while (izq <= der) {
const mid = Math.floor((izq + der) / 2);
if (arr[mid] === objetivo) return mid;
if (arr[mid] < objetivo) izq = mid + 1;
else der = mid - 1;
}
return -1; // log(n) iteraciones
}
// O(n²) - Búsqueda con anidamiento
function busquedaAnidada(arr1, arr2) {
let count = 0;
for (let i = 0; i < arr1.length; i++) {
for (let j = 0; j < arr2.length; j++) {
if (arr1[i] === arr2[j]) count++;
}
}
return count; // n * m operaciones
}
// O(n log n) - Merge sort
function mergeSort(arr) {
if (arr.length <= 1) return arr;
const mid = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, mid));
const right = mergeSort(arr.slice(mid));
return merge(left, right);
}
function merge(left, right) {
const result = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result.push(left[i++]);
} else {
result.push(right[j++]);
}
}
return result.concat(left.slice(i), right.slice(j));
}
// Comparación de complejidades
const tamaños = [10, 100, 1000, 10000];
console.log("n | O(1) | O(log n) | O(n) | O(n log n) | O(n²)");
tamaños.forEach(n => {
console.log(
`${n} | ${1} | ${Math.log2(n).toFixed(1)} | ${n} | ${(n * Math.log2(n)).toFixed(0)} | ${n * n}`
);
});
Un orden total es una relación binaria reflexiva, antisimétrica, transitiva y que compara todo par de elementos. Los algoritmos de ordenamiento explotan esta estructura.
// Algoritmos de ordenamiento basados en relaciones de orden
// Bubble Sort: compara pares adyacentes (O(n²))
function bubbleSort(arr, comparar) {
const n = arr.length;
for (let i = 0; i < n - 1; i++) {
for (let j = 0; j < n - i - 1; j++) {
if (comparar(arr[j], arr[j + 1]) > 0) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
}
// Quick Sort: particiona por pivot (O(n log n) promedio)
function quickSort(arr, comparar, izq = 0, der = arr.length - 1) {
if (izq < der) {
const pi = particionar(arr, comparar, izq, der);
quickSort(arr, comparar, izq, pi - 1);
quickSort(arr, comparar, pi + 1, der);
}
return arr;
}
function particionar(arr, comparar, izq, der) {
const pivot = arr[der];
let i = izq - 1;
for (let j = izq; j < der; j++) {
if (comparar(arr[j], pivot) < 0) {
i++;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
[arr[i + 1], arr[der]] = [arr[der], arr[i + 1]];
return i + 1;
}
// Función comparadora: relación binaria
function compararPor(atributo) {
return (a, b) => {
if (a[atributo] < b[atributo]) return -1;
if (a[atributo] > b[atributo]) return 1;
return 0;
};
}
// Uso
const estudiantes = [
{ nombre: "Ana", calificación: 85 },
{ nombre: "Luis", calificación: 92 },
{ nombre: "Marta", calificación: 78 }
];
const comparar = compararPor("calificación");
const ordenados = bubbleSort([...estudiantes], comparar);
console.log(ordenados);
// Ordenados por calificación ascendente
Un grafo es una relación binaria reflexiva sobre un conjunto de vértices. Las aristas son los pares en la relación. Los algoritmos de recorrido (BFS, DFS) operan sobre esta estructura relacional.
// Grafos como relaciones discretas
class Grafo {
constructor(vertices) {
this.vertices = vertices;
this.adyacencia = new Map();
vertices.forEach(v => this.adyacencia.set(v, []));
}
agregarArista(u, v) {
this.adyacencia.get(u).push(v);
}
// BFS: Breadth-First Search (O(V + E))
bfs(inicio) {
const visitados = new Set();
const cola = [inicio];
const recorrido = [];
while (cola.length > 0) {
const vertice = cola.shift();
if (!visitados.has(vertice)) {
visitados.add(vertice);
recorrido.push(vertice);
cola.push(...this.adyacencia.get(vertice));
}
}
return recorrido;
}
// DFS: Depth-First Search (O(V + E))
dfs(inicio, visitados = new Set(), recorrido = []) {
visitados.add(inicio);
recorrido.push(inicio);
for (const vecino of this.adyacencia.get(inicio)) {
if (!visitados.has(vecino)) {
this.dfs(vecino, visitados, recorrido);
}
}
return recorrido;
}
// Detectar ciclo usando DFS
tieneCiclo(vertice = this.vertices[0], visitados = new Set(),
recStack = new Set()) {
visitados.add(vertice);
recStack.add(vertice);
for (const vecino of this.adyacencia.get(vertice)) {
if (!visitados.has(vecino)) {
if (this.tieneCiclo(vecino, visitados, recStack)) {
return true;
}
} else if (recStack.has(vecino)) {
return true;
}
}
recStack.delete(vertice);
return false;
}
// Camino más corto (unweighted)
caminoMásCorto(inicio, fin) {
const cola = [[inicio, [inicio]]];
const visitados = new Set([inicio]);
while (cola.length > 0) {
const [vertice, camino] = cola.shift();
if (vertice === fin) return camino;
for (const vecino of this.adyacencia.get(vertice)) {
if (!visitados.has(vecino)) {
visitados.add(vecino);
cola.push([vecino, [...camino, vecino]]);
}
}
}
return null;
}
}
// Uso
const grafo = new Grafo(["A", "B", "C", "D", "E"]);
grafo.agregarArista("A", "B");
grafo.agregarArista("A", "C");
grafo.agregarArista("B", "D");
grafo.agregarArista("C", "E");
grafo.agregarArista("D", "E");
console.log("BFS desde A:", grafo.bfs("A")); // [A, B, C, D, E]
console.log("DFS desde A:", grafo.dfs("A")); // [A, B, D, E, C]
console.log("Camino A→E:", grafo.caminoMásCorto("A", "E")); // [A, C, E]
console.log("¿Tiene ciclo?:", grafo.tieneCiclo()); // false
La búsqueda binaria requiere un array ordenado (una relación de orden). Divide el espacio de búsqueda por la mitad en cada paso, logrando O(log n).
// Búsqueda binaria aplicada a diferentes contextos
// 1. Búsqueda simple en array ordenado
function busquedaBinaria(arr, objetivo) {
let izq = 0, der = arr.length - 1;
while (izq <= der) {
const mid = Math.floor((izq + der) / 2);
if (arr[mid] === objetivo) return mid;
if (arr[mid] < objetivo) izq = mid + 1;
else der = mid - 1;
}
return -1;
}
// 2. Encontrar primera ocurrencia (búsqueda con relación ≤)
function busquedaBinariaIzquierda(arr, objetivo) {
let izq = 0, der = arr.length;
while (izq < der) {
const mid = Math.floor((izq + der) / 2);
if (arr[mid] < objetivo) izq = mid + 1;
else der = mid;
}
return izq < arr.length && arr[izq] === objetivo ? izq : -1;
}
// 3. Encontrar última ocurrencia
function busquedaBinariaDerecha(arr, objetivo) {
let izq = 0, der = arr.length;
while (izq < der) {
const mid = Math.floor((izq + der) / 2);
if (arr[mid] <= objetivo) izq = mid + 1;
else der = mid;
}
return izq > 0 && arr[izq - 1] === objetivo ? izq - 1 : -1;
}
// 4. Búsqueda binaria con función comparadora
function busquedaBinariaConComparador(arr, objetivo, comparar) {
let izq = 0, der = arr.length - 1;
while (izq <= der) {
const mid = Math.floor((izq + der) / 2);
const cmp = comparar(arr[mid], objetivo);
if (cmp === 0) return mid;
if (cmp < 0) izq = mid + 1;
else der = mid - 1;
}
return -1;
}
// 5. Rango de valores: encontrar todos los elementos en [min, max]
function busquedaBinariaRango(arr, min, max) {
const inicio = busquedaBinariaIzquierda(arr, min);
const fin = busquedaBinariaDerecha(arr, max);
if (inicio === -1) return [];
return arr.slice(inicio, fin + 1);
}
// Pruebas
const datos = [1, 3, 5, 7, 9, 11, 13, 15];
console.log(busquedaBinaria(datos, 7)); // 3
console.log(busquedaBinariaRango(datos, 5, 11)); // [5, 7, 9, 11]
const estudiantes = [
{ id: 1, nombre: "Ana" },
{ id: 3, nombre: "Luis" },
{ id: 5, nombre: "Marta" }
];
const comparador = (a, b) => {
if (a.id < b.id) return -1;
if (a.id > b.id) return 1;
return 0;
};
console.log(busquedaBinariaConComparador(estudiantes, { id: 3 }, comparador));
// 1
Una tabla hash implementa una función inyectiva
// Tabla hash implementada desde cero
class TablaHash {
constructor(tamaño = 11) {
this.tamaño = tamaño;
this.tabla = Array(tamaño).fill(null).map(() => []);
this.colisiones = 0;
}
// Función hash simple: suma de códigos ASCII módulo tamaño
_funcionHash(clave) {
let hash = 0;
for (let i = 0; i < clave.length; i++) {
hash += clave.charCodeAt(i);
}
return hash % this.tamaño;
}
// Insertar con resolución de colisiones mediante encadenamiento
insertar(clave, valor) {
const índice = this._funcionHash(clave);
const lista = this.tabla[índice];
// Verificar si la clave ya existe
for (let i = 0; i < lista.length; i++) {
if (lista[i][0] === clave) {
lista[i][1] = valor;
return;
}
}
// Nueva clave
if (lista.length > 0) this.colisiones++;
lista.push([clave, valor]);
}
// Obtener valor por clave (O(1) en promedio, O(n) en peor caso)
obtener(clave) {
const índice = this._funcionHash(clave);
const lista = this.tabla[índice];
for (const [k, v] of lista) {
if (k === clave) return v;
}
return undefined;
}
// Eliminar por clave
eliminar(clave) {
const índice = this._funcionHash(clave);
const lista = this.tabla[índice];
for (let i = 0; i < lista.length; i++) {
if (lista[i][0] === clave) {
lista.splice(i, 1);
return true;
}
}
return false;
}
// Estadísticas de la tabla
estadísticas() {
const ocupadas = this.tabla.filter(lista => lista.length > 0).length;
const cargaToral = this.tabla.reduce((sum, lista) => sum + lista.length, 0);
const factorCarga = cargaToral / this.tamaño;
return {
tamaño: this.tamaño,
ocupadas,
total: cargaToral,
colisiones: this.colisiones,
factorCarga: factorCarga.toFixed(2),
promedioPorBucket: (cargaToral / ocupadas).toFixed(2)
};
}
}
// Pruebas
const tabla = new TablaHash(11);
tabla.insertar("Ana", 85);
tabla.insertar("Luis", 92);
tabla.insertar("Marta", 78);
tabla.insertar("Carlos", 88);
console.log(tabla.obtener("Ana")); // 85
console.log(tabla.estadísticas());
// { tamaño: 11, ocupadas: ?, total: 4, colisiones: ?, factorCarga: '0.36', ... }
La recursión modelada como una relación de dependencia entre subproblemas. Un problema se reduce a versiones más pequeñas de sí mismo hasta alcanzar un caso base.
// Ejemplos de recursión como relaciones de dependencia
// 1. Factorial: f(n) depende de f(n-1)
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
console.log(factorial(5)); // 120
// 2. Fibonacci: f(n) depende de f(n-1) y f(n-2)
function fibonacci(n, memo = {}) {
if (n in memo) return memo[n];
if (n <= 1) return n;
memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
return memo[n];
}
console.log(fibonacci(10)); // 55
console.log(fibonacci(50)); // 12586269025 (rápido con memoización)
// 3. Torre de Hanoi: 3 subproblemas recurentes
function torreDeHanoi(n, origen = "A", destino = "C", auxiliar = "B",
movimientos = []) {
if (n === 1) {
movimientos.push(`Mover disco 1 de ${origen} a ${destino}`);
return movimientos;
}
// Mover n-1 discos de origen a auxiliar (usando destino)
torreDeHanoi(n - 1, origen, auxiliar, destino, movimientos);
// Mover disco n de origen a destino
movimientos.push(`Mover disco ${n} de ${origen} a ${destino}`);
// Mover n-1 discos de auxiliar a destino (usando origen)
torreDeHanoi(n - 1, auxiliar, destino, origen, movimientos);
return movimientos;
}
console.log(torreDeHanoi(3));
// ["Mover disco 1 de A a C", "Mover disco 2 de A a B", ...]
// 4. Búsqueda en árbol binario de búsqueda
function buscarEnABB(nodo, objetivo) {
if (nodo === null) return false;
if (objetivo === nodo.valor) return true;
if (objetivo < nodo.valor) {
return buscarEnABB(nodo.izquierda, objetivo);
}
return buscarEnABB(nodo.derecha, objetivo);
}
// 5. Análisis de complejidad de recursión: T(n) depende de T(n-1) y T(n-2)
// Fibonacci sin memoización: T(n) = 2^n (exponencial)
// Fibonacci con memoización: T(n) = O(n) (lineal)
La programación dinámica resuelve problemas dividiendo en subproblemas que se reutilizan. Estructura la relación de dependencia para evitar recálculos.
// Ejemplos de Programación Dinámica
// 1. Monedas de cambio: mínimas monedas para formar n
function cambioMínimo(n, monedas) {
const dp = Array(n + 1).fill(Infinity);
dp[0] = 0;
for (let i = 1; i <= n; i++) {
for (const moneda of monedas) {
if (moneda <= i) {
dp[i] = Math.min(dp[i], dp[i - moneda] + 1);
}
}
}
return dp[n];
}
console.log(cambioMínimo(11, [1, 5, 10])); // 2 (10 + 1)
// 2. Subsecuencia común más larga (LCS)
function lcs(texto1, texto2) {
const m = texto1.length, n = texto2.length;
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (texto1[i - 1] === texto2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
console.log(lcs("abcde", "ace")); // 3
// 3. Problema de la mochila (0/1 Knapsack)
function mochila(pesos, valores, capacidad) {
const n = pesos.length;
const dp = Array(n + 1).fill(null).map(() => Array(capacidad + 1).fill(0));
for (let i = 1; i <= n; i++) {
for (let w = 1; w <= capacidad; w++) {
if (pesos[i - 1] <= w) {
dp[i][w] = Math.max(
valores[i - 1] + dp[i - 1][w - pesos[i - 1]],
dp[i - 1][w]
);
} else {
dp[i][w] = dp[i - 1][w];
}
}
}
return dp[n][capacidad];
}
console.log(mochila([2, 3, 4, 5], [3, 4, 5, 6], 5)); // 10
// 4. Número de formas de subir escaleras
function formasSubirEscaleras(n) {
if (n <= 2) return n;
const dp = [0, 1, 2];
for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
console.log(formasSubirEscaleras(4)); // 5 (1+1+1+1, 1+1+2, 1+2+1, 2+1+1, 2+2)
Un ordenamiento topológico organiza vértices de un DAG de modo que todo arista va de un vértice anterior a uno posterior. Usa relaciones de dependencia entre tareas.
// Ordenamiento Topológico mediante DFS
class GrafoDAG {
constructor(vertices) {
this.vertices = vertices;
this.adyacencia = new Map();
vertices.forEach(v => this.adyacencia.set(v, []));
}
agregarArista(u, v) {
this.adyacencia.get(u).push(v);
}
ordenamientoTopologico() {
const visitados = new Set();
const pila = [];
const dfs = (v) => {
visitados.add(v);
for (const vecino of this.adyacencia.get(v)) {
if (!visitados.has(vecino)) {
dfs(vecino);
}
}
pila.push(v);
};
for (const v of this.vertices) {
if (!visitados.has(v)) {
dfs(v);
}
}
return pila.reverse();
}
detectarCiclo() {
const visitados = new Set();
const recStack = new Set();
const hayCiclo = (v) => {
visitados.add(v);
recStack.add(v);
for (const vecino of this.adyacencia.get(v)) {
if (!visitados.has(vecino)) {
if (hayCiclo(vecino)) return true;
} else if (recStack.has(vecino)) {
return true;
}
}
recStack.delete(v);
return false;
};
for (const v of this.vertices) {
if (!visitados.has(v) && hayCiclo(v)) {
return true;
}
}
return false;
}
}
// Uso: planificación de tareas
const dag = new GrafoDAG(["Diseño", "Implementación", "Pruebas",
"Documentación", "Despliegue"]);
dag.agregarArista("Diseño", "Implementación");
dag.agregarArista("Implementación", "Pruebas");
dag.agregarArista("Pruebas", "Documentación");
dag.agregarArista("Pruebas", "Despliegue");
console.log("Orden de ejecución:", dag.ordenamientoTopologico());
// ["Diseño", "Implementación", "Pruebas", "Documentación", "Despliegue"]
// o ["Diseño", "Implementación", "Pruebas", "Despliegue", "Documentación"]
console.log("¿Hay ciclo?", dag.detectarCiclo()); // false
La comprensión de cómo las relaciones discretas subyacen en estos algoritmos es fundamental para diseñar soluciones eficientes y correctas.
A lo largo de este curso hemos visto que las relaciones y funciones discretas no son abstracciones matemáticas remotas, sino herramientas fundamentales para entender cómo funcionan los sistemas reales de software.
En los temas anteriores (40-42) exploramos aplicaciones directas: cómo los usuarios se relacionan con roles (RBAC), cómo los datos en bases de datos forman relaciones formales, y cómo esas relaciones se modelan correctamente.
En este tema (43) vimos que el corazón de los algoritmos está también en relaciones discretas:
Comprender estos conceptos te permite:
Las relaciones y funciones discretas son el lenguaje oculto de la programación. Dominarlas te da el poder de ver claridad donde otros ven complejidad, y de resolver problemas con precisión matemática y elegancia algorítmica.