TL;DR
- El Problema: Optimización de complejidad temporal y espacial para estructuras de datos clave.
- El Enfoque: Problema estilo CTCI 8.4 para principiantes: devolver cada subconjunto de un conjunto, incluido el vacío y el completo. Construcción recursiva, enumeración opcional con máscaras de bits y código Java.
- Complejidad: Relación óptima de tiempo y espacio con gestión de casos límite.
Tienes una bolsa de pegatinas distintas: {A, B, C}. ¿Cuántas bolsas diferentes puedes formar si cada pegatina entra o no? Cuenta la bolsa vacía. Cuenta la bolsa llena. Cuentan los pares. Esa lista de bolsas es el power set (conjunto potencia): todos los subconjuntos del conjunto original.
Este post es enseñanza original para principiantes en Java. Misma familia de calentamientos de recursión en entrevista, no una copia de libro. Parte de la serie CTCI en Java. Capítulo 8, recursión y programación dinámica, problema 8.4.
1. Analogía cotidiana
Imagina un local de sándwiches con tres toppings: lechuga, tomate, queso. Cada topping es un sí o un no. Pedidos en el mostrador:
- sin toppings
- solo lechuga
- solo tomate
- solo queso
- lechuga + tomate
- lechuga + queso
- tomate + queso
- los tres
Eso son 2 × 2 × 2 = 8 pedidos. Misma cuenta que el power set de un conjunto de 3 elementos: 2^n subconjuntos para n elementos.
Puedes crecer el menú de forma recursiva. Con cero toppings solo tienes el pedido vacío. Añades queso: cada pedido viejo se queda, y también una copia de cada uno con queso. Añades tomate igual. Añades lechuga igual. Esa es la construcción recursiva. Las máscaras de bits hacen el mismo trabajo con un bucle de 0 a 2^n - 1, donde cada bit dice "incluye este topping".
2. Enunciado en claro
Entrada: un conjunto de elementos distintos. En código suele ser un List o un array de valores únicos (por ejemplo caracteres o enteros).
Salida: una colección de todos los subconjuntos. El orden de los subconjuntos casi nunca importa. El orden dentro de un subconjunto puede seguir el de la entrada para demos estables.
Debe incluir:
- el subconjunto vacío
{} - el conjunto completo
- cada subconjunto propio intermedio
Ejemplo:
Input: {1, 2, 3}
Power set (8 subsets):
{}
{1}
{2}
{3}
{1, 2}
{1, 3}
{2, 3}
{1, 2, 3}
Aclara en la entrevista:
- ¿Elementos únicos? (Sí para el power set clásico. Duplicados son otro problema.)
- Tipo de retorno:
List<List<T>>es habitual en Java. - ¿Mutar listas del llamador? Mejor copias defensivas de cada subconjunto al guardarlo.
- ¿n pequeño? El tamaño de salida es 2^n. Con n = 20 ya hay cerca de un millón de subconjuntos. Dilo en voz alta.
3. Piensa primero
Cuenta primero
| n | Número de subconjuntos |
|---|---|
| 0 | 1 (solo {}) |
| 1 | 2 |
| 2 | 4 |
| 3 | 8 |
| n | 2^n |
No puedes bajar de O(2^n · poly(n)) si debes listar cada subconjunto. El espacio de la respuesta es del mismo orden.
Idea recursiva (construir desde n-1)
Sea P(S) el power set de S.
- Si
Sestá vacío,P(S) = { {} }. - Si no, elige un elemento
ey dejarest = S sin e. - Calcula
P(rest). - Para cada subconjunto
subenP(rest), guardasubtal cual y tambiénsub ∪ {e}.
Cada subconjunto contiene e o no. Esas dos familias cubren el power set sin solaparse.
P({1,2}) with e=2, rest={1}:
P(rest) = { {}, {1} }
without 2: {}, {1}
with 2: {2}, {1,2}
result: {}, {1}, {2}, {1,2}
Recursión por índice (include / exclude)
Misma matemática, otra forma de código: recorre índices 0 .. n-1 con un camino actual.
- En el índice
i, rama exclude del elementoi, luego rama include (push, recurse, pop). - Cuando
i == n, copia el camino actual a la respuesta.
Es backtracking clásico. A muchos entrevistadores les gusta porque el árbol de llamadas se dibuja fácil.
Idea de máscara de bits
Hay exactamente 2^n enteros de 0 a 2^n - 1. Para la máscara m, el bit j decide si el elemento j está en el subconjunto:
n = 3, elements [a, b, c]
mask 0 = 000 -> {}
mask 1 = 001 -> {a}
mask 2 = 010 -> {b}
mask 3 = 011 -> {a,b}
mask 4 = 100 -> {c}
...
mask 7 = 111 -> {a,b,c}
Sin pila de recursión. Buen segundo enfoque tras el recursivo.
Qué no hacer
- Bucles anidados solo para n fijo (profundidad hardcodeada).
- Meter una sola lista compartida en la respuesta sin copiar (todos los subconjuntos guardados acaban iguales).
- Olvidar el conjunto vacío (o el completo).
- Usar un set-of-sets sin historia clara de tipos/hash cuando un list-of-lists basta en la entrevista.
4. Solución en Java
4.1 Construcción recursiva desde power sets más pequeños
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Power set by growing from P(rest).
* Each new element doubles the number of subsets.
*/
public class PowerSetRecursive {
public static List<List<Integer>> powerSet(List<Integer> set) {
List<List<Integer>> result = new ArrayList<>();
if (set == null) {
return result;
}
// start with the empty subset
result.add(new ArrayList<>());
for (int element : set) {
// snapshot size: only clone subsets built so far
int sizeBefore = result.size();
for (int i = 0; i < sizeBefore; i++) {
List<Integer> withElement = new ArrayList<>(result.get(i));
withElement.add(element);
result.add(withElement);
}
}
return result;
}
public static void main(String[] args) {
List<Integer> set = Arrays.asList(1, 2, 3);
List<List<Integer>> all = powerSet(set);
System.out.println(all.size()); // 8
for (List<Integer> subset : all) {
System.out.println(subset);
}
}
}
Recorrido para {1, 2, 3}:
| Paso | Elemento añadido | Subconjuntos tras el paso |
|---|---|---|
| inicio | - | {} |
| 1 | 1 | {}, {1} |
| 2 | 2 | {}, {1}, {2}, {1,2} |
| 3 | 3 | ocho subconjuntos: los cuatro previos más cada uno con 3 |
4.2 Backtracking include / exclude
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class PowerSetBacktrack {
public static List<List<Integer>> powerSet(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
if (nums == null) {
return result;
}
backtrack(nums, 0, new ArrayList<>(), result);
return result;
}
private static void backtrack(
int[] nums,
int index,
List<Integer> path,
List<List<Integer>> result) {
if (index == nums.length) {
// must copy: path is reused on the way back
result.add(new ArrayList<>(path));
return;
}
// exclude nums[index]
backtrack(nums, index + 1, path, result);
// include nums[index]
path.add(nums[index]);
backtrack(nums, index + 1, path, result);
path.remove(path.size() - 1); // pop
}
public static void main(String[] args) {
List<List<Integer>> all = powerSet(new int[] {1, 2, 3});
System.out.println(all.size()); // 8
for (List<Integer> subset : all) {
System.out.println(subset);
}
}
}
Árbol de llamadas para dos elementos [a, b]:
[]
/ \
exclude a include a
[] [a]
/ \ / \
exclude b include b exclude b include b
[] [b] [a] [a,b]
Cuatro hojas, cuatro subconjuntos. El mismo patrón escala a n.
4.3 Enumeración opcional con máscara de bits
import java.util.ArrayList;
import java.util.List;
public class PowerSetBitMask {
public static List<List<Integer>> powerSet(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
if (nums == null) {
return result;
}
int n = nums.length;
// 1 << n is 2^n. For n >= 31 use care with int overflow.
int total = 1 << n;
for (int mask = 0; mask < total; mask++) {
List<Integer> subset = new ArrayList<>();
for (int j = 0; j < n; j++) {
if ((mask & (1 << j)) != 0) {
subset.add(nums[j]);
}
}
result.add(subset);
}
return result;
}
public static void main(String[] args) {
List<List<Integer>> all = powerSet(new int[] {1, 2, 3});
System.out.println(all.size()); // 8
for (List<Integer> subset : all) {
System.out.println(subset);
}
}
}
Demo de máscaras para [1, 2, 3]:
| mask | binary | subset |
|---|---|---|
| 0 | 000 | {} |
| 1 | 001 | {1} |
| 2 | 010 | {2} |
| 3 | 011 | {1, 2} |
| 4 | 100 | {3} |
| 5 | 101 | {1, 3} |
| 6 | 110 | {2, 3} |
| 7 | 111 | {1, 2, 3} |
¿Con cuál empezar en entrevista? Empieza con include/exclude o crecer desde P(rest). Menciona las máscaras de bits como alternativa iterativa limpia. Las tres producen los mismos 2^n subconjuntos.
5. Tabla de complejidad
| Enfoque | Tiempo | Espacio extra (además de la salida) | Notas |
|---|---|---|---|
| Crecer desde P(rest) | O(n · 2^n) | O(1) más allá del crecimiento del resultado | cada uno de 2^n subconjuntos copia hasta n elementos a lo largo del tiempo |
| Backtracking | O(n · 2^n) | O(n) recursión + path | 2^n hojas; copiar el path cuesta O(n) |
| Máscara de bits | O(n · 2^n) | O(1) más allá del resultado | bucles simples; cuidado con 1 << n si n es grande |
| Tamaño de salida | - | O(n · 2^n) | no se reduce si listas todo |
Los entrevistadores quieren oír 2^n subconjuntos antes de codificar. Si preguntan "¿se puede mejor?", no para la enumeración completa; solo generación perezosa o parada temprana con más restricciones.
6. Casos límite y errores frecuentes
Los entrevistadores tocan esto:
- Entrada vacía: devolver una lista con un subconjunto vacío, no una lista vacía de subconjuntos.
- Entrada null: resultado vacío o tratar como conjunto vacío. Elige uno y dilo.
- Un solo elemento: solo
{}y{x}. - n grande: 2^20 es ~1e6; 2^30 no cabe a la ligera. Habla de memoria y overflow de
1 << nen máscaras cuando n ≥ 31 (1L << no tope de n). - Duplicados en la entrada: el power set clásico asume únicos. Duplicados piden ordenar + saltar (subset II), otro problema.
- Path mutable compartido: olvidar
new ArrayList<>(path)hace que todos los subconjuntos guardados sean idénticos. - Mutar
sizeal iterar la lista que crece sin snapshot: bucle infinito o duplicado mal. Haz snapshot desizeBeforeprimero. - Requisitos de orden: si piden subconjuntos ordenados o lexicográficos, ordena cada uno o genera en orden de índices fijo y ordena la lista exterior al final.
Errores frecuentes:
- Falta el subconjunto vacío. Caso base mal.
- Sin copia al guardar. Todas las respuestas alias de una lista.
- Bucles anidados hardcodeados solo para n = 3.
1 << ncon n = 31 desborda int (bit de signo). Habla de límites.- Tratar el power set como permutaciones. El orden dentro de un subconjunto no crea subconjuntos nuevos;
{1,2}y{2,1}son el mismo conjunto.
Idea mínima de smoke:
List<List<Integer>> p0 = PowerSetRecursive.powerSet(List.of());
assert p0.size() == 1 && p0.get(0).isEmpty();
List<List<Integer>> p1 = PowerSetRecursive.powerSet(List.of(7));
assert p1.size() == 2;
List<List<Integer>> p3 = PowerSetBitMask.powerSet(new int[] {1, 2, 3});
assert p3.size() == 8;
7. Recap para contárselo a un amigo
Power set en lenguaje de entrevista:
- Un conjunto de n elementos distintos tiene 2^n subconjuntos: cada elemento entra o no.
- Siempre incluye
{}y el conjunto completo. - Crecimiento recursivo: empieza con
{ {} }. Por cada elemento nuevo, clona cada subconjunto actual y añade el elemento al clon. - Backtrack: en cada índice, rama exclude y luego include; copia el path en las hojas.
- Máscara de bits: para máscara
0 .. 2^n - 1, incluye el elementojcuando el bitjestá a 1. - Tiempo y espacio de salida son Θ(n · 2^n) en la formulación habitual de listar todo.
- Copia los subconjuntos al guardarlos. No alias de una lista de path compartida.
Si dibujas el árbol include/exclude para {1,2}, duplicas subconjuntos al añadir un tercero, y escribes recursión o un bucle de máscaras sin bugs de listas compartidas, dominas el problema 8.4.
Serie
- Guía: Guía de la serie CTCI
- Anterior: Magic Index
- Siguiente: Recursive Multiply
