Ordenación rápida - Quicksort

Ordenación rápida
Ordenar quicksort anim.gif
Visualización animada del algoritmo de clasificación rápida. Las líneas horizontales son valores de pivote.
Clase Algoritmo de clasificación
Rendimiento en el peor de los casos
Rendimiento en el mejor de los casos (partición simple)
o ( partición de tres vías y claves iguales)
Rendimiento medio
Complejidad espacial en el peor de los casos auxiliar (ingenuo) auxiliar (Hoare 1962)

Quicksort es un algoritmo de clasificación in situ . Desarrollado por el científico informático británico Tony Hoare en 1959 y publicado en 1961, sigue siendo un algoritmo de uso común para la clasificación. Cuando se implementa bien, puede ser algo más rápido que la ordenación combinada y aproximadamente dos o tres veces más rápido que la ordenación en pila .

Quicksort es un algoritmo de divide y vencerás . Funciona seleccionando un elemento 'pivote' de la matriz y dividiendo los otros elementos en dos submatrices, según sean menores o mayores que el pivote. Por esta razón, a veces se denomina ordenación de intercambio de particiones . A continuación, las submatrices se ordenan de forma recursiva . Esto se puede hacer en el lugar , requiriendo pequeñas cantidades adicionales de memoria para realizar la clasificación.

Quicksort es un tipo de comparación , lo que significa que puede clasificar elementos de cualquier tipo para los que se define una relación "menor que" (formalmente, un orden total ). Las implementaciones eficientes de Quicksort no son una ordenación estable , lo que significa que no se conserva el orden relativo de elementos de ordenación iguales.

El análisis matemático de la ordenación rápida muestra que, en promedio , el algoritmo toma comparaciones O ( n  log  n ) para ordenar n elementos. En el peor de los casos , hace comparaciones O ( n 2 ).

Historia

El algoritmo de clasificación rápida fue desarrollado en 1959 por Tony Hoare mientras era un estudiante visitante en la Universidad Estatal de Moscú . En ese momento, Hoare estaba trabajando en un proyecto de traducción automática para el Laboratorio Nacional de Física . Como parte del proceso de traducción, necesitaba ordenar las palabras en oraciones en ruso antes de buscarlas en un diccionario ruso-inglés, que estaba en orden alfabético en cinta magnética . Después de reconocer que su primera idea, el tipo de inserción , sería lenta, se le ocurrió una nueva idea. Escribió la parte de la partición en Mercury Autocode, pero tuvo problemas para manejar la lista de segmentos sin clasificar. A su regreso a Inglaterra, se le pidió que escribiera código para Shellsort . Hoare le mencionó a su jefe que conocía un algoritmo más rápido y su jefe apostó seis peniques a que no. Su jefe finalmente aceptó que había perdido la apuesta. Más tarde, Hoare se enteró de ALGOL y su capacidad para hacer recursividad, lo que le permitió publicar el código en Communications of the Association for Computing Machinery , la principal revista de ciencias de la computación de la época.

Quicksort ganó una adopción generalizada, apareciendo, por ejemplo, en Unix como la subrutina de ordenación de bibliotecas predeterminada. Por lo tanto, prestó su nombre a la subrutina qsort de la biblioteca estándar de C y en la implementación de referencia de Java .

La tesis de doctorado de Robert Sedgewick en 1975 se considera un hito en el estudio de Quicksort, donde resolvió muchos problemas abiertos relacionados con el análisis de varios esquemas de selección de pivotes , incluido Samplesort , partición adaptativa de Van Emden, así como la derivación del número esperado de comparaciones y intercambios. Jon Bentley y Doug McIlroy incorporaron varias mejoras para su uso en bibliotecas de programación, incluida una técnica para tratar con elementos iguales y un esquema de pivote conocido como pseudomedia de nueve, donde una muestra de nueve elementos se divide en grupos de tres y luego la mediana de la se eligen tres medianas de tres grupos. Bentley describió otro esquema de partición más simple y compacto en su libro Programming Pearls que atribuyó a Nico Lomuto. Más tarde, Bentley escribió que usó la versión de Hoare durante años, pero nunca la entendió realmente, pero la versión de Lomuto era lo suficientemente simple como para demostrar que era correcta. Bentley describió Quicksort como el "código más hermoso que jamás haya escrito" en el mismo ensayo. El esquema de partición de Lomuto también fue popularizado por el libro de texto Introducción a los algoritmos, aunque es inferior al esquema de Hoare porque hace tres veces más intercambios en promedio y se degrada al tiempo de ejecución O ( n 2 ) cuando todos los elementos son iguales.

En 2009, Vladimir Yaroslavskiy propuso una nueva implementación de Quicksort utilizando dos pivotes en lugar de uno. En las listas de correo de la biblioteca principal de Java, inició una discusión afirmando que su nuevo algoritmo era superior al método de clasificación de la biblioteca en tiempo de ejecución, que en ese momento se basaba en la variante ampliamente utilizada y cuidadosamente ajustada del Quicksort clásico de Bentley y McIlroy. Quicksort de Yaroslavskiy ha sido elegido como el nuevo algoritmo de clasificación predeterminado en la biblioteca de tiempo de ejecución de Java 7 de Oracle después de extensas pruebas empíricas de rendimiento.

Algoritmo

Ejemplo completo de clasificación rápida en un conjunto aleatorio de números. El elemento sombreado es el pivote. Siempre se elige como último elemento de la partición. Sin embargo, elegir siempre el último elemento de la partición como pivote de esta manera da como resultado un rendimiento deficiente ( O ( n 2 ) ) en matrices ya ordenadas o matrices de elementos idénticos. Dado que las submatrices de elementos ordenados / idénticos surgen mucho hacia el final de un procedimiento de clasificación en un conjunto grande, las versiones del algoritmo de ordenación rápida que eligen el pivote como elemento intermedio se ejecutan mucho más rápidamente que el algoritmo descrito en este diagrama en grandes conjuntos de números.

