⚔️ Técnicas de Diseño de Algoritmos

Fundamentos del diseño algorítmico: divide y vencerás, programación dinámica, algoritmos avaros, backtracking y ramificación y poda.

📑 Contenido

1.1 Divide y Vencerás

Divide y Vencerás (D&C) es una técnica que resuelve problemas dividiéndolos en subproblemas más pequeños del mismo tipo, resolviéndolos recursivamente y combinando sus soluciones.

Estructura General

  1. Dividir: Partir el problema en subproblemas más pequeños
  2. Conquistar: Resolver los subproblemas recursivamente (caso base si es trivial)
  3. Combinar: Unir las soluciones de los subproblemas

🔍 Visual: ¿Qué hace realmente Divide & Conquer?

Ve paso a paso cómo Merge Sort divide el arreglo y luego combina las piezas ordenadas.

Master Theorem

Para recurrencias de la forma T(n) = aT(n/b) + f(n):

Caso 1: Si f(n) = O(n^(log_b(a) - ε)) → T(n) = Θ(n^log_b(a))

Caso 2: Si f(n) = Θ(n^log_b(a)) → T(n) = Θ(n^log_b(a) · log n)

Caso 3: Si f(n) = Ω(n^(log_b(a) + ε)) → T(n) = Θ(f(n))

Ejemplo: Merge Sort

[38, 27, 43, 3, 9, 82, 10] [38, 27, 43, 3] [9, 82, 10] [38,27] [43,3] [9,82] [10] Merge: [3, 9, 10, 27, 38, 43, 82] T(n) = 2T(n/2) + O(n) → O(n log n)

Implementación

C++ Python
// Merge Sort - C++
void merge(vector<int>& arr, int l, int m, int r) {
    vector<int> L(arr.begin()+l, arr.begin()+m+1);
    vector<int> R(arr.begin()+m+1, arr.begin()+r+1);
    int i = 0, j = 0, k = l;
    while (i < L.size() && j < R.size())
        arr[k++] = (L[i] <= R[j]) ? L[i++] : R[j++];
    while (i < L.size()) arr[k++] = L[i++];
    while (j < R.size()) arr[k++] = R[j++];
}

void mergeSort(vector<int>& arr, int l, int r) {
    if (l < r) {
        int m = l + (r - l) / 2;
        mergeSort(arr, l, m);      // Divide izquierda
        mergeSort(arr, m + 1, r);  // Divide derecha
        merge(arr, l, m, r);       // Combina
    }
}

Otros Ejemplos de D&C

AlgoritmoRecurrenciaComplejidad
Binary SearchT(n) = T(n/2) + O(1)O(log n)
Merge SortT(n) = 2T(n/2) + O(n)O(n log n)
Quick SortT(n) = 2T(n/2) + O(n)O(n log n) avg
StrassenT(n) = 7T(n/2) + O(n²)O(n^2.807)
KaratsubaT(n) = 3T(n/2) + O(n)O(n^1.585)

1.2 Programación Dinámica

Programación Dinámica (DP) resuelve problemas combinando soluciones de subproblemas superpuestos, almacenando resultados para evitar recálculos.

🧠 Visual: Tabla DP como mosaico de subproblemas

Pasa el mouse sobre una celda para ver qué subproblemas (izquierda, arriba, diagonal) se usan para calcularla (ejemplo estilo LCS).

Estado actual dp[i][j] Subproblemas usados (dp[i-1][j], dp[i][j-1], dp[i-1][j-1])

Idea clave: cada celda depende de unos pocos subproblemas; por eso tiene sentido guardar la tabla completa.

Condiciones para usar DP

  1. Subestructura Óptima: La solución óptima contiene soluciones óptimas de subproblemas
  2. Subproblemas Superpuestos: Los mismos subproblemas se resuelven múltiples veces

Enfoques

🔝 Top-Down (Memoización)

Recursión + caché. Más intuitivo, resuelve solo subproblemas necesarios.

🔽 Bottom-Up (Tabulación)

Iterativo, llena tabla desde casos base. Generalmente más eficiente en espacio.

Ejemplo: Fibonacci

Sin DP: fib(n) recursivo tiene O(2^n) — ¡exponencial!

Con DP: Almacenamos resultados → O(n) tiempo, O(n) o O(1) espacio

// Fibonacci con DP - C++
// Top-Down (Memoización)
int memo[1001];
int fib(int n) {
    if (n <= 1) return n;
    if (memo[n] != -1) return memo[n];
    return memo[n] = fib(n-1) + fib(n-2);
}

// Bottom-Up (Tabulación)
int fibBottomUp(int n) {
    if (n <= 1) return n;
    int dp[n+1];
    dp[0] = 0; dp[1] = 1;
    for (int i = 2; i <= n; i++)
        dp[i] = dp[i-1] + dp[i-2];
    return dp[n];
}

// Optimizado O(1) espacio
int fibOptimized(int n) {
    if (n <= 1) return n;
    int a = 0, b = 1;
    for (int i = 2; i <= n; i++) {
        int c = a + b;
        a = b; b = c;
    }
    return b;
}

