Quelqu'un peut-il aider à expliquer comment la construction d'un tas peut être complexe O(n)?
L'insertion d'un élément dans un tas correspond à O(log n)
et l'insertion est répétée n/2 fois (les autres sont des feuilles et ne peuvent pas violer la propriété du tas). Donc, cela signifie que la complexité devrait être O(n log n)
, je pense.
En d'autres termes, pour chaque élément que nous "clous", il peut potentiellement être filtré une fois pour chaque niveau du tas jusqu'à présent (c'est-à-dire log n level).
Qu'est-ce que je rate?
Je pense qu'il y a plusieurs questions cachées dans ce sujet:
buildHeap
pour qu'il s'exécute dans le temps O (n)?buildHeap
s'exécute dans le temps O (n) lorsqu'il est correctement implémenté?buildHeap
pour qu'il s'exécute dans le temps O (n)?Souvent, les réponses à ces questions portent sur la différence entre siftUp
et siftDown
. Faire le bon choix entre siftUp
et siftDown
est essentiel pour obtenir O (n) performances pour 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 de buildHeap
et de heapSort
utiliseront seulement et siftDown
. L'opération siftUp
n'est nécessaire que pour effectuer des insertions dans un segment de mémoire existant. Elle serait donc utilisée pour implémenter une file d'attente prioritaire à l'aide d'un segment de mémoire binaire, par exemple.
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 de priorités où les valeurs les 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 tas spécifie que chaque nœud d'un tas binaire doit être au moins aussi grand que ses deux enfants. En particulier, cela implique que l'élément le plus volumineux du tas se trouve à la racine. Les analyses vers le bas et vers le haut sont essentiellement la même opération dans des directions opposées: déplacez un nœud incriminé jusqu'à ce qu'il satisfasse à la propriété de segment de mémoire:
siftDown
échange un noeud 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 noeuds en dessous.siftUp
échange un noeud trop volumineux avec son parent (le déplaçant ainsi vers le haut) jusqu'à ce qu'il ne dépasse pas le noeud situé au-dessus de lui.Le nombre d'opérations requises pour siftDown
et siftUp
est proportionnel à la distance que le nœud peut avoir à parcourir. Pour siftDown
, il s’agit de la distance au bas de l’arbre, donc siftDown
est coûteux pour les nœuds situés en haut de l’arbre. Avec siftUp
, le travail est proportionnel à la distance qui le sépare du sommet de l’arbre. Ainsi, siftUp
coûte cher pour les nœuds situés au bas de l’arbre. Bien que les deux opérations soient O (log n) dans le pire des cas, dans un segment de mémoire, un seul nœud se trouve 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 devions appliquer une opération à chaque nœud, nous préférerions siftDown
à siftUp
.
La fonction buildHeap
prend un tableau d'éléments non triés et les déplace jusqu'à ce qu'ils satisfassent tous à la propriété heap, produisant ainsi un tas valide. Il existe deux approches possibles pour buildHeap
à l'aide des opérations siftUp
et siftDown
que nous avons décrites.
Commencez en haut du tas (le début du tableau) et appelez siftUp
sur chaque élément. À chaque étape, les éléments précédemment criblés (les éléments précédant l'élément en cours dans le tableau) forment un segment valide et le fait de tamiser le prochain élément le place dans une position valide dans le segment. Après avoir passé au crible chaque nœud, tous les éléments satisfont à la propriété heap.
Ou bien, allez dans le sens opposé: commencez à la fin du tableau et reculez vers l’avant. À chaque itération, vous filtrez un élément jusqu'à ce qu'il se trouve au bon emplacement.
buildHeap
est 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 représente la hauteur du tas. Le travail requis pour l’approche siftDown
est donné par la somme
(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).
Chaque terme de la somme correspond à la distance maximale qu'un nœud à la hauteur donnée devra déplacer (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 devrait être clair que la deuxième somme est plus grande. Le premier terme seul est hn/2 = 1/2 n log n, de sorte que cette approche a au mieux une complexité O (n log n).
siftDown
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. On peut ignorer le premier terme, qui est zéro:
Si vous ne savez pas pourquoi chacune de ces étapes fonctionne, voici une justification en quelques mots:
Puisque la somme infinie est exactement n, nous concluons que la somme finie n’est pas plus grande et qu’elle est donc O (n).
S'il est possible d'exécuter buildHeap
en temps linéaire, pourquoi le tri par tas nécessite-t-il O (n log n) temps? Eh bien, le tri en tas consiste en deux étapes. Premièrement, nous appelons buildHeap
sur le tableau, ce qui nécessite O (n) temps s'il est mis en œuvre 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. Comme nous supprimons un élément du tas, il y a toujours un espace libre juste après la fin du tas où nous pouvons stocker l'élément. Ainsi, le tri par tas réalise un ordre trié en supprimant successivement le prochain élément le plus grand et en le plaçant dans le tableau en commençant par la dernière position et en se dirigeant vers l'avant. C'est la complexité de cette dernière partie qui domine dans le tri en tas. La boucle ressemble à ceci:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
Clairement, la boucle 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 segment de mémoire 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 la remplaçant par le dernier élément du tas, qui est une feuille, et par conséquent l'un des plus petits éléments. Cette nouvelle racine violera presque certainement la propriété heap. Vous devez donc appeler siftDown
jusqu'à ce que vous la replaciez dans une position acceptable. Cela a également pour effet de déplacer le prochain élément le plus important à la racine. Notez que contrairement à buildHeap
où, pour la plupart des nœuds, nous appelons siftDown
du bas de l'arbre, nous appelons maintenant siftDown
du haut de l'arbre à chaque itération. ! Bien que l'arbre soit en train de rétrécir, il ne le fait pas assez vite: La hauteur de l'arbre reste constante jusqu'à ce que vous ayez supprimé la première moitié des nœuds (lorsque vous effacez complètement le calque du bas). Ensuite, pour le prochain trimestre, la hauteur est de h - 1. Donc, le travail total pour cette deuxième étape est
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Remarquez le commutateur: le cas de travail zéro correspond maintenant à un seul noeud et le cas de travail h correspond à la moitié des noeuds. Cette somme est O (n log n) tout comme la version inefficace de buildHeap
mise en œuvre avec siftUp. Mais dans ce cas, nous n’avons pas le choix, car nous essayons de trier et nous demandons que l’élément le plus volumineux suivant soit supprimé.
En résumé, le travail de tri de tas correspond à la somme des deux étapes: O (n) fois pour buildHeap et O (n log n) pour supprimer chaque nœud dans l’ordre , la complexité est donc O (n log n). Vous pouvez prouver (en utilisant certaines idées de la théorie de l'information) que pour un tri fondé sur la comparaison, O (n log n) est le meilleur que vous puissiez espérer de toute façon, il n'y a donc aucune raison d'être déçu par cela. ou attendez-vous que le tri par tas atteigne le O(n) délai que buildHeap
accomplit.
Votre analyse est correcte. Cependant, ce n'est pas serré.
Il n’est pas facile d’expliquer pourquoi la construction d’un tas est une opération linéaire, vous devriez mieux le lire.
Une grande analyse de l'algorithme peut être vue ici .
L'idée principale est que, dans l'algorithme build_heap
, le coût réel de heapify
n'est pas O(log n)
pour tous les éléments.
Lorsque heapify
est appelé, la durée d'exécution dépend de la distance qu'un élément peut atteindre dans l'arborescence avant la fin du processus. En d'autres termes, cela dépend de la hauteur de l'élément dans le tas. Dans le pire des cas, l'élément peut descendre jusqu'au niveau de la feuille.
Laissez-nous compter le travail effectué niveau par niveau.
Au niveau le plus bas, il y a 2^(h)
node, mais nous n'appelons pas heapify
, le travail est donc égal à 0. Au niveau immédiatement supérieur, il existe des nœuds 2^(h − 1)
et chacun peut descendre d'un niveau. Au 3ème niveau à partir du bas, il y a des nœuds 2^(h − 2)
, chacun pouvant descendre de 2 niveaux.
Comme vous pouvez le constater, toutes les opérations heapify sont des O(log n)
, c’est pourquoi vous obtenez O(n)
.
"La complexité devrait être O (nLog n) ... pour chaque élément que nous" heapifions ", il peut potentiellement être filtré une fois pour chaque niveau du segment de mémoire jusqu'à présent (c'est-à-dire les niveaux de journalisation)."
Pas assez. Votre logique ne produit pas de limite étroite - elle surestime la complexité de chaque heapify. Si construit de bas en haut, l'insertion (heapify) peut être beaucoup moins que O(log(n))
. Le processus est le suivant:
(Étape 1) Les premiers éléments n/2
vont sur la dernière ligne du tas. h=0
, donc Heapify n'est pas nécessaire.
(Étape 2) Les éléments n/22
suivants apparaissent sur la ligne 1 en partant du bas. h=1
, filtre heapify 1 niveau vers le bas.
(Étape i) Les éléments n/2i
suivants apparaissent dans la ligne i
à partir du bas. h=i
, heapify filter i
level down.
(Étape log (n)) Le dernier élément n/2log2(n) = 1
apparaît dans la ligne log(n)
à partir du bas. h=log(n)
, filtre heapify log(n)
AVIS: qu'après la première étape, 1/2
des éléments (n/2)
sont déjà dans le tas, et nous n'avons même pas eu besoin d'appeler heapify une fois. En outre, notez que seul un élément, la racine, implique la complexité complète de log(n)
.
Le nombre total d'étapes N
pour créer un segment de taille n
peut être écrit mathématiquement.
À hauteur i
, nous avons montré (ci-dessus) qu'il y aura des éléments n/2i+1
qui doivent appeler heapify, et nous savons que heapify à hauteur i
est O(i)
. Cela donne:
La solution à la dernière somme peut être trouvée en prenant la dérivée des deux côtés de l’équation bien connue des séries géométriques:
Finalement, en branchant x = 1/2
dans l'équation ci-dessus, on obtient 2
. Brancher ceci dans la première équation donne:
Ainsi, le nombre total d'étapes est de taille O(n)
Ce serait O (n log n) si vous construisiez le tas en insérant plusieurs fois des éléments. Toutefois, vous pouvez créer un nouveau segment de mémoire plus efficacement en insérant les éléments dans un ordre arbitraire, puis en appliquant un algorithme pour les "hiérarchiser" dans le bon ordre (en fonction du type de segment de mémoire, bien sûr).
Voir http://en.wikipedia.org/wiki/Binary_heap , "Construire un tas" pour un exemple. Dans ce cas, vous travaillez essentiellement à partir du bas de l'arborescence, en échangeant les nœuds parent et enfant jusqu'à ce que les conditions de tas soient remplies.
Comme nous le savons, la hauteur d'un tas est log (n), où n est le nombre total d'éléments. Représentons-le sous la forme h
Lorsque nous effectuons une opération heapify, les éléments du dernier niveau (h) ne bougent même pas.
Le nombre d'éléments à l'avant dernier niveau (h-1) est 2h-1 et ils peuvent se déplacer au maximum 1 niveau (pendant heapify).
De même, pour le ith, niveau nous avons 2je éléments pouvant déplacer h-i positions.
Donc nombre total de coups = S = 2h* 0 + 2h-1* 1 + 2h-2* 2 + ... 20* h
S = 2h {1/2 + 2/22 + 3/23+ ... h/2h} ----------------------------------------------------- 1
ceci est AGP série, pour résoudre cette division des 2 côtés par 2
S/2 = 2h {1/22 + 2/23+ ... h/2h + 1} --------------------------------------------- ---- 2
soustrayant l’équation 2 de 1 donne
S/2 = 2h {1/2 + 1/22 + 1/23+ ... + 1/2h+ h/2h + 1}
S = 2h + 1 {1/2 + 1/22 + 1/23+ ... + 1/2h+ h/2h + 1}
maintenant 1/2 + 1/22 + 1/23+ ... + 1/2h est décroissante GP dont la somme est inférieure à 1 (lorsque h tend vers l'infini, la somme tend vers 1). Dans une analyse plus poussée, prenons une limite supérieure sur la somme qui est 1.
Cela donne S = 2h + 1{1 + h/2h + 1}
= 2h + 1+ h
~ 2h+ h
comme h = log (n), 2h= n
Par conséquent, S = n + log (n)
T (C) = O (n)
Tout en construisant un tas, disons que vous prenez une approche ascendante.
Il y a déjà de bonnes réponses mais j'aimerais ajouter une petite explication visuelle
Maintenant, regardez l'image, il y an/2^1
nœuds verts avec hauteur 0 (ici 23/2 = 12)n/2^2
nœuds rouges avec hauteur 1 (ici 23/4 = 6)n/2^3
noeud bleu avec hauteur 2 (ici 23/8 = 3)n/2^4
nœuds violets avec hauteur 3 (ici 23/16 = 2)
donc il y a n/2^(h+1)
noeuds pour la hauteur h
Pour trouver la complexité temporelle, comptons la quantité de travail effectué ou nombre maximum d'itérations effectuées par chaque nœud
Maintenant, on peut remarquer que chaque nœud peut effectuer (au maximum) itérations == hauteur du nœud
Green = n/2^1 * 0 (no iterations since no children)
red = n/2^2 * 1 (*heapify* will perform atmost one swap for each red node)
blue = n/2^3 * 2 (*heapify* will perform atmost two swaps for each blue node)
purple = n/4^3 * 3
ainsi, pour tout nœud de hauteur h , le travail maximum est de n/2 ^ (h + 1) * h
Maintenant, le travail total est
->(n/2^1 * 0) + (n/2^2 * 1)+ (n/2^3 * 2) + (n/2^4 * 3) +...+ (n/2^(h+1) * h)
-> n * ( 0 + 1/4 + 2/8 + 3/16 +...+ h/2^(h+1) )
maintenant pour toute valeur de h , la séquence
-> ( 0 + 1/4 + 2/8 + 3/16 +...+ h/2^(h+1) )
ne dépassera jamais 1
Ainsi, la complexité temporelle ne dépassera jamais O(n) pour la construction de tas
En cas de construction du tas, nous partons de la hauteur, logn -1 (où logn est la hauteur de l'arbre de n éléments). Pour chaque élément présent à la hauteur 'h', nous allons à la hauteur maximale (logn -h) inférieure.
So total number of traversal would be:-
T(n) = sigma((2^(logn-h))*h) where h varies from 1 to logn
T(n) = n((1/2)+(2/4)+(3/8)+.....+(logn/(2^logn)))
T(n) = n*(sigma(x/(2^x))) where x varies from 1 to logn
and according to the [sources][1]
function in the bracket approaches to 2 at infinity.
Hence T(n) ~ O(n)
Les insertions successives peuvent être décrites par:
T = O(log(1) + log(2) + .. + log(n)) = O(log(n!))
Par approximation de Starling, n! =~ O(n^(n + O(1)))
, donc T =~ O(nlog(n))
J'espère que cela vous aidera, de manière optimale O(n)
utilise l'algorithme de construction de tas pour un ensemble donné (l'ordre n'a pas d'importance).
La preuve n'est pas sophistiquée, et assez simple, je n'ai démontré que le cas d'un arbre binaire complet, le résultat peut être généralisé pour un arbre binaire complet.
@bcorso a déjà démontré la preuve de l'analyse de complexité. Mais pour ceux qui apprennent encore l'analyse de la complexité, j'ai ceci à ajouter:
La base de votre erreur initiale est due à une interprétation erronée du sens de la déclaration, "l'insertion dans un segment de mémoire prend O (log n) fois". L'insertion dans un tas est bien O (log n), mais vous devez reconnaître que n est la taille du tas pendant l'insertion .
Dans le contexte de l'insertion de n objets dans un segment de mémoire, la complexité de la ième insertion est O (log n_i), où n_i est la taille du segment de mémoire comme à l'insertion i. Seule la dernière insertion a une complexité de O (log n).
Supposons que vous ayez des éléments N dans un tas. Alors sa hauteur serait Log (N)
Maintenant, vous voulez insérer un autre élément, alors la complexité serait: Log (N), nous devons comparer tout le chemin JUSQU'À à la racine.
Vous avez maintenant N + 1 éléments et hauteur = Log (N + 1)
En utilisant la technique induction , il peut être prouvé que la complexité de l'insertion serait ∑logi.
Maintenant en utilisant
log a + log b = log ab
Ceci simplifie à: ∑logi = log (n!)
qui est en fait O(NlogN)
Mais
nous faisons quelque chose de mal ici, car dans tous les cas nous n'atteignons pas le sommet… .. Par conséquent, lors de l'exécution la plupart du temps, nous pouvons constater que nous n'allons même pas à la moitié de l'arbre. Par conséquent, cette limite peut être optimisée pour avoir une autre limite plus étroite en utilisant les mathématiques données dans les réponses ci-dessus.
Cette réalisation m'est venue après un détail et une expérimentation sur Heaps.
J'aime beaucoup les explications de Jeremy West .... une autre approche très facile à comprendre est donnée ici http://courses.washington.edu/css343/zander/NotesProbs/heapcomplexity
depuis, buildheap dépend de dépend de heapify et l’approche par décalage est utilisée, laquelle dépend de la somme des hauteurs de tous les nœuds. Donc, pour trouver la somme de la hauteur des nœuds qui est donnée par S = somme de i = 0 à i = h de (2 ^ i * (h-i)), où h = logn est la hauteur de l'arbre en résolvant s, on obtient s = 2 ^ (h + 1) - 1 - (h + 1) puisque n = 2 ^ (h + 1) - 1 s = n - h - 1 = n - logn - 1 s = O (n), et la complexité de buildheap est donc O (n).
"La limite de temps linéaire de la construction Heap peut être montrée en calculant la somme des hauteurs de tous les nœuds du tas, ce qui correspond au nombre maximum de lignes en pointillés . 2 ^ (h + 1) - 1 nœuds, la somme des hauteurs des nœuds est N - H - 1 . Ainsi, il s’agit de O (N). "