Quicksort es un tipo de algoritmo de divide y vencerás para ordenar una matriz, basado en una rutina de partición; los detalles de esta partición pueden variar un poco, por lo que quicksort es realmente una familia de algoritmos estrechamente relacionados. Aplicado a un rango de al menos dos elementos, la partición produce una división en dos subrangos consecutivos no vacíos, de tal manera que ningún elemento del primer subrango es mayor que cualquier elemento del segundo subrango. Después de aplicar esta partición, quicksort ordena de forma recursiva los sub-rangos, posiblemente después de excluir de ellos un elemento en el punto de división que en este punto se sabe que ya se encuentra en su ubicación final. Debido a su naturaleza recursiva, la ordenación rápida (como la rutina de partición) debe formularse de manera que se pueda llamar para un rango dentro de una matriz más grande, incluso si el objetivo final es ordenar una matriz completa. Los pasos para la ordenación rápida en el lugar son:

  1. Si el rango tiene menos de dos elementos, regrese inmediatamente ya que no hay nada que hacer. Posiblemente, para otras longitudes muy cortas, se aplica un método de clasificación especial y se omiten el resto de estos pasos.
  2. De lo contrario, elija un valor, llamado pivote , que ocurra en el rango (la forma precisa de elegir depende de la rutina de partición y puede implicar aleatoriedad).
  3. Particione el rango: reordene sus elementos, mientras determina un punto de división, de modo que todos los elementos con valores menores que el pivote vengan antes de la división, mientras que todos los elementos con valores mayores que el pivote vengan después de ella; los elementos que son iguales al pivote pueden ir en cualquier dirección. Dado que al menos una instancia del pivote está presente, la mayoría de las rutinas de partición aseguran que el valor que termina en el punto de división sea igual al pivote, y ahora esté en su posición final (pero la terminación de quicksort no depende de esto, siempre que se produzcan subrangos estrictamente más pequeños que el original).
  4. Aplique de forma recursiva la clasificación rápida al subrango hasta el punto de división y al subrango después de él, posiblemente excluyendo de ambos rangos el elemento igual al pivote en el punto de división. (Si la partición produce un subrango posiblemente mayor cerca del límite donde se sabe que todos los elementos son iguales al pivote, estos también se pueden excluir).

La elección de la rutina de partición (incluida la selección de pivote) y otros detalles no especificados por completo anteriormente pueden afectar el rendimiento del algoritmo, posiblemente en gran medida para matrices de entrada específicas. Por lo tanto, al discutir la eficiencia de la clasificación rápida, es necesario especificar estas opciones primero. Aquí mencionamos dos métodos de partición específicos.

Esquema de partición Lomuto

Este esquema es atribuido a Nico Lomuto y popularizado por Bentley en su libro Programming Pearls y Cormen et al. en su libro Introducción a los algoritmos . Este esquema elige un pivote que suele ser el último elemento de la matriz. El algoritmo mantiene el índice i mientras escanea la matriz usando otro índice j, de modo que los elementos en lo a i-1 (inclusive) son menores que el pivote, y los elementos en i a j (inclusive) son iguales o mayores que el pivote. Como este esquema es más compacto y fácil de entender, se usa con frecuencia en material introductorio, aunque es menos eficiente que el esquema original de Hoare, por ejemplo, cuando todos los elementos son iguales. Este esquema se degrada a O ( n 2 ) cuando la matriz ya está en orden. Se han propuesto varias variantes para mejorar el rendimiento, incluidas varias formas de seleccionar el pivote, tratar con elementos iguales, usar otros algoritmos de clasificación, como la clasificación por inserción para arreglos pequeños, etc. En pseudocódigo , una ordenación rápida que ordena los elementos desde lo hasta hi (inclusive) de una matriz A se puede expresar como:

// Sorts a (portion of an) array, divides it into partitions, then sorts those
algorithm quicksort(A, lo, hi) is 
  // If indices are in correct order
  if lo >= 0 && hi >= 0 && lo < hi then 
    // Partition array and get pivot index
    p := partition(A, lo, hi) 
      
    // Sort the two partitions
    quicksort(A, lo, p - 1) // Left side of pivot
    quicksort(A, p + 1, hi) // Right side of pivot

// Divides array into two partitions
algorithm partition(A, lo, hi) is 
  pivot := A[hi] // The pivot must be the last element

  // Pivot index
  i := lo - 1

  for j := lo to hi do 
    // If the current element is less than or equal to the pivot
    if A[j] <= pivot then 
      // Move the pivot index forward
      i := i + 1

      // Swap the current element with the element at the pivot
      swap A[i] with A[j] 
  return i // the pivot index

La clasificación de toda la matriz se realiza mediante clasificación rápida (A, 0, longitud (A) - 1) .

Esquema de partición Hoare

Una demostración animada de Quicksort usando el esquema de partición de Hoare. Los contornos rojos muestran las posiciones de los punteros izquierdo y derecho ( iy jrespectivamente), los contornos negros muestran las posiciones de los elementos ordenados y el cuadrado negro relleno muestra el valor que se está comparando con ( pivot).

El esquema de partición original descrito por Tony Hoare usa dos punteros (índices en el rango) que comienzan en ambos extremos de la matriz que se está particionando, luego se mueven uno hacia el otro, hasta que detectan una inversión: un par de elementos, uno mayor que el límite. (Términos de Hoare para el valor de pivote) en el primer puntero, y uno menos que el límite en el segundo puntero; si en este punto el primer puntero todavía está antes del segundo, estos elementos están en el orden incorrecto entre sí y luego se intercambian. Después de esto, los punteros se mueven hacia adentro y se repite la búsqueda de una inversión; cuando finalmente los punteros se cruzan (los primeros puntos después del segundo), no se realiza ningún intercambio; se encuentra una partición válida, con el punto de división entre los punteros cruzados (cualquier entrada que pueda estar estrictamente entre los punteros cruzados es igual al pivote y se puede excluir de los dos subrangos formados). Con esta formulación es posible que un subrango resulte ser todo el rango original, lo que evitaría que el algoritmo avance. Por lo tanto, Hoare estipula que al final, el sub-rango que contiene el elemento pivote (que todavía está en su posición original) puede reducirse en tamaño excluyendo ese pivote, después (si es necesario) intercambiarlo con el elemento del sub-rango más cercano a la separación; por lo tanto, se asegura la terminación de la clasificación rápida.

