Cos'è davvero un deque in STL?


194

Stavo guardando i contenitori STL e cercavo di capire cosa fossero realmente (cioè la struttura dei dati utilizzata), e il deque mi ha fermato: all'inizio ho pensato che fosse un doppio elenco collegato, che avrebbe permesso l'inserimento e la cancellazione da entrambe le estremità in tempo costante, ma sono turbato dalla promessa fatta dall'operatore [] di essere fatta in tempo costante. In un elenco collegato, l'accesso arbitrario dovrebbe essere O (n), giusto?

E se è un array dinamico, come può aggiungere elementi a tempo costante? Dovrebbe essere menzionato che la riallocazione può avvenire e che O (1) è un costo ammortizzato, come per un vettore .

Quindi mi chiedo quale sia questa struttura che consente un accesso arbitrario in tempo costante e allo stesso tempo non deve mai essere spostato in un nuovo posto più grande.


4
possibile duplicato dell'accesso al deque STL per indice è O (1)?
Fredoverflow

1
@Graham "dequeue" è un altro nome comune per "deque". Ho ancora approvato la modifica poiché "deque" è di solito il nome canonico.
Konrad Rudolph,

@Konrad Grazie. La domanda riguardava specificamente il deque C ++ STL, che utilizza l'ortografia più breve.
Graham Borland,

2
dequesta per doppia coda , anche se ovviamente il rigoroso requisito di accesso O (1) agli elementi di mezzo è particolare per C ++
Matthieu M.

Risposte:


186

