Apuntes DAM
Volver al inicio

Divide y vencerás

AlgoritmosTécnicas de diseñoNivel intermedioTambién: divide and conquer

Parte el problema en trozos más pequeños del mismo tipo, resuelve cada uno (casi siempre con recursividad) y combina los resultados: mergesort, quicksort, la potencia rápida o la búsqueda binaria.

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

Mergesort

Escribe los números que se ordenan (de 2 a 16) y mira cómo se parten y se mezclan.

  • en la zona de trabajo

Paso 1

Array inicial: [38, 27, 43, 3, 9, 82, 10].

1static void mergeSort(int[] a, int ini, int fin) {
2    if (fin - ini < 2) return;               // 0 o 1 elementos: ya ordenado
3    int mitad = (ini + fin) / 2;  // tramo = —, comparaciones = 0, escrituras = 0
4    mergeSort(a, ini, mitad);
5    mergeSort(a, mitad, fin);
6    mezclar(a, ini, mitad, fin);
7}
8
9static void mezclar(int[] a, int ini, int mitad, int fin) {
10    int[] aux = Arrays.copyOfRange(a, ini, fin);
11    int i = 0, j = mitad - ini, k = ini;
12    while (i < mitad - ini && j < fin - ini) {
13        if (aux[i] <= aux[j]) a[k++] = aux[i++];
14        else a[k++] = aux[j++];
15    }
16    while (i < mitad - ini) a[k++] = aux[i++];
17    while (j < fin - ini) a[k++] = aux[j++];
18}

Variables

tramo
—
comparaciones
0
escrituras
0

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

Un problema grande a veces es muy difícil de resolver de golpe y muy fácil si es pequeño. Divide y vencerás se aprovecha de eso en tres pasos: dividir el problema en subproblemas del mismo tipo y más pequeños, vencer cada subproblema (con la misma técnica, hasta llegar a un caso tan pequeño que se resuelve directamente) y combinar las soluciones de los trozos en la del problema entero.

El ejemplo clásico es mergesort: ordenar un array es difícil, pero un array de un elemento ya está ordenado, y mezclar dos arrays ordenados en uno ordenado es fácil y rápido. Así que se parte el array por la mitad, se ordena cada mitad (partiéndola otra vez) y se mezclan.

Su coste se calcula contando niveles: si cada nivel parte los problemas por la mitad, hay log₂ n niveles; si combinar en cada nivel cuesta n en total, el algoritmo cuesta n · log₂ n. Para un millón de elementos son unos 20 millones de pasos frente al billón de los métodos cuadráticos.

No todos los divide y vencerás generan dos subproblemas: la búsqueda binaria y la potencia rápida generan uno solo de la mitad de tamaño, y por eso cuestan log n.

Cuándo usarlo

  • El problema se puede partir en trozos independientes del mismo tipo y las soluciones se combinan de forma sencilla.
  • Para ordenar (mergesort, quicksort), buscar en datos ordenados, multiplicar números o matrices enormes, calcular potencias.
  • Cuando los trozos se pueden resolver en paralelo: cada hilo resuelve uno (el framework Fork/Join de Java está pensado para esto).

Cuándo no

  • Si los subproblemas se repiten (como en la definición recursiva de Fibonacci): se recalcula lo mismo exponencialmente; ahí va la programación dinámica.
  • Si combinar es tan caro como resolver el problema directamente, no se gana nada.
  • Para problemas pequeños, la recursión tiene un coste fijo: por eso los algoritmos reales pasan a inserción por debajo de unos pocos elementos.

Paso a paso

  1. Caso base. Si el problema es lo bastante pequeño (un elemento, exponente 0…), se resuelve directamente.
  2. Dividir. Se parte el problema en subproblemas del mismo tipo, normalmente por la mitad.
  3. Vencer. Se resuelve cada subproblema con una llamada recursiva.
  4. Combinar. Se unen las soluciones de los subproblemas en la solución del problema: mezclar dos mitades ordenadas, elevar al cuadrado la potencia de la mitad.

El código

Mergesort

El tramo se indica con desde (incluido) y hasta (excluido), así no hace falta crear arrays nuevos para las mitades: solo un array temporal al mezclar.

