Ordenación rápida | |
---|---|
Visualización del algoritmo quicksort. Las líneas horizontales representan elementos de referencia. | |
Autor | Hoare, Charles Anthony Richard [1] |
objetivo | Algoritmo de clasificación |
peor momento | O( n 2 ) |
Mejor tiempo |
O( n log n ) (división normal) u O( n ) (tripartición) |
Tiempo promedio | O( n registro n ) |
costo de memoria |
O( n ) ayudantes O(log n ) ayudantes (Sedgwick 1978) |
Archivos multimedia en Wikimedia Commons |
La clasificación rápida, la clasificación de Hoare ( eng. quicksort ), a menudo llamada qsort (después del nombre en la biblioteca estándar de C ) es un algoritmo de clasificación desarrollado por el científico informático inglés Tony Hoare durante su trabajo en STU en 1960 .
Uno de los algoritmos de clasificación de matrices universales más rápidos conocidos: en promedio intercambios al ordenar elementos; Debido a la presencia de una serie de deficiencias en la práctica, generalmente se usa con algunas modificaciones.
QuickSort es una versión significativamente mejorada del algoritmo de clasificación de intercambio directo (cuyas variantes se conocen como " Bubble Sort " y " Shaker Sort "), que también es conocido por su baja eficiencia. La diferencia fundamental es que primero se realizan las permutaciones más grandes posibles y, después de cada pasada, los elementos se dividen en dos grupos independientes (por lo tanto, mejorar el método de clasificación directa más ineficiente resultó en uno de los métodos mejorados más eficientes).
La idea general del algoritmo es la siguiente:
En la práctica, la matriz generalmente no se divide en tres, sino en dos partes: por ejemplo, "referencia más pequeña" e "igual y mayor"; este enfoque es generalmente más eficiente, ya que simplifica el algoritmo de separación (ver más abajo).
Hoare desarrolló este método en relación con la traducción automática ; el diccionario se almacenó en cinta magnética , y la clasificación de las palabras del texto procesado hizo posible obtener sus traducciones en una sola pasada de la cinta, sin rebobinarla. El algoritmo fue inventado por Hoare durante su estancia en la Unión Soviética , donde estudió traducción informática en la Universidad de Moscú y desarrolló un libro de frases ruso-inglés.
Quicksort se refiere a los algoritmos de " divide y vencerás ".
El algoritmo consiste de tres pasos:
En la forma más general, el algoritmo de pseudocódigo (donde A es la matriz que se ordenará, y bajo y alto son los límites inferior y superior de la sección ordenada de esta matriz, respectivamente) se ve así:
algoritmo quicksort(A, bajo, alto) es si bajo <alto entonces p:= partición(A, bajo, alto) clasificación rápida (A, bajo, p) clasificación rápida (A, p + 1, alta)Aquí se supone que la matriz A se pasa por referencia, es decir, la clasificación ocurre "en el mismo lugar", y la función de partición indefinida devuelve el índice del elemento pivote.
Existen diferentes enfoques para la selección del elemento pivote y la operación de partición que afectan el rendimiento del algoritmo.
También es posible la siguiente implementación de quicksort:
el algoritmo quicksort(A) es si A está vacío, devuelve A pivot:= A.pop() (extrae el último o el primer elemento de la matriz) lA:= A.filter(donde e <= pivote) (crear matriz con elementos menores que pivote) rA := A.filter(donde e > pivote) (crear matriz con elementos mayores que pivote) return quicksort(lA) + [pivot] + quicksort(rA) (devuelve una matriz que consiste en el lado izquierdo ordenado, el pivote y el lado derecho ordenado).En la práctica, no se usa, pero sirve solo con fines educativos, ya que usa memoria adicional, que se puede evitar.
En las primeras implementaciones, por regla general, el primer elemento se elegía como referencia, lo que reducía el rendimiento en las matrices ordenadas. Para mejorar la eficiencia, se puede elegir el elemento aleatorio medio o (para arreglos grandes) la mediana de los elementos primero, medio y último. [3] La mediana de toda la secuencia es un pivote óptimo, pero su cálculo requiere demasiado tiempo para utilizarlo en la clasificación.
Seleccionando un pivote por la mediana de tres para la partición de Lomuto:
medio := (bajo + alto) / 2 si A[medio] < A[bajo] intercambiar A[bajo] con A[medio] si A[alto] < A[bajo] intercambiar A[bajo] con A[alto] si A[alto] < A[medio] intercambiar A [alto] con A [medio] pivote:= A[medio]Este algoritmo de partición fue propuesto por Nico Lomuto [4] y popularizado en los libros de Bentley (Programming Pearls) y Cormen (Introduction to Algorithms). [5] En este ejemplo, el último elemento se selecciona como pivote. El algoritmo almacena el índice en la variable i . Cada vez que se encuentra un elemento que es menor o igual que el pivote, el índice se incrementa y el elemento se inserta antes del pivote. Aunque este esquema de partición es más simple y compacto que el esquema de Hoare, es menos eficiente y se usa en tutoriales. La complejidad de este ordenamiento rápido aumenta a O ( n2 ) cuando la matriz ya está ordenada o todos sus elementos son iguales. Existen varios métodos para optimizar esta clasificación: algoritmos de selección de pivote, utilizando la clasificación por inserción en matrices pequeñas. En este ejemplo, los elementos de la matriz A se ordenan de menor a mayor (inclusive) [5] :
la partición del algoritmo (A, bajo, alto) es pivote := A[alto] yo := bajo para j := bajo a alto - 1 hacer si A[j] ≤ pivote entonces intercambiar A[i] con A[j] yo := yo + 1 intercambiar A[i] con A[high] vuelvo yoSe puede ordenar una matriz completa haciendo quicksort(A, 1, length(A)) .
Este esquema utiliza dos índices (uno al principio de la matriz, el otro al final), que se aproximan entre sí hasta que hay un par de elementos donde uno es mayor que el pivote y está ubicado antes de él, y el segundo es más pequeño y ubicado después de este. Estos elementos se intercambian. El intercambio ocurre hasta que los índices se cruzan. El algoritmo devuelve el último índice. [6] El esquema de Hoare es más eficiente que el esquema de Lomuto, ya que en promedio hay tres veces menos intercambios (swap) de elementos, y la partición es más eficiente incluso cuando todos los elementos son iguales. [7] Similar al esquema de Lomuto, este esquema también muestra la eficiencia O ( n2 ) cuando la matriz de entrada ya está ordenada. Ordenar usando este esquema es inestable. Tenga en cuenta que la posición final del elemento ancla no es necesariamente la misma que el índice devuelto. Pseudocódigo [5] :
algoritmo quicksort(A, lo, hola) es si lo <hi entonces p:= partición(A, bajo, hola) clasificación rápida (A, lo, p) clasificación rápida (A, p + 1, hola) la partición del algoritmo (A, bajo, alto) es pivote:= A[(bajo + alto) / 2] yo:= bajo j:= alto bucle para siempre mientras que A[i] < pivote yo:= yo + 1 mientras que A[j] > pivote j:= j-1 si i >= j entonces regresa j intercambia A[i++] con A[j--]El libro [5] menciona que tal implementación del algoritmo tiene "muchos puntos sutiles". Aquí hay uno: seleccionar un elemento ya existente en la matriz como elemento de referencia puede provocar un desbordamiento de la pila de llamadas debido al hecho de que el número del elemento se calcula como un promedio, que no es un número entero. Por lo tanto, es más lógico en este algoritmo elegir el valor medio entre los elementos inicial y final como elemento de referencia:
pivote := (A[bajo] + A[alto])/2
Para mejorar el rendimiento con una gran cantidad de elementos idénticos en la matriz, se puede aplicar el procedimiento para dividir la matriz en tres grupos: elementos menores que la referencia, iguales y mayores. (Bentley y McIlroy llaman a esto una "partición gruesa". Esta partición se usa en la función qsort en Unix 7 [8] ). Pseudocódigo:
algoritmo quicksort(A, bajo, alto) es si bajo <alto entonces p := pivote(A, bajo, alto) izquierda, derecha := partición(A, p, bajo, alto) // devuelve dos valores clasificación rápida (A, bajo, izquierda) clasificación rápida (A, derecha, alta)Quicksort se utiliza en el estándar de lenguaje C++, en particular en el método de clasificación de la estructura de datos de lista.
#incluir <iostream> #incluir <lista> int principal () { // ordenación rápida std :: lista < int > lista ; const int N = 20 ; para ( int i = 0 ; i < N ; i ++ ) { si ( yo % 2 == 0 ) lista _ push_back ( N - i ); más lista _ empujar_frente ( N - i ); } for ( std :: list < int >:: iterator it = list . begin (); it != list . end (); it ++ ) { std :: cout << ( * it ) << " " ; } std :: cout << std :: endl ; lista _ ordenar (); for ( std :: list < int >:: iterator it = list . begin (); it != list . end (); it ++ ) { std :: cout << ( * it ) << " " ; } // fin de clasificación rápida std :: cout << std :: endl ; // ordenar mayor lista _ ordenar ( std :: mayor < int > ()); for ( std :: list < int >:: iterator it = list . begin (); it != list . end (); it ++ ) { std :: cout << ( * it ) << " " ; } std :: cout << std :: endl ; // ordena el final mayor estándar :: cin . ignorar (); }Está claro que la operación de dividir un arreglo en dos partes con respecto al elemento de referencia lleva su tiempo . Dado que todas las operaciones de partición realizadas a la misma profundidad de recursión procesan diferentes partes de la matriz original, cuyo tamaño es constante, en total, también se requerirán operaciones en cada nivel de recursión. Por lo tanto, la complejidad general del algoritmo está determinada únicamente por el número de divisiones, es decir, la profundidad de la recursividad. La profundidad de la recursividad, a su vez, depende de la combinación de datos de entrada y de cómo se determina el pivote.
Mejor caso. En la versión más equilibrada, en cada operación de división, la matriz se divide en dos partes idénticas (más o menos un elemento), por lo tanto, la profundidad de recursión máxima a la que los tamaños de las subarreglas procesadas alcanzan 1 será . Como resultado, el número de comparaciones realizadas por quicksort sería igual al valor de la expresión recursiva , que da la complejidad general del algoritmo . Promedio. La complejidad promedio con una distribución aleatoria de datos de entrada solo puede estimarse probabilísticamente. En primer lugar, debe tenerse en cuenta que en realidad no es necesario que el elemento de pivote divida la matriz en dos partes idénticas cada vez. Por ejemplo, si en cada etapa habrá una división en arreglos con una longitud de 75% y 25% del original, la profundidad de recursión será igual a , y esto aún da complejidad . En general, para cualquier relación fija entre las partes izquierda y derecha de la división, la complejidad del algoritmo será la misma, solo que con diferentes constantes. Consideraremos una división “exitosa” tal que el elemento de referencia estará entre el 50% central de los elementos de la parte compartida del arreglo; claramente, la probabilidad de suerte con una distribución aleatoria de elementos es 0.5. Con una separación exitosa, los tamaños de los subarreglos seleccionados serán al menos el 25 % y no más del 75 % del original. Dado que cada subarreglo seleccionado también tendrá una distribución aleatoria, todas estas consideraciones se aplican a cualquier etapa de clasificación y cualquier fragmento de arreglo inicial. Una división exitosa da una profundidad de recursión de no más de . Dado que la probabilidad de suerte es 0,5, se necesitarán llamadas recursivas en promedio para obtener el elemento pivote k veces en el centro del 50 % de la matriz para obtener divisiones afortunadas. Aplicando estas consideraciones, podemos concluir que en promedio la profundidad de recursión no excederá , que es igual a A ya que no se siguen realizando más operaciones en cada nivel de recursión , la complejidad promedio será . Peor de los casos. En la versión más desequilibrada, cada división produce dos subarreglos de tamaños 1 y , lo que significa que con cada llamada recursiva, el arreglo más grande será 1 más corto que la vez anterior. Esto puede suceder si se selecciona el más pequeño o el más grande de todos los elementos procesados como elemento de referencia en cada etapa. Con la elección más simple de un elemento de referencia, el primero o el último en la matriz, dicho efecto será dado por una matriz ya ordenada (en orden directo o inverso), para el medio o cualquier otro elemento fijo, la "matriz del peor caso". ” también se puede seleccionar especialmente. En este caso , se requerirán operaciones de división y el tiempo total de ejecución serán operaciones, es decir, la clasificación se realizará en tiempo cuadrático. Pero la cantidad de intercambios y, en consecuencia, el tiempo de funcionamiento no es su mayor inconveniente. Peor aún, en este caso, la profundidad de recursión durante la ejecución del algoritmo alcanzará n, lo que significará un ahorro de n veces de la dirección de retorno y las variables locales del procedimiento de partición de la matriz. Para valores grandes de n, el peor de los casos puede provocar el agotamiento de la memoria ( desbordamiento de pila ) mientras se ejecuta el programa.ventajas:
Defectos:
Las mejoras del algoritmo están destinadas principalmente a eliminar o mitigar las desventajas anteriores, por lo que todas ellas se pueden dividir en tres grupos: hacer que el algoritmo sea estable, eliminar la degradación del rendimiento mediante una elección especial del elemento pivote y proteger contra la pila de llamadas. desbordamiento debido a la gran profundidad de recursión en caso de datos de entrada fallidos.
Algoritmos de clasificación | |
---|---|
Teoría | Complejidad O notación relación de orden Ordenar tipos sostenible Interno Externo |
Intercambio | |
Elección | |
inserciones | |
fusión | |
sin comparaciones | |
híbrido | |
Otro | |
poco práctico |