Con respecto a esta descripción original, las implementaciones a menudo presentan variaciones menores pero importantes. En particular, el esquema que se presenta a continuación incluye elementos iguales al pivote entre los candidatos para una inversión (por lo que las pruebas "mayor o igual que" y "menor o igual" se utilizan en lugar de "mayor que" respectivamente "menor que"; ya que la formulación utiliza do ... while en lugar de repetir ... hasta que en realidad se refleja en el uso de operadores de comparación estrictos). Si bien no hay ninguna razón para intercambiar elementos iguales al límite, este cambio permite omitir las pruebas en los punteros, que de lo contrario son necesarios para garantizar que no se salgan del rango. De hecho, dado que al menos una instancia del valor de pivote está presente en el rango, el primer avance de cualquiera de los punteros no puede pasar a través de esta instancia si se usa una prueba inclusiva; una vez que se realiza un intercambio, estos elementos intercambiados ahora están estrictamente por delante del puntero que los encontró, evitando que ese puntero se escape. (Esto último es cierto independientemente de la prueba utilizada, por lo que sería posible utilizar la prueba inclusiva solo cuando se busque la primera inversión. Sin embargo, el uso de una prueba inclusiva en todas partes también garantiza que se encuentre una división cerca de la mitad cuando todos los elementos en los rangos son iguales, lo que proporciona una ganancia de eficiencia importante para clasificar matrices con muchos elementos iguales). El riesgo de producir una separación sin avance se evita de una manera diferente a la descrita por Hoare. Tal separación solo puede producirse cuando no se encuentran inversiones, con ambos punteros avanzando hacia el elemento pivote en la primera iteración (luego se considera que se han cruzado y no se produce ningún intercambio). La división devuelta es posterior a la posición final del segundo puntero, por lo que el caso a evitar es donde el pivote es el elemento final del rango y todos los demás son más pequeños que él. Por lo tanto, la elección del pivote debe evitar el elemento final (en la descripción de Hoare podría ser cualquier elemento del rango); esto se hace aquí redondeando hacia abajo la posición media, usando la floorfunción. Esto ilustra que el argumento a favor de la corrección de una implementación del esquema de partición de Hoare puede ser sutil y es fácil equivocarse.

En pseudocódigo ,

// Sorts a (portion of an) array, divides it into partitions, then sorts those
algorithm quicksort(A, lo, hi) is 
  if lo >= 0 && hi >= 0 && lo < hi then
    p := partition(A, lo, hi) 
    quicksort(A, lo, p) // Note: the pivot is now included
    quicksort(A, p + 1, hi) 

// Divides array into two partitions
algorithm partition(A, lo, hi) is 
  // Pivot value
  pivot := A[ floor((hi + lo) / 2) ] // The value in the middle of the array

  // Left index
  i := lo - 1 

  // Right index
  j := hi + 1

  loop forever 
    // Move the left index to the right at least once and while the element at 
    // the left index is less than the pivot 
    do i := i + 1 while A[i] < pivot 
    
    // Move the right index to the left at least once and while the element at
    // the right index is greater than the pivot 
    do j := j - 1 while A[j] > pivot 

    // If the indices crossed, return
    if i ≥ j then return j
    
    // Swap the elements at the left and right indices
    swap A[i] with A[j]

Toda la matriz se ordena por orden rápido (A, 0, longitud (A) - 1) .

El esquema de Hoare es más eficiente que el esquema de partición de Lomuto porque hace tres veces menos intercambios en promedio. Además, como se mencionó, la implementación dada crea una partición balanceada incluso cuando todos los valores son iguales, lo que no ocurre con el esquema de Lomuto. Al igual que el esquema de partición de Lomuto, la partición de Hoare también haría que Quicksort se degradara a O ( n 2 ) para la entrada ya ordenada, si el pivote se eligiera como primer o último elemento. Sin embargo, con el elemento intermedio como pivote, los datos ordenados resultan con (casi) ningún intercambio en particiones de igual tamaño, lo que conduce al mejor comportamiento de caso de Quicksort, es decir, O ( n log ( n )) . Como otros, la partición de Hoare no produce un tipo estable. En este esquema, la ubicación final del pivote no está necesariamente en el índice que se devuelve, ya que el pivote y los elementos iguales al pivote pueden terminar en cualquier lugar dentro de la partición después de un paso de partición, y no pueden ordenarse hasta el caso base de un la partición con un solo elemento se alcanza mediante recursividad. Los siguientes dos segmentos en los que se repite el algoritmo principal son (lo..p) (elementos ≤ pivote) y (p + 1..hi) (elementos ≥ pivote) en contraposición a (lo..p-1) y (p + 1..hi) como en el esquema de Lomuto.

Problemas de implementación

Elección de pivote

En las primeras versiones de quicksort, el elemento más a la izquierda de la partición a menudo se elegía como elemento pivote. Desafortunadamente, esto causa el peor de los casos en arreglos ya ordenados, que es un caso de uso bastante común. El problema se resolvió fácilmente eligiendo un índice aleatorio para el pivote, eligiendo el índice medio de la partición o (especialmente para particiones más largas) eligiendo la mediana del primer, medio y último elemento de la partición para el pivote (como recomienda Sedgewick ). Esta regla de "mediana de tres" contrarresta el caso de la entrada ordenada (o ordenada al revés) y proporciona una mejor estimación del pivote óptimo (la verdadera mediana) que la selección de un solo elemento, cuando no hay información sobre el orden de la la entrada es conocida.

