web-dev-qa-db-fra.com

Façon la plus simple d'utiliser la file d'attente prioritaire minimale avec la mise à jour des clés en C ++

Parfois, lors des concours de programmation, etc., nous avons besoin d'une implémentation de travail simple de la file d'attente prioritaire min avec clé de diminution pour implémenter l'algorithme Dijkstra, etc. J'utilise souvent set <pair <key_value, ID>> et un tableau (mapping ID -> key_value ) ensemble pour y parvenir.

  • L'ajout d'un élément à l'ensemble prend O(log(N)) temps. Pour créer une file d'attente prioritaire à partir de N éléments, nous les ajoutons simplement un par un dans l'ensemble. Cela prend O ( N log (N)) temps au total.

  • L'élément avec min key_value est simplement le premier élément de l'ensemble. Le sondage du plus petit élément prend O(1) temps. Le supprimer prend O(log(N)) temps.

  • Pour tester si un ID = k est dans l'ensemble, nous recherchons d'abord sa valeur_clé = v_k dans le tableau, puis recherchons l'élément (v_k, k) dans l'ensemble. Cela prend O(log(N)) temps.

  • Pour changer la valeur_clé de certains ID = k de v_k à v_k ', nous recherchons d'abord sa valeur_clé = v_k dans le tableau, puis recherchons l'élément (v_k, k) dans l'ensemble. Ensuite, nous supprimons cet élément de l'ensemble, puis insérons l'élément (v_k ', k) dans l'ensemble. Nous mettons ensuite à jour le tableau également. Cela prend O(log(N)) temps.

Bien que l'approche ci-dessus fonctionne, la plupart des manuels recommandent généralement d'utiliser des tas binaires pour implémenter des files d'attente prioritaires, car le temps de construction des tas binaires est juste O (N). J'ai entendu dire qu'il existe une structure de données de file d'attente prioritaire intégrée dans STL de C++ qui utilise des tas binaires. Cependant, je ne sais pas comment mettre à jour la valeur_clé pour cette structure de données.

Quelle est la manière la plus simple et la plus efficace d'utiliser la file d'attente prioritaire minimale avec la mise à jour des clés en C++?

51
Chong Luo

Eh bien, comme Darren l'a déjà dit, std::priority_queue n'a pas de moyen pour diminuer la priorité d'un élément et ni la suppression d'un élément autre que le min courant. Mais la valeur par défaut std::priority_queue n'est rien de plus qu'un simple adaptateur de conteneur autour d'un std::vector qui utilise les fonctions de tas std de <algorithm> ( std::Push_heap , std::pop_heap et std::make_heap ). Donc, pour Dijkstra (où vous avez besoin d'une mise à jour prioritaire), je le fais généralement moi-même et j'utilise un simple std::vector.

Un Push n'est alors que l'opération O (log N)

vec.Push_back(item);
std::Push_heap(vec.begin(), vec.end());

Bien sûr, pour construire à nouveau une file d'attente à partir de N éléments, nous ne les poussons pas tous en utilisant cette opération O (log N) (ce qui rend le tout O (Nlog N)), mais nous les mettons simplement tous dans le vecteur suivi d'un simple O (N)

std::make_heap(vec.begin(), vec.end());

L'élément min est un simple O (1)

vec.front();

Un pop est la simple séquence O (log N)

std::pop_heap(vec.begin(), vec.end());
vec.pop_back();

Jusqu'à présent, c'est exactement ce que std::priority_queue fait généralement sous le capot. Maintenant, pour changer la priorité d'un élément, il nous suffit de le changer (mais il peut être incorporé dans le type de l'élément) et de faire de la séquence un tas valide à nouveau

std::make_heap(vec.begin(), vec.end());