Ejemplo Clásico: Longest Common Subsequence (LCS)

Dadas dos cadenas X e Y, encontrar la subsecuencia común más larga.

Recurrencia:

LCS(i,j) = LCS(i-1, j-1) + 1     si X[i] == Y[j]

LCS(i,j) = max(LCS(i-1,j), LCS(i,j-1))     si X[i] ≠ Y[j]

# LCS - Python
def lcs(X, Y):
    m, n = len(X), len(Y)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i-1] == Y[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    return dp[m][n]

# Ejemplo: lcs("ABCDGH", "AEDFHR") = 3 ("ADH")
# Complejidad: O(mn) tiempo, O(mn) espacio

Problemas Clásicos de DP

ProblemaEstadoComplejidad
Fibonaccidp[i] = i-th fibO(n)
LCSdp[i][j] = LCS de prefijosO(nm)
Edit Distancedp[i][j] = dist entre prefijosO(nm)
0/1 Knapsackdp[i][w] = max valorO(nW)
Coin Changedp[i] = min monedas para iO(nS)
Matrix Chaindp[i][j] = min ops para i..jO(n³)

1.3 Algoritmos Avaros (Greedy)

Greedy toma la decisión localmente óptima en cada paso, esperando que lleve a una solución global óptima.

Clave: Greedy NO siempre da la solución óptima. Solo funciona cuando la elección greedy y la subestructura óptima se pueden demostrar.

⏱️ Visual: Selección de actividades con enfoque Greedy

Cada actividad tiene un intervalo de tiempo. Greedy elige siempre la que termina primero y no se traslapa con las ya elegidas.

Cuándo usar Greedy

  1. Propiedad Greedy: Una solución global óptima se puede alcanzar eligiendo óptimos locales
  2. Subestructura Óptima: Un problema tiene solución óptima que contiene soluciones óptimas de subproblemas

Ejemplo: Activity Selection

Seleccionar el máximo número de actividades que no se solapan.

// Activity Selection - C++
// Greedy: siempre elegir la actividad que termina primero
struct Activity { int start, end; };

int activitySelection(vector<Activity>& acts) {
    // Ordenar por tiempo de fin
    sort(acts.begin(), acts.end(), 
         [](auto& a, auto& b) { return a.end < b.end; });
    
    int count = 1;
    int lastEnd = acts[0].end;
    
    for (int i = 1; i < acts.size(); i++) {
        if (acts[i].start >= lastEnd) {
            count++;
            lastEnd = acts[i].end;
        }
    }
    return count;
}
// Complejidad: O(n log n) por el sort
# Activity Selection - Python
def activity_selection(activities):
    # Ordenar por tiempo de fin
    activities.sort(key=lambda x: x[1])
    
    selected = [activities[0]]
    last_end = activities[0][1]
    
    for start, end in activities[1:]:
        if start >= last_end:
            selected.append((start, end))
            last_end = end
    
    return selected

Greedy vs DP

Greedy

• Una elección por paso
• No reconsidera
• Más rápido si funciona
• Requiere prueba de correctitud

DP

• Considera todas las opciones
• Almacena subsoluciones
• Siempre óptimo si aplica
• Generalmente más lento

Ejemplos de Greedy

ProblemaEstrategia Greedy¿Óptimo?
Activity SelectionElegir la que termina primero✅ Sí
Huffman CodingCombinar los 2 de menor freq✅ Sí
Fractional KnapsackMayor ratio valor/peso✅ Sí
0/1 KnapsackMayor ratio valor/peso❌ No (usar DP)
DijkstraNodo más cercano no visitado✅ Sí (pesos ≥0)
Prim/Kruskal (MST)Arista de menor peso✅ Sí

1.4 Backtracking

Backtracking es una técnica de búsqueda exhaustiva que construye soluciones incrementalmente, abandonando candidatos ("backtrack") cuando detecta que no pueden llevar a una solución válida.

♟️ Visual: Cómo va probando el algoritmo en N-Queens (4×4)

Sigue los movimientos: el algoritmo coloca una reina por fila; si se queda sin opciones, hace backtrack y prueba otra columna.

Paso 1 / 6

Estructura General

// Plantilla de Backtracking
void backtrack(estado, solución_parcial) {
    if (es_solución(estado)) {
        guardar_solución(solución_parcial);
        return;
    }
    
    for (cada opción válida) {
        hacer_elección(opción);          // Elegir
        backtrack(nuevo_estado, ...);    // Explorar
        deshacer_elección(opción);       // Backtrack
    }
}
// N-Queens - C++
class NQueens {
    int n;
    vector<int> col, diag1, diag2;  // Para verificar ataques O(1)
    
    bool isSafe(int row, int c) {
        return !col[c] && !diag1[row+c] && !diag2[row-c+n-1];
    }
    
    void solve(int row, vector<string>& board, vector<vector<string>>& res) {
        if (row == n) { res.push_back(board); return; }
        
        for (int c = 0; c < n; c++) {
            if (isSafe(row, c)) {
                board[row][c] = 'Q';
                col[c] = diag1[row+c] = diag2[row-c+n-1] = 1;
                
                solve(row + 1, board, res);  // Recursión
                
                board[row][c] = '.';         // Backtrack
                col[c] = diag1[row+c] = diag2[row-c+n-1] = 0;
            }
        }
    }
public:
    vector<vector<string>> solveNQueens(int n) {
        this->n = n;
        col.assign(n, 0);
        diag1.assign(2*n, 0);
        diag2.assign(2*n, 0);
        vector<string> board(n, string(n, '.'));
        vector<vector<string>> res;
        solve(0, board, res);
        return res;
    }
};
# N-Queens - Python
def solve_n_queens(n):
    def backtrack(row, cols, diag1, diag2, board):
        if row == n:
            result.append(["".join(r) for r in board])
            return
        
        for col in range(n):
            if col in cols or (row+col) in diag1 or (row-col) in diag2:
                continue
            
            board[row][col] = 'Q'
            backtrack(row+1, cols|{col}, diag1|{row+col}, diag2|{row-col}, board)
            board[row][col] = '.'  # Backtrack
    
    result = []
    backtrack(0, set(), set(), set(), [['.']*n for _ in range(n)])
    return result

1.5 Ramificación y Poda (Branch & Bound)

Branch and Bound es una extensión de backtracking para problemas de optimización. Usa cotas (bounds) para podar ramas que no pueden mejorar la mejor solución encontrada.

✂️ Visual: ¿Qué ramas vale la pena explorar?

Ejemplo de maximización con mejor solución actual UB = 25. Los nodos verdes se exploran; el rojo se poda.

root bound = 40 Nodo A bound = 27 Nodo B bound = 18 Nodo A₁ bound = 24 Nodo A₂ bound = 32 UB (mejor solución) = 25

• Se exploran nodos con bound > UB (root, A, A₂).
• Se podan nodos con bound ≤ UB (B, A₁) porque nunca podrán superar la mejor solución actual.

Diferencia con Backtracking

Backtracking

• Encuentra todas las soluciones
• Poda por factibilidad
• Pregunta: "¿Es válido?"

Branch & Bound

• Busca la MEJOR solución
• Poda por optimalidad
• Pregunta: "¿Puede mejorar al mejor que ya tengo?"

Componentes Clave

  1. Branching: Dividir el problema en subproblemas
  2. Bounding: Calcular cotas inferior/superior para podar
  3. Pruning: Descartar ramas que no pueden mejorar la solución actual

Lower Bound (LB): Estimación optimista del mejor valor alcanzable desde un nodo

Upper Bound (UB): Mejor solución completa encontrada hasta ahora

Poda (maximización): Si bound(nodo) ≤ UB → la rama no puede mejorar la solución actual → se puede podar.

Ejemplo: 0/1 Knapsack con B&B

// 0/1 Knapsack con Branch and Bound - C++
struct Item { int weight, value; double ratio; };
struct Node { int level, profit, weight; double bound; };

double bound(Node u, int n, int W, vector<Item>& items) {
    if (u.weight >= W) return 0.0;

    double profit_bound = u.profit;
    int j = u.level + 1;
    int totweight = u.weight;

    // Greedy: agregar items completos mientras quepa
    while (j < n && totweight + items[j].weight <= W) {
        totweight += items[j].weight;
        profit_bound += items[j].value;
        j++;
    }
    // Agregar fracción del siguiente item (relajación fraccionaria)
    if (j < n)
        profit_bound += (W - totweight) * items[j].ratio;

    return profit_bound;
}

int knapsackBB(int W, vector<Item>& items) {
    int n = items.size();
    for (auto &it : items)
        it.ratio = (double)it.value / it.weight;

    sort(items.begin(), items.end(),
         [](const Item& a, const Item& b){ return a.ratio > b.ratio; });

    queue<Node> Q;
    Node u, v;
    u.level = -1; u.profit = 0; u.weight = 0;
    u.bound = bound(u, n, W, items);
    Q.push(u);

    int maxProfit = 0;

    while (!Q.empty()) {
        u = Q.front(); Q.pop();

        if (u.level == n-1) continue;        // ya no hay más items
        if (u.bound <= maxProfit) continue;  // poda por bound

        // Rama 1: incluir siguiente item
        v.level = u.level + 1;
        v.weight = u.weight + items[v.level].weight;
        v.profit = u.profit + items[v.level].value;

        if (v.weight <= W && v.profit > maxProfit)
            maxProfit = v.profit;

        v.bound = bound(v, n, W, items);
        if (v.bound > maxProfit)
            Q.push(v);

        // Rama 2: NO incluir siguiente item
        v.weight = u.weight;
        v.profit = u.profit;
        v.bound = bound(v, n, W, items);
        if (v.bound > maxProfit)
            Q.push(v);
    }

    return maxProfit;
}

Aplicaciones de B&B

ProblemaBound típico
TSPMST, relajación LP
KnapsackGreedy fraccionario
Job SchedulingEDD, relajación
Integer ProgrammingLP relaxation

📝 Quiz - Módulo 1

Pon a prueba tu conocimiento sobre técnicas de diseño de algoritmos.