Fragmento de código de mediana de tres para la partición Lomuto:

mid := ⌊(lo + hi) / 2⌋
if A[mid] < A[lo]
    swap A[lo] with A[mid]
if A[hi] < A[lo]
    swap A[lo] with A[hi]
if A[mid] < A[hi]
    swap A[mid] with A[hi]
pivot := A[hi]

Primero coloca una mediana A[hi], luego ese nuevo valor de A[hi]se usa para un pivote, como en un algoritmo básico presentado anteriormente.

Específicamente, el número esperado de comparaciones necesarias para ordenar n elementos (ver § Análisis de ordenación rápida aleatoria ) con selección de pivote aleatorio es 1.386 n log n . La pivotación mediana de tres reduce esto a C n , 2 ≈ 1.188 n log n , a expensas de un aumento del tres por ciento en el número esperado de intercambios. Una regla de pivote aún más fuerte, para matrices más grandes, es elegir el ninther , una mediana recursiva de tres (Mo3), definida como

ninther ( a ) = mediana (Mo3 (primero 1/3de a ), Mo3 (medio1/3de a ), Mo3 (final1/3de a ))

La selección de un elemento pivote también se complica por la existencia de un desbordamiento de enteros . Si los índices de límite del subarreglo que se está ordenando son lo suficientemente grandes, la expresión ingenua para el índice medio, ( lo + hi ) / 2 , provocará un desbordamiento y proporcionará un índice de pivote no válido. Esto puede superarse utilizando, por ejemplo, lo + ( hi - lo ) / 2 para indexar el elemento intermedio, a costa de una aritmética más compleja. Surgen problemas similares en algunos otros métodos de selección del elemento pivote.

Elementos repetidos

Con un algoritmo de partición como el esquema de partición de Lomuto descrito anteriormente (incluso uno que elige buenos valores de pivote), quicksort exhibe un rendimiento deficiente para las entradas que contienen muchos elementos repetidos. El problema es claramente evidente cuando todos los elementos de entrada son iguales: en cada recursión, la partición izquierda está vacía (ningún valor de entrada es menor que el pivote) y la partición derecha solo ha disminuido en un elemento (el pivote se elimina). En consecuencia, el esquema de partición de Lomuto toma un tiempo cuadrático para ordenar una matriz de valores iguales. Sin embargo, con un algoritmo de partición como el esquema de partición de Hoare, los elementos repetidos generalmente dan como resultado una mejor partición, y aunque pueden ocurrir intercambios innecesarios de elementos iguales al pivote, el tiempo de ejecución generalmente disminuye a medida que aumenta el número de elementos repetidos (con memoria caché reduciendo los gastos generales de permuta). En el caso de que todos los elementos sean iguales, el esquema de partición de Hoare intercambia elementos innecesariamente, pero la partición en sí es el mejor de los casos, como se indica en la sección de partición de Hoare anterior.

Para resolver el problema del esquema de partición de Lomuto (a veces llamado problema de la bandera nacional holandesa ), se puede usar una rutina alternativa de partición en tiempo lineal que separa los valores en tres grupos: valores menores que el pivote, valores iguales al pivote y valores mayores. que el pivote. (Bentley y McIlroy llaman a esto una "partición gruesa" y ya se implementó en el qsort de la versión 7 de Unix ). Los valores iguales al pivote ya están ordenados, por lo que solo las particiones menor que y mayor que deben ser recursivas ordenados. En pseudocódigo, el algoritmo de ordenación rápida se convierte en

algorithm quicksort(A, lo, hi) is
    if lo < hi then
        p := pivot(A, lo, hi)
        left, right := partition(A, p, lo, hi)  // note: multiple return values
        quicksort(A, lo, left - 1)
        quicksort(A, right + 1, hi)

El partitionalgoritmo devuelve índices al primer elemento ('más a la izquierda') y al último ('más a la derecha') de la partición del medio. Todos los elementos de la partición son iguales py, por lo tanto, están ordenados. En consecuencia, los elementos de la partición no necesitan incluirse en las llamadas recursivas a quicksort.

El mejor caso para el algoritmo ocurre ahora cuando todos los elementos son iguales (o se eligen de un pequeño conjunto de kn elementos). En el caso de todos los elementos iguales, la ordenación rápida modificada realizará solo dos llamadas recursivas en subarreglos vacíos y, por lo tanto, finalizará en tiempo lineal (asumiendo que la partitionsubrutina no toma más tiempo que el tiempo lineal).

Optimizaciones

Otras dos optimizaciones importantes, también sugeridas por Sedgewick y ampliamente utilizadas en la práctica, son:

  • Para asegurarse de que se usa como máximo el espacio O (log n ) , recurra primero al lado más pequeño de la partición, luego use una llamada de cola para recurrir al otro, o actualice los parámetros para que ya no incluyan el lado más pequeño ahora ordenado, y iterar para ordenar el lado más grande.
  • Cuando el número de elementos está por debajo de algún umbral (quizás diez elementos), cambie a un algoritmo de clasificación no recursivo, como el ordenamiento por inserción, que realiza menos intercambios, comparaciones u otras operaciones en matrices tan pequeñas. El 'umbral' ideal variará según los detalles de la implementación específica.
  • Una variante más antigua de la optimización anterior: cuando el número de elementos es menor que el umbral k , simplemente deténgase; luego, una vez que se haya procesado toda la matriz, realice la ordenación por inserción en ella. Detener la recursividad antes de tiempo deja la matriz k- ordenada, lo que significa que cada elemento está en la mayoría de las k posiciones de su posición final ordenada. En este caso, la ordenación por inserción tarda O ( kn ) en finalizar la ordenación, que es lineal si k es una constante. En comparación con la optimización de "muchos tipos pequeños", esta versión puede ejecutar menos instrucciones, pero hace un uso subóptimo de las memorias caché en las computadoras modernas.

Paralelización

