Penso che ci siano diverse domande sepolte in questo argomento:
- Come si implementa in
buildHeap
modo che funzioni in tempo O (n) ?
- Come si mostra che
buildHeap
viene eseguito in tempo O (n) se implementato correttamente?
- Perché la stessa logica non funziona per far funzionare l'heap sort in O (n) time anziché O (n log n) ?
Come si implementa in buildHeap
modo che funzioni in tempo O (n) ?
Spesso, le risposte a queste domande si concentrano sulla differenza tra siftUp
e siftDown
. Fare la scelta corretta tra siftUp
ed siftDown
è fondamentale per ottenere prestazioni O (n)buildHeap
, ma non fa nulla per aiutare a capire la differenza tra buildHeap
e heapSort
in generale. Infatti, adeguate implementazioni di entrambi buildHeap
e heapSort
saranno solo utilizzare siftDown
. L' siftUp
operazione è necessaria solo per eseguire inserimenti in un heap esistente, quindi sarebbe utilizzata per implementare una coda di priorità utilizzando un heap binario, ad esempio.
Ho scritto questo per descrivere come funziona un heap max. Questo è il tipo di heap generalmente utilizzato per l'heap ordinamento o per una coda di priorità in cui valori più alti indicano una priorità più alta. È utile anche un heap minimo; ad esempio, quando si recuperano elementi con chiavi intere in ordine crescente o stringhe in ordine alfabetico. I principi sono esattamente gli stessi; cambia semplicemente l'ordinamento.
La proprietà heap specifica che ciascun nodo in un heap binario deve essere almeno grande quanto entrambi i suoi figli. In particolare, ciò implica che l'elemento più grande nell'heap è alla radice. Setacciare verso il basso e setacciare verso l'alto sono essenzialmente la stessa operazione in direzioni opposte: spostare un nodo offensivo fino a quando non soddisfa la proprietà heap:
siftDown
scambia un nodo che è troppo piccolo con il suo figlio più grande (spostandolo così verso il basso) fino a quando non è almeno grande quanto entrambi i nodi sottostanti.
siftUp
scambia un nodo che è troppo grande con il suo genitore (spostandolo così verso l'alto) fino a quando non è più grande del nodo sopra di esso.
Il numero di operazioni richieste siftDown
ed siftUp
è proporzionale alla distanza che il nodo potrebbe dover spostare. Perché siftDown
è la distanza dal fondo dell'albero, quindi siftDown
è costoso per i nodi nella parte superiore dell'albero. Con siftUp
, il lavoro è proporzionale alla distanza dalla cima dell'albero, quindi siftUp
è costoso per i nodi nella parte inferiore dell'albero. Sebbene entrambe le operazioni siano O (log n) nel peggiore dei casi, in un heap, solo un nodo è nella parte superiore mentre metà dei nodi si trova nel livello inferiore. Quindi non dovrebbe essere troppo sorprendente che, se dobbiamo applicare un'operazione per ogni nodo, preferiremmo siftDown
sopra siftUp
.
La buildHeap
funzione accetta una matrice di elementi non ordinati e li sposta fino a quando tutti soddisfano la proprietà heap, producendo così un heap valido. Ci sono due approcci che si potrebbero adottare per buildHeap
usare le operazioni siftUp
e siftDown
che abbiamo descritto.
Inizia nella parte superiore dell'heap (l'inizio dell'array) e chiama siftUp
ogni elemento. Ad ogni passaggio, gli elementi precedentemente setacciati (gli elementi prima dell'articolo corrente nell'array) formano un heap valido e il set-up dell'oggetto successivo lo posiziona in una posizione valida nell'heap. Dopo aver setacciato ciascun nodo, tutti gli elementi soddisfano la proprietà heap.
Oppure, andare nella direzione opposta: iniziare alla fine dell'array e spostarsi indietro verso la parte anteriore. Ad ogni iterazione, setacci un elemento verso il basso fino a quando non si trova nella posizione corretta.
Per quale implementazione buildHeap
è più efficiente?
Entrambe queste soluzioni produrranno un heap valido. Non sorprende che la più efficiente sia la seconda operazione che utilizza siftDown
.
Lascia che h = log n rappresenti l'altezza dell'heap. Il lavoro richiesto per l' siftDown
approccio è dato dalla somma
(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).
Ogni termine nella somma ha la distanza massima che un nodo alla data altezza dovrà spostare (zero per il livello inferiore, h per la radice) moltiplicato per il numero di nodi a quella altezza. Al contrario, la somma per chiamare siftUp
su ciascun nodo è
(h * n/2) + ((h-1) * n/4) + ((h-2)*n/8) + ... + (0 * 1).
Dovrebbe essere chiaro che la seconda somma è maggiore. Il primo termine da solo è hn / 2 = 1/2 n log n , quindi questo approccio ha al massimo complessità O (n log n) .
Come possiamo dimostrare che la somma per l' siftDown
approccio è davvero O (n) ?
Un metodo (ci sono altre analisi che funzionano anche) è trasformare la somma finita in una serie infinita e quindi usare la serie di Taylor. Potremmo ignorare il primo termine, che è zero:
Se non sei sicuro del motivo per cui ciascuno di questi passaggi funziona, ecco una giustificazione per il processo in parole:
- I termini sono tutti positivi, quindi la somma finita deve essere inferiore alla somma infinita.
- La serie è uguale a una serie di potenze valutata in x = 1/2 .
- Quella serie di potenze è uguale (a tempi costanti) alla derivata delle serie di Taylor per f (x) = 1 / (1-x) .
- x = 1/2 è all'interno dell'intervallo di convergenza di quella serie di Taylor.
- Pertanto, possiamo sostituire la serie Taylor con 1 / (1-x) , differenziare e valutare per trovare il valore della serie infinita.
Poiché la somma infinita è esattamente n , concludiamo che la somma finita non è più grande, ed è quindi O (n) .
Perché l'heap sort richiede tempo O (n log n) ?
Se è possibile eseguire buildHeap
in tempo lineare, perché l'ordinamento heap richiede tempo O (n log n) ? Bene, l'heap sort consiste in due fasi. Innanzitutto, chiamiamo buildHeap
l'array, che richiede tempo O (n) se implementato in modo ottimale. Il passaggio successivo consiste nell'eliminare ripetutamente l'elemento più grande nell'heap e inserirlo alla fine dell'array. Poiché eliminiamo un articolo dall'heap, c'è sempre un punto aperto subito dopo la fine dell'heap in cui possiamo archiviare l'articolo. Quindi l'heap sort raggiunge un ordinamento rimuovendo in successione il prossimo oggetto più grande e inserendolo nell'array a partire dall'ultima posizione e spostandosi verso la parte anteriore. È la complessità di quest'ultima parte che domina nell'ordinamento degli heap. Il ciclo è simile al seguente:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
Chiaramente, il ciclo esegue O (n) volte ( n - 1 per essere precisi, l'ultimo elemento è già in atto). La complessità di deleteMax
per un heap è O (log n) . In genere viene implementato rimuovendo la radice (l'elemento più grande rimasto nell'heap) e sostituendolo con l'ultimo elemento nell'heap, che è una foglia, e quindi uno degli elementi più piccoli. Questa nuova radice quasi sicuramente violerà la proprietà dell'heap, quindi devi chiamare siftDown
fino a quando non la sposti in una posizione accettabile. Ciò ha anche l'effetto di spostare l'elemento successivo più grande fino alla radice. Si noti che, diversamente da buildHeap
dove per la maggior parte dei nodi stiamo chiamando siftDown
dalla parte inferiore dell'albero, ora stiamo chiamando siftDown
dalla parte superiore dell'albero su ogni iterazione!Sebbene l'albero si stia restringendo, non si restringe abbastanza velocemente : l'altezza dell'albero rimane costante fino a quando non si è rimossa la prima metà dei nodi (quando si cancella completamente lo strato inferiore). Quindi per il prossimo trimestre, l'altezza è h - 1 . Quindi il lavoro totale per questa seconda fase è
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Nota l'interruttore: ora il caso di lavoro zero corrisponde a un singolo nodo e il caso di lavoro h corrisponde alla metà dei nodi. Questa somma è O (n log n) proprio come la versione inefficiente di buildHeap
ciò è implementata usando siftUp. Ma in questo caso, non abbiamo scelta poiché stiamo cercando di ordinare e richiediamo che il prossimo articolo più grande venga rimosso successivamente.
In breve, il lavoro per l'heap sort è la somma delle due fasi: O (n) tempo per buildHeap e O (n log n) per rimuovere ciascun nodo in ordine , quindi la complessità è O (n log n) . Puoi provare (usando alcune idee della teoria dell'informazione) che per un ordinamento basato sul confronto, O (n log n) è il migliore che potresti sperare comunque, quindi non c'è motivo di essere deluso da questo o aspettarti che l'heap sort raggiunga il O (n) limite di tempo che lo buildHeap
fa.