Apuntes DAM

Problema de la mochila (0/1)

Elegir qué objetos meter en una mochila con un peso máximo para que su valor sea el mayor posible. Probarlo todo es exponencial; la programación dinámica lo resuelve con una tabla.

nivel avanzadoTambién: mochila 0/1, knapsack, knapsack problem, problema de la mochila

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.

Mochila 0/1

Escribe los pesos y los valores de los objetos y la capacidad de la mochila: la tabla se rellena casilla a casilla y al final se reconstruye qué objetos entran.

De 1 a 6 objetos, pesos de 1 a 20
Uno por objeto, de 0 a 99
De 1 a 10

Paso 1

Una fila por objeto y una columna por capacidad, de 0 a 7. La casilla dp[i][w] guarda el mayor valor que se consigue con los i primeros objetos en una mochila de w kg. Sin objetos (fila de arriba), todo vale 0.

1static int mochila(int[] peso, int[] valor, int capacidad, List<Integer> elegidos) {
2    int n = peso.length;
3    int[][] dp = new int[n + 1][capacidad + 1];  // n = 4, capacidad = 7
4    for (int i = 1; i <= n; i++)
5        for (int w = 0; w <= capacidad; w++) {
6            dp[i][w] = dp[i - 1][w];
7            if (peso[i - 1] <= w)
8                dp[i][w] = Math.max(dp[i][w], dp[i - 1][w - peso[i - 1]] + valor[i - 1]);
9        }
10    for (int i = n, w = capacidad; i > 0; i--)
11        if (dp[i][w] != dp[i - 1][w]) {                   // si cambió, el objeto i entró
12            elegidos.add(i - 1);
13            w -= peso[i - 1];
14        }
15    return dp[n][capacidad];
16}

Variables

n
4
capacidad
7

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

La idea

Hay n objetos, cada uno con su peso y su valor, y una mochila que aguanta W kilos. Cada objeto entra entero o no entra (por eso es la mochila 0/1). ¿Qué combinación da más valor sin pasarse del peso? Coger primero lo más valioso, o lo que más vale por kilo, parece razonable y falla: con 10 kg y objetos de 6 kg (30 €), 3 kg (14 €), 4 kg (16 €) y 2 kg (9 €), el voraz se queda en 44 € y lo mejor son 46.

Probar todas las combinaciones funciona, pero con n objetos hay 2ⁿ subconjuntos: con 30 objetos, más de mil millones. La salida es la programación dinámica. Se define dp[i][w] como el mejor valor que se consigue con los i primeros objetos en una mochila de w kilos, y cada casilla sale de dos de la fila de arriba: sin el objeto i, dp[i − 1][w]; con él, si cabe, su valor más dp[i − 1][w − peso], lo mejor que se puede hacer con el sitio que deja libre.

La tabla se rellena fila a fila (un objeto más cada vez) y la respuesta queda en la esquina, dp[n][W]. Para saber qué objetos son, se recorre al revés: si una casilla es distinta de la de arriba, ese objeto entró y se baja su peso de la columna; si es igual, no hacía falta.

El coste es O(n · W): pseudopolinómico, porque depende del valor de W y no de cuántos datos hay. Con capacidades de millones la tabla se vuelve enorme. Una mejora habitual es guardar una sola fila y recorrer w de mayor a menor (al revés, cada objeto se podría usar varias veces: eso es la mochila ilimitada). Y si los objetos se pueden partir (la mochila fraccionaria), basta el voraz por valor por kilo.

Cuándo usarlo

  • Elegir un subconjunto con un límite: proyectos con presupuesto, tareas en un tiempo dado, anuncios en un espacio.
  • Cargar un camión, un contenedor o una mochila cuando importa una sola medida (peso o volumen).
  • Variantes con la misma tabla: suma de subconjuntos (¿se puede formar justo esta cantidad?), repartir en dos mitades iguales, cambio de monedas.
  • Cualquier problema en el que cada elemento se coge o no y hay que optimizar una suma con una restricción.