La fórmula de dividir y conquistar de Quicksort lo hace apto para la paralelización mediante el paralelismo de tareas . El paso de partición se logra mediante el uso de un algoritmo de suma de prefijo paralelo para calcular un índice para cada elemento de la matriz en su sección de la matriz particionada. Dada una matriz de tamaño n , el paso de partición realiza O ( n ) trabajo en O (log n ) tiempo y requiere O ( n ) espacio de borrador adicional. Una vez que se ha particionado la matriz, las dos particiones se pueden ordenar de forma recursiva en paralelo. Suponiendo una elección ideal de pivotes, la ordenación rápida en paralelo ordena una matriz de tamaño n en O ( n log n ) trabajo en O (log 2 n ) tiempo usando O ( n ) espacio adicional.

La ordenación rápida tiene algunas desventajas en comparación con los algoritmos de ordenación alternativos, como la ordenación por fusión , que complican su eficiente paralelización. La profundidad del árbol de divide y vencerás de quicksort impacta directamente en la escalabilidad del algoritmo, y esta profundidad depende en gran medida de la elección de pivote del algoritmo. Además, es difícil paralelizar el paso de partición de manera eficiente en el lugar. El uso de espacio temporal simplifica el paso de partición, pero aumenta la huella de memoria del algoritmo y los gastos generales constantes.

Otros algoritmos de clasificación en paralelo más sofisticados pueden lograr límites de tiempo aún mejores. Por ejemplo, en 1991 David Powers describió una ordenación rápida en paralelo (y una ordenación de radix relacionada ) que puede operar en tiempo O (log n ) en una PRAM (máquina de acceso aleatorio en paralelo ) CRCW (lectura y escritura concurrentes ) con n procesadores por realizar particiones implícitamente.

Análisis formal

Análisis del peor de los casos

La partición más desequilibrada ocurre cuando una de las sublistas devueltas por la rutina de partición es de tamaño n - 1 . Esto puede ocurrir si el pivote es el elemento más pequeño o más grande de la lista, o en algunas implementaciones (por ejemplo, el esquema de partición de Lomuto como se describe arriba) cuando todos los elementos son iguales.

Si esto sucede repetidamente en cada partición, entonces cada llamada recursiva procesa una lista de tamaño uno menos que la lista anterior. En consecuencia, podemos hacer n - 1 llamadas anidadas antes de alcanzar una lista de tamaño 1. Esto significa que el árbol de llamadas es una cadena lineal de n - 1 llamadas anidadas. La i- ésima llamada hace que O ( n - i ) funcione para hacer la partición y , en ese caso, la ordenación rápida toma O ( n 2 ) tiempo.

Análisis del mejor de los casos

En el caso más equilibrado, cada vez que realizamos una partición, dividimos la lista en dos partes casi iguales. Esto significa que cada llamada recursiva procesa una lista de la mitad del tamaño. En consecuencia, solo podemos hacer log 2 n llamadas anidadas antes de alcanzar una lista de tamaño 1. Esto significa que la profundidad del árbol de llamadas es log 2 n . Pero no hay dos llamadas al mismo nivel del árbol de llamadas que procesen la misma parte de la lista original; por lo tanto, cada nivel de llamadas necesita solo O ( n ) tiempo en total (cada llamada tiene una sobrecarga constante, pero como solo hay O ( n ) llamadas en cada nivel, esto se incluye en el factor O ( n ) ). El resultado es que el algoritmo usa solo O ( n log n ) tiempo.

Análisis de casos promedio

Para ordenar una matriz de n elementos distintos, la ordenación rápida toma O ( n log n ) tiempo de expectativa, promediado sobre todos los n ! permutaciones de n elementos con igual probabilidad . Aquí enumeramos tres pruebas comunes de esta afirmación que brindan diferentes conocimientos sobre el funcionamiento de quicksort.

Usando percentiles

Si cada pivote tiene un rango en algún lugar en el medio del 50 por ciento, es decir, entre el percentil 25 y el percentil 75, entonces divide los elementos con al menos el 25% y como máximo el 75% en cada lado. Si pudiéramos elegir consistentemente tales pivotes, solo tendríamos que dividir la lista la mayoría de las veces antes de llegar a listas de tamaño 1, lo que produciría un algoritmo O ( n log n ) .

Cuando la entrada es una permutación aleatoria, el pivote tiene un rango aleatorio, por lo que no se garantiza que esté en el medio del 50 por ciento. Sin embargo, cuando partimos de una permutación aleatoria, en cada llamada recursiva el pivote tiene un rango aleatorio en su lista, por lo que está en el medio 50 por ciento aproximadamente la mitad del tiempo. Eso es suficientemente bueno. Imagine que se lanza una moneda: cara significa que el rango del pivote está en el medio del 50 por ciento, cola significa que no lo es. Ahora imagina que la moneda se voltea una y otra vez hasta que sale k caras. Aunque esto puede llevar mucho tiempo, en promedio solo se requieren 2 k lanzamientos, y la posibilidad de que la moneda no obtenga k caras después de 100 k lanzamientos es muy improbable (esto se puede hacer más riguroso usando los límites de Chernoff ). Con el mismo argumento, la recursividad de Quicksort terminará en promedio a una profundidad de llamada de solo . Pero si su profundidad de llamada promedio es O (log n ) , y cada nivel del árbol de llamadas procesa como máximo n elementos, la cantidad total de trabajo realizado en promedio es el producto, O ( n log n ) . El algoritmo no tiene que verificar que el pivote esté en la mitad del medio; si lo golpeamos una fracción constante de las veces, eso es suficiente para la complejidad deseada.

Usar recurrencias

Un enfoque alternativo es establecer una relación de recurrencia para el factor T ( n ) , el tiempo necesario para ordenar una lista de tamaño n . En el caso más desequilibrado, una sola llamada de ordenación rápida implica trabajo O ( n ) más dos llamadas recursivas en listas de tamaño 0 y n −1 , por lo que la relación de recurrencia es

