web-dev-qa-db-fra.com

Quelle est l'intuition derrière la structure de données du tas de Fibonacci?

J'ai lu le article Wikipedia sur les tas de Fibonacci et lu la description de la structure de données par CLRS, mais ils fournissent peu d'intuition pour expliquer pourquoi cette structure de données fonctionne. Pourquoi les tas de Fibonacci sont-ils conçus comme ils sont? Comment travaillent-ils?

Merci!

63
templatetypedef

Cette réponse va être assez longue, mais j'espère qu'elle vous aidera à comprendre d'où vient le tas de Fibonacci. Je vais supposer que vous connaissez déjà tas binomiaux et analyse amortie .

Motivation: pourquoi des tas de Fibonacci?

Avant de sauter dans les tas de Fibonacci, il est probablement bon d'explorer pourquoi nous en avons même besoin en premier lieu. Il existe de nombreux autres types de tas ( tas binaires et tas binomiaux , par exemple), alors pourquoi en avons-nous besoin d'un autre?

La principale raison vient de algorithme de Dijkstra et algorithme de Prim . Ces deux algorithmes de graphe fonctionnent en maintenant une file d'attente prioritaire contenant des nœuds avec des priorités associées. Fait intéressant, ces algorithmes reposent sur une opération de tas appelée diminution-clé qui prend une entrée déjà dans la file d'attente prioritaire puis diminue sa clé (c'est-à-dire augmente sa priorité). En fait, une grande partie de l'exécution de ces algorithmes s'explique par le nombre de fois que vous devez appeler la touche de diminution. Si nous pouvions construire une structure de données qui optimisait la clé de diminution, nous pourrions optimiser les performances de ces algorithmes. Dans le cas du tas binaire et du tas binomial, la touche de diminution prend le temps O (log n), où n est le nombre de nœuds dans la file d'attente prioritaire. Si nous pouvions laisser tomber cela à O (1), alors la complexité temporelle de l'algorithme de Dijkstra et de l'algorithme de Prim passerait de O (m log n) à (m + n log n), ce qui est asymptotiquement plus rapide qu'auparavant. Par conséquent, il est logique d'essayer de créer une structure de données qui prend en charge la clé de diminution de manière efficace.

Il existe une autre raison d'envisager la conception d'une meilleure structure de tas. Lors de l'ajout d'éléments à un tas binaire vide, chaque insertion prend le temps O (log n). Il est possible de construire un tas binaire dans le temps O (n) si nous connaissons tous les n éléments à l'avance, mais si les éléments arrivent dans un flux, ce n'est pas possible. Dans le cas du tas binomial, l'insertion de n éléments consécutifs prend du temps amorti O(1) chacun), mais si les insertions sont entrelacées avec des suppressions, les insertions peuvent finir par prendre du temps Ω (log n) Par conséquent, nous pourrions vouloir rechercher une implémentation de file d'attente prioritaire qui optimise les insertions pour prendre du temps O(1) each).

Première étape: tas binomiaux paresseux

Pour commencer à construire le tas de Fibonacci, nous allons commencer par un tas binomial et le modifier essayer de faire prendre aux insertions le temps O (1). Ce n'est pas si déraisonnable d'essayer cela - après tout, si nous allons faire beaucoup d'insertions et moins de files d'attente, il est logique d'optimiser les insertions.