Cuándo no

  • Si los objetos se pueden partir (arena, líquidos, tiempo): la mochila fraccionaria se resuelve con el voraz por €/kg.
  • Si la capacidad es enorme (millones) y hay pocos objetos: la tabla no cabe; mejor backtracking con poda o partir los objetos en dos mitades.
  • Si hay varias restricciones a la vez (peso y volumen): la tabla gana una dimensión por cada una y crece muy deprisa.

Paso a paso

  1. Definir el subproblema. dp[i][w] = el mejor valor con los i primeros objetos y w kilos de capacidad.
  2. Caso base. Sin objetos (fila 0) el valor es 0 para cualquier capacidad.
  3. Recurrencia. dp[i][w] = el máximo entre no coger el objeto (dp[i − 1][w]) y cogerlo si cabe (valor + dp[i − 1][w − peso]).
  4. Rellenar y reconstruir. Fila a fila hasta dp[n][W]. Después, desde la esquina hacia arriba: donde una casilla cambia respecto a la de arriba, ese objeto entró.

El código

La tabla y los objetos elegidos

Los cuatro objetos del visualizador: se imprime la tabla entera y se reconstruye qué entra.

Java
1import java.util.*;
2
3public class Main {
4    public static void main(String[] args) {
5        String[] nombre = {"agua", "libro", "linterna", "tablet"};
6        int[] peso = {1, 3, 4, 5};
7        int[] valor = {1, 4, 5, 7};
8        int capacidad = 7, n = peso.length;
9
10        int[][] dp = new int[n + 1][capacidad + 1];          // dp[i][w]: lo mejor con los i primeros y w kg
11        for (int i = 1; i <= n; i++)
12            for (int w = 0; w <= capacidad; w++) {
13                dp[i][w] = dp[i - 1][w];                       // sin el objeto i
14                if (peso[i - 1] <= w)                          // con él, si cabe
15                    dp[i][w] = Math.max(dp[i][w], dp[i - 1][w - peso[i - 1]] + valor[i - 1]);
16            }
17
18        StringBuilder cabecera = new StringBuilder(String.format("%-10s", "kg:"));
19        for (int w = 0; w <= capacidad; w++) cabecera.append(String.format("%3d", w));
20        System.out.println(cabecera);
21        for (int i = 0; i <= n; i++) {
22            StringBuilder fila = new StringBuilder(String.format("%-10s", i == 0 ? "-" : nombre[i - 1]));
23            for (int w = 0; w <= capacidad; w++) fila.append(String.format("%3d", dp[i][w]));
24            System.out.println(fila);
25        }
26
27        List<String> dentro = new ArrayList<>();
28        int w = capacidad;
29        for (int i = n; i > 0; i--)
30            if (dp[i][w] != dp[i - 1][w]) {                    // distinto de la fila de arriba: entró
31                dentro.add(0, nombre[i - 1]);
32                w -= peso[i - 1];
33            }
34        System.out.println("Valor máximo: " + dp[n][capacidad] + " con " + String.join(" y ", dentro) + " (" + (capacidad - w) + " de " + capacidad + " kg)");
35    }
36}

Salida al ejecutarlo (la misma en los 5 lenguajes)

kg:         0  1  2  3  4  5  6  7
-           0  0  0  0  0  0  0  0
agua        0  1  1  1  1  1  1  1
libro       0  1  1  4  5  5  5  5
linterna    0  1  1  4  5  6  6  9
tablet      0  1  1  4  5  7  8  9
Valor máximo: 9 con libro y linterna (7 de 7 kg)

Voraz, fuerza bruta y tabla

El mismo problema resuelto de tres formas: el voraz por €/kg se equivoca, la fuerza bruta prueba los 16 subconjuntos y la tabla de una sola fila acierta con 11 casillas.