Java
1import java.util.Arrays;
2
3public class Main {
4    /** Ordena a[desde..hasta) partiéndolo por la mitad, ordenando cada mitad y mezclándolas. */
5    static void mergesort(int[] a, int desde, int hasta) {
6        if (hasta - desde <= 1) return;                 // caso base: 0 o 1 elementos ya están ordenados
7        int medio = (desde + hasta) / 2;
8        mergesort(a, desde, medio);                     // vencer: cada mitad, recursivamente
9        mergesort(a, medio, hasta);
10        mezclar(a, desde, medio, hasta);                // combinar
11    }
12
13    /** Mezcla dos tramos ordenados consecutivos en uno ordenado. */
14    static void mezclar(int[] a, int desde, int medio, int hasta) {
15        int[] tmp = new int[hasta - desde];
16        int i = desde, j = medio, k = 0;
17        while (i < medio && j < hasta) tmp[k++] = a[i] <= a[j] ? a[i++] : a[j++];  // el menor de los dos
18        while (i < medio) tmp[k++] = a[i++];            // lo que quede de la izquierda
19        while (j < hasta) tmp[k++] = a[j++];            // o de la derecha
20        System.arraycopy(tmp, 0, a, desde, tmp.length);
21    }
22
23    public static void main(String[] args) {
24        int[] a = {38, 27, 43, 3, 9, 82, 10};
25        mergesort(a, 0, a.length);
26        System.out.println(Arrays.toString(a));
27    }
28}

Salida al ejecutarlo (la misma en los 5 lenguajes)

[3, 9, 10, 27, 38, 43, 82]

Potencia rápida

Un solo subproblema de la mitad de tamaño: x¹⁰⁰ se calcula con unas 8 multiplicaciones en lugar de 99.

Java
1/** x elevado a n con O(log n) multiplicaciones: x^n = (x^(n/2))², por x si n es impar. */
2static long potencia(long x, int n) {
3    if (n == 0) return 1;                  // caso base
4    long mitad = potencia(x, n / 2);       // un solo subproblema, de la mitad de tamaño
5    long r = mitad * mitad;                // combinar
6    return n % 2 == 0 ? r : r * x;
7}

Traza: mergesort de {38, 27, 43, 3, 9, 82, 10}

PasoOperaciónResultado
1dividir {38, 27, 43, 3, 9, 82, 10}{38, 27, 43} y {3, 9, 82, 10}
2dividir {38, 27, 43}{38} y {27, 43}
3dividir y mezclar {27, 43}{27, 43}
4mezclar {38} con {27, 43}{27, 38, 43}
5dividir {3, 9, 82, 10}{3, 9} y {82, 10}
6dividir y mezclar {3, 9} y {82, 10}{3, 9} y {10, 82}
7mezclar {3, 9} con {10, 82}{3, 9, 10, 82}
8mezclar {27, 38, 43} con {3, 9, 10, 82}{3, 9, 10, 27, 38, 43, 82}

Al mezclar se compara siempre el primero que queda de cada mitad y se toma el menor: cada elemento se mueve una vez por nivel.

Complejidad

AlgoritmoSubproblemasCoste de combinarCoste total
Mergesort2 de tamaño n/2O(n)O(n log n) siempre; memoria O(n)
Quicksort2 de tamaño variableO(n) al partirO(n log n) de media; O(n²) con pivotes malos
Búsqueda binaria1 de tamaño n/2O(1)O(log n)
Potencia rápida1 de tamaño n/2O(1)O(log n) multiplicaciones

La regla práctica: cuenta los niveles (log n si se divide por la mitad) y lo que cuesta cada nivel. Mergesort hace n de trabajo en cada uno de sus log₂ n niveles.

0102030405015101520tamaño de la entrada (n)operacionesO(n!)O(2ⁿ)O(n²)O(n log n)O(1)O(log n)O(n)
  • Mejor caso: O(n log n)
  • Caso medio: O(n log n)
  • Peor caso: O(n log n)

Mergesort: log₂ n niveles con n de trabajo en cada uno, siempre. Las curvas grises son las demás clases, para comparar.

