Creo que hay varias preguntas enterradas en este tema:
- ¿Cómo se implementa
buildHeap
para que se ejecute en O (n) tiempo?
- ¿Cómo muestra que se
buildHeap
ejecuta en tiempo O (n) cuando se implementa correctamente?
- ¿Por qué esa misma lógica no funciona para hacer que la ordenación del montón se ejecute en tiempo O (n) en lugar de O (n log n) ?
¿Cómo se implementa buildHeap
para que se ejecute en O (n) tiempo?
A menudo, las respuestas a estas preguntas se centran en la diferencia entre siftUp
y siftDown
. Hacer la elección correcta entre siftUp
y siftDown
es fundamental para obtener el rendimiento de O (n)buildHeap
, pero no ayuda a comprender la diferencia entre buildHeap
y heapSort
en general. De hecho, las implementaciones adecuadas de ambos buildHeap
y heapSort
serán sólo se utilice siftDown
. La siftUp
operación solo es necesaria para realizar inserciones en un montón existente, por lo que se usaría para implementar una cola prioritaria utilizando un montón binario, por ejemplo.
He escrito esto para describir cómo funciona un montón máximo. Este es el tipo de almacenamiento dinámico que normalmente se utiliza para la ordenación del almacenamiento dinámico o para una cola de prioridad donde los valores más altos indican una prioridad más alta. Un montón mínimo también es útil; por ejemplo, al recuperar elementos con claves enteras en orden ascendente o cadenas en orden alfabético. Los principios son exactamente los mismos; simplemente cambie el orden de clasificación.
La propiedad de montón especifica que cada nodo en un montón binario debe ser al menos tan grande como sus dos hijos. En particular, esto implica que el elemento más grande en el montón está en la raíz. Desplazar hacia abajo y hacia arriba son esencialmente la misma operación en direcciones opuestas: mover un nodo ofensivo hasta que satisfaga la propiedad del montón:
siftDown
intercambia un nodo que es demasiado pequeño con su hijo más grande (lo que lo mueve hacia abajo) hasta que sea al menos tan grande como los dos nodos debajo de él.
siftUp
intercambia un nodo que es demasiado grande con su padre (moviéndolo hacia arriba) hasta que no sea más grande que el nodo por encima de él.
El número de operaciones requeridas siftDown
y siftUp
es proporcional a la distancia que el nodo puede tener que moverse. Para siftDown
, es la distancia a la parte inferior del árbol, por lo que siftDown
es costoso para los nodos en la parte superior del árbol. Con siftUp
, el trabajo es proporcional a la distancia hasta la parte superior del árbol, por lo que siftUp
es costoso para los nodos en la parte inferior del árbol. Aunque ambas operaciones son O (log n) en el peor de los casos, en un montón, solo un nodo está en la parte superior, mientras que la mitad de los nodos se encuentran en la capa inferior. Por lo tanto, no debería sorprendernos demasiado que si tenemos que aplicar una operación a cada nodo, preferiríamos siftDown
terminar siftUp
.
La buildHeap
función toma una matriz de elementos sin clasificar y los mueve hasta que todos satisfagan la propiedad del montón, produciendo así un montón válido. Hay dos enfoques que uno podría tomar para buildHeap
usar las operaciones siftUp
y siftDown
que hemos descrito.
Comience en la parte superior del montón (el comienzo de la matriz) y llame siftUp
a cada elemento. En cada paso, los elementos previamente seleccionados (los elementos anteriores al elemento actual en la matriz) forman un montón válido, y al seleccionar el siguiente elemento hacia arriba lo coloca en una posición válida en el montón. Después de seleccionar cada nodo, todos los elementos satisfacen la propiedad del montón.
O bien, vaya en la dirección opuesta: comience al final de la matriz y avance hacia el frente. En cada iteración, tamiza un elemento hacia abajo hasta que esté en la ubicación correcta.
¿Qué implementación buildHeap
es más eficiente?
Ambas soluciones producirán un montón válido. Como era de esperar, la más eficiente es la segunda operación que utiliza siftDown
.
Deje que h = log n represente la altura del montón. El trabajo requerido para el siftDown
enfoque viene dado por la suma
(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).
Cada término en la suma tiene la distancia máxima que un nodo a la altura dada tendrá que moverse (cero para la capa inferior, h para la raíz) multiplicado por el número de nodos a esa altura. En contraste, la suma para llamar siftUp
a cada nodo es
(h * n/2) + ((h-1) * n/4) + ((h-2)*n/8) + ... + (0 * 1).
Debe quedar claro que la segunda suma es mayor. El primer término solo es hn / 2 = 1/2 n log n , por lo que este enfoque tiene complejidad en el mejor de los casos O (n log n) .
¿Cómo demostramos que la suma del siftDown
enfoque es de hecho O (n) ?
Un método (hay otros análisis que también funcionan) es convertir la suma finita en una serie infinita y luego usar la serie Taylor. Podemos ignorar el primer término, que es cero:
Si no está seguro de por qué funciona cada uno de esos pasos, aquí hay una justificación del proceso en palabras:
- Todos los términos son positivos, por lo que la suma finita debe ser menor que la suma infinita.
- La serie es igual a una serie de potencia evaluada en x = 1/2 .
- Esa serie de potencia es igual a (un tiempo constante) la derivada de la serie de Taylor para f (x) = 1 / (1-x) .
- x = 1/2 está dentro del intervalo de convergencia de esa serie de Taylor.
- Por lo tanto, podemos reemplazar la serie de Taylor con 1 / (1-x) , diferenciar y evaluar para encontrar el valor de la serie infinita.
Dado que la suma infinita es exactamente n , concluimos que la suma finita no es mayor y, por lo tanto, es O (n) .
¿Por qué la ordenación del montón requiere tiempo O (n log n) ?
Si es posible ejecutar buildHeap
en tiempo lineal, ¿por qué la ordenación en montón requiere tiempo O (n log n) ? Bueno, la ordenación del montón consta de dos etapas. Primero, llamamos buildHeap
a la matriz, que requiere tiempo O (n) si se implementa de manera óptima. La siguiente etapa es eliminar repetidamente el elemento más grande en el montón y ponerlo al final de la matriz. Debido a que eliminamos un artículo del montón, siempre hay un espacio abierto justo después del final del montón donde podemos almacenar el artículo. Por lo tanto, la ordenación en montón logra un orden ordenado eliminando sucesivamente el siguiente elemento más grande y colocándolo en la matriz comenzando en la última posición y avanzando hacia el frente. Es la complejidad de esta última parte la que domina en el montón. El bucle se ve así:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
Claramente, el ciclo se ejecuta O (n) veces ( n - 1 para ser precisos, el último elemento ya está en su lugar). La complejidad de deleteMax
para un montón es O (log n) . Normalmente se implementa eliminando la raíz (el elemento más grande que queda en el montón) y reemplazándolo con el último elemento del montón, que es una hoja y, por lo tanto, uno de los elementos más pequeños. Esta nueva raíz seguramente violará la propiedad del montón, por lo que debe llamar siftDown
hasta que la vuelva a colocar en una posición aceptable. Esto también tiene el efecto de mover el siguiente elemento más grande hasta la raíz. Tenga en cuenta que, en contraste con buildHeap
dónde para la mayoría de los nodos que estamos llamando siftDown
desde la parte inferior del árbol, ¡ahora estamos llamando siftDown
desde la parte superior del árbol en cada iteración!Aunque el árbol se está encogiendo, no se encoge lo suficientemente rápido : la altura del árbol se mantiene constante hasta que haya eliminado la primera mitad de los nodos (cuando despeja la capa inferior por completo). Luego, para el próximo trimestre, la altura es h - 1 . Entonces, el trabajo total para esta segunda etapa es
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Observe el cambio: ahora el caso de trabajo cero corresponde a un solo nodo y el caso de trabajo h corresponde a la mitad de los nodos. Esta suma es O (n log n) al igual que la versión ineficiente buildHeap
que se implementa usando siftUp. Pero en este caso, no tenemos otra opción ya que estamos tratando de ordenar y requerimos que el siguiente elemento más grande se elimine a continuación.
En resumen, el trabajo para la ordenación del montón es la suma de las dos etapas: O (n) tiempo para buildHeap y O (n log n) para eliminar cada nodo en orden , por lo que la complejidad es O (n log n) . Puede probar (usando algunas ideas de la teoría de la información) que, para una clasificación basada en la comparación, O (n log n) es lo mejor que podría esperar de todos modos, por lo que no hay razón para decepcionarse o esperar que la clasificación en montón logre O (n) límite de tiempo que buildHeap
sí.