Atlasingeniería

Estructuras de datosOrdenamiento y búsquedaTema 2

Mergesort y quicksort

Los dos parten el problema a la mitad, pero en momentos opuestos: uno hace el trabajo al combinar y el otro al dividir. De ahí salen sus garantías y sus riesgos.

Para este tema conviene tener claro:Burbuja, inserción y selecciónPensar en recursivo

Mergesort y quicksort son los dos ordenamientos de propósito general que se usan de verdad, y los dos aplican divide y vencerás. La diferencia es dónde ponen el esfuerzo: mergesort divide sin pensar y trabaja al unir; quicksort trabaja al dividir y no tiene nada que unir.

Mergesort: partir, ordenar y fusionar

Mergesort parte el arreglo al medio, ordena cada mitad recursivamente y fusiona las dos mitades ordenadas recorriéndolas en paralelo y tomando siempre el menor disponible.

La partición siempre es exacta, así que la recursión tiene profundidad logn\log n y cada nivel fusiona nn elementos. El costo es Θ(nlogn)\Theta(n\log n) siempre: mejor, promedio y peor caso. No hay entrada que lo haga tropezar.

Antes de seguir, predecí

Quicksort con el último elemento como pivote, sobre un arreglo ya ordenado. ¿Qué pasa?

La fusión pide memoria, y a cambio es estable

La fusión necesita un arreglo auxiliar: mergesort no es in situ y usa Θ(n)\Theta(n) de memoria extra. Existen versiones in situ, pero son complicadas y más lentas en la práctica.

A cambio es estable, siempre que al empatar se tome el elemento de la mitad izquierda. Ese detalle de una línea es lo que lo hace la elección por defecto cuando el orden de los empates importa, y es el motivo por el que varios lenguajes lo usan para ordenar objetos.

Quicksort: particionar y seguir

Quicksort elige un pivote y reordena el arreglo para que a la izquierda queden los menores y a la derecha los mayores. El pivote queda en su posición definitiva y se recurre sobre cada lado; no hay paso de combinación.

Trabaja in situ, con memoria extra O(logn)O(\log n) por la pila de recursión, y sus constantes son mejores que las de mergesort porque recorre el arreglo en el lugar. En promedio es Θ(nlogn)\Theta(n\log n) y suele ser el más rápido de todos.

Elegimos el último elemento como pivote: el 4. i marca dónde termina la zona de los menores.

1 / 6
Una sola pasada por el arreglo. Al terminar, el pivote está en la posición que le corresponde en el arreglo ordenado y ya no se vuelve a tocar: por eso no hay paso de combinación.

Todo depende del pivote

Todo depende del pivote. Si parte cerca de la mitad, la profundidad es logarítmica. Si parte mal —el menor o el mayor cada vez—, un lado queda vacío, la recursión se vuelve de profundidad nn y el costo salta a Θ(n2)\Theta(n^2).

Tomar siempre el primer elemento es exactamente el peor caso con un arreglo ya ordenado, que es una entrada frecuente. Por eso se usa la mediana de tres, o un pivote aleatorio, que vuelve el peor caso improbable en vez de dependiente de la entrada.

Los repetidos y la partición en tres bloques

El otro punto delicado son los elementos repetidos. Una partición en dos bloques con muchas claves iguales manda todo a un lado y reproduce el caso cuadrático.

La solución es particionar en tres: menores, iguales y mayores, y recurrir sólo sobre los extremos. Con pocas claves distintas eso baja el costo a casi lineal. La partición también intercambia elementos a distancia, y eso es lo que le quita la estabilidad.

Las bibliotecas no usan ninguno puro

Las bibliotecas no usan ninguno puro. Introsort arranca con quicksort, corta a inserción en tramos chicos y, si la profundidad de recursión pasa un umbral, cambia a heapsort para garantizar O(nlogn)O(n\log n) en el peor caso.

Timsort, del lado de mergesort, detecta tramos ya ordenados y los fusiona: sobre datos reales —que casi nunca son aleatorios— termina bastante por debajo del límite teórico, y es estable.

La partición, que es donde está todo

Mergesort es directo: partir, ordenar cada mitad y fusionar.

const merge = (left: number[], right: number[]): number[] => {
  const result: number[] = [];
  let a = 0;
  let b = 0;

  while (a < left.length && b < right.length) {
    // <= y no <: con iguales gana el de la izquierda, y eso es la estabilidad
    if (left[a]! <= right[b]!) result.push(left[a++]!);
    else result.push(right[b++]!);
  }

  return [...result, ...left.slice(a), ...right.slice(b)];
};

Quicksort no tiene paso de combinación, y a cambio todo el peso cae en la partición:

const partition = (values: number[], low: number, high: number): number => {
  const pivot = values[high]!;
  let boundary = low; // todo lo que está antes de boundary es menor que el pivote

  for (let index = low; index < high; index += 1) {
    if (values[index]! >= pivot) continue;
    [values[boundary], values[index]] = [values[index]!, values[boundary]!];
    boundary += 1;
  }

  [values[boundary], values[high]] = [values[high]!, values[boundary]!];
  return boundary; // el pivote quedó en su posición definitiva
};

Esa última línea es la clave de por qué quicksort no necesita combinar: al terminar la partición, el pivote está exactamente donde va a estar en el arreglo ordenado, y no se lo vuelve a tocar nunca.

Cuál de los dos, y por qué las bibliotecas eligen distinto

MergesortQuicksort
Peor casoO(n log n) garantizadoO(n²) si el pivote sale mal
Caso promedioO(n log n)O(n log n), con mejores constantes
Memoria extraO(n)O(log n) de pila
Estableno
Localidad de cachébuenaexcelente: trabaja en el lugar
Paralelizablemuy bienbien
Datos que no entran en memoriasí, es el que se usano
Las dos filas de memoria explican casi todo: quicksort gana en la práctica porque no reserva nada y recorre el arreglo en el lugar, no porque haga menos operaciones.
Más a fondo · nivel seniorOrdenar lo que no entra en memoria

Con cien gigabytes de datos y dieciséis de memoria, ninguno de los dos sirve tal cual, y la respuesta es mergesort externo: se leen tramos que sí entren en memoria, se ordena cada uno y se escribe a disco, y después se fusionan todos a la vez con un heap que mantiene el mínimo actual de cada tramo.

La cuenta que importa ahí ya no es la de comparaciones sino la de pasadas por el disco. Con kk tramos y un heap, alcanza una sola pasada de fusión, y el ancho de banda del disco pasa a ser el límite. Es el mismo algoritmo con otra unidad de costo, y es lo que hace un ORDER BY sobre una tabla que no entra en memoria: si mirás el plan de ejecución de una consulta así, vas a ver justamente un ordenamiento externo con archivos temporales.

Cierre

Mergesort garantiza nlognn\log n y estabilidad a cambio de nn de memoria extra. Quicksort ordena in situ y suele ser más rápido, a cambio de un peor caso cuadrático que se controla eligiendo bien el pivote y particionando en tres. La elección es entre garantía y constante.

Autoevaluación

¿Lo entendiste?

¿Dónde pone el esfuerzo cada uno?
¿Por qué mergesort es Θ(n log n) también en el peor caso?
Tomar siempre el primer elemento como pivote. ¿Con qué entrada es el peor caso?
Un arreglo con muchas claves repetidas rompe la partición en dos bloques. ¿Cómo se arregla?