Je sais que c'est une opération O(N), mais d'un autre côté, cela supprime tout besoin de suivre la position d'un élément dans le tas avec une structure de données supplémentaire ou (pire encore) une Et la pénalité de performance par rapport à une mise à jour de priorité logarithmique n'est en pratique pas si importante, étant donné la facilité d'utilisation, l'utilisation compacte et linéaire de la mémoire de std::vector (ce qui a également un impact sur l'exécution), et le fait que je travaille souvent avec des graphiques qui ont assez peu d'arêtes (linéaires dans le nombre de vertex) de toute façon.

Ce n'est peut-être pas dans tous les cas le moyen le plus rapide, mais certainement le plus simple.

EDIT: Oh, et puisque la bibliothèque standard utilise max-tas, vous devez utiliser un équivalent à > pour comparer les priorités (comme vous les obtenez dans les éléments), au lieu de la valeur par défaut < opérateur.

42
Christian Rau

Bien que ma réponse ne réponde pas à la question d'origine, je pense qu'elle pourrait être utile pour les personnes qui parviennent à cette question en essayant d'implémenter l'algorithme Dijkstra en C++/Java (comme moi), quelque chose qui a été commenté par l'OP,

priority_queue en C++ (ou PriorityQueue en Java) ne fournit pas de decrease-key opération, comme indiqué précédemment. Une bonne astuce pour utiliser ces classes lors de l'implémentation de Dijkstra est d'utiliser la "suppression paresseuse". La boucle principale de l'algorithme de Dijkstra extrait le nœud suivant à traiter de la file d'attente prioritaire et analyse tous ses nœuds adjacents, modifiant éventuellement le coût du chemin minimal pour un nœud dans la file d'attente prioritaire. C'est le point où decrease-key est généralement nécessaire pour mettre à jour la valeur de ce nœud.

L'astuce est pas le changer du tout. Au lieu de cela, une "nouvelle copie" pour ce nœud (avec son nouveau meilleur coût) est ajoutée dans la file d'attente prioritaire. Ayant un coût inférieur, cette nouvelle copie du nœud sera extraite avant la copie d'origine dans la file d'attente, elle sera donc traitée plus tôt.

Le problème avec cette "suppression paresseuse" est que la deuxième copie du nœud, avec le mauvais coût le plus élevé, sera finalement extraite de la file d'attente prioritaire. Mais cela se produira toujours après le traitement de la deuxième copie ajoutée, avec un meilleur coût. Donc la toute première chose que la boucle Dijkstra principale doit faire lors de l'extraction du nœud suivant de la file d'attente prioritaire vérifie si le nœud a déjà été visité (et nous connaissons déjà le chemin le plus court). C'est à ce moment précis que nous allons faire la "suppression paresseuse" et l'élément doit être simplement ignoré.

Cette solution aura un coût à la fois en mémoire et en temps, car la file d'attente prioritaire stocke des "éléments morts" que nous n'avons pas supprimés. Mais le coût réel sera assez faible, et la programmation de cette solution est, à mon humble avis, plus facile que toute autre alternative qui tente de simuler le decrease-key opération.

37
Googol

Je ne pense pas que la classe std::priority_queue permet une implémentation efficace des opérations de style decrease-key.

J'ai roulé ma propre structure de données basée sur un tas binaire qui prend en charge cela, essentiellement sur des lignes très similaires à ce que vous avez décrit pour la file d'attente prioritaire basée sur std::set Que vous avez:

  • Maintenez un tas binaire, trié par value qui stocke des éléments de pair<value, ID> Et un tableau qui mappe ID -> heap_index. Dans les routines de tas heapify_up, heapify_down, Etc., il est nécessaire de s'assurer que le tableau de mappage est synchronisé avec la position actuelle des tas des éléments. Cela ajoute une surcharge supplémentaire de O(1).
  • La conversion d'un tableau en tas peut se faire dans O(N) selon l'algorithme standard décrit ici .
  • Furtivement à l'élément racine est O(1).
  • Vérifier si un ID est actuellement dans le tas nécessite simplement une recherche O(1) dans le tableau de mappage. Cela permet également à O(1) de jeter un œil à l'élément correspondant à tout ID.
  • Decrease-key Nécessite une recherche O(1) dans le tableau de mappage suivie d'une mise à jour O(log(N)) vers le tas via heapify_up, heapify_down.
  • Pousser un nouvel élément sur le tas est O(log(N)) tout comme faire sauter un élément sortant du tas.

Donc, asymptotiquement, le runtime est amélioré pour quelques-unes des opérations par rapport à la structure de données basée sur std::set. Une autre amélioration importante est que les tas binaires peuvent être implémentés sur un tableau, tandis que les arbres binaires sont des conteneurs basés sur des nœuds. La localité de données supplémentaire du tas binaire se traduit généralement par une amélioration de l'exécution.

Quelques problèmes avec cette implémentation:

  • Il autorise uniquement les éléments entiers ID.
  • Il suppose une distribution étroite des éléments ID, commençant à zéro (sinon la complexité de l'espace du tableau de mappage explose!).

Vous pourriez potentiellement surmonter ces problèmes si vous mainteniez une table de hachage de mappage, plutôt qu'un tableau de mappage, mais avec un peu plus de temps d'exécution. Pour mon usage, les ID entiers ont toujours suffi.

J'espère que cela t'aides.

17
Darren Engwirda