En informática , el heapsort es un algoritmo de ordenamiento basado en comparación que puede considerarse como "una implementación del ordenamiento por selección utilizando la estructura de datos correcta ". [3] Al igual que el ordenamiento por selección, el heapsort divide su entrada en una región ordenada y otra sin ordenar, y reduce iterativamente la región sin ordenar extrayendo el elemento más grande de ella e insertándolo en la región ordenada. A diferencia del ordenamiento por selección, el heapsort no pierde tiempo con un escaneo de tiempo lineal de la región sin ordenar; en cambio, el heapsort mantiene la región sin ordenar en una estructura de datos de montón para encontrar de manera eficiente el elemento más grande en cada paso.
Aunque en la práctica es un poco más lento en la mayoría de las máquinas que un quicksort bien implementado , tiene las ventajas de una implementación muy simple y un tiempo de ejecución O ( n log n ) más favorable en el peor de los casos . La mayoría de las variantes de quicksort del mundo real incluyen una implementación de heapsort como respaldo en caso de que detecten que quicksort se está degenerando. Heapsort es un algoritmo in situ , pero no es un ordenamiento estable .
JWJ Williams inventó el algoritmo Heapsort en 1964. [4] El artículo también presentó el montón binario como una estructura de datos útil por derecho propio. [5] Ese mismo año, Robert W. Floyd publicó una versión mejorada que podía ordenar una matriz en el lugar, continuando su investigación anterior sobre el algoritmo treesort . [5]
El algoritmo de ordenación del montón se puede dividir en dos fases: construcción del montón y extracción del montón.
El montón es una estructura de datos implícita que no ocupa espacio más allá de la matriz de objetos que se van a ordenar; la matriz se interpreta como un árbol binario completo donde cada elemento de la matriz es un nodo y los vínculos padre e hijo de cada nodo se definen mediante aritmética simple en los índices de la matriz. Para una matriz basada en cero, el nodo raíz se almacena en el índice 0 y los nodos vinculados al nodo i
son
iHijoIzquierdo(i) = 2⋅i + 1 iHijoDerecho(i) = 2⋅i + 2 iParent(i) = piso((i−1) / 2)
donde la función floor redondea hacia abajo hasta el entero anterior. Para una explicación más detallada, consulte Heap binario § Implementación de heap .
Este árbol binario es un montón máximo cuando cada nodo es mayor o igual que sus dos hijos. De manera equivalente, cada nodo es menor o igual que su padre. Esta regla, aplicada en todo el árbol, da como resultado que el nodo máximo se ubique en la raíz del árbol.
En la primera fase, se construye un montón a partir de los datos (ver Montón binario § Construcción de un montón ).
En la segunda fase, el montón se convierte en una matriz ordenada eliminando repetidamente el elemento más grande del montón (la raíz del montón) y colocándolo al final de la matriz. El montón se actualiza después de cada eliminación para mantener la propiedad del montón. Una vez que se han eliminado todos los objetos del montón, el resultado es una matriz ordenada.
La ordenación por montículos se realiza normalmente en el lugar. Durante la primera fase, la matriz se divide en un prefijo sin ordenar y un sufijo ordenado por montículos (inicialmente vacío). Cada paso reduce el prefijo y expande el sufijo. Cuando el prefijo está vacío, esta fase está completa. Durante la segunda fase, la matriz se divide en un prefijo ordenado por montículos y un sufijo ordenado (inicialmente vacío). Cada paso reduce el prefijo y expande el sufijo. Cuando el prefijo está vacío, la matriz se ordena.
El algoritmo de ordenación por montículos comienza reorganizando la matriz en un montón binario de máximo orden. Luego, el algoritmo intercambia repetidamente la raíz del montón (el elemento más grande que queda en el montón) con su último elemento, que luego se declara como parte del sufijo ordenado. Luego, el montón, que se dañó al reemplazar la raíz, se repara de modo que el elemento más grande vuelva a estar en la raíz. Esto se repite hasta que solo quede un valor en el montón.
Los pasos son:
heapify()
función en la matriz. Esto crea un montón a partir de una matriz en O ( n ) operaciones.siftDown()
función en la matriz para mover el nuevo primer elemento a su lugar correcto en el montón.La heapify()
operación se ejecuta una vez y tiene un rendimiento de O ( n ) . La siftDown()
función se llama n veces y requiere O (log n ) de trabajo cada vez, debido a que su recorrido comienza desde el nodo raíz. Por lo tanto, el rendimiento de este algoritmo es O ( n + n log n ) = O ( n log n ) .
El núcleo del algoritmo es la siftDown()
función. Esta construye montones binarios a partir de montones más pequeños y se puede considerar de dos maneras equivalentes:
Para establecer la propiedad max-heap en la raíz, se deben comparar hasta tres nodos (la raíz y sus dos hijos), y el mayor debe convertirse en la raíz. Esto se hace más fácilmente encontrando el hijo mayor y luego comparándolo con la raíz. Hay tres casos:
siftDown()
operación en el subárbol con raíz en el ex-raíz recién degradado.El número de iteraciones en cualquier siftdown()
llamada está limitado por la altura del árbol, que es ⌊ log 2 n ⌋ = O (log n ) .
La siguiente es una forma sencilla de implementar el algoritmo en pseudocódigo . Las matrices se basan en cero y swap
se utilizan para intercambiar dos elementos de la matriz. El movimiento "hacia abajo" significa desde la raíz hacia las hojas, o desde índices inferiores a superiores. Tenga en cuenta que durante la ordenación, el elemento más grande está en la raíz del montón en a[0]
, mientras que al final de la ordenación, el elemento más grande está en a[end]
.
El procedimiento heapsort(a, count) se ingresa como entrada: una matriz desordenada a de longitud count (Construya el montón en la matriz a de modo que el valor más grande esté en la raíz) heapify(a, contar) (El siguiente bucle mantiene las invariantes de que a[0:end−1] es un montón, y cada elemento a[end:count−1] más allá de end es mayor que todo lo anterior, es decir, a[end:count−1] está en orden ordenado). fin ← contar mientras que end > 1 do (el tamaño del montón se reduce en uno) fin ← fin − 1 (a[0] es la raíz y el valor más grande. El intercambio lo mueve al frente de los elementos ordenados). intercambiar(a[fin], a[0]) (el intercambio arruinó la propiedad del montón, así que restáurelo) tamizar(a, 0, fin)
La rutina de ordenamiento utiliza dos subrutinas, heapify
y siftDown
. La primera es la rutina común de construcción de montón en el lugar, mientras que la última es una subrutina común para implementar heapify
.
(Coloca los elementos de 'a' en orden de montón, en su lugar) procedimiento heapify(a, count) es (start se inicializa en el primer nodo de hoja) (el último elemento en una matriz basada en 0 está en el índice count-1; encuentra el padre de ese elemento) inicio ← iParent(count-1) + 1 mientras start > 0 do (ir al último nodo que no pertenece al montón) inicio ← inicio − 1 (clasificar el nodo en el índice 'inicio' hasta el lugar apropiado de modo que todos los nodos debajo del índice de inicio estén en orden de montón) siftDown(a, inicio, recuento) (después de filtrar la raíz, todos los nodos/elementos están en orden de montón) (Reparar el montón cuyo elemento raíz está en el índice 'inicio', asumiendo que los montones enraizados en sus hijos son válidos) procedimiento siftDown(a, root, end) es while iLeftChild(root) < end do (Mientras la raíz tenga al menos un hijo) child ← iLeftChild(root) (Hijo izquierdo de la raíz) (Si hay un hijo derecho y ese hijo es mayor) if child+1 < end and a[child] < a[child+1] then niño ← niño + 1 si a[raíz] < a[hijo] entonces swap(a[raíz], a[hijo]) raíz ← hijo (repetir para continuar seleccionando el hijo ahora) de lo contrario (la raíz contiene el elemento más grande. Dado que podemos asumir que los montones enraizados en los hijos son válidos, esto significa que hemos terminado) .
El heapify
procedimiento funciona creando pequeños montones y fusionándolos repetidamente usando siftDown
. Comienza con las hojas, observando que son montones triviales pero válidos por sí mismos, y luego agrega los padres. Comenzando con el elemento n /2 y trabajando hacia atrás, cada nodo interno se convierte en la raíz de un montón válido mediante el tamizado descendente. El último paso es tamizar el primer elemento, después de lo cual toda la matriz obedece la propiedad del montón.
Para ver que esto lleva un tiempo de O ( n ) , cuente el número de iteraciones en el peor de los casos siftDown
. La última mitad de la matriz requiere cero iteraciones, el cuarto anterior requiere como máximo una iteración, la octava anterior requiere como máximo dos iteraciones, la decimosexta anterior requiere como máximo tres, y así sucesivamente.
Visto de otra manera, si asumimos que cada siftDown
llamada requiere el número máximo de iteraciones, la primera mitad de la matriz requiere una iteración, el primer cuarto requiere una más (total 2), el primer octavo requiere otra más (total 3), y así sucesivamente.
Esto da como resultado n /2 + n /4 + n /8 + ⋯ = n⋅(1/2 + 1/4 + 1/8 + ⋯) , donde la suma infinita es una serie geométrica bien conocida cuya suma es 1 , por lo tanto el producto es simplemente n .
Lo anterior es una aproximación. Se sabe que el número exacto de comparaciones en el peor de los casos durante la fase de construcción del montón de heapsort es igual a 2 n − 2 s 2 ( n ) − e 2 ( n ) , donde s 2 ( n ) es el número de bits 1 en la representación binaria de n y e 2 ( n ) es el número de bits 0 finales . [6] [7]
Aunque es conveniente pensar en las dos fases por separado, la mayoría de las implementaciones combinan las dos, lo que permite que una sola instancia de siftDown
se expanda en línea . [8] : Algoritmo H Dos variables (aquí, start
y end
) mantienen un registro de los límites del área del montón. La parte de la matriz anterior start
no está ordenada, mientras que la parte que comienza en end
está ordenada. La construcción del montón disminuye start
hasta que es cero, después de lo cual la extracción del montón disminuye end
hasta que es 1 y la matriz está completamente ordenada.
El procedimiento heapsort(a, count) se ingresa como entrada: una matriz desordenada a de longitud count inicio ← piso(cuenta/2) fin ← contar mientras fin > 1 hacer si inicio > 0 entonces (construcción del montón) inicio ← inicio − 1 De lo contrario (Extracción del montón) fin ← fin − 1 intercambiar(a[fin], a[0]) (Lo siguiente es siftDown(a, inicio, fin)) raíz ← inicio mientras iLeftChild(root) < fin hacer niño ← iLeftChild(raíz) (Si hay un hijo correcto y ese hijo es mayor) si hijo+1 < fin y a[hijo] < a[hijo+1] entonces niño ← niño + 1 si a[raíz] < a[hijo] entonces swap(a[raíz], a[hijo]) raíz ← hijo (repetir para continuar filtrando al hijo ahora) de lo contrario romper (regresar al bucle externo)
La descripción anterior utiliza el algoritmo de construcción de montón mejorado de Floyd, que opera en tiempo O ( n ) y utiliza la misma siftDown
primitiva que la fase de extracción de montón. Aunque este algoritmo, al ser más rápido y más simple de programar, se utiliza en todas las implementaciones prácticas de heapsort, el algoritmo original de Williams puede ser más fácil de entender y es necesario para implementar una cola de prioridad de montón binario más general .
En lugar de fusionar muchos montones pequeños, el algoritmo de Williams mantiene un único montón al principio de la matriz y añade repetidamente un elemento adicional utilizando un siftUp
primitivo. Al estar al final de la matriz, el nuevo elemento es una hoja y no tiene hijos de los que preocuparse, pero puede violar la propiedad del montón al ser mayor que su padre. En este caso, intercámbielo con su padre y repita la prueba hasta que el padre sea mayor o no haya padre (hemos llegado a la raíz). En pseudocódigo, esto es:
procedimiento siftUp(a, end) es la entrada: a es la matriz, que se ordena en montón hasta end-1. end es el nodo que se va a cribar. mientras end > 0 padre := iParent(fin) si a[padre] < a[fin] entonces (fuera del orden de montón máximo) swap(a[padre], a[fin]) fin := padre (continuar seleccionando) de lo contrario regresar procedimiento heapify(a, count) es (comienza con un montón trivial de un solo elemento) fin := 1 mientras que fin < recuento (seleccione el nodo en el índice fin hasta el lugar apropiado de modo que todos los nodos por encima del índice fin estén en orden de montón) tamizar(a, fin) fin := fin + 1 (después de seleccionar el último nodo, todos los nodos están en orden de montón)
Para entender por qué este algoritmo puede tardar asintóticamente más tiempo en construir un montón ( O ( n log n ) frente a O ( n ) en el peor de los casos), tenga en cuenta que en el algoritmo de Floyd, casi todas las llamadas a siftDown
operaciones se aplican a montones muy pequeños . La mitad de los montones son montones triviales de altura 1 y se pueden omitir por completo, la mitad del resto son de altura 2, y así sucesivamente. Solo dos llamadas son en montones de tamaño n /2 , y solo siftDown
se realiza una operación en el montón completo de n elementos. La operación promedio general siftDown
toma O (1) tiempo.
Por el contrario, en el algoritmo de Williams la mayoría de las llamadas a siftUp
se realizan en grandes montones de altura O (log n ) . La mitad de las llamadas se realizan con un tamaño de montón de n /2 o más, tres cuartas partes se realizan con un tamaño de montón de n /4 o más, y así sucesivamente. Aunque el número promedio de pasos es similar a la técnica de Floyd, [9] : 3 entradas preordenadas causarán el peor de los casos: cada nodo agregado se tamiza hasta la raíz, por lo que la llamada promedio a siftUp
requerirá aproximadamente (log 2 n − 1)/2 + (log 2 n − 2)/4 + (log 2 n − 3)/8 + ⋯ = log 2 n − (1 + 1/2 + 1/4 + ⋯) = log 2 n − 2 iteraciones.
Debido a que está dominado por la segunda fase de extracción del montón, el algoritmo heapsort en sí tiene una complejidad de tiempo O ( n log n ) utilizando cualquiera de las versiones de heapify.
El método bottom-up heapsort es una variante que reduce el número de comparaciones requeridas en un factor significativo. Mientras que el método "top-down" heapsort común requiere 2 n log 2 n + O ( n ) comparaciones en el peor de los casos y en promedio, [10] la variante bottom-up requiere n log 2 n + O (1) comparaciones en promedio, [10] y 1,5 n log 2 n + O ( n ) en el peor de los casos. [11]
Si las comparaciones son baratas (por ejemplo, claves enteras), entonces la diferencia no es importante, [12] ya que la ordenación descendente compara valores que ya se han cargado desde la memoria. Sin embargo, si las comparaciones requieren una llamada a una función u otra lógica compleja, entonces la ordenación ascendente resulta ventajosa.
Esto se logra mediante un siftDown
procedimiento más elaborado. El cambio mejora ligeramente la fase de construcción del montón en tiempo lineal, [13] pero es más significativo en la segunda fase. Al igual que en el heapsort de arriba hacia abajo, cada iteración de la segunda fase extrae la parte superior del montón, a[0]
y llena el espacio que deja con a[end]
, luego tamiza este último elemento hacia abajo en el montón. Pero este elemento vino del nivel más bajo del montón, lo que significa que es uno de los elementos más pequeños del montón, por lo que el tamizado probablemente tomará muchos pasos para moverlo hacia abajo. [14] En el heapsort de arriba hacia abajo, cada paso de siftDown
requiere dos comparaciones, para encontrar el mínimo de tres elementos: el nuevo nodo y sus dos hijos.
El método bottom-up heapsort reemplaza conceptualmente la raíz con un valor de −∞ y lo filtra hacia abajo usando solo una comparación por nivel (ya que ningún hijo puede ser menor que −∞) hasta que se alcanzan las hojas, luego reemplaza el −∞ con el valor correcto y lo filtra hacia arriba (nuevamente, usando una comparación por nivel) hasta que se encuentra la posición correcta.
Esto coloca la raíz en la misma ubicación que la técnica de arriba hacia abajo siftDown
, pero se requieren menos comparaciones para encontrar esa ubicación. Para cualquier siftDown
operación individual, la técnica de abajo hacia arriba es ventajosa si el número de movimientos hacia abajo es al menos 2 ⁄ 3 de la altura del árbol (cuando el número de comparaciones es 4 ⁄ 3 veces la altura para ambas técnicas), y resulta que esto es más que cierto en promedio, incluso para las entradas del peor caso. [11]
Una implementación ingenua de este algoritmo conceptual provocaría una copia redundante de datos, ya que la parte de cribado hacia arriba deshace parte del cribado hacia abajo. Una implementación práctica busca hacia abajo una hoja donde se colocaría −∞, luego hacia arriba donde debería colocarse la raíz. Finalmente, el recorrido hacia arriba continúa hasta la posición inicial de la raíz, sin realizar más comparaciones, sino intercambiando nodos para completar la reorganización necesaria. Esta forma optimizada realiza la misma cantidad de intercambios que la de arriba hacia abajo siftDown
.
Debido a que llega hasta el fondo y luego vuelve a subir, algunos autores lo denominan "heapsort" con rebote . [15]
La función leafSearch(a, i, fin) es j ← yo mientras iRightChild(j) < end do (Determinar cuál de los dos hijos de j es el mayor) si a[iRightChild(j)] > a[iLeftChild(j)] entonces j ← iHijoDerecho(j) demás j ← iHijoIzquierdo(j) (En el último nivel, puede haber solo un hijo) si iLeftChild(j) < end entonces j ← iHijoIzquierdo(j) volver j
El valor de retorno de se utiliza en la rutina leafSearch
modificada : [11]siftDown
El procedimiento siftDown(a, i, fin) es j ← leafSearch(a, i, fin) mientras que a[i] > a[j] lo hace j ← iPadre(j) mientras j > yo hago intercambiar(a[i], a[j]) j ← iPadre(j)
Se anunció que el ordenamiento por montículos de abajo hacia arriba superaba al ordenamiento rápido (con selección de pivote de mediana de tres) en matrices de tamaño ≥16000. [10]
Una reevaluación de este algoritmo en 2008 demostró que no es más rápido que el heapsort de arriba hacia abajo para claves enteras, presumiblemente porque la predicción de bifurcaciones moderna anula el costo de las comparaciones predecibles que el heapsort de abajo hacia arriba logra evitar. [12]
Un refinamiento adicional realiza una búsqueda binaria en la búsqueda ascendente y ordena en el peor caso de ( n +1)(log 2 ( n +1) + log 2 log 2 ( n +1) + 1.82) + O (log 2 n ) comparaciones, acercándose al límite inferior de la teoría de la información de n log 2 n − 1.4427 n comparaciones. [16]
Una variante que utiliza dos bits adicionales por nodo interno ( n −1 bits en total para un montón de n elementos) para almacenar en caché información sobre qué hijo es mayor (se requieren dos bits para almacenar tres casos: izquierdo, derecho y desconocido) [13] utiliza menos de n log 2 n + 1,1 n comparaciones. [17]
Heapsort compite principalmente con quicksort , otro algoritmo de ordenamiento basado en comparación inestable, de uso general y muy eficiente.
Las principales ventajas de Heapsort son su código simple y no recursivo , el requisito mínimo de almacenamiento auxiliar y un rendimiento confiablemente bueno: sus mejores y peores casos están dentro de un pequeño factor constante entre sí y del límite inferior teórico de los ordenamientos por comparación . Si bien no puede hacerlo mejor que O ( n log n ) para entradas preordenadas, tampoco sufre del peor caso de quicksort O ( n 2 ) .
Las implementaciones de quicksort en el mundo real utilizan una variedad de heurísticas para evitar el peor de los casos, pero eso hace que su implementación sea mucho más compleja, y las implementaciones como introsort y quicksort que anula patrones [29] utilizan heapsort como último recurso si detectan un comportamiento degenerado. Por lo tanto, su rendimiento en el peor de los casos es ligeramente peor que si se hubiera utilizado heapsort desde el principio.
Las principales desventajas de Heapsort son su pobre localidad de referencia y su naturaleza inherentemente serial; los accesos al árbol implícito están muy dispersos y son mayormente aleatorios, y no hay una manera sencilla de convertirlo en un algoritmo paralelo .
Las garantías de rendimiento en el peor de los casos hacen que heapsort sea popular en la computación en tiempo real y en sistemas relacionados con entradas elegidas maliciosamente [30] como el kernel de Linux. [31] La combinación de una implementación pequeña y un rendimiento confiablemente "suficientemente bueno" lo hacen popular en sistemas integrados y, en general, en cualquier aplicación donde la clasificación no sea un cuello de botella en el rendimiento . Por ejemplo, heapsort es ideal para ordenar una lista de nombres de archivos para su visualización, pero un sistema de administración de bases de datos probablemente querría un algoritmo de clasificación optimizado de manera más agresiva.
Un quicksort bien implementado es generalmente 2-3 veces más rápido que el heapsort. [19] [32] Aunque el quicksort requiere menos comparaciones, este es un factor menor. (Los resultados que afirman el doble de comparaciones miden la versión de arriba hacia abajo; consulte § Bottom-up heapsort.) La principal ventaja del quicksort es su localidad de referencia mucho mejor: la partición es un escaneo lineal con buena localidad espacial, y la subdivisión recursiva tiene buena localidad temporal. Con un esfuerzo adicional, el quicksort también se puede implementar en código mayoritariamente libre de ramificaciones , [33] [34] y se pueden usar múltiples CPU para ordenar subparticiones en paralelo. Por lo tanto, se prefiere el quicksort cuando el rendimiento adicional justifica el esfuerzo de implementación.
El otro algoritmo de ordenamiento O ( n log n ) principal es el ordenamiento por combinación , pero rara vez compite directamente con el ordenamiento por montículos porque no se realiza en el lugar. El requisito del ordenamiento por combinación de Ω( n ) de espacio adicional (aproximadamente la mitad del tamaño de la entrada) suele ser prohibitivo, excepto en las situaciones en las que el ordenamiento por combinación tiene una clara ventaja:
Los ejemplos ordenan los valores {6, 5, 3, 1, 8, 7, 2, 4} en orden creciente utilizando ambos algoritmos de construcción de montón. Los elementos que se comparan se muestran en negrita . Normalmente hay dos cuando se realiza un cribado ascendente y tres cuando se realiza un cribado descendente, aunque puede haber menos cuando se alcanza la parte superior o inferior del árbol.
normalmente se le da a este algoritmo, heapsort , oculta el hecho de que el algoritmo no es nada más que una implementación de ordenamiento por selección que utiliza la estructura de datos correcta.
llamamos a este programa mejorado 'heapsort con rebote ' .
Escriba una rutina de clasificación similar a heapsort excepto que utiliza un montón ternario.