Esta es la misma relación que para el ordenamiento por inserción y el ordenamiento por selección , y resuelve al peor de los casos T ( n ) = O ( n 2 ) .

En el caso más equilibrado, una sola llamada de ordenación rápida implica O ( n ) trabajo más dos llamadas recursivas en listas de tamaño n / 2 , por lo que la relación de recurrencia es

El teorema maestro para las recurrencias de divide y vencerás nos dice que T ( n ) = O ( n log n ) .

A continuación se muestra el esquema de una prueba formal de la complejidad de tiempo esperado O ( n log n ) . Suponga que no hay duplicados, ya que los duplicados podrían manejarse con pre y posprocesamiento de tiempo lineal, o considerar los casos más fáciles que los analizados. Cuando la entrada es una permutación aleatoria, el rango del pivote es aleatorio uniforme de 0 a n - 1 . Entonces, las partes resultantes de la partición tienen tamaños i y n - i - 1 , e i es aleatorio uniforme de 0 a n - 1 . Entonces, promediando todas las posibles divisiones y observando que el número de comparaciones para la partición es n - 1 , el número promedio de comparaciones sobre todas las permutaciones de la secuencia de entrada se puede estimar con precisión resolviendo la relación de recurrencia:

Resolver la recurrencia da C ( n ) = 2 n ln n ≈ 1.39 n log 2 n .

Esto significa que, en promedio, la clasificación rápida funciona solo un 39% peor que en el mejor de los casos. En este sentido, está más cerca del mejor de los casos que del peor de los casos. Un orden de comparación no puede usar menos de log 2 ( n !) Comparaciones en promedio para ordenar n elementos (como se explica en el artículo Orden de comparación ) y en el caso de n grande , la aproximación de Stirling produce log 2 ( n !) ≈ n (log 2 n - log 2 e ) , por lo que la clasificación rápida no es mucho peor que una clasificación de comparación ideal. Este tiempo de ejecución medio rápido es otra razón del dominio práctico de quicksort sobre otros algoritmos de clasificación.

Usando un árbol de búsqueda binario

El siguiente árbol de búsqueda binaria (BST) corresponde a cada ejecución de ordenación rápida: el pivote inicial es el nodo raíz; el pivote de la mitad izquierda es la raíz del subárbol izquierdo, el pivote de la mitad derecha es la raíz del subárbol derecho, y así sucesivamente. El número de comparaciones de la ejecución de clasificación rápida es igual al número de comparaciones durante la construcción de la BST mediante una secuencia de inserciones. Entonces, el número promedio de comparaciones para la clasificación rápida aleatoria es igual al costo promedio de construir una BST cuando los valores insertados forman una permutación aleatoria.

Considere una BST creada mediante la inserción de una secuencia de valores que forman una permutación aleatoria. Sea C el costo de creación de la BST. Tenemos , where es una variable aleatoria binaria que expresa si durante la inserción de hubo una comparación con .

Por linealidad de la expectativa , el valor esperado de C es .

Fije i y j < i . Los valores , una vez ordenados, definen j +1 intervalos. La observación estructural central es la que se compara con en el algoritmo si y solo si cae dentro de uno de los dos intervalos adyacentes a .

Observe que dado que es una permutación aleatoria, también es una permutación aleatoria, por lo que la probabilidad de que sea ​​adyacente a es exactamente .

Terminamos con un breve cálculo:

Complejidad espacial

El espacio utilizado por la clasificación rápida depende de la versión utilizada.

La versión local de quicksort tiene una complejidad de espacio de O (log n ) , incluso en el peor de los casos, cuando se implementa cuidadosamente utilizando las siguientes estrategias.

  • Se utiliza la partición in situ. Esta partición inestable requiere O (1) espacio.
  • Después de la partición, la partición con la menor cantidad de elementos se ordena (recursivamente) primero, requiriendo como máximo O (log n ) espacio. Luego, la otra partición se ordena utilizando la recursividad o la iteración de la cola , lo que no se agrega a la pila de llamadas. Esta idea, como se discutió anteriormente, fue descrita por R. Sedgewick y mantiene la profundidad de la pila limitada por O (log n ) .

La ordenación rápida con particiones in situ e inestables solo usa espacio adicional constante antes de realizar cualquier llamada recursiva. Quicksort debe almacenar una cantidad constante de información para cada llamada recursiva anidada. Dado que el mejor de los casos hace como máximo O (log n ) llamadas recursivas anidadas, utiliza el espacio O (log n ) . Sin embargo, sin el truco de Sedgewick para limitar las llamadas recursivas, en el peor de los casos, la ordenación rápida podría realizar O ( n ) llamadas recursivas anidadas y necesitar O ( n ) espacio auxiliar.

Desde el punto de vista de la complejidad de un poco, las variables como lo y hi no utilizan un espacio constante; se necesitan O (log n ) bits para indexar en una lista de n elementos. Debido a que existen tales variables en cada marco de pila, la ordenación rápida usando el truco de Sedgewick requiere O ((log n ) 2 ) bits de espacio. Sin embargo, este requisito de espacio no es demasiado terrible, ya que si la lista contuviera elementos distintos, necesitaría al menos O ( n log n ) bits de espacio.

Otra versión menos común, que no está en el lugar, de la ordenación rápida utiliza el espacio O ( n ) para el almacenamiento de trabajo y puede implementar una ordenación estable. El almacenamiento de trabajo permite que la matriz de entrada se particione fácilmente de manera estable y luego se vuelva a copiar en la matriz de entrada para sucesivas llamadas recursivas. La optimización de Sedgewick sigue siendo apropiada.

Relación con otros algoritmos

