Je regardais les conteneurs STL et essayais de comprendre ce qu'ils étaient vraiment (c'est-à-dire la structure de données utilisée), et le deque m'a arrêté: j'ai d'abord pensé que c’était une liste à double liaison, ce qui permettrait l’insertion et la suppression des deux côtés en temps constant, mais je suis troublé par la promesse faite par l’opérateur [] à effectuer en temps constant. Dans une liste chaînée, l'accès arbitraire devrait être O (n), non?
Et si c'est un tableau dynamique, comment peut-il ajouter des éléments en temps constant? Il convient de mentionner qu'une réaffectation peut se produire et que O(1) est un coût amorti, comme pour un vecteur .
Je me demande donc quelle est cette structure qui permet un accès arbitraire en temps constant et qui, en même temps, n’a jamais besoin d’être déplacée vers un nouvel endroit plus vaste.
Un deque est défini de manière quelque peu récursive: en interne, il maintient une file d'attente à deux extrémités de morceaux de taille fixe. Chaque morceau est un vecteur, et la file d'attente ("carte" dans le graphique ci-dessous) des morceaux est également un vecteur.
Il existe une excellente analyse des caractéristiques de performance et de la comparaison avec les vector
sur CodeProject .
L'implémentation standard de la bibliothèque GCC utilise en interne un T**
Pour représenter la carte. Chaque bloc de données est un T*
Auquel est attribuée une taille fixe __deque_buf_size
(Qui dépend de sizeof(T)
).
Imaginez-le comme un vecteur de vecteurs. Seulement, ils ne sont pas standard std::vector
s.
Le vecteur externe contient des pointeurs vers les vecteurs internes. Lorsque sa capacité est modifiée via une réaffectation, plutôt que d'allouer tout l'espace vide à la fin sous la forme std::vector
fait, il divise l’espace vide en parties égales au début et à la fin du vecteur. Ceci permet Push_front
et Push_back
sur ce vecteur pour que les deux apparaissent en temps amorti O(1).
Le comportement du vecteur interne doit changer selon qu'il se trouve à l'avant ou à l'arrière du deque
. À l'arrière, il peut se comporter comme une norme std::vector
où il pousse à la fin, et Push_back
se produit dans O(1) temps. Au début, il faut faire le contraire, en augmentant au début avec chaque Push_front
. En pratique, ceci est facilement réalisé en ajoutant un pointeur sur l'élément avant et la direction de la croissance avec la taille. Avec cette simple modification Push_front
_ peut également être O(1) heure.
L'accès à n'importe quel élément nécessite une compensation et une division en fonction du bon index de vecteur externe qui apparaît dans O (1), ainsi qu'une indexation dans le vecteur interne qui est également O (1). Cela suppose que les vecteurs internes ont tous une taille fixe, à l'exception de ceux situés au début ou à la fin du deque
.
deque = file d'attente double
Un conteneur qui peut pousser dans les deux sens.
Deque est généralement implémenté en tant que vector
sur vectors
(une liste de vecteurs ne peut pas donner un accès aléatoire à temps constant). Bien que la taille des vecteurs secondaires dépende de la mise en œuvre, un algorithme courant consiste à utiliser une taille constante en octets.
(C’est une réponse que j’ai donnée dans n autre fil . En gros, je soutiens que même des implémentations assez naïves, utilisant un seul vector
, sont conformes aux exigences de "constante non amorti Push_ {avant, arrière} ". Vous serez peut-être surpris et pensez que c'est impossible, mais j'ai trouvé d'autres citations pertinentes dans la norme qui définissent le contexte de manière surprenante. S'il vous plaît supportez-moi; si j'ai commis une erreur dans cette réponse, il serait très utile d’identifier les choses que j’ai dites correctement et où ma logique s’est effondrée.)
Dans cette réponse, je ne cherche pas à identifier une implémentation bien, je cherche simplement à nous aider à interpréter les exigences de complexité du standard C++. Je cite de N3242 , qui est, selon Wikipedia , le dernier document de normalisation C++ 11 disponible gratuitement. (Elle semble être organisée différemment de la norme finale et, par conséquent, je ne citerai pas les numéros de page exacts. Bien sûr, ces règles ont peut-être changé dans la norme finale, mais je ne pense pas que cela soit arrivé.)
Un deque<T>
Pourrait être implémenté correctement en utilisant un vector<T*>
. Tous les éléments sont copiés sur le tas et les pointeurs stockés dans un vecteur. (Plus d'informations sur le vecteur plus tard).
Pourquoi T*
Au lieu de T
? Parce que la norme exige que
"Une insertion à l'une des extrémités du deque annule tous les itérateurs du deque, mais n'a aucun effet sur la validité des références aux éléments du deque."
(mon emphase). Le T*
Aide à satisfaire cela. Cela nous aide également à satisfaire ceci:
"L'insertion d'un seul élément soit au début ou à la fin d'un deque toujours ..... provoque un appel unique à un constructeur de T."
Passons maintenant au bit (controversé). Pourquoi utiliser un vector
pour stocker le T*
? Cela nous donne un accès aléatoire, ce qui est un bon début. Oublions un instant la complexité du vecteur et montons-la prudemment:
La norme parle du "nombre d'opérations sur les objets contenus". Pour deque::Push_front
, Il s'agit clairement de 1, car un seul objet T
est construit et zéro des objets T
existants sont lus ou numérisés de quelque manière que ce soit. Ce nombre, 1, est clairement une constante et est indépendant du nombre d'objets actuellement dans le deque. Cela nous permet de dire que:
'Pour notre deque::Push_front
, Le nombre d'opérations sur les objets contenus (les Ts) est fixe et est indépendant du nombre d'objets déjà dans le deque.'
Bien sûr, le nombre d'opérations sur le T*
Ne sera pas aussi sage. Lorsque le vector<T*>
Devient trop gros, il sera réaffecté et de nombreux T*
Seront copiés. Donc oui, le nombre d'opérations sur le T*
Variera énormément, mais le nombre d'opérations sur T
ne sera pas affecté.
Pourquoi nous soucions-nous de cette distinction entre compter les opérations sur T
et compter les opérations sur T*
? C'est parce que la norme dit:
Toutes les exigences de complexité de cette clause sont exprimées uniquement en termes de nombre d'opérations sur les objets contenus.
Pour le deque
, les objets contenus sont le T
et non le T*
, Ce qui signifie que nous pouvons ignorer toute opération qui copie (ou reallocs) un T*
.
Je n'ai pas beaucoup parlé de la manière dont un vecteur se comporterait dans une deque. Peut-être pourrions-nous l’interpréter comme un tampon circulaire (le vecteur prenant toujours son maximum de capacity()
), puis réaffectant tout dans un tampon plus grand lorsque le vecteur est plein. Les détails importent peu.
Dans les derniers paragraphes, nous avons analysé deque::Push_front
Et la relation entre le nombre d'objets déjà présents dans le deque et le nombre d'opérations effectuées par Push_front sur les objets T
- contenus. Et nous avons trouvé qu'ils étaient indépendants les uns des autres. Comme le mandat standard est que la complexité est en termes d'opérations sur T
, alors nous pouvons dire que cela a une complexité constante.
Oui, la Operations-On-T * -Complexity est amortie (en raison de la vector
), mais nous ne sommes intéressés que par la Opérations sur T-Complexité et ceci est constant (non amorti).
La complexité de vector :: Push_back ou vector :: Push_front n'est pas pertinente dans cette implémentation; ces considérations impliquent des opérations sur T*
et ne sont donc pas pertinentes. Si la norme faisait référence à la notion théorique "classique" de complexité, elle ne se serait pas explicitement limitée au "nombre d'opérations sur les objets contenus". Est-ce que je surinterprète cette phrase?
Bien que la norme n'impose aucune implémentation particulière (uniquement un accès aléatoire à temps constant), un deque est généralement implémenté sous la forme d'une collection de "pages" de mémoire contiguë. De nouvelles pages sont allouées au besoin, mais vous avez toujours un accès aléatoire. Contrairement à std::vector
, on ne vous promet pas que les données sont stockées de manière contiguë, mais comme pour les vecteurs, les insertions au milieu nécessitent de nombreux déplacements.
Je lisais "Structures de données et algorithmes en C++" de Adam Drozdek, et le trouvai utile. HTH.
Un aspect très intéressant de deque STL est sa mise en œuvre. Un deque STL n'est pas implémenté en tant que liste chaînée mais en tant que tableau de pointeurs vers des blocs ou des tableaux de données. Le nombre de blocs change de manière dynamique en fonction des besoins en stockage, et la taille du tableau de pointeurs change en conséquence.
Vous remarquerez au milieu que se trouve le tableau de pointeurs sur les données (blocs à droite) et que le tableau au milieu change de façon dynamique.
Une image vaut mille mots.