En la práctica

  • Arrays.sort de objetos usa TimSort, una mezcla de mergesort e inserción; con tipos primitivos, quicksort con dos pivotes.
  • El framework Fork/Join (RecursiveTask) y los streams paralelos de Java reparten los trozos entre hilos y combinan los resultados.
  • La exponenciación modular rápida está en el corazón del cifrado RSA y de BigInteger.modPow.
  • MapReduce, la técnica de procesamiento de grandes volúmenes de datos, aplica la misma idea a miles de máquinas.

Errores típicos

  • Olvidar el caso base o ponerlo mal: la recursión no termina y da StackOverflowError.
  • Calcular dos veces el mismo subproblema (potencia(x, n/2) * potencia(x, n/2)): la potencia rápida se vuelve lineal.
  • En quicksort, elegir siempre el primer elemento como pivote: con datos ya ordenados los trozos son de 0 y n−1 elementos y el coste sube a O(n²).
  • Equivocarse con los límites de los tramos (incluidos o excluidos) al dividir y al mezclar: elementos repetidos o perdidos.
  • Crear arrays nuevos para cada mitad en cada llamada: funciona, pero gasta mucha más memoria de la necesaria.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. Contar inversiones con mergesort

Una inversión en un array es un par de posiciones i < j con a[i] > a[j]: mide lo desordenado que está (un array ordenado tiene 0; uno al revés, el máximo). Cuéntalas mientras ordenas con mergesort: al mezclar, cada vez que se toma un elemento de la mitad derecha, es menor que todos los que quedan en la izquierda, y cada uno de ellos forma una inversión con él. El main ya está: completa ordenarYContar.

  • Entrada: una línea con los números separados por espacios (puede estar vacía).
  • Salida: Inversiones: N y Ordenado: … con los números ordenados (o (vacío)). Si no son números: Entrada no válida.
☕JavaContar inversiones con mergesortMedio

Ejemplo

Entrada (lo que se escribe por teclado)
2 4 1 3 5
Salida esperada
Inversiones: 3
Ordenado: 1 2 3 4 5
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    /** Ordena a[desde..hasta) y devuelve cuántas inversiones (i < j con a[i] > a[j]) tenía. */
5    static long ordenarYContar(int[] a, int desde, int hasta) {
6        if (hasta - desde <= 1) return 0;
7        int medio = (desde + hasta) / 2;
8        long inv = ordenarYContar(a, desde, medio) + ordenarYContar(a, medio, hasta);
9        int[] tmp = new int[hasta - desde];
10        int i = desde, j = medio, k = 0;
11        while (i < medio && j < hasta) {
12            if (a[i] <= a[j]) tmp[k++] = a[i++];
13            else {
14                inv += medio - i;              // a[j] es menor que todos los que quedan a la izquierda
15                tmp[k++] = a[j++];
16            }
17        }
18        while (i < medio) tmp[k++] = a[i++];
19        while (j < hasta) tmp[k++] = a[j++];
20        System.arraycopy(tmp, 0, a, desde, tmp.length);
21        return inv;
22    }
23
24    public static void main(String[] args) {
25        Scanner sc = new Scanner(System.in);
26        String linea = sc.hasNextLine() ? sc.nextLine().trim() : "";
27        int[] a;
28        try {
29            a = linea.isEmpty() ? new int[0] : Arrays.stream(linea.split("\\s+")).mapToInt(Integer::parseInt).toArray();
30        } catch (NumberFormatException e) {
31            System.out.println("Entrada no válida");
32            return;
33        }
34        long inv = ordenarYContar(a, 0, a.length);
35        System.out.println("Inversiones: " + inv);
36        System.out.println("Ordenado: " + (a.length == 0 ? "(vacío)" : String.join(" ", Arrays.stream(a).mapToObj(String::valueOf).toList())));
37    }
38}

Contar las inversiones con dos bucles es O(n²). Contarlas al mezclar aprovecha que las dos mitades ya están ordenadas: se cuentan muchas de golpe y el total sigue siendo O(n log n).

Es la misma técnica que se usa para comparar dos rankings (cuántos pares están en distinto orden) o para medir lo parecidas que son dos listas de preferencias.

