⚔️ ¿Qué es divide y vencerás?
Divide y vencerás (divide and conquer) es uno de los paradigmas algorítmicos más importantes y elegantes de la informática. Su idea central es engañosamente simple: si un problema es demasiado grande para resolverlo directamente, divídelo en subproblemas más pequeños, resuélvelos por separado y combina sus soluciones para obtener la solución del problema original.
Esta estrategia tiene raíces milenarias — Julio César la empleó en la política y Napoleón en la guerra — pero en informática adquirió forma rigurosa con los trabajos de John von Neumann (MergeSort, 1945) y Tony Hoare (QuickSort, 1961). Hoy es la base de algunos de los algoritmos más eficientes que existen: ordenación, búsqueda, multiplicación de matrices, transformada rápida de Fourier y criptografía, entre otros.
En Java, esta técnica se implementa naturalmente mediante recursión y, desde Java 7, también puede paralelizarse con el framework Fork/Join para aprovechar procesadores multinúcleo. Comprender divide y vencerás es esencial tanto para entrevistas técnicas como para desarrollar software eficiente en el mundo real.
🔄 Las tres fases: dividir, conquistar, combinar
Todo algoritmo de divide y vencerás sigue exactamente tres fases. Entenderlas con claridad es la clave para diseñar tus propios algoritmos con esta técnica:
| Fase | Descripción | Ejemplo (MergeSort) |
|---|---|---|
| 1. Dividir | Descomponer el problema en subproblemas más pequeños del mismo tipo. Idealmente, los subproblemas deben tener tamaño similar para obtener la mejor eficiencia. | Partir el array por la mitad: [5,3,8,1] → [5,3] y [8,1] |
| 2. Conquistar | Resolver cada subproblema. Si es lo suficientemente pequeño (caso base), resolverlo directamente. Si no, aplicar divide y vencerás recursivamente. | Seguir dividiendo: [5,3] → [5] y [3] (caso base: 1 elemento, ya está ordenado) |
| 3. Combinar | Fusionar las soluciones de los subproblemas para construir la solución del problema original. Esta suele ser la fase más compleja de implementar. | Mezclar ordenadamente: [3,5] + [1,8] → [1,3,5,8] |
La elegancia de esta técnica radica en que la misma función se aplica recursivamente hasta llegar a casos triviales. No necesitas saber cómo resolver el problema grande: solo necesitas saber cómo dividirlo y cómo combinar soluciones parciales.
[5, 3, 8, 1, 4, 7, 2, 6] ← Problema original
/ \
[5, 3, 8, 1] [4, 7, 2, 6] ← DIVIDIR
/ \ / \
[5, 3] [8, 1] [4, 7] [2, 6] ← DIVIDIR
/ \ / \ / \ / \
[5] [3] [8] [1] [4] [7] [2] [6] ← CASO BASE
[3,5] [1,8] [4,7] [2,6] ← COMBINAR (mezclar)
[1, 3, 5, 8] [2, 4, 6, 7] ← COMBINAR
[1, 2, 3, 4, 5, 6, 7, 8] ← SOLUCIÓN
📋 Plantilla general en Java
La estructura de cualquier algoritmo divide y vencerás en Java sigue un patrón reconocible. Esta plantilla te servirá como punto de partida para diseñar tus propios algoritmos:
/**
* Plantilla genérica de divide y vencerás.
* @param problema el problema (o porción) a resolver
* @return la solución al problema
*/
public Solucion divideYVenceras(Problema problema) {
// 1. CASO BASE: si el problema es lo bastante pequeño, resolverlo directamente
if (esCasoBase(problema)) {
return resolverDirectamente(problema);
}
// 2. DIVIDIR: descomponer en subproblemas
Problema[] subproblemas = dividir(problema);
// 3. CONQUISTAR: resolver cada subproblema recursivamente
Solucion[] soluciones = new Solucion[subproblemas.length];
for (int i = 0; i < subproblemas.length; i++) {
soluciones[i] = divideYVenceras(subproblemas[i]);
}
// 4. COMBINAR: fusionar las soluciones parciales
return combinar(soluciones);
}
🔍 Ejemplo 1: búsqueda binaria
La búsqueda binaria es el ejemplo más sencillo de divide y vencerás. Dado un array ordenado, busca un elemento descartando la mitad del array en cada paso. Es un caso especial donde solo se resuelve un subproblema de los dos posibles (a veces llamado decrease and conquer).
public class BusquedaBinaria {
/**
* Busca un valor en un array ordenado usando divide y vencerás.
* @param arr array ordenado de enteros
* @param valor el valor a buscar
* @param izq índice izquierdo del rango actual
* @param der índice derecho del rango actual
* @return índice del valor, o -1 si no se encuentra
*/
public static int buscar(int[] arr, int valor, int izq, int der) {
// CASO BASE: rango vacío → no encontrado
if (izq > der) {
return -1;
}
// DIVIDIR: calcular el punto medio
int medio = izq + (der - izq) / 2; // Evita overflow con (izq+der)/2
// CONQUISTAR: comparar y elegir la mitad correcta
if (arr[medio] == valor) {
return medio; // ¡Encontrado!
} else if (valor < arr[medio]) {
return buscar(arr, valor, izq, medio - 1); // Buscar en mitad izquierda
} else {
return buscar(arr, valor, medio + 1, der); // Buscar en mitad derecha
}
// COMBINAR: no es necesario (solo hay un subproblema)
}
public static void main(String[] args) {
int[] datos = {2, 5, 8, 12, 16, 23, 38, 56, 72, 91};
int objetivo = 23;
int indice = buscar(datos, objetivo, 0, datos.length - 1);
if (indice >= 0) {
System.out.println("Valor " + objetivo + " encontrado en índice " + indice);
} else {
System.out.println("Valor " + objetivo + " no encontrado");
}
}
}
📋 Salida esperada
Valor 23 encontrado en índice 5
izq + (der - izq) / 2 en lugar de (izq + der) / 2 para evitar un desbordamiento de enteros (integer overflow) cuando izq y der son valores grandes. Este bug estuvo presente durante años en la implementación de la JDK de Java y fue corregido por Joshua Bloch en 2006.
Complejidad: T(n) = T(n/2) + O(1) → O(log n). Cada paso descarta la mitad del array, así que para 1.000.000 de elementos solo necesita ~20 comparaciones.
📊 Ejemplo 2: MergeSort (ordenación por mezcla)
MergeSort, inventado por John von Neumann en 1945, es el ejemplo canónico de divide y vencerás aplicado a la ordenación. Divide el array por la mitad, ordena recursivamente cada mitad y luego mezcla (merge) las dos mitades ordenadas en un único array ordenado.
public class MergeSort {
/**
* Ordena un array de enteros usando MergeSort.
* @param arr array a ordenar
* @param izq índice izquierdo (inclusivo)
* @param der índice derecho (inclusivo)
*/
public static void mergeSort(int[] arr, int izq, int der) {
// CASO BASE: un solo elemento o rango vacío → ya está ordenado
if (izq >= der) {
return;
}
// DIVIDIR: calcular el punto medio
int medio = izq + (der - izq) / 2;
// CONQUISTAR: ordenar recursivamente cada mitad
mergeSort(arr, izq, medio); // Mitad izquierda
mergeSort(arr, medio + 1, der); // Mitad derecha
// COMBINAR: mezclar las dos mitades ordenadas
merge(arr, izq, medio, der);
}
/**
* Mezcla dos subarrays ordenados: arr[izq..medio] y arr[medio+1..der].
*/
private static void merge(int[] arr, int izq, int medio, int der) {
// Crear copias temporales de las dos mitades
int n1 = medio - izq + 1;
int n2 = der - medio;
int[] izqArr = new int[n1];
int[] derArr = new int[n2];
System.arraycopy(arr, izq, izqArr, 0, n1);
System.arraycopy(arr, medio + 1, derArr, 0, n2);
// Mezclar comparando elemento a elemento
int i = 0, j = 0, k = izq;
while (i < n1 && j < n2) {
if (izqArr[i] <= derArr[j]) {
arr[k++] = izqArr[i++];
} else {
arr[k++] = derArr[j++];
}
}
// Copiar elementos restantes (si los hay)
while (i < n1) arr[k++] = izqArr[i++];
while (j < n2) arr[k++] = derArr[j++];
}
public static void main(String[] args) {
int[] datos = {38, 27, 43, 3, 9, 82, 10};
System.out.println("Original: " + java.util.Arrays.toString(datos));
mergeSort(datos, 0, datos.length - 1);
System.out.println("Ordenado: " + java.util.Arrays.toString(datos));
}
}
Original: [38, 27, 43, 3, 9, 82, 10]
Ordenado: [3, 9, 10, 27, 38, 43, 82]
Complejidad: T(n) = 2T(n/2) + O(n) → O(n log n) en todos los casos (mejor, promedio y peor). Usa O(n) de memoria extra por los arrays temporales de la mezcla. Es un algoritmo estable (preserva el orden relativo de elementos iguales).
Arrays.sort() para objetos y Collections.sort(). TimSort combina MergeSort con InsertionSort para subarrays pequeños, logrando O(n) en arrays parcialmente ordenados.
⚡ Ejemplo 3: QuickSort (ordenación rápida)
QuickSort, diseñado por Tony Hoare en 1961, es otro algoritmo de ordenación divide y vencerás, pero con un enfoque diferente a MergeSort. En lugar de dividir por posición y mezclar después, QuickSort elige un pivote, particiona el array colocando los menores a la izquierda y los mayores a la derecha, y luego ordena recursivamente cada partición.
public class QuickSort {
/**
* Ordena un array de enteros usando QuickSort.
*/
public static void quickSort(int[] arr, int izq, int der) {
// CASO BASE: rango de 0 o 1 elementos
if (izq >= der) {
return;
}
// DIVIDIR + COMBINAR: particionar alrededor del pivote
int indicePivote = particionar(arr, izq, der);
// CONQUISTAR: ordenar recursivamente cada partición
quickSort(arr, izq, indicePivote - 1); // Menores que pivote
quickSort(arr, indicePivote + 1, der); // Mayores que pivote
}
/**
* Partición de Lomuto: usa el último elemento como pivote.
* Coloca todos los menores a la izquierda del pivote
* y todos los mayores a la derecha.
* @return el índice final del pivote
*/
private static int particionar(int[] arr, int izq, int der) {
int pivote = arr[der]; // Elegir último elemento como pivote
int i = izq - 1; // Frontera de los elementos menores
for (int j = izq; j < der; j++) {
if (arr[j] <= pivote) {
i++;
intercambiar(arr, i, j);
}
}
// Colocar el pivote en su posición final
intercambiar(arr, i + 1, der);
return i + 1;
}
private static void intercambiar(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
public static void main(String[] args) {
int[] datos = {10, 80, 30, 90, 40, 50, 70};
System.out.println("Original: " + java.util.Arrays.toString(datos));
quickSort(datos, 0, datos.length - 1);
System.out.println("Ordenado: " + java.util.Arrays.toString(datos));
}
}
Original: [10, 80, 30, 90, 40, 50, 70]
Ordenado: [10, 30, 40, 50, 70, 80, 90]
📊 MergeSort vs. QuickSort: comparativa
| Característica | MergeSort | QuickSort |
|---|---|---|
| Mejor caso | O(n log n) | O(n log n) |
| Caso promedio | O(n log n) | O(n log n) |
| Peor caso | O(n log n) | O(n²) — array ya ordenado con pivote extremo |
| Memoria extra | O(n) | O(log n) — solo la pila de recursión |
| Estable | Sí | No (en la versión estándar) |
| In-place | No | Sí |
| Velocidad práctica | Buena | Excelente (mejor localidad de caché) |
| Uso en Java | TimSort para objetos (Arrays.sort(Object[])) |
Dual-pivot para primitivos (Arrays.sort(int[])) |
📐 Análisis de complejidad y el Teorema Maestro
Los algoritmos divide y vencerás generan una relación de recurrencia que describe su complejidad temporal. La forma general es:
T(n) = a · T(n/b) + f(n)
Donde:
a = número de subproblemas generados en cada división
b = factor por el que se reduce el tamaño del problema
f(n) = coste de dividir + combinar
El Teorema Maestro permite resolver estas recurrencias directamente. Para T(n) = aT(n/b) + O(nd), compara logb(a) con d:
| Caso | Condición | Complejidad | Ejemplo |
|---|---|---|---|
| Caso 1 | logb(a) > d | O(nlogb(a)) | Karatsuba: T(n) = 3T(n/2) + O(n) → O(n1.585) |
| Caso 2 | logb(a) = d | O(nd · log n) | MergeSort: T(n) = 2T(n/2) + O(n) → O(n log n) |
| Caso 3 | logb(a) < d | O(nd) | Búsqueda binaria: T(n) = T(n/2) + O(1) → O(log n) |
🚀 Divide y vencerás en Java moderno: Fork/Join
Desde Java 7, el framework java.util.concurrent.ForkJoinPool permite implementar algoritmos divide y vencerás que se ejecutan en paralelo, aprovechando los múltiples núcleos del procesador. La idea es dividir el trabajo en tareas, lanzarlas (fork) en hilos independientes y esperar sus resultados (join).
import java.util.concurrent.RecursiveAction;
import java.util.concurrent.ForkJoinPool;
public class MergeSortParalelo extends RecursiveAction {
private static final int UMBRAL = 1024; // Debajo de este tamaño, usar sort secuencial
private final int[] arr;
private final int izq, der;
public MergeSortParalelo(int[] arr, int izq, int der) {
this.arr = arr;
this.izq = izq;
this.der = der;
}
@Override
protected void compute() {
// Caso base: si es pequeño, ordenar secuencialmente
if (der - izq < UMBRAL) {
java.util.Arrays.sort(arr, izq, der + 1);
return;
}
// Dividir
int medio = izq + (der - izq) / 2;
// Crear subtareas
MergeSortParalelo tareaIzq = new MergeSortParalelo(arr, izq, medio);
MergeSortParalelo tareaDer = new MergeSortParalelo(arr, medio + 1, der);
// Fork: lanzar la tarea izquierda en otro hilo
tareaIzq.fork();
// Ejecutar la tarea derecha en el hilo actual
tareaDer.compute();
// Join: esperar a que termine la tarea izquierda
tareaIzq.join();
// Combinar: mezclar las dos mitades ordenadas
merge(arr, izq, medio, der);
}
private void merge(int[] arr, int izq, int medio, int der) {
int[] temp = new int[der - izq + 1];
int i = izq, j = medio + 1, k = 0;
while (i <= medio && j <= der) {
temp[k++] = (arr[i] <= arr[j]) ? arr[i++] : arr[j++];
}
while (i <= medio) temp[k++] = arr[i++];
while (j <= der) temp[k++] = arr[j++];
System.arraycopy(temp, 0, arr, izq, temp.length);
}
public static void main(String[] args) {
int[] datos = new int[1_000_000];
java.util.Random rng = new java.util.Random(42);
for (int i = 0; i < datos.length; i++) datos[i] = rng.nextInt();
long inicio = System.nanoTime();
ForkJoinPool pool = ForkJoinPool.commonPool();
pool.invoke(new MergeSortParalelo(datos, 0, datos.length - 1));
long fin = System.nanoTime();
System.out.println("Ordenados " + datos.length + " elementos en "
+ (fin - inicio) / 1_000_000 + " ms");
System.out.println("¿Ordenado correctamente? "
+ estaOrdenado(datos));
}
private static boolean estaOrdenado(int[] arr) {
for (int i = 1; i < arr.length; i++) {
if (arr[i] < arr[i - 1]) return false;
}
return true;
}
}
UMBRAL) es crítico. Si es demasiado bajo, se crean demasiadas tareas y el overhead de gestión supera la ganancia del paralelismo. Si es demasiado alto, no se aprovechan los núcleos. Valores entre 1.000 y 10.000 suelen funcionar bien en la práctica.
🧮 Otros algoritmos clásicos de divide y vencerás
Más allá de la ordenación y la búsqueda, divide y vencerás es la base de algoritmos que han transformado la informática moderna:
| Algoritmo | Problema que resuelve | Complejidad | Importancia |
|---|---|---|---|
| Strassen | Multiplicación de matrices | O(n2.807) vs. O(n³) naíf | Primer algoritmo subcúbico; base de variantes aún más rápidas. |
| Karatsuba | Multiplicación de enteros grandes | O(n1.585) vs. O(n²) escolar | Fundamental en criptografía (RSA, números de miles de dígitos). |
| FFT (Cooley-Tukey) | Transformada rápida de Fourier | O(n log n) vs. O(n²) | Compresión de audio/vídeo (MP3, JPEG), telecomunicaciones, IA. |
| Par más cercano | Encontrar los dos puntos más cercanos en un plano | O(n log n) vs. O(n²) fuerza bruta | Sistemas GIS, gráficos, robótica. |
| Exponenciación rápida | Calcular an eficientemente | O(log n) vs. O(n) | Criptografía, aritmética modular. |
| MapReduce | Procesamiento masivo de datos | Escalado horizontal | Google, Hadoop, Spark. Divide y vencerás a escala de clúster. |
🎯 Cuándo usar y cuándo evitar esta técnica
✅ Usa divide y vencerás cuando:
El problema puede descomponerse en subproblemas independientes del mismo tipo. Esta es la condición fundamental. Si los subproblemas se solapan (comparten cálculos), es mejor programación dinámica.
La combinación de resultados es eficiente. Si combinar las soluciones parciales cuesta tanto como resolver el problema original, divide y vencerás no aporta ventaja.
Puedes aprovechar el paralelismo. Los subproblemas independientes son naturalmente paralelizables con Fork/Join, streams paralelos o MapReduce.
❌ Evita divide y vencerás cuando:
Los subproblemas se solapan. Si calculas los mismos subproblemas repetidamente (como en la secuencia de Fibonacci), necesitas memoización → usa programación dinámica.
El overhead de la recursión es excesivo. Para problemas pequeños, la recursión añade coste de llamadas y pila. Es habitual establecer un umbral e usar un algoritmo simple (InsertionSort para arrays pequeños en MergeSort/QuickSort).
No existe una forma natural de combinar subsoluciones. Si fusionar las respuestas parciales es tan costoso como resolver el problema completo, la técnica no ofrece ventaja.
🐛 Errores comunes al implementar divide y vencerás
| Error | Consecuencia | Solución |
|---|---|---|
| Caso base incorrecto | Recursión infinita → StackOverflowError |
Verificar que toda cadena de divisiones termina en el caso base. Probar con arrays de 0, 1 y 2 elementos. |
| Overflow en cálculo del medio | Índice negativo → ArrayIndexOutOfBoundsException |
Usar izq + (der - izq) / 2 en vez de (izq + der) / 2. |
| Subproblemas desbalanceados | Complejidad degenerada (ej: QuickSort O(n²) con pivote extremo) | Elegir pivote aleatorio o mediana de tres. Dividir lo más equitativamente posible. |
| No reducir el tamaño | Recursión infinita | Garantizar que cada subproblema es estrictamente más pequeño que el original. Cuidado con los límites izq/der. |
| Ignorar el paso de combinación | Resultado incorrecto | Verificar que la fase de combinación reconstruye correctamente la solución global a partir de las parciales. |
📝 Ejercicios resueltos
🏋️ Ejercicio 1: Máximo de un array con divide y vencerás
Enunciado: Implementa una función que encuentre el elemento máximo de un array de enteros usando divide y vencerás. No uses bucles en la función recursiva.
public class MaximoDyV {
public static int maximo(int[] arr, int izq, int der) {
// Caso base: un solo elemento
if (izq == der) {
return arr[izq];
}
// Caso base: dos elementos
if (der - izq == 1) {
return Math.max(arr[izq], arr[der]);
}
// Dividir
int medio = izq + (der - izq) / 2;
// Conquistar
int maxIzq = maximo(arr, izq, medio);
int maxDer = maximo(arr, medio + 1, der);
// Combinar
return Math.max(maxIzq, maxDer);
}
public static void main(String[] args) {
int[] datos = {3, 7, 2, 9, 1, 8, 5, 4};
int max = maximo(datos, 0, datos.length - 1);
System.out.println("Máximo: " + max); // Máximo: 9
}
}
// Complejidad: T(n) = 2T(n/2) + O(1) → O(n)
// (No mejora la versión lineal, pero ilustra la técnica)
🏋️ Ejercicio 2: Exponenciación rápida
Enunciado: Implementa una función que calcule baseexponente usando divide y vencerás, aprovechando que an = (an/2)² si n es par, y an = a · (an/2)² si n es impar. La complejidad debe ser O(log n).
public class ExponenciacionRapida {
/**
* Calcula base^exponente usando divide y vencerás.
* Complejidad: O(log n) multiplicaciones.
*/
public static long potencia(long base, int exponente) {
// Caso base
if (exponente == 0) return 1;
if (exponente == 1) return base;
// Dividir: resolver el subproblema de tamaño mitad
long mitad = potencia(base, exponente / 2);
// Combinar
if (exponente % 2 == 0) {
return mitad * mitad; // a^n = (a^(n/2))²
} else {
return base * mitad * mitad; // a^n = a · (a^(n/2))²
}
}
public static void main(String[] args) {
System.out.println("2^10 = " + potencia(2, 10)); // 1024
System.out.println("3^13 = " + potencia(3, 13)); // 1594323
System.out.println("5^0 = " + potencia(5, 0)); // 1
// Comparar: la versión ingenua haría 13 multiplicaciones para 3^13
// La versión DyV solo necesita ~4 (log₂(13) ≈ 3.7)
}
}
🏋️ Ejercicio 3: Contar inversiones en un array
Enunciado: Una inversión es un par (i, j) donde i < j pero arr[i] > arr[j]. Implementa una función que cuente el número total de inversiones en un array usando una modificación de MergeSort. La complejidad debe ser O(n log n), no O(n²).
public class ContarInversiones {
/**
* Cuenta inversiones usando MergeSort modificado.
* Cada vez que en merge() un elemento de la derecha es menor
* que uno de la izquierda, contamos las inversiones.
*/
public static long contarInversiones(int[] arr, int izq, int der) {
if (izq >= der) return 0;
int medio = izq + (der - izq) / 2;
// Contar inversiones en cada mitad + las cruzadas en merge
long inversiones = 0;
inversiones += contarInversiones(arr, izq, medio);
inversiones += contarInversiones(arr, medio + 1, der);
inversiones += mergeYContar(arr, izq, medio, der);
return inversiones;
}
private static long mergeYContar(int[] arr, int izq, int medio, int der) {
int n1 = medio - izq + 1;
int n2 = der - medio;
int[] izqArr = new int[n1];
int[] derArr = new int[n2];
System.arraycopy(arr, izq, izqArr, 0, n1);
System.arraycopy(arr, medio + 1, derArr, 0, n2);
int i = 0, j = 0, k = izq;
long inversiones = 0;
while (i < n1 && j < n2) {
if (izqArr[i] <= derArr[j]) {
arr[k++] = izqArr[i++];
} else {
arr[k++] = derArr[j++];
// Todos los elementos restantes en izqArr son mayores
// que derArr[j], así que todos forman inversiones
inversiones += (n1 - i);
}
}
while (i < n1) arr[k++] = izqArr[i++];
while (j < n2) arr[k++] = derArr[j++];
return inversiones;
}
public static void main(String[] args) {
int[] datos = {8, 4, 2, 1};
int[] copia = datos.clone();
long inv = contarInversiones(copia, 0, copia.length - 1);
System.out.println("Array: " + java.util.Arrays.toString(datos));
System.out.println("Inversiones: " + inv); // 6 inversiones
// (8,4)(8,2)(8,1)(4,2)(4,1)(2,1)
}
}
❓ Preguntas frecuentes sobre Divide y vencerás en Java: algoritmos recursivos con ejemplos
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Divide y vencerás en Java: algoritmos recursivos con ejemplos? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!