Java
1import java.util.*;
2
3public class Main {
4    static final int[] PESO = {6, 3, 4, 2};
5    static final int[] VALOR = {30, 14, 16, 9};
6    static final int CAPACIDAD = 10;
7
8    /** Voraz: por orden de €/kg, cada objeto que quepa. Rápido, pero en la mochila 0/1 puede fallar. */
9    static int voraz() {
10        Integer[] orden = {0, 1, 2, 3};
11        Arrays.sort(orden, (a, b) -> VALOR[b] * PESO[a] - VALOR[a] * PESO[b]);   // más €/kg primero, sin decimales
12        int libre = CAPACIDAD, total = 0;
13        for (int i : orden)
14            if (PESO[i] <= libre) {
15                libre -= PESO[i];
16                total += VALOR[i];
17            }
18        return total;
19    }
20
21    /** Fuerza bruta: cada número de 0 a 2^n − 1 es un subconjunto (el bit k dice si el objeto k entra). */
22    static int fuerzaBruta() {
23        int mejor = 0, n = PESO.length;
24        for (int mascara = 0; mascara < (1 << n); mascara++) {
25            int p = 0, v = 0;
26            for (int k = 0; k < n; k++)
27                if ((mascara & (1 << k)) != 0) {
28                    p += PESO[k];
29                    v += VALOR[k];
30                }
31            if (p <= CAPACIDAD) mejor = Math.max(mejor, v);
32        }
33        return mejor;
34    }
35
36    /** La tabla con una sola fila: w de mayor a menor, para no usar un objeto dos veces. */
37    static int tabla() {
38        int[] dp = new int[CAPACIDAD + 1];
39        for (int i = 0; i < PESO.length; i++)
40            for (int w = CAPACIDAD; w >= PESO[i]; w--)
41                dp[w] = Math.max(dp[w], dp[w - PESO[i]] + VALOR[i]);
42        return dp[CAPACIDAD];
43    }
44
45    public static void main(String[] args) {
46        System.out.println("Voraz por €/kg: " + voraz());
47        System.out.println("Fuerza bruta, " + (1 << PESO.length) + " subconjuntos: " + fuerzaBruta());
48        System.out.println("Tabla de una fila, " + (CAPACIDAD + 1) + " casillas: " + tabla());
49        System.out.println("El voraz coge 6 kg + 3 kg (44) y ya no le cabe nada; lo mejor es 6 kg + 4 kg (46).");
50    }
51}

Salida al ejecutarlo (la misma en los 5 lenguajes)

Voraz por €/kg: 44
Fuerza bruta, 16 subconjuntos: 46
Tabla de una fila, 11 casillas: 46
El voraz coge 6 kg + 3 kg (44) y ya no le cabe nada; lo mejor es 6 kg + 4 kg (46).

Traza: La tabla del visualizador (capacidad 7 kg)

Objetos01234567
(ninguno)00000000
agua (1 kg, 1 €)01111111
libro (3 kg, 4 €)01145555
linterna (4 kg, 5 €)01145669
tablet (5 kg, 7 €)01145789

Cada fila añade un objeto. La esquina (9) es la respuesta: libro + linterna. La tablet sola (7) o con el agua (8) no llega.

Complejidad

MétodoTiempoMemoria
Fuerza bruta (todos los subconjuntos)O(2ⁿ · n)O(n)
Tabla (programación dinámica)O(n · W)O(n · W)
Tabla de una sola filaO(n · W)O(W)
Voraz por €/kg (solo vale en la fraccionaria)O(n log n)O(1)

O(n · W) es pseudopolinómico: crece con el valor de W, no con los datos. La mochila 0/1 es NP-difícil: no se conoce ningún algoritmo polinómico en el tamaño de la entrada.

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²)
  • Caso medio: O(n²)
  • Peor caso: O(n²)

En realidad O(n · W): una casilla por objeto y kilo de capacidad. Probar todos los subconjuntos sería O(2ⁿ); ojo, si W es enorme la tabla también lo es. Las curvas grises son las demás clases, para comparar.

En la práctica

  • Selección de proyectos o inversiones con un presupuesto limitado.
  • Carga de vehículos y contenedores (y su primo, el empaquetado en cajas, *bin packing*).
  • Planificar qué tareas caben en un sprint o qué anuncios entran en un bloque publicitario.
  • Asignación de recursos en la nube: qué procesos caben en una máquina con memoria limitada.
  • Fue la base de uno de los primeros sistemas de cifrado de clave pública (Merkle-Hellman), que acabó roto.