Quicksort es una versión optimizada para el espacio del tipo de árbol binario . En lugar de insertar elementos secuencialmente en un árbol explícito, quicksort los organiza simultáneamente en un árbol que está implícito en las llamadas recursivas. Los algoritmos hacen exactamente las mismas comparaciones, pero en un orden diferente. Una propiedad a menudo deseable de un algoritmo de clasificación es la estabilidad, es decir, el orden de los elementos que se comparan igual no se cambia, lo que permite controlar el orden de las tablas de varias claves (por ejemplo, directorios o listados de carpetas) de forma natural. Esta propiedad es difícil de mantener para la ordenación rápida in situ (o in situ) (que utiliza solo espacio adicional constante para punteros y búferes, y espacio adicional O (log n ) para la gestión de la recursividad explícita o implícita). Para las variantes de ordenación rápida que implican memoria adicional debido a representaciones que utilizan punteros (por ejemplo, listas o árboles) o archivos (listas de hecho), es trivial mantener la estabilidad. Las estructuras de datos más complejas, o ligadas a disco, tienden a incrementar el costo de tiempo, en general haciendo un uso cada vez mayor de la memoria virtual o el disco.

El competidor más directo de quicksort es heapsort . El tiempo de ejecución de heapsort es O ( n log n ) , pero el tiempo de ejecución promedio de heapsort generalmente se considera más lento que el quicksort en el lugar. Este resultado es discutible; algunas publicaciones indican lo contrario. Introsort es una variante de quicksort que cambia a heapsort cuando se detecta un caso incorrecto para evitar el peor tiempo de ejecución de quicksort.

Quicksort también compite con merge sort , otro algoritmo de clasificación O ( n log n ) . Mergesort es una ordenación estable , a diferencia de la ordenación rápida in situ estándar y la ordenación en pila, y tiene un excelente rendimiento en el peor de los casos. La principal desventaja de mergesort es que, cuando se opera en matrices, las implementaciones eficientes requieren espacio auxiliar O ( n ) , mientras que la variante de ordenación rápida con particiones en el lugar y recursividad de cola usa solo espacio O (log n ) .

Mergesort funciona muy bien en listas enlazadas , requiriendo solo una pequeña cantidad constante de almacenamiento auxiliar. Aunque la ordenación rápida se puede implementar como una ordenación estable usando listas enlazadas, a menudo sufrirá de malas elecciones de pivote sin acceso aleatorio. Mergesort es también el algoritmo de elección para la clasificación externa de conjuntos de datos muy grandes almacenados en medios de acceso lento, como el almacenamiento en disco o el almacenamiento conectado a la red .

La clasificación por cubos con dos cubos es muy similar a la clasificación rápida; el pivote en este caso es efectivamente el valor en el medio del rango de valores, que funciona bien en promedio para entradas distribuidas uniformemente.

Pivoteo basado en selección

Un algoritmo de selección elige el k- ésimo más pequeño de una lista de números; este es un problema más fácil en general que la clasificación. Un algoritmo de selección simple pero efectivo funciona casi de la misma manera que la ordenación rápida y, por lo tanto, se conoce como selección rápida . La diferencia es que en lugar de realizar llamadas recursivas en ambas sublistas, solo realiza una única llamada recursiva de cola en la sublista que contiene el elemento deseado. Este cambio reduce la complejidad promedio a tiempo lineal o O ( n ) , que es óptimo para la selección, pero el algoritmo de selección sigue siendo O ( n 2 ) en el peor de los casos.

Una variante de selección rápida, el algoritmo de mediana de medianas , elige pivotes con más cuidado, asegurando que los pivotes estén cerca de la mitad de los datos (entre los percentiles 30 y 70) y, por lo tanto, tiene un tiempo lineal garantizado - O ( n ) . Esta misma estrategia de pivote se puede utilizar para construir una variante de clasificación rápida (mediana de medianas clasificación rápida) con tiempo O ( n log n ) . Sin embargo, la sobrecarga de elegir el pivote es significativa, por lo que generalmente no se usa en la práctica.

De manera más abstracta, dado un algoritmo de selección O ( n ) , se puede usar para encontrar el pivote ideal (la mediana) en cada paso de clasificación rápida y así producir un algoritmo de clasificación con tiempo de ejecución O ( n log n ) . Las implementaciones prácticas de esta variante son considerablemente más lentas en promedio, pero son de interés teórico porque muestran que un algoritmo de selección óptimo puede producir un algoritmo de clasificación óptimo.

Variantes

Clasificación rápida de pivotes múltiples

En lugar de dividir en dos submatrices utilizando un único pivote, quicksort multi-pivote (también multiquicksort) particiona su entrada en algunos s número de submatrices usando s - 1 pivotes. Mientras que el caso de doble pivote ( s = 3 ) fue considerado por Sedgewick y otros ya a mediados de la década de 1970, los algoritmos resultantes no eran más rápidos en la práctica que la clasificación rápida "clásica". Una evaluación de 1999 de una ordenación rápida con un número variable de pivotes, ajustada para hacer un uso eficiente de las cachés del procesador, encontró que aumentaba el recuento de instrucciones en un 20%, pero los resultados de la simulación sugirieron que sería más eficiente en entradas muy grandes. Una versión de ordenación rápida de doble pivote desarrollada por Yaroslavskiy en 2009 resultó ser lo suficientemente rápida como para garantizar la implementación en Java 7 , como el algoritmo estándar para ordenar matrices de primitivas (la ordenación de matrices de objetos se realiza mediante Timsort ). Posteriormente, se descubrió que el beneficio de rendimiento de este algoritmo estaba relacionado principalmente con el rendimiento de la caché, y los resultados experimentales indican que la variante de tres pivotes puede funcionar incluso mejor en las máquinas modernas.

Clasificación rápida externa