2. Potencia rápida modular

Calcula base^exp mod m con la potencia rápida recursiva y cuenta las multiplicaciones: si exp es 0 el resultado es 1 mod m y si es 1, base mod m (sin multiplicar); si no, se calcula la potencia de exp / 2, se eleva al cuadrado (1 multiplicación) y, si exp es impar, se multiplica además por la base (otra). Haz siempre el módulo después de cada multiplicación para no desbordar. El main ya está: cambia el bucle de potencia por divide y vencerás.

  • Cada línea: base exp m (base y exponente ≥ 0, m entre 1 y 2.000.000.000). Con este método, 3¹³ necesita 5 multiplicaciones: para 3³, un cuadrado y un producto (3² · 3); para 3⁶, un cuadrado; y para 3¹³, un cuadrado y un producto (3¹² · 3).
  • Salida: b^e mod m = r (k multiplicaciones; la forma directa haría d), donde d = e − 1 (o 0). 1 multiplicación en singular.
  • Una línea mal escrita o fuera de rango: Línea no válida: línea.
☕JavaPotencia rápida modularDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
3 13 1000
2 10 1000000
5 0 7
7 1 10
Salida esperada
3^13 mod 1000 = 323 (5 multiplicaciones; la forma directa haría 12)
2^10 mod 1000000 = 1024 (4 multiplicaciones; la forma directa haría 9)
5^0 mod 7 = 1 (0 multiplicaciones; la forma directa haría 0)
7^1 mod 10 = 7 (0 multiplicaciones; la forma directa haría 0)
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    static int multiplicaciones;
5
6    /** base^exp mod m con potencia rápida recursiva, contando las multiplicaciones. */
7    static long potencia(long base, long exp, long m) {
8        if (exp == 0) return 1 % m;
9        if (exp == 1) return base % m;
10        long mitad = potencia(base, exp / 2, m);
11        long r = mitad * mitad % m;
12        multiplicaciones++;
13        if (exp % 2 == 1) {
14            r = r * (base % m) % m;
15            multiplicaciones++;
16        }
17        return r;
18    }
19
20    public static void main(String[] args) {
21        Scanner sc = new Scanner(System.in);
22        while (sc.hasNextLine()) {
23            String linea = sc.nextLine().trim();
24            if (linea.isEmpty()) continue;
25            String[] p = linea.split("\\s+");
26            long b, e, m;
27            try {
28                if (p.length != 3) throw new NumberFormatException();
29                b = Long.parseLong(p[0]);
30                e = Long.parseLong(p[1]);
31                m = Long.parseLong(p[2]);
32            } catch (NumberFormatException ex) {
33                System.out.println("Línea no válida: " + linea);
34                continue;
35            }
36            if (b < 0 || e < 0 || m < 1 || m > 2_000_000_000L) {
37                System.out.println("Línea no válida: " + linea);
38                continue;
39            }
40            multiplicaciones = 0;
41            long r = potencia(b, e, m);
42            long directa = Math.max(0, e - 1);
43            System.out.println(b + "^" + e + " mod " + m + " = " + r + " (" + multiplicaciones
44                    + (multiplicaciones == 1 ? " multiplicación" : " multiplicaciones") + "; la forma directa haría " + directa + ")");
45        }
46    }
47}

Cada nivel de la recursión divide el exponente entre dos, así que hay unos log₂ e niveles con una o dos multiplicaciones cada uno: para e = 10¹⁸, unas 90 multiplicaciones en lugar de 10¹⁸.

Hacer el módulo en cada paso mantiene los números pequeños; es la misma cuenta que hace BigInteger.modPow y que usan RSA y otros cifrados.

Test

Test: Divide y vencerás

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.¿Cuáles son los tres pasos de divide y vencerás?

  2. 2.¿Por qué mergesort cuesta O(n log n)?

  3. 3.¿Qué pasa con la potencia rápida si escribes potencia(x, n/2) * potencia(x, n/2)?

  4. 4.¿Cuándo es mala idea aplicar divide y vencerás sin más?

  5. 5.¿En qué caso quicksort, eligiendo como pivote el primer elemento, tarda O(n²)?

Relacionado