Si vous vous en souvenez, les tas binomiaux fonctionnent en stockant tous les éléments du tas dans une collection de arbres binomiaux . Un arbre binomial d'ordre n a 2n nœuds en elle, et le tas est des structures comme une collection d'arbres binomiaux qui obéissent tous à la propriété du tas. En règle générale, l'algorithme d'insertion dans un tas binomial fonctionne comme suit:

  • Créez un nouveau nœud singleton (il s'agit d'un arbre d'ordre 0).
  • S'il existe un arbre d'ordre 0:
    • Fusionnez les deux arbres d'ordre 0 ensemble dans un arbre d'ordre 1.
    • S'il y a un arbre d'ordre 1:
      • Fusionnez les deux arbres d'ordre 1 ensemble dans un arbre d'ordre 2.
      • S'il y a un arbre d'ordre 2:
      • ...

Ce processus garantit qu'à chaque instant, il existe au plus une arborescence de chaque commande. Étant donné que chaque arbre contient exponentiellement plus de nœuds que son ordre, cela garantit que le nombre total d'arbres est petit, ce qui permet aux files d'attente de s'exécuter rapidement (car nous n'avons pas à regarder trop d'arbres différents après avoir effectué une étape de file d'attente-min).

Cependant, cela signifie également que le pire cas d'exécution de l'insertion d'un nœud dans un segment binomial est Θ (log n), car nous pouvons avoir des arbres Θ (log n) qui doivent être fusionnés. Ces arbres doivent être fusionnés uniquement parce que nous devons maintenir le nombre d'arbres bas lors d'une étape de retrait, et il n'y a absolument aucun avantage dans les insertions futures à maintenir le nombre d'arbres bas.

Cela introduit le premier départ des tas binomiaux:

Modification 1 : lors de l'insertion d'un nœud dans le tas, créez simplement une arborescence d'ordre 0 et ajoutez-la à la collection d'arbres existante. Ne consolidez pas les arbres ensemble.

Il y a un autre changement que nous pouvons faire. Normalement, lorsque nous fusionnons deux tas binomiaux, nous effectuons une étape de fusion pour les combiner de manière à garantir qu'il y ait au plus un arbre de chaque ordre dans l'arbre résultant. Encore une fois, nous effectuons cette compression pour que les files d'attente soient rapides, et il n'y a aucune raison réelle pour laquelle l'opération de fusion devrait payer pour cela. Par conséquent, nous ferons un deuxième changement:

Modification 2 : Lors de la fusion de deux tas, combinez simplement tous leurs arbres sans faire de fusion. Ne consolidez aucun arbre ensemble.

Si nous apportons cette modification, nous obtenons assez facilement O(1) performace sur nos opérations de mise en file d'attente, car tout ce que nous faisons est de créer un nouveau nœud et de l'ajouter à la collection d'arbres. Cependant , si nous effectuons simplement cette modification et que nous ne faisons rien d'autre, nous interrompons complètement les performances de l'opération de mise en file d'attente-min. Rappelons que dequeue-min doit parcourir les racines de tous les arbres du tas après avoir supprimé la valeur minimale. afin qu'il puisse trouver la plus petite valeur. Si nous ajoutons des nœuds Θ (n) en les insérant dans le chemin, notre opération de retrait de la file d'attente devra alors passer time (n) de temps à regarder tous ces arbres. ... pouvons-nous l'éviter?

Si nos insertions ajoutent vraiment plus d'arbres, alors la première file d'attente que nous faisons prendra certainement le temps Ω (n). Cependant, cela ne signifie pas que chaque la mise en file d'attente doit être coûteuse. Que se passe-t-il si, après avoir effectué une file d'attente, nous fusionnons tous les arbres du tas de sorte que nous nous retrouvons avec un seul arbre de chaque ordre? Cela prendra beaucoup de temps au départ, mais si nous commençons à faire plusieurs files d'attente successivement, ces futures files d'attente seront beaucoup plus rapides car il y a moins d'arbres qui traînent.

Il y a cependant un léger problème avec cette configuration. Dans un tas binomial normal, les arbres sont toujours stockés dans l'ordre. Si nous continuons à jeter de nouveaux arbres dans notre collection d'arbres, à les fusionner à des moments aléatoires et à ajouter encore plus d'arbres après cela, il n'y a aucune garantie que les arbres seront dans n'importe quel ordre. Par conséquent, nous allons avoir besoin d'un nouvel algorithme pour fusionner ces arbres ensemble.

L'intuition derrière cet algorithme est la suivante. Supposons que nous créons une table de hachage qui mappe des ordres d'arbre aux arbres. Nous pourrions alors effectuer l'opération suivante pour chaque arbre dans la structure de données:

  • Levez les yeux et voyez s'il y a déjà un arbre de cet ordre.
  • Sinon, insérez l'arborescence actuelle dans la table de hachage.
  • Autrement:
    • Fusionnez l'arborescence actuelle avec l'arborescence de cet ordre, en supprimant l'ancien arbre de la table de hachage.
    • Répétez récursivement ce processus.

Cette opération garantit que lorsque nous avons terminé, il y a au plus une arborescence de chaque commande. C'est aussi relativement efficace. Supposons que nous commençons avec T arbres totaux et finissons avec t arbres totaux. Le nombre total de fusions que nous finirons par faire sera T - t - 1, et chaque fois que nous ferons une fusion, cela prendra du temps O(1) pour le faire. Par conséquent, le le temps d'exécution de cette opération sera linéaire dans le nombre d'arbres (chaque arbre est visité au moins une fois) plus le nombre de fusions effectuées.

Si le nombre d'arbres est petit (disons Θ (log n)), cette opération ne prendra que le temps O (log n). Si le nombre d'arbres est grand (disons, Θ (n)), cette opération prendra alors Θ (n) temps, mais ne laissera que trees (log n) arbres restants, ce qui accélérera les retraits de file.

Nous pouvons quantifier à quel point les choses s'amélioreront en faisant une analyse amortie et en utilisant une fonction potentielle. Soit Φ notre fonction potentielle et soit Φ le nombre d'arbres dans la structure de données. Cela signifie que les coûts des opérations sont les suivants:

  • Insertion : Est-ce que O(1) fonctionne et augmente le potentiel de un. Le coût amorti est O (1).
  • Fusion : Est-ce que O(1) fonctionne. Le potentiel d'un tas est abaissé à 0 et le potentiel de l'autre tas est augmenté d'un montant correspondant, il n'y a donc pas de variation nette de potentiel. Le coût amorti est donc O (1).
  • Dequeue-Min : Est-ce que O (#trees + #merges) fonctionne et diminue le potentiel jusqu'à Θ (log n), le nombre d'arbres que nous ' J'aurais dans l'arbre binomial si nous fusionnions avec impatience les arbres ensemble. Nous pouvons expliquer cela d'une manière différente. Faisons en sorte que le nombre d'arbres soit écrit comme) (log n) + E, où E est le nombre "excédentaire" d'arbres. Dans ce cas, le travail total effectué est Θ (log n + E + #merges). Notez que nous ferons une fusion par arbre en excès, et donc le travail total effectué est Θ (log n + E). Puisque notre potentiel fait chuter le nombre d'arbres de Θ (log n) + E à Θ (log n), la baisse de potentiel est -E. Par conséquent, le coût amorti d'une file d'attente-min est Θ (log n).

Une autre façon intuitive de voir pourquoi le coût amorti d'une file d'attente-min est Θ (log n) est de regarder pourquoi nous avons des arbres excédentaires. Ces arbres supplémentaires sont là parce que ces inserts gourmands sacrés font tous ces arbres supplémentaires et ne paient pas pour eux! Nous pouvons donc "rétrocharger" le coût associé à la réalisation de toutes les fusions vers les insertions individuelles qui ont pris tout ce temps, en laissant derrière l'opération "core" Θ (log n) et un tas d'autres opérations que nous blâmerons. les insertions.

Donc:

Modification 3 : lors d'une opération de retrait de la file d'attente, consolidez tous les arbres pour vous assurer qu'il y a au plus un arbre de chaque commande.

À ce stade, nous avons insérer et fusionner en cours d'exécution dans le temps O(1) et retirer de la file d'attente en temps amorti O (log n). C'est assez astucieux! Cependant, nous n'avons toujours pas de diminution -touche fonctionne encore. Ça va être la partie difficile.

Deuxième étape: implémentation de la clé de diminution

À l'heure actuelle, nous avons un "tas binomial paresseux" plutôt qu'un tas de Fibonacci. Le vrai changement entre un tas binomial et un tas de Fibonacci est la façon dont nous implémentons la clé de diminution.

Rappelez-vous que l'opération de diminution de la clé doit prendre une entrée déjà dans le tas (généralement, nous aurions un pointeur vers elle) et une nouvelle priorité qui est inférieure à la priorité existante. Il modifie ensuite la priorité de cet élément à la nouvelle priorité inférieure.

Nous pouvons implémenter cette opération très rapidement (dans le temps O (log n)) en utilisant un algorithme simple. Prenez l'élément dont la clé doit être diminuée (qui peut être située dans O(1) time; rappelez-vous, nous supposons que nous avons un pointeur vers lui) et diminuez sa priorité. Ensuite, à plusieurs reprises l'échanger avec son nœud parent tant que sa priorité est inférieure à son parent, s'arrêtant lorsque le nœud s'arrête ou lorsqu'il atteint la racine de l'arbre. Cette opération prend du temps O (log n) car chaque arbre a une hauteur au plus O (log n) et chaque comparaison prend le temps O (1).

N'oubliez pas, cependant, que nous essayons de faire encore mieux que cela - nous voulons que le runtime soit O (1)! C'est un très difficile à égaler. Nous ne pouvons utiliser aucun processus qui déplace le nœud vers le haut ou vers le bas de l'arbre, car ces arbres ont des hauteurs qui peuvent être Ω (log n). Nous devrons essayer quelque chose de plus drastique.

Supposons que nous voulons diminuer la clé d'un nœud. La seule façon dont la propriété de tas sera violée est si la nouvelle priorité du nœud est inférieure à celle de son parent. Si nous regardons le sous-arbre enraciné dans ce nœud particulier, il obéira toujours à la propriété du tas. Voici donc une idée totalement folle: que se passe-t-il si chaque fois que nous diminuons la clé d'un nœud, nous coupons le lien vers le nœud parent, puis remettons le sous-arbre entier enraciné au nœud au niveau supérieur de l'arbre?

Modification 4 : Demandez à diminuer-clé de diminuer la clé d'un nœud et, si sa priorité est inférieure à celle de son parent, coupez-la et ajoutez-la à la liste racine.

Quel sera l'effet de cette opération? Plusieurs choses vont arriver.

  1. Le nœud qui avait auparavant notre nœud comme enfant pense maintenant qu'il a le mauvais nombre d'enfants. Rappelons qu'un arbre binomial d'ordre n est défini pour avoir n enfants, mais ce n'est plus vrai.
  2. La collection d'arbres dans la liste racine augmentera, augmentant le coût des futures opérations de mise en file d'attente.
  3. Les arbres de notre tas ne seront plus nécessairement des arbres binomiaux. Il peut s'agir d'arbres binomiaux "anciens" qui ont perdu des enfants à différents moments.

Le numéro (1) n'est pas trop un problème. Si nous coupons un nœud de son parent, nous pouvons simplement diminuer l'ordre de ce nœud d'un pour indiquer qu'il a moins d'enfants qu'il ne le pensait auparavant. Le numéro (2) n'est pas non plus un problème. Nous pouvons simplement facturer le travail supplémentaire effectué lors de la prochaine opération de retrait de la file d'attente à l'opération de diminution des touches.

Le numéro (3) est un problème très, très grave que nous devrons régler. Voici le problème: l'efficacité d'un tas binomial provient en partie du fait que toute collection de n nœuds peut être stockée dans une collection d'arbres Θ (log n) d'ordre différent. La raison en est que chaque arbre binomial a 2n nœuds en elle. Si nous pouvons commencer à couper des nœuds d'arbres, nous risquons d'avoir des arbres qui ont un grand nombre d'enfants (c'est-à-dire un ordre élevé) mais qui ne contiennent pas beaucoup de nœuds. Par exemple, supposons que nous commencions avec un seul arbre d'ordre k, puis effectuions des opérations de diminution de clé sur tous les petits-enfants de k. Cela laisse k comme un arbre d'ordre k, mais qui ne contient que k + 1 nœuds totaux. Si nous continuons à répéter ce processus partout, nous pourrions nous retrouver avec un tas d'arbres de divers ordres qui ont un très petit nombre d'enfants en eux. Par conséquent, lorsque nous effectuons notre opération de fusion pour regrouper les arbres, nous ne pouvons pas réduire le nombre d'arbres à un niveau gérable, brisant la limite de temps Θ (log n) que nous ne voulons vraiment pas perdre.

À ce stade, nous sommes un peu dans une impasse. Nous devons avoir beaucoup de flexibilité sur la façon dont les arbres peuvent être remodelés afin que nous puissions obtenir la fonctionnalité O(1) clé de diminution de temps, mais nous ne pouvons pas laisser les arbres se remodeler arbitrairement ou nous nous retrouverons avec un temps d'exécution amorti de diminution de la clé augmentant à quelque chose de supérieur à O (log n).

La perspicacité nécessaire - et, honnêtement, ce que je pense être le vrai génie du tas de Fibonacci - est un compromis entre les deux. L'idée est la suivante. Si nous coupons un arbre de son parent, nous prévoyons déjà de diminuer d'un rang le rang du nœud parent. Le problème se pose vraiment quand un nœud perd un lot d'enfants, auquel cas son rang diminue considérablement sans qu'aucun nœud plus haut dans l'arbre ne le sache. Par conséquent, nous dirons que chaque nœud ne peut perdre qu'un seul enfant. Si un nœud perd un deuxième enfant, nous allons couper ce nœud de son parent, ce qui propage les informations qui manquent aux nœuds plus haut dans l'arborescence.

Il s'avère que c'est un grand compromis. Il nous permet de faire des clés de diminution rapidement dans la plupart des contextes (tant que les nœuds ne sont pas des enfants du même arbre), et nous n'avons que rarement à "propager" une clé de diminution en coupant un nœud de son parent, puis couper ce nœud de ses grands-parents.

Pour garder une trace des nœuds qui ont perdu des enfants, nous allons attribuer un bit "marquer" à chaque nœud. Chaque nœud aura initialisé le bit de marque effacé, mais chaque fois qu'il perd un enfant, il aura le bit défini. S'il perd un deuxième enfant après que le bit a déjà été défini, nous effacerons le bit, puis coupons le nœud de son parent.

Modification 5 : Attribuez un bit de repère à chaque nœud qui est initialement faux. Lorsqu'un enfant est coupé d'un parent non marqué, marquez le parent. Lorsqu'un enfant est coupé d'un parent marqué, décochez le parent et coupez le parent de son parent.

Dans cette ancienne question de débordement de pile, j'ai esquissé une preuve qui montre que si les arbres peuvent être modifiés dans de cette façon, tout arbre d'ordre n doit contenir au moins Θ (φn) nœuds, où φ est le nombre d'or , environ 1,61. Cela signifie que le nombre de nœuds dans chaque arbre est toujours exponentiel dans l'ordre de l'arbre, bien qu'il s'agisse d'un exposant inférieur d'avant. Par conséquent, l'analyse que nous avons faite plus tôt sur la complexité temporelle de l'opération de diminution de la clé est toujours valable, bien que le terme caché dans le bit Θ (log n) soit différent.

Il y a une toute dernière chose à considérer - qu'en est-il de la complexité de la touche de diminution? Auparavant, c'était O(1) parce que nous venons de couper l'arbre enraciné au nœud approprié et de le déplacer vers la liste des racines. Cependant, nous devrons peut-être maintenant effectuer une "coupe en cascade". dans lequel nous coupons un nœud de son parent, puis coupons que nœud de son parent, etc. Comment cela donne-t-il O(1) touches de diminution du temps?

L'observation ici est que nous pouvons ajouter une "charge" à chaque opération de diminution de clé que nous pouvons ensuite dépenser pour couper le nœud parent de son parent. Comme nous ne coupons un nœud de son parent que s'il a déjà perdu deux enfants, nous pouvons prétendre que chaque opération de diminution de la clé paie le travail nécessaire pour couper son nœud parent. Lorsque nous supprimons le parent, nous pouvons imputer le coût de cette opération à l'une des opérations de diminution de clé précédentes. Par conséquent, même si toute opération de diminution de touche individuelle peut prendre beaucoup de temps pour se terminer, nous pouvons toujours amortir le travail sur les appels précédents afin que le temps d'exécution soit amorti O (1).

Troisième étape: les listes liées abondent!

Il y a un dernier détail dont nous devons parler. La structure de données que j'ai décrite jusqu'à présent est délicate, mais elle ne semble pas catastrophiquement compliquée. Les tas de Fibonacci ont la réputation d'être redoutables ... pourquoi?

La raison en est que pour implémenter toutes les opérations décrites ci-dessus, les structures arborescentes doivent être implémentées de manière très intelligente.

En règle générale, vous représenteriez un arbre à plusieurs voies soit en ayant chaque parent pointant vers tous les enfants (peut-être en ayant un tableau d'enfants) ou en en utilisant la représentation enfant gauche/frère droit , où le parent a un pointeur sur un enfant, qui à son tour pointe vers une liste des autres enfants. Pour un tas binomial, c'est parfait. L'opération principale que nous devons faire sur les arbres est une opération de jointure dans laquelle nous faisons d'un nœud racine un enfant d'un autre, il est donc parfaitement raisonnable pour les pointeurs de l'arbre dirigés des parents aux enfants.

Le problème dans un tas de Fibonacci est que cette représentation est inefficace lorsque l'on considère l'étape de diminution de la clé. Les tas de Fibonacci doivent prendre en charge toutes les manipulations de pointeur de base d'un tas binomial standard et la possibilité de couper un seul enfant d'un parent.

Considérez les représentations standard des arbres multivoies. Si nous représentons l'arbre en faisant en sorte que chaque nœud parent stocke un tableau ou une liste de pointeurs vers ses enfants, alors nous ne pouvons pas efficacement (dans O(1)) supprimer un nœud enfant de la liste En d'autres termes, le temps d'exécution pour la clé de diminution serait dominé par l'étape de tenue de livres de supprimer l'enfant plutôt que l'étape logique de déplacer un sous-arbre vers la liste racine! Le même problème apparaît dans le gauche-enfant, droite- représentation des frères et sœurs.

La solution à ce problème consiste à stocker l'arbre d'une manière bizarre. Chaque nœud parent stocke un pointeur sur un seul (et arbitraire) un de ses enfants. Les enfants sont ensuite stockés dans une liste liée de manière circulaire et chacun pointe vers son parent. Puisqu'il est possible de concaténer deux listes liées de manière circulaire en O(1) heure) et d'insérer ou de supprimer une seule entrée d'une en O(1) heure , cela permet de supporter efficacement les opérations d'arborescence nécessaires:

  • Faites d'un arbre un enfant d'un autre: si le premier arbre n'a pas d'enfants, définissez son pointeur enfant pour qu'il pointe vers le deuxième arbre. Sinon, épissez le deuxième arbre dans la liste enfant liée de manière circulaire du premier arbre.

  • Supprimer un enfant d'une arborescence: épissez ce nœud enfant de la liste des enfants liés pour le nœud parent. S'il s'agit du nœud unique choisi pour représenter les enfants du nœud parent, choisissez l'un des nœuds frères pour le remplacer (ou définissez le pointeur sur null s'il s'agit du dernier enfant).

Il y a absurdement de nombreux cas à considérer et à vérifier lors de l'exécution de toutes ces opérations simplement en raison du nombre de cas Edge différents qui peuvent survenir. Les frais généraux associés à tous les jonglages de pointeurs sont l'une des raisons pour lesquelles les tas de Fibonacci sont plus lents dans la pratique que ce que leur complexité asymptotique pourrait suggérer (l'autre grand est la logique de suppression de la valeur minimale, qui nécessite une structure de données auxiliaire).

Modification 6 : utilisez une représentation personnalisée de l'arbre qui prend en charge la jonction efficace des arbres et la coupe d'un arbre d'un autre.

Conclusion

J'espère que cette réponse éclaire le mystère qu'est le tas de Fibonacci. J'espère que vous pouvez voir la progression logique d'une structure plus simple (le tas binomial) à une structure plus complexe par une série d'étapes simples basées sur des idées raisonnables. Il n'est pas déraisonnable de vouloir rendre les insertions amorties de manière efficace au détriment des suppressions, et il n'est pas non plus trop fou d'implémenter la clé de diminution en supprimant les sous-arbres. De là, le reste des détails consiste à s'assurer que la structure est toujours efficace, mais ce sont plus conséquences des autres parties plutôt que causes.

Si vous souhaitez en savoir plus sur les tas de Fibonacci, vous pouvez consulter cette série de diapositives en deux parties. Première partie présente les tas binomiaux et montre comment fonctionnent les tas binomiaux paresseux. Deuxième partie explore les tas de Fibonacci. Ces diapositives vont plus en profondeur mathématique que ce que j'ai couvert ici.

J'espère que cela t'aides!

143
templatetypedef