Para los archivos de disco, es posible una clasificación externa basada en particiones similar a la clasificación rápida. Es más lento que la ordenación por combinación externa, pero no requiere espacio adicional en el disco. Se utilizan 4 búferes, 2 para entrada y 2 para salida. Sea N = el número de registros en el archivo, B = el número de registros por búfer y M = N / B = el número de segmentos del búfer en el archivo. Los datos se leen (y escriben) desde ambos extremos del archivo hacia adentro. Sea X los segmentos que comienzan al principio del archivo e Y los segmentos que comienzan al final del archivo. Los datos se leen en los búferes de lectura X e Y. Se elige un registro dinámico y los registros de los búferes X e Y distintos del registro dinámico se copian al búfer de escritura X en orden ascendente y al búfer de escritura Y en orden descendente en función de la comparación con el registro dinámico. Una vez que se llena el búfer X o Y, se escribe en el archivo y el siguiente búfer X o Y se lee del archivo. El proceso continúa hasta que se leen todos los segmentos y queda un búfer de escritura. Si ese búfer es un búfer de escritura X, se le agrega el registro dinámico y se escribe el búfer X. Si ese búfer es un búfer de escritura Y, el registro dinámico se antepone al búfer Y y el búfer Y se escribe. Esto constituye un paso de partición del archivo, y el archivo ahora está compuesto por dos subarchivos. Las posiciones inicial y final de cada subarchivo se empujan / abren a una pila independiente o la pila principal mediante recursividad. Para limitar el espacio de la pila a O (log2 (n)), el subarchivo más pequeño se procesa primero. Para una pila independiente, inserte los parámetros del subarchivo más grande en la pila, repita en el subarchivo más pequeño. Para la recursividad, primero recurra en el subarchivo más pequeño y luego repita para manejar el subarchivo más grande. Una vez que un subarchivo es menor o igual a 4 registros B, el subarchivo se clasifica en su lugar mediante clasificación rápida y se escribe. Ese subarchivo ahora está ordenado y en su lugar en el archivo. El proceso continúa hasta que todos los subarchivos estén ordenados y en su lugar. El número medio de pasadas en el archivo es aproximadamente 1 + ln (N + 1) / (4 B), pero el patrón del peor caso es N pasadas (equivalente a O (n ^ 2) para la clasificación interna del peor caso).

Clasificación rápida de radix de tres vías

Este algoritmo es una combinación de ordenación por base y ordenación rápida. Elija un elemento de la matriz (el pivote) y considere el primer carácter (clave) de la cadena (de varias claves). Divida los elementos restantes en tres conjuntos: aquellos cuyo carácter correspondiente sea menor, igual y mayor que el carácter del pivote. Ordene de forma recursiva las particiones "menor que" y "mayor que" en el mismo carácter. Ordene recursivamente la partición "igual a" por el siguiente carácter (clave). Dado que ordenamos usando bytes o palabras de longitud W bits, el mejor caso es O ( KN ) y el peor caso es O (2 K N ) o al menos O ( N 2 ) como para la ordenación rápida estándar, dado para claves únicas N <2 K , y K es una constante oculta en todos los algoritmos de ordenación de comparación estándar , incluida la ordenación rápida. Esta es una especie de ordenación rápida de tres vías en la que la partición del medio representa una submatriz ordenada (trivialmente) de elementos que son exactamente iguales al pivote.

Clasificación rápida por radix

También desarrollado por Powers como un algoritmo PRAM paralelo O ( K ) . Esta es nuevamente una combinación de ordenamiento por radix y ordenamiento rápido, pero la decisión de partición izquierda / derecha de ordenamiento rápido se toma en bits sucesivos de la clave y, por lo tanto, es O ( KN ) para claves de N K bits. Todos los algoritmos de ordenación por comparación asumen implícitamente el modelo transdicotómico con K en Θ (log N ) , como si K fuera más pequeño, podemos ordenar en tiempo O ( N ) usando una tabla hash o ordenación de enteros . Si K ≫ log N, pero los elementos son únicos dentro de O (log N ) bits, los bits restantes no serán examinados ni por clasificación rápida ni por clasificación rápida por base. De lo contrario, todos los algoritmos de clasificación de comparación también tendrán la misma sobrecarga de mirar a través de O ( K ) bits relativamente inútiles, pero la clasificación rápida por base evitará los comportamientos O ( N 2 ) del peor caso de clasificación rápida estándar y clasificación rápida de base, y será incluso más rápido. en el mejor de los casos de esos algoritmos de comparación bajo estas condiciones de uniqueprefix ( K ) »log N . Consulte Powers para obtener más información sobre los gastos generales ocultos en comparación, ordenación por base y paralela.

BlockQuicksort

En cualquier algoritmo de clasificación basado en comparaciones, minimizar el número de comparaciones requiere maximizar la cantidad de información obtenida de cada comparación, lo que significa que los resultados de la comparación son impredecibles. Esto provoca frecuentes errores de predicción en las ramas , lo que limita el rendimiento. BlockQuicksort reorganiza los cálculos de quicksort para convertir ramas impredecibles en dependencias de datos . Al particionar, la entrada se divide en bloques de tamaño moderado (que encajan fácilmente en la caché de datos ) y dos matrices se llenan con las posiciones de los elementos para intercambiar. (Para evitar bifurcaciones condicionales, la posición se almacena incondicionalmente al final de la matriz y el índice del final se incrementa si se necesita un intercambio). Una segunda pasada intercambia los elementos en las posiciones indicadas en las matrices. Ambos bucles tienen solo una rama condicional, una prueba de terminación, que generalmente se realiza.

Clasificación rápida parcial e incremental

Existen varias variantes de ordenación rápida que separan los k elementos más pequeños o más grandes del resto de la entrada.

Generalización

Richard Cole y David C.Kandathil, en 2004, descubrieron una familia de algoritmos de clasificación de un parámetro, denominados tipos de partición, que en promedio (con todos los ordenamientos de entrada igualmente probables) realizan en la mayoría de las comparaciones (cerca del límite inferior teórico de la información) y operaciones; en el peor de los casos, realizan comparaciones (y también operaciones); estos están en su lugar y solo requieren espacio adicional . Se demostró una eficiencia práctica y una menor variación en el rendimiento frente a las selecciones rápidas optimizadas (de Sedgewick y Bentley - McIlroy ).

Ver también

  • Introsort  : algoritmo de clasificación híbrido

Notas

Referencias

enlaces externos