Un deque è in qualche modo definito in modo ricorsivo: internamente mantiene una coda doppia di blocchi di dimensioni fisse. Ogni blocco è un vettore e anche la coda ("mappa" nell'immagine seguente) dei blocchi stessi è un vettore.

schematico del layout di memoria di un deque

C'è una grande analisi delle caratteristiche prestazionali e di come si confronta con il codicevector di CodeProject .

L'implementazione della libreria standard GCC utilizza internamente a T**per rappresentare la mappa. Ogni blocco di dati è un T*allocato con una dimensione fissa __deque_buf_size(che dipende da sizeof(T)).


28
Questa è la definizione di un deque come l'ho imparato, ma in questo modo non può garantire un accesso costante al tempo, quindi ci deve essere qualcosa che manca.
stefaanv,

14
@stefaanv, @Konrad: le implementazioni C ++ che ho visto hanno usato una serie di puntatori per array di dimensioni fisse. Ciò significa effettivamente che push_front e push_back non sono tempi realmente costanti, ma con fattori di crescita intelligenti, tuttavia, si ottengono comunque tempi costanti ammortizzati, quindi O (1) non è così errato, e in pratica è più veloce del vettore perché si scambia puntatori singoli anziché interi (e meno puntatori rispetto agli oggetti).
Matthieu M.

5
L'accesso a tempo costante è ancora possibile. Solo, se è necessario allocare un nuovo blocco nella parte anteriore, spingere indietro un nuovo puntatore sul vettore principale e spostare tutti i puntatori.
Xeo,

4
Se la mappa (la coda stessa) era un elenco a doppia estremità, non vedo come potesse consentire l'accesso casuale O (1). Potrebbe essere implementato come un buffer circolare, che consentirebbe un ridimensionamento del buffer circolare più efficiente: copia solo i puntatori anziché tutti gli elementi nella coda. Sembra comunque un piccolo vantaggio.
Wernight,

15
@JeremyWest Perché no? L'accesso indicizzato va all'elemento i% B-esimo nel blocco i / B-esimo (B = dimensione del blocco), che è chiaramente O (1). È possibile aggiungere un nuovo blocco in O (1) ammortizzato, quindi l'aggiunta di elementi è O (1) ammortizzata alla fine. L'aggiunta di un nuovo elemento all'inizio è O (1) a meno che non sia necessario aggiungere un nuovo blocco. L'aggiunta di un nuovo blocco all'inizio non è O (1), vero, è O (N) ma in realtà ha un fattore costante molto piccolo poiché è necessario spostare solo i puntatori N / B anziché N gli elementi.
Konrad Rudolph,

22

Immaginalo come un vettore di vettori. Solo che non sono standard std::vector.

Il vettore esterno contiene puntatori ai vettori interni. Quando la sua capacità viene modificata tramite la riallocazione, anziché allocare tutto lo spazio vuoto alla fine, come std::vectorsi divide, lo spazio vuoto viene diviso in parti uguali all'inizio e alla fine del vettore. Ciò consente push_fronte push_backsu questo vettore si verificano entrambi nel tempo O (1) ammortizzato.

Il comportamento del vettore interno deve cambiare a seconda che si trovi nella parte anteriore o posteriore del file deque. Sul retro può comportarsi come uno standard in std::vectorcui cresce alla fine e si push_backverifica in O (1) tempo. Nella parte anteriore deve fare il contrario, crescendo all'inizio con ciascuno push_front. In pratica, ciò si ottiene facilmente aggiungendo un puntatore all'elemento frontale e alla direzione di crescita insieme alla dimensione. Con questa semplice modifica push_frontpuò anche essere O (1) tempo.

L'accesso a qualsiasi elemento richiede la compensazione e la divisione per il corretto indice di vettore esterno che si verifica in O (1) e l'indicizzazione nel vettore interno che è anche O (1). Ciò presuppone che i vettori interni siano tutti di dimensioni fisse, ad eccezione di quelli all'inizio o alla fine di deque.


1
Puoi descrivere i vettori interni con una capacità
Caleth,

18

deque = coda a doppia estremità

Un contenitore che può crescere in entrambe le direzioni.

Deque è in genere implementato come vectordi vectors(un elenco di vettori non può fornire un accesso casuale a tempo costante). Mentre la dimensione dei vettori secondari dipende dall'implementazione, un algoritmo comune è quello di utilizzare una dimensione costante in byte.


6
Non è abbastanza vettori internamente. Le strutture interne possono avere allocato ma inutilizzata la capacità all'inizio e alla fine
Mooing Duck,

@MooingDuck: l'implementazione è definita davvero. Può essere una matrice di matrici o un vettore di vettori o qualsiasi cosa in grado di fornire il comportamento e la complessità imposti dallo standard.
Alok Salva il

1
@Als: non penso a arrayniente o a qualsiasi vectorcosa possa promettere O(1)push_front ammortizzato . Almeno l'interno delle due strutture deve essere in grado di avere un O(1)push_front, che né un arrayné un vectorpossono garantire.
Mooing Duck,

4
@MooingDuck tale requisito è facilmente soddisfatto se il primo pezzo cresce dall'alto verso il basso anziché dal basso verso l'alto. Ovviamente uno standard vectornon lo fa, ma è una modifica abbastanza semplice da renderlo tale.
Mark Ransom,

3
@ Mooing Duck, sia push_front che push_back possono essere facilmente eseguiti in O ammortizzato (1) con una singola struttura vettoriale. È solo un po 'più di contabilità di un buffer circolare, niente di più. Supponiamo di avere un vettore regolare di capacità 1000 con 100 elementi nelle posizioni da 0 a 99. Ora quando si verifica un push_Front basta spingere alla fine, cioè nella posizione 999, quindi 998 ecc., Finché le due estremità non si incontrano. Quindi riallocare (con crescita esponenziale per garantire tempi costanti di ammortamento) proprio come si farebbe con un vettore normale. Così efficacemente hai solo bisogno di un puntatore aggiuntivo per prima el.
plamenko,

14

(Questa è una risposta che ho dato in un altro thread . Sostanzialmente sto sostenendo che anche implementazioni abbastanza ingenue, usando una sola vector, sono conformi ai requisiti di "push non ammortizzato costante_ {front, back}". Potresti essere sorpreso e penso che sia impossibile, ma ho trovato altre citazioni pertinenti nello standard che definiscono il contesto in modo sorprendente. Per favore, abbi pazienza con me; se ho fatto un errore in questa risposta, sarebbe molto utile identificare quali cose Ho detto correttamente e dove la mia logica si è rotta.)

In questa risposta, non sto cercando di identificare una buona implementazione, sto solo cercando di aiutarci a interpretare i requisiti di complessità nello standard C ++. Sto citando da N3242 , che è, secondo Wikipedia , l'ultimo documento di standardizzazione C ++ 11 disponibile gratuitamente. (Sembra essere organizzato in modo diverso dallo standard finale, e quindi non citerò gli esatti numeri di pagina. Naturalmente, queste regole potrebbero essere cambiate nello standard finale, ma non penso che sia successo.)

A deque<T>potrebbe essere implementato correttamente usando a vector<T*>. Tutti gli elementi vengono copiati nell'heap e i puntatori memorizzati in un vettore. (Maggiori informazioni sul vettore più tardi).

Perché T*invece di T? Perché lo standard lo richiede

"Un inserimento alle due estremità del deque invalida tutti gli iteratori del deque, ma non ha alcun effetto sulla validità dei riferimenti agli elementi del deque. "

(la mia enfasi). La T*aiuta a soddisfare tale. Ci aiuta anche a soddisfare questo:

"L'inserimento di un singolo elemento all'inizio o alla fine di una deque sempre ..... provoca una singola chiamata a un costruttore di T. "

Ora per il (controverso) pezzo. Perché usare a vectorper memorizzare T*? Ci dà accesso casuale, che è un buon inizio. Dimentichiamo per un momento la complessità del vettore e costruiamo attentamente questo:

Lo standard parla di "il numero di operazioni sugli oggetti contenuti". Per deque::push_frontquesto è chiaramente 1 perché Tviene costruito esattamente un oggetto e zero degli Toggetti esistenti viene letto o scansionato in alcun modo. Questo numero, 1, è chiaramente una costante ed è indipendente dal numero di oggetti attualmente nel deque. Questo ci consente di dire che:

"Per noi deque::push_front, il numero di operazioni sugli oggetti contenuti (Ts) è fisso ed è indipendente dal numero di oggetti già presenti nel deque."

Naturalmente, il numero di operazioni sul T*non sarà così ben educato. Quando vector<T*>diventa troppo grande, sarà realloced e molte T*s verranno copiate in giro. Quindi sì, il numero di operazioni su T*varia notevolmente, ma il numero di operazioni su Tnon sarà interessato.

Perché ci preoccupiamo di questa distinzione tra contare le operazioni Te contare le operazioni T*? È perché lo standard dice:

Tutti i requisiti di complessità di questa clausola sono indicati esclusivamente in termini di numero di operazioni sugli oggetti contenuti.

Per il deque, gli oggetti contenuti sono il T, non il T*, il che significa che possiamo ignorare qualsiasi operazione che copia (o reallocs) a T*.

Non ho parlato molto di come un vettore si comporterebbe in un deque. Forse lo interpreteremmo come un buffer circolare (con il vettore che occupa sempre il massimo capacity(), quindi rialloceremo tutto in un buffer più grande quando il vettore è pieno. I dettagli non contano.

Negli ultimi paragrafi, abbiamo analizzato deque::push_fronte la relazione tra il numero di oggetti nel deque già e il numero di operazioni eseguite da push_front su Toggetti contenuti . E abbiamo scoperto che erano indipendenti l'uno dall'altro. Poiché lo standard impone che la complessità sia in termini di operazioni on-on T, allora possiamo dire che ha una complessità costante.

Sì, la complessità Operations-On-T * è ammortizzata (a causa di vector), ma siamo interessati solo alla complessità Operations-On-T e questa è costante (non ammortizzata).

La complessità di vector :: push_back o vector :: push_front è irrilevante in questa implementazione; tali considerazioni implicano operazioni su T*e quindi irrilevanti. Se lo standard si riferisse alla nozione teorica "convenzionale" di complessità, allora non si sarebbero esplicitamente limitati al "numero di operazioni sugli oggetti contenuti". Sto interpretando in modo eccessivo quella frase?


8
Mi sembra molto un tradimento! Quando si specifica la complessità di un'operazione, non lo si fa solo su alcune parti dei dati: si vuole avere un'idea del tempo di esecuzione previsto dell'operazione che si sta chiamando, indipendentemente da ciò su cui opera. Se seguo la tua logica sulle operazioni su T, significherebbe che potresti verificare se il valore di ogni T * è un numero primo ogni volta che viene eseguita un'operazione e comunque rispettare lo standard poiché non tocchi Ts. Potresti specificare da dove provengono le tue citazioni?
Zonko,

2
Penso che gli scrittori standard sappiano che non possono usare la teoria della complessità convenzionale perché non abbiamo un sistema completamente specificato in cui conosciamo, ad esempio, la complessità dell'allocazione della memoria. Non è realistico fingere che la memoria possa essere allocata per un nuovo membro di un listindipendentemente dalle dimensioni correnti dell'elenco; se l'elenco è troppo grande, l'allocazione sarà lenta o fallirà. Quindi, per quanto posso vedere, il comitato ha deciso di specificare solo le operazioni che possono essere obiettivamente conteggiate e misurate. (PS: ho un'altra teoria su questo per un'altra risposta.)
Aaron McDaid il

Sono abbastanza sicuro che O(n)il numero di operazioni è asintoticamente proporzionale al numero di elementi. IE, le meta-operazioni contano. Altrimenti non avrebbe senso limitare la ricerca O(1). Ergo, le liste collegate non si qualificano.
Mooing Duck il

8
Questa è un'interpretazione molto interessante, ma con questa logica listpotrebbe essere implementato anche come vectorpuntatore (gli inserimenti nel mezzo comporteranno una chiamata al costruttore di una singola copia, indipendentemente dalle dimensioni dell'elenco, e il O(N)mescolamento dei puntatori può essere ignorato perché non sono operazioni su T).
Mankarse,

1
Questo è un buon avvocato linguistico (anche se non proverò a capire se sia effettivamente corretto o se c'è qualche punto sottile nello standard che proibisce questa implementazione). Ma in pratica non sono informazioni utili, perché (1) implementazioni comuni non implementano in dequequesto modo e (2) "barano" in questo modo (anche se consentito dallo standard) quando la complessità algoritmica del calcolo non è utile nella scrittura di programmi efficienti .
Kyle Strand,

13

Dalla panoramica, puoi pensare dequecome adouble-ended queue

panoramica deque

I dati in dequesono memorizzati da blocchi di vettore di dimensioni fisse, che sono

puntato da un map(che è anche un pezzo di vettore, ma le sue dimensioni possono cambiare)

deque struttura interna

Il codice parte principale di deque iteratorè il seguente:

/*
buff_size is the length of the chunk
*/
template <class T, size_t buff_size>
struct __deque_iterator{
    typedef __deque_iterator<T, buff_size>              iterator;
    typedef T**                                         map_pointer;

    // pointer to the chunk
    T* cur;       
    T* first;     // the begin of the chunk
    T* last;      // the end of the chunk

    //because the pointer may skip to other chunk
    //so this pointer to the map
    map_pointer node;    // pointer to the map
}

Il codice parte principale di dequeè il seguente:

/*
buff_size is the length of the chunk
*/
template<typename T, size_t buff_size = 0>
class deque{
    public:
        typedef T              value_type;
        typedef T&            reference;
        typedef T*            pointer;
        typedef __deque_iterator<T, buff_size> iterator;

        typedef size_t        size_type;
        typedef ptrdiff_t     difference_type;

    protected:
        typedef pointer*      map_pointer;

        // allocate memory for the chunk 
        typedef allocator<value_type> dataAllocator;

        // allocate memory for map 
        typedef allocator<pointer>    mapAllocator;

    private:
        //data members

        iterator start;
        iterator finish;

        map_pointer map;
        size_type   map_size;
}

Di seguito ti fornirò il codice di base deque, principalmente di tre parti:

  1. iteratore

  2. Come costruire a deque

1. iteratore ( __deque_iterator)

Il problema principale di iteratore è, quando ++, - iteratore, può saltare ad un altro blocco (se punta al bordo del blocco). Per esempio, ci sono tre blocchi di dati: chunk 1, chunk 2, chunk 3.

I pointer1puntatori all'inizio di chunk 2, quando l'operatore --pointerpunterà alla fine di chunk 1, così come al pointer2.

inserisci qui la descrizione dell'immagine

Di seguito fornirò la funzione principale di __deque_iterator:

Innanzitutto, passa a qualsiasi blocco:

void set_node(map_pointer new_node){
    node = new_node;
    first = *new_node;
    last = first + chunk_size();
}

Nota che, la chunk_size()funzione che calcola la dimensione del blocco, puoi pensare che ritorni 8 per semplificare qui.

operator* ottenere i dati nel blocco

reference operator*()const{
    return *cur;
}

operator++, --

// prefisso forme di incremento

self& operator++(){
    ++cur;
    if (cur == last){      //if it reach the end of the chunk
        set_node(node + 1);//skip to the next chunk
        cur = first;
    }
    return *this;
}

// postfix forms of increment
self operator++(int){
    self tmp = *this;
    ++*this;//invoke prefix ++
    return tmp;
}
self& operator--(){
    if(cur == first){      // if it pointer to the begin of the chunk
        set_node(node - 1);//skip to the prev chunk
        cur = last;
    }
    --cur;
    return *this;
}

self operator--(int){
    self tmp = *this;
    --*this;
    return tmp;
}
iteratore salta n passaggi / accesso casuale
self& operator+=(difference_type n){ // n can be postive or negative
    difference_type offset = n + (cur - first);
    if(offset >=0 && offset < difference_type(buffer_size())){
        // in the same chunk
        cur += n;
    }else{//not in the same chunk
        difference_type node_offset;
        if (offset > 0){
            node_offset = offset / difference_type(chunk_size());
        }else{
            node_offset = -((-offset - 1) / difference_type(chunk_size())) - 1 ;
        }
        // skip to the new chunk
        set_node(node + node_offset);
        // set new cur
        cur = first + (offset - node_offset * chunk_size());
    }

    return *this;
}

// skip n steps
self operator+(difference_type n)const{
    self tmp = *this;
    return tmp+= n; //reuse  operator +=
}

self& operator-=(difference_type n){
    return *this += -n; //reuse operator +=
}

self operator-(difference_type n)const{
    self tmp = *this;
    return tmp -= n; //reuse operator +=
}

// random access (iterator can skip n steps)
// invoke operator + ,operator *
reference operator[](difference_type n)const{
    return *(*this + n);
}

2. Come costruire a deque

funzione comune di deque

iterator begin(){return start;}
iterator end(){return finish;}

reference front(){
    //invoke __deque_iterator operator*
    // return start's member *cur
    return *start;
}

reference back(){
    // cna't use *finish
    iterator tmp = finish;
    --tmp; 
    return *tmp; //return finish's  *cur
}

reference operator[](size_type n){
    //random access, use __deque_iterator operator[]
    return start[n];
}


template<typename T, size_t buff_size>
deque<T, buff_size>::deque(size_t n, const value_type& value){
    fill_initialize(n, value);
}

template<typename T, size_t buff_size>
void deque<T, buff_size>::fill_initialize(size_t n, const value_type& value){
    // allocate memory for map and chunk
    // initialize pointer
    create_map_and_nodes(n);

    // initialize value for the chunks
    for (map_pointer cur = start.node; cur < finish.node; ++cur) {
        initialized_fill_n(*cur, chunk_size(), value);
    }

    // the end chunk may have space node, which don't need have initialize value
    initialized_fill_n(finish.first, finish.cur - finish.first, value);
}

template<typename T, size_t buff_size>
void deque<T, buff_size>::create_map_and_nodes(size_t num_elements){
    // the needed map node = (elements nums / chunk length) + 1
    size_type num_nodes = num_elements / chunk_size() + 1;

    // map node num。min num is  8 ,max num is "needed size + 2"
    map_size = std::max(8, num_nodes + 2);
    // allocate map array
    map = mapAllocator::allocate(map_size);

    // tmp_start,tmp_finish poniters to the center range of map
    map_pointer tmp_start  = map + (map_size - num_nodes) / 2;
    map_pointer tmp_finish = tmp_start + num_nodes - 1;

    // allocate memory for the chunk pointered by map node
    for (map_pointer cur = tmp_start; cur <= tmp_finish; ++cur) {
        *cur = dataAllocator::allocate(chunk_size());
    }

    // set start and end iterator
    start.set_node(tmp_start);
    start.cur = start.first;

    finish.set_node(tmp_finish);
    finish.cur = finish.first + num_elements % chunk_size();
}

Supponiamo che i_dequeabbia 20 elementi int la 0~19cui dimensione del blocco è 8 e ora push_back 3 elementi (0, 1, 2) per i_deque:

i_deque.push_back(0);
i_deque.push_back(1);
i_deque.push_back(2);

È una struttura interna come di seguito:

inserisci qui la descrizione dell'immagine

Quindi push_back di nuovo, richiamerà allocare nuovo blocco:

push_back(3)

inserisci qui la descrizione dell'immagine

Se lo facciamo push_front, assegnerà un nuovo pezzo prima del precedentestart

inserisci qui la descrizione dell'immagine

Nota quando l' push_backelemento in deque, se tutte le mappe e i blocchi sono pieni, causerà l'allocazione di una nuova mappa e la regolazione dei blocchi, ma il codice sopra potrebbe essere sufficiente per la comprensione deque.


Hai menzionato "Nota quando l'elemento push_back in deque, se tutte le mappe e i blocchi sono riempiti, causerà l'allocazione di una nuova mappa e la regolazione dei blocchi". Mi chiedo perché lo standard C ++ dice "[26.3.8.4.3] L'inserimento di un singolo elemento all'inizio o alla fine di un deque richiede sempre tempo costante" in N4713. Allocare un blocco di dati richiede più di un tempo costante. No?
HCSF

7

Stavo leggendo "Strutture dati e algoritmi in C ++" di Adam Drozdek, e l'ho trovato utile. HTH.

Un aspetto molto interessante del deque STL è la sua implementazione. Un deque STL non è implementato come un elenco collegato ma come una matrice di puntatori a blocchi o matrici di dati. Il numero di blocchi cambia in modo dinamico a seconda delle esigenze di archiviazione e la dimensione dell'array di puntatori cambia di conseguenza.

Si può notare che nel mezzo si trova l'array di puntatori ai dati (blocchi a destra), e si può anche notare che l'array nel mezzo sta cambiando dinamicamente.

Un'immagine vale più di mille parole.

inserisci qui la descrizione dell'immagine


1
Grazie per aver segnalato un libro. Ho letto la dequeparte ed è abbastanza buona.
Rick,

@Rick felice di sentirlo. Ricordo di aver scavato nel deque ad un certo punto perché non riuscivo a capire come si possa avere accesso casuale (operatore []) in O (1). Dimostrare anche che (push / pop) _ (back / front) ha ammortizzato la complessità di O (1) è un interessante "aha moment".
Keloo,

6

Sebbene lo standard non imponga una particolare implementazione (solo accesso casuale a tempo costante), un deque viene solitamente implementato come una raccolta di "pagine" di memoria contigue. Le nuove pagine vengono allocate in base alle esigenze, ma hai ancora accesso casuale. Diversamente std::vector, non ti viene promesso che i dati vengano archiviati in modo contiguo, ma come nel caso del vettore, gli inserimenti nel mezzo richiedono molti trasferimenti.


4
o eliminazioni nel mezzo richiedono un sacco di trasferimento
Mark Hendrickson,

Se insertrichiede un sacco di delocalizzazione come fa esperimento 4 qui mostrare sconcertante differenza tra vector::insert()e deque::insert()?
Bula,

1
@Bula: forse a causa di una comunicazione errata dei dettagli? La complessità dell'inserto del deque è "lineare nel numero di elementi inseriti più la minore delle distanze all'inizio e alla fine del deque". Per sentire questo costo, è necessario inserire nel centro corrente; è quello che sta facendo il tuo benchmark?
Kerrek SB,

@KerrekSB: l'articolo con benchmark è stato citato nella risposta di Konrad sopra. In realtà non ho notato la sezione dei commenti dell'articolo qui sotto. Nel thread "Ma deque ha un tempo di inserimento lineare?" l'autore ha menzionato che ha usato l'inserimento nella posizione 100 in tutti i test, il che rende i risultati un po 'più comprensibili.
Bula,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.