Je pense qu'il y a plusieurs questions enfouies dans ce sujet:
- Comment l'implémentez-vous
buildHeap
pour qu'il fonctionne en temps O (n) ?
- Comment montrez-vous que
buildHeap
s'exécute en temps O (n) lorsqu'il est correctement implémenté?
- Pourquoi cette même logique ne fonctionne-t-elle pas pour que le tri du tas s'exécute en temps O (n) plutôt qu'en O (n log n) ?
Comment l'implémentez-vous buildHeap
pour qu'il fonctionne en temps O (n) ?
Souvent, les réponses à ces questions se concentrent sur la différence entre siftUp
et siftDown
. Faire le bon choix entre siftUp
et siftDown
est essentiel pour obtenir des performances O (n)buildHeap
, mais ne fait rien pour aider à comprendre la différence entre buildHeap
et heapSort
en général. En effet, les implémentations appropriées des deux buildHeap
et neheapSort
seront utilisées . L' opération n'est nécessaire que pour effectuer des insertions dans un segment existant, elle serait donc utilisée pour implémenter une file d'attente prioritaire à l'aide d'un segment binaire, par exemple.siftDown
siftUp
J'ai écrit ceci pour décrire le fonctionnement d'un tas max. Il s'agit du type de segment de mémoire généralement utilisé pour le tri de segment de mémoire ou pour une file d'attente prioritaire où des valeurs plus élevées indiquent une priorité plus élevée. Un tas min est également utile; par exemple, lors de la récupération d'éléments avec des clés entières dans l'ordre croissant ou des chaînes dans l'ordre alphabétique. Les principes sont exactement les mêmes; changez simplement l'ordre de tri.
La propriété de segment de mémoire spécifie que chaque nœud d'un segment de mémoire binaire doit être au moins aussi grand que ses deux enfants. En particulier, cela implique que le plus gros élément du tas est à la racine. Le filtrage et le filtrage sont essentiellement la même opération dans des directions opposées: déplacer un nœud incriminé jusqu'à ce qu'il satisfasse la propriété du tas:
siftDown
échange un nœud trop petit avec son plus grand enfant (le déplaçant ainsi vers le bas) jusqu'à ce qu'il soit au moins aussi grand que les deux nœuds en dessous.
siftUp
échange un nœud trop grand avec son parent (le déplaçant ainsi vers le haut) jusqu'à ce qu'il ne soit pas plus grand que le nœud au-dessus de lui.
Le nombre d'opérations nécessaires siftDown
et siftUp
proportionnel à la distance que le nœud peut avoir à parcourir. Car siftDown
, c'est la distance jusqu'au bas de l'arbre, donc siftDown
c'est cher pour les nœuds en haut de l'arbre. Avec siftUp
, le travail est proportionnel à la distance jusqu'au sommet de l'arbre, donc siftUp
est coûteux pour les nœuds au bas de l'arbre. Bien que les deux opérations soient O (log n) dans le pire des cas, dans un tas, un seul nœud est en haut tandis que la moitié des nœuds se trouvent dans la couche inférieure. Donc , il ne devrait pas être trop surprenant que si nous devons appliquer une opération à chaque nœud, nous préférerions siftDown
plus siftUp
.
La buildHeap
fonction prend un tableau d'éléments non triés et les déplace jusqu'à ce qu'ils satisfassent tous à la propriété du tas, produisant ainsi un tas valide. Il existe deux approches pour buildHeap
utiliser les opérations siftUp
et que siftDown
nous avons décrites.
Commencez par le haut du tas (le début du tableau) et appelez siftUp
chaque élément. À chaque étape, les éléments précédemment triés (les éléments précédant l'élément actuel dans le tableau) forment un segment de mémoire valide, et le tri de l'élément suivant le place dans une position valide dans le segment de mémoire. Après avoir trié chaque nœud, tous les éléments satisfont la propriété du tas.
Ou, allez dans la direction opposée: commencez à la fin du tableau et reculez vers l'avant. À chaque itération, vous tamisez un article jusqu'à ce qu'il soit au bon endroit.
Quelle implémentation buildHeap
est la plus efficace?
Ces deux solutions produiront un tas valide. Sans surprise, la plus efficace est la deuxième opération qui utilise siftDown
.
Soit h = log n la hauteur du tas. Le travail requis pour la siftDown
démarche est donné par la somme
(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).
Chaque terme de la somme a la distance maximale qu'un nœud à la hauteur donnée devra parcourir (zéro pour la couche inférieure, h pour la racine) multiplié par le nombre de nœuds à cette hauteur. En revanche, la somme pour appeler siftUp
sur chaque nœud est
(h * n/2) + ((h-1) * n/4) + ((h-2)*n/8) + ... + (0 * 1).
Il doit être clair que la deuxième somme est plus importante. Le premier terme seul est hn / 2 = 1/2 n log n , donc cette approche a au mieux une complexité O (n log n) .
Comment prouver que la somme de l' siftDown
approche est bien O (n) ?
Une méthode (il existe d'autres analyses qui fonctionnent également) consiste à transformer la somme finie en une série infinie, puis à utiliser la série de Taylor. Nous pouvons ignorer le premier terme, qui est zéro:
Si vous ne savez pas pourquoi chacune de ces étapes fonctionne, voici une justification du processus en mots:
- Les termes sont tous positifs, donc la somme finie doit être inférieure à la somme infinie.
- La série est égale à une série de puissance évaluée à x = 1/2 .
- Cette série de puissances est égale à (un temps constant) la dérivée de la série de Taylor pour f (x) = 1 / (1-x) .
- x = 1/2 est dans l'intervalle de convergence de cette série de Taylor.
- Par conséquent, nous pouvons remplacer la série de Taylor par 1 / (1-x) , différencier et évaluer pour trouver la valeur de la série infinie.
Puisque la somme infinie est exactement n , nous concluons que la somme finie n'est pas plus grande, et est donc O (n) .
Pourquoi le tri en tas nécessite-t-il O (n log n) ?
S'il est possible de s'exécuter buildHeap
en temps linéaire, pourquoi le tri en tas requiert-il un temps O (n log n) ? Eh bien, le tri en tas se compose de deux étapes. Tout d'abord, nous faisons appel buildHeap
au tableau, qui nécessite un temps O (n) s'il est implémenté de manière optimale. L'étape suivante consiste à supprimer à plusieurs reprises le plus gros élément du tas et à le placer à la fin du tableau. Parce que nous supprimons un élément du tas, il y a toujours un endroit ouvert juste après la fin du tas où nous pouvons stocker l'élément. Ainsi, le tri en tas atteint un ordre trié en supprimant successivement le prochain élément le plus grand et en le plaçant dans le tableau en commençant à la dernière position et en se déplaçant vers l'avant. C'est la complexité de cette dernière partie qui domine en tri par tas. La boucle ressemble à ceci:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
De toute évidence, la boucle s'exécute O (n) fois ( n - 1 pour être précis, le dernier élément est déjà en place). La complexité de deleteMax
pour un tas est O (log n) . Il est généralement implémenté en supprimant la racine (le plus grand élément restant dans le tas) et en le remplaçant par le dernier élément du tas, qui est une feuille, et donc l'un des plus petits éléments. Cette nouvelle racine violera presque certainement la propriété du tas, vous devez donc appeler siftDown
jusqu'à ce que vous la remettiez dans une position acceptable. Cela a également pour effet de déplacer l'élément suivant le plus grand jusqu'à la racine. Notez que, contrairement à l' buildHeap
endroit où pour la plupart des nœuds que nous appelons siftDown
depuis le bas de l'arbre, nous appelons maintenant siftDown
depuis le haut de l'arbre à chaque itération!Bien que l'arbre rétrécisse, il ne rétrécit pas assez rapidement : la hauteur de l'arbre reste constante jusqu'à ce que vous ayez supprimé la première moitié des nœuds (lorsque vous supprimez complètement la couche inférieure). Ensuite, pour le trimestre suivant, la hauteur est h - 1 . Donc, le travail total pour cette deuxième étape est
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Remarquez le commutateur: maintenant le cas de travail zéro correspond à un seul nœud et le cas de travail h correspond à la moitié des nœuds. Cette somme est O (n log n) tout comme la version inefficace de buildHeap
celle-ci est implémentée à l'aide de siftUp. Mais dans ce cas, nous n'avons pas le choix, car nous essayons de trier et nous exigeons que le prochain élément le plus grand soit supprimé.
En résumé, le travail pour le tri de tas est la somme des deux étapes: temps O (n) pour buildHeap et O (n log n) pour supprimer chaque nœud dans l'ordre , donc la complexité est O (n log n) . Vous pouvez prouver (en utilisant certaines idées de la théorie de l'information) que pour un tri basé sur la comparaison, O (n log n) est le meilleur que vous puissiez espérer de toute façon, donc il n'y a aucune raison d'être déçu par cela ou de s'attendre à ce que le tri en tas atteigne le O (n) limité dans le temps buildHeap
.