Errores típicos

  • Resolver la 0/1 con el voraz por valor o por €/kg: da buenas soluciones, pero no siempre la mejor.
  • Con una sola fila, recorrer w de menor a mayor: un objeto se puede usar varias veces y se resuelve otro problema (la mochila ilimitada).
  • Olvidar la columna 0 o la fila 0: la tabla es de (n + 1) × (W + 1).
  • Confundir índices: el objeto de la fila i es el i − 1 del array.
  • Leer la respuesta en dp[n][W] y no reconstruir: el enunciado suele pedir también qué objetos son.

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. Presupuesto de proyectos

Una empresa tiene un presupuesto y varios proyectos posibles, cada uno con su coste y su beneficio. Cada proyecto se hace entero o no se hace. Elige los que dan más beneficio sin pasarse del presupuesto. El main lee los datos y escribe el resultado: completa elegir con la tabla de la mochila.

  • Primera línea: el presupuesto (entero de 0 a 9999). Después, un proyecto por línea: nombre coste beneficio (coste de 1 a 9999).
  • Salida: Proyectos: web, app (en el orden de la entrada) y Beneficio: 120 · Coste: 95 de 100, o No cabe ningún proyecto.
  • Líneas mal escritas: Línea no válida: «…». En las pruebas, la mejor combinación es única.
JavaPresupuesto de proyectosMedio

Ejemplo

Entrada (lo que se escribe por teclado)
100
web 40 50
app 55 70
tienda 60 75
blog 10 12
Salida esperada
Proyectos: web, tienda
Beneficio: 125 · Coste: 100 de 100
Test oculto #3
Test oculto #4
Test oculto #5
Test oculto #6
0/6 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    /** Las posiciones (de menor a mayor) de los proyectos que dan más beneficio sin pasarse del presupuesto. */
5    static List<Integer> elegir(int[] coste, int[] beneficio, int presupuesto) {
6        int n = coste.length;
7        int[][] dp = new int[n + 1][presupuesto + 1];
8        for (int i = 1; i <= n; i++)
9            for (int w = 0; w <= presupuesto; w++) {
10                dp[i][w] = dp[i - 1][w];
11                if (coste[i - 1] <= w) dp[i][w] = Math.max(dp[i][w], dp[i - 1][w - coste[i - 1]] + beneficio[i - 1]);
12            }
13        LinkedList<Integer> elegidos = new LinkedList<>();
14        for (int i = n, w = presupuesto; i > 0; i--)
15            if (dp[i][w] != dp[i - 1][w]) {                    // distinto de la fila de arriba: entró
16                elegidos.addFirst(i - 1);
17                w -= coste[i - 1];
18            }
19        return elegidos;
20    }
21
22    public static void main(String[] args) {
23        Scanner sc = new Scanner(System.in);
24        String primera = sc.hasNextLine() ? sc.nextLine().trim() : "";
25        if (!primera.matches("\\d{1,4}")) {
26            System.out.println("Presupuesto no válido: «" + primera + "»");
27            return;
28        }
29        int presupuesto = Integer.parseInt(primera);
30        List<String> nombres = new ArrayList<>();
31        List<Integer> costes = new ArrayList<>(), beneficios = new ArrayList<>();
32        while (sc.hasNextLine()) {
33            String linea = sc.nextLine().trim();
34            if (linea.isEmpty()) continue;
35            String[] p = linea.split("\\s+");
36            if (p.length != 3 || !p[1].matches("[1-9]\\d{0,3}") || !p[2].matches("\\d{1,5}")) {
37                System.out.println("Línea no válida: «" + linea + "»");
38                continue;
39            }
40            nombres.add(p[0]);
41            costes.add(Integer.parseInt(p[1]));
42            beneficios.add(Integer.parseInt(p[2]));
43        }
44        int[] c = costes.stream().mapToInt(Integer::intValue).toArray();
45        int[] b = beneficios.stream().mapToInt(Integer::intValue).toArray();
46        int coste = 0, beneficio = 0;
47        List<String> lista = new ArrayList<>();
48        for (int i : elegir(c, b, presupuesto)) {
49            coste += c[i];
50            beneficio += b[i];
51            lista.add(nombres.get(i));
52        }
53        if (lista.isEmpty()) System.out.println("No cabe ningún proyecto");
54        else {
55            System.out.println("Proyectos: " + String.join(", ", lista));
56            System.out.println("Beneficio: " + beneficio + " · Coste: " + coste + " de " + presupuesto);
57        }
58    }
59}

