QuickSort en Python: implementación completa, explicación paso a paso y uso práctico
Aprende a implementar QuickSort en Python con ejemplos listos para producción, análisis de complejidad, variantes de partición, optimizaciones y buenas prácticas modernas.
Introducción
Ordenar eficientemente listas grandes es una necesidad común en ciencia de datos, backend y sistemas. QuickSort es uno de los algoritmos de ordenación más usados por su excelente rendimiento promedio O(n log n), simplicidad y eficiencia en memoria cuando se implementa in-place.
En esta guía verás:
- Cómo funciona QuickSort y cuándo usarlo
- Dos implementaciones en Python: una didáctica (no in-place) y otra optimizada in-place con particionado Hoare
- Soporte de clave de ordenación (key) y orden inverso (reverse)
- Buenas prácticas, optimizaciones, pruebas rápidas y comparativas con otros algoritmos
Planteamiento del problema
Dado un conjunto desordenado de elementos (números, cadenas u objetos), necesitamos ordenarlos:
- De forma ascendente o descendente
- Con una clave de ordenación personalizada (por ejemplo, ordenar por “edad” en una lista de diccionarios)
- Con buen rendimiento para tamaños grandes y bajo consumo de memoria
Cómo funciona QuickSort (visión de alto nivel)
- Elegir un pivote de la lista.
- Particionar los elementos en torno al pivote: menores a la izquierda, mayores a la derecha.
- Aplicar recursivamente el proceso a las sublistas izquierda y derecha.
La clave del rendimiento está en una buena elección del pivote y en un particionado eficiente. Verás ambas cosas en el código.
Implementación didáctica (no in-place)
Esta versión es ideal para aprender: es concisa y clara, pero usa memoria extra al crear listas temporales.
# quicksort_functional.py
from typing import Iterable, Callable, List, TypeVar, Optional
T = TypeVar("T")
def quicksort(
data: Iterable[T],
key: Optional[Callable[[T], object]] = None,
reverse: bool = False
) -> List[T]:
"""
Retorna una nueva lista ordenada usando QuickSort (no in-place).
- key: función para extraer la clave de comparación (opcional)
- reverse: True para orden descendente
Nota: Esta versión crea listas temporales (no apta para memoria crítica).
"""
arr = list(data)
if len(arr) <= 1:
return arr
if key is None:
key = lambda x: x # identidad
pivot = arr[len(arr) // 2]
k_pivot = key(pivot)
left = [x for x in arr if key(x) < k_pivot]
equal = [x for x in arr if key(x) == k_pivot]
right = [x for x in arr if key(x) > k_pivot]
result = quicksort(left, key=key, reverse=reverse) + equal + quicksort(right, key=key, reverse=reverse)
return result if not reverse else list(reversed(result))
if __name__ == "__main__":
nums = [8, 3, 5, 4, 7, 6, 1, 2]
print(quicksort(nums)) # ascendente
print(quicksort(nums, reverse=True)) # descendente
users = [{"name": "Ana", "age": 30}, {"name": "Luis", "age": 25}, {"name": "Sara", "age": 35}]
print(quicksort(users, key=lambda u: u["age"]))
Implementación optimizada in-place con particionado Hoare
Esta versión modifica la lista en sitio, minimizando memoria adicional y realizando menos intercambios que el esquema de Lomuto. Incluye:
- Particionado Hoare
- Mediana de tres para pivotear (reduce peores casos)
- Inserción para subarreglos pequeños (mejora cache y rendimiento práctico)
# quicksort_inplace.py
from typing import List, Callable, Optional, TypeVar
import sys
T = TypeVar("T")
def _insertion_sort(a: List[T], lo: int, hi: int, key: Callable[[T], object], reverse: bool) -> None:
# Ordena a[lo:hi+1] por inserción
for i in range(lo + 1, hi + 1):
x = a[i]
kx = key(x)
j = i - 1
if not reverse:
while j >= lo and key(a[j]) > kx:
a[j + 1] = a[j]
j -= 1
else:
while j >= lo and key(a[j]) < kx:
a[j + 1] = a[j]
j -= 1
a[j + 1] = x
def _median_of_three(a: List[T], lo: int, mid: int, hi: int, key: Callable[[T], object]) -> int:
# Retorna el índice de la mediana entre a[lo], a[mid], a[hi]
trio = [(key(a[lo]), lo), (key(a[mid]), mid), (key(a[hi]), hi)]
trio.sort(key=lambda x: x[0])
return trio[1][1]
def _hoare_partition(a: List[T], lo: int, hi: int, key: Callable[[T], object], reverse: bool) -> int:
# Elige pivote con mediana de tres para reducir peores casos
mid = (lo + hi) // 2
p_idx = _median_of_three(a, lo, mid, hi, key)
pivot_key = key(a[p_idx])
i, j = lo - 1, hi + 1
if not reverse:
while True:
i += 1
while key(a[i]) < pivot_key:
i += 1
j -= 1
while key(a[j]) > pivot_key:
j -= 1
if i >= j:
return j
a[i], a[j] = a[j], a[i]
else:
while True:
i += 1
while key(a[i]) > pivot_key:
i += 1
j -= 1
while key(a[j]) < pivot_key:
j -= 1
if i >= j:
return j
a[i], a[j] = a[j], a[i]
def quicksort_inplace(
a: List[T],
key: Optional[Callable[[T], object]] = None,
reverse: bool = False,
_lo: Optional[int] = None,
_hi: Optional[int] = None,
_depth: int = 0
) -> None:
"""
Ordena la lista 'a' en sitio usando QuickSort (Hoare + mediana de tres).
- key: función de clave (como en sorted)
- reverse: True para descendente
Nota: QuickSort no es estable. Para estabilidad, usa sorted() (Timsort).
"""
if key is None:
key = lambda x: x
lo = 0 if _lo is None else _lo
hi = len(a) - 1 if _hi is None else _hi
if lo >= hi:
return
# Umbral para usar inserción (optimización práctica)
INSERTION_THRESHOLD = 16
if hi - lo + 1 <= INSERTION_THRESHOLD:
_insertion_sort(a, lo, hi, key, reverse)
return
# Partición Hoare: divide y conquista
p = _hoare_partition(a, lo, hi, key, reverse)
# Recur en el lado más pequeño primero para limitar profundidad (optimización tail-recursion-like)
if p - lo < hi - (p + 1):
quicksort_inplace(a, key, reverse, lo, p, _depth + 1)
quicksort_inplace(a, key, reverse, p + 1, hi, _depth + 1)
else:
quicksort_inplace(a, key, reverse, p + 1, hi, _depth + 1)
quicksort_inplace(a, key, reverse, lo, p, _depth + 1)
if __name__ == "__main__":
data = [29, 10, 14, 37, 13, 5, 88, 1, 2, 0, 7]
quicksort_inplace(data)
print("Asc:", data)
quicksort_inplace(data, reverse=True)
print("Desc:", data)
# Objetos
products = [{"sku": "A1", "price": 19.9}, {"sku": "B2", "price": 5.5}, {"sku": "C3", "price": 12.0}]
quicksort_inplace(products, key=lambda p: p["price"])
print("Por precio:", products)
key
consistente. Python lanzará TypeError.
Uso práctico: ejemplos
1) Números enteros
nums = [8, 3, 5, 4, 7, 6, 1, 2]
print(quicksort(nums)) # [1, 2, 3, 4, 5, 6, 7, 8]
quicksort_inplace(nums) # in-place
print(nums) # [1, 2, 3, 4, 5, 6, 7, 8]
2) Orden descendente
data = [3.5, 2.1, 9.0, -1.0]
print(quicksort(data, reverse=True)) # [9.0, 3.5, 2.1, -1.0]
quicksort_inplace(data, reverse=True)
print(data)
3) Lista de diccionarios con key
people = [
{"name": "Ana", "age": 30},
{"name": "Luis", "age": 25},
{"name": "Sara", "age": 35},
]
# Ordenar por edad
print(quicksort(people, key=lambda p: p["age"]))
# In-place por nombre
quicksort_inplace(people, key=lambda p: p["name"])
print(people)
sorted()
o list.sort()
(Timsort de Python), que es estable y altamente optimizado en C.
Paso a paso: trazado simple
Ejemplo con [8, 3, 5, 4]
y pivote 5:
- Particionar: menores que 5 → [3, 4], iguales [5], mayores [8]
- Recur izquierda [3,4] → particiona con pivote 4: [3] + [4]
- Recur derecha [8] → ya ordenada
- Resultado final: [3, 4, 5, 8]
Rendimiento, complejidad y escalabilidad
- Promedio O(n log n)
- Peor caso O(n^2) con pivote desfavorable
- Espacio O(log n) in-place (profundidad de recursión)
- Pivote aleatorio o mediana de tres
- Cambiar a inserción para subarreglos pequeños
- Recurrir primero al subproblema más pequeño (limita profundidad)
Comparativa rápida
Algoritmo | Promedio | Peor caso | Estable | Espacio | Comentarios |
---|---|---|---|---|---|
QuickSort | O(n log n) | O(n^2) | No | O(log n) | Muy rápido en práctica, sensible al pivote |
MergeSort | O(n log n) | O(n log n) | Sí | O(n) | Rendimiento predecible, requiere memoria extra |
HeapSort | O(n log n) | O(n log n) | No | O(1) | Buen uso de memoria, constantes más altas |
Timsort (sorted de Python) | O(n log n) | O(n log n) | Sí | O(n) | Excelente en datos parcialmente ordenados; recomendado en producción |
Troubleshooting y mejores prácticas
- RecursionError en listas grandes: asegúrate de usar un buen pivote y recurrir primero en el subarreglo más pequeño. Si tu dataset puede generar profundas recursiones, considera una versión iterativa o introsort (cambiar a heapsort al exceder cierta profundidad).
-
Tipos heterogéneos: define
key
para comparar consistentemente. Evita mezclar números conNone
o tipos no comparables sin normalizar. -
Estabilidad: si necesitas mantener el orden relativo de elementos iguales, usa
sorted()
que es estable. QuickSort no lo es. -
Micro-optimizaciones: para Python puro, reducir llamadas repetidas a
key()
en bucles internos mejora el rendimiento (como se ve en el código). - Pruebas: valida con casos vacíos, repetidos, todos iguales, ya ordenados y reverso-ordenados.
Pruebas rápidas (sanity checks)
# tests_quicksort.py
import random
from quicksort_functional import quicksort
from quicksort_inplace import quicksort_inplace
def is_sorted(a, key=lambda x: x, reverse=False):
pairs = zip(a, a[1:])
if not reverse:
return all(key(x) <= key(y) for x, y in pairs)
return all(key(x) >= key(y) for x, y in pairs)
def test_random_lists():
for _ in range(50):
arr = [random.randint(-1000, 1000) for _ in range(200)]
expect = sorted(arr)
got = quicksort(arr)
assert got == expect
def test_inplace():
arr = [5, 1, 4, 2, 8, 0, 2, 2]
quicksort_inplace(arr)
assert is_sorted(arr)
def test_key_and_reverse():
items = [{"v": v} for v in [3, 1, 4, 1, 5]]
out = quicksort(items, key=lambda x: x["v"], reverse=True)
assert is_sorted(out, key=lambda x: x["v"], reverse=True)
¿Cuándo usar QuickSort y cuándo no?
- Úsalo cuando: necesitas alto rendimiento promedio, bajo uso de memoria y no requieres estabilidad.
- Evítalo cuando: la estabilidad es crítica, trabajas con datos que podrían gatillar peores casos (casi ordenados sin pivote robusto), o si ya puedes usar
sorted()
de Python que suele ser la mejor opción general.
Conclusión
QuickSort sigue siendo un pilar en algoritmos por su elegancia y rendimiento. Con las implementaciones anteriores tienes una versión didáctica para comprender la idea y una versión in-place optimizada lista para casos reales. Evalúa siempre si necesitas estabilidad o si el sorted() incorporado de Python cubre tus necesidades con mejor rendimiento práctico.
Resumen rápido
- Paradigma: divide y vencerás
- Complejidad promedio: O(n log n)
- Peor caso: O(n^2) sin buena estrategia de pivote
- Memoria: O(log n) in-place
- Estabilidad: No
QuickSort implementación