Cada casilla resuelve un problema más pequeño (menos proyectos, menos presupuesto) y se apoya en dos de la fila anterior: con 30 proyectos y 1000 € son 31.000 casillas, frente a más de mil millones de combinaciones.

La reconstrucción no necesita guardar nada más: basta comparar cada casilla con la de arriba.

2. Repartir la carga en dos camiones

Hay que repartir unos paquetes entre dos camiones para que vayan lo más igualados posible. Escribe cuánto lleva cada uno y la diferencia. El truco: si un camión lleva s kg, el otro lleva total − s, así que basta encontrar la suma más cercana a la mitad que se pueda formar con algunos paquetes. El main lee los pesos: completa mejorMitad.

  • Entrada: los pesos de los paquetes (enteros de 1 a 1000), separados por espacios o saltos de línea. Un valor que no lo sea: Peso no válido: «…» (y se ignora).
  • Salida: 7 paquetes, 47 kg en total y Camión 1: 23 kg · Camión 2: 24 kg · Diferencia: 1 kg (el camión 1, el que menos lleva).
  • Sin paquetes válidos: No hay paquetes.
JavaRepartir la carga en dos camionesDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
8 7 6 5 4 9 8
Salida esperada
7 paquetes, 47 kg en total
Camión 1: 23 kg · Camión 2: 24 kg · Diferencia: 1 kg
Test oculto #3
Test oculto #4
Test oculto #5
Test oculto #6
Test oculto #7
0/7 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    /** El mayor peso, sin pasar de la mitad del total, que se puede formar con algunos de los paquetes. */
5    static int mejorMitad(int[] pesos) {
6        int total = 0;
7        for (int p : pesos) total += p;
8        boolean[] posible = new boolean[total / 2 + 1];     // posible[s]: ¿hay paquetes que sumen justo s?
9        posible[0] = true;
10        for (int p : pesos)
11            for (int s = total / 2; s >= p; s--)              // de mayor a menor: cada paquete, una sola vez
12                if (posible[s - p]) posible[s] = true;
13        int s = total / 2;
14        while (!posible[s]) s--;
15        return s;
16    }
17
18    public static void main(String[] args) {
19        Scanner sc = new Scanner(System.in);
20        List<Integer> pesos = new ArrayList<>();
21        while (sc.hasNext()) {
22            String t = sc.next();
23            if (t.matches("[1-9]\\d{0,3}") && Integer.parseInt(t) <= 1000) pesos.add(Integer.parseInt(t));
24            else System.out.println("Peso no válido: «" + t + "»");
25        }
26        if (pesos.isEmpty()) {
27            System.out.println("No hay paquetes");
28            return;
29        }
30        int[] p = pesos.stream().mapToInt(Integer::intValue).toArray();
31        int total = Arrays.stream(p).sum();
32        int uno = mejorMitad(p), otro = total - uno;
33        System.out.println(p.length + (p.length == 1 ? " paquete, " : " paquetes, ") + total + " kg en total");
34        System.out.println("Camión 1: " + uno + " kg · Camión 2: " + otro + " kg · Diferencia: " + (otro - uno) + " kg");
35    }
36}

Repartir «a ojo» (el más pesado al camión que menos lleve) suele quedarse cerca, pero no garantiza el mejor reparto. La tabla de booleanos prueba todas las sumas posibles sin probar todas las combinaciones: O(n · total).

Es el problema de la partición, un caso particular de la suma de subconjuntos y, por tanto, de la mochila.

Test

Test: Problema de la mochila (0/1)

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.¿Qué guarda dp[i][w] en la mochila 0/1?

  2. 2.¿Por qué no basta coger los objetos de mayor €/kg en la mochila 0/1?

  3. 3.¿Qué coste tiene la tabla de la mochila con n objetos y capacidad W?

  4. 4.Con una sola fila, ¿por qué se recorre w de mayor a menor?

  5. 5.¿Cómo se sabe qué objetos entran, mirando la tabla terminada?

Relacionado