Perché push_back nei vettori C ++ è costantemente ammortizzato?


23

Sto imparando il C ++ e ho notato che il tempo di esecuzione della funzione push_back per i vettori è costante "ammortizzato". La documentazione rileva inoltre che "Se si verifica una riallocazione, la riallocazione è essa stessa fino a lineare nell'intera dimensione".

Questo non dovrebbe significare che la funzione push_back è , dove è la lunghezza del vettore? Dopotutto, siamo interessati all'analisi del caso peggiore, giusto?O(n)n

Immagino, soprattutto, non capisco come l'aggettivo "ammortizzato" cambi il tempo di esecuzione.


Con una macchina RAM, l'allocazione di byte di memoria non è un'operazione : è considerata un tempo praticamente costante. nO(n)
usul

Risposte:


24

La parola importante qui è "ammortizzata". L'analisi ammortizzata è una tecnica di analisi che esamina una sequenza di operazioni. Se l'intera sequenza viene eseguita nel tempo , ogni operazione nella sequenza viene eseguita in . L'idea è che mentre alcune operazioni nella sequenza potrebbero essere costose, non possono accadere abbastanza spesso da appesantire il programma. È importante notare che questo è diverso dall'analisi del caso medio rispetto ad alcune distribuzioni di input o analisi randomizzate. L'analisi ammortizzata ha stabilito il caso peggiore legato all'esecuzione di un algoritmo indipendentemente dagli input. È più comunemente usato per analizzare le strutture di dati, che hanno uno stato persistente in tutto il programma.nT(n)T(n)/n

Uno degli esempi più comuni forniti è l'analisi di uno stack con operazioni multipop che popolano elementi. Un'analisi ingenua di multipop direbbe che nel caso peggiore multipop deve prenderekO(n)nO(n)O(1)

m

nmlogm(n)imini=1logm(n)minmm1nmm1m1m1.5


12

Sebbene @Marc abbia fornito (quello che penso sia) un'analisi eccellente, alcune persone potrebbero preferire considerare le cose da un'angolazione leggermente diversa.

Uno è considerare un modo leggermente diverso di fare una riallocazione. Invece di copiare immediatamente tutti gli elementi dalla vecchia memoria alla nuova memoria, prendere in considerazione la copia di un solo elemento alla volta, ovvero ogni volta che si esegue un push_back, si aggiunge il nuovo elemento al nuovo spazio e si copia esattamente uno esistente elemento dal vecchio spazio al nuovo spazio. Supponendo un fattore di crescita di 2, è abbastanza ovvio che quando il nuovo spazio è pieno, avremmo finito di copiare tutti gli elementi dal vecchio spazio al nuovo spazio e ogni push_back è stato esattamente un tempo costante. A quel punto, avremmo scartato il vecchio spazio, allocato un nuovo blocco di memoria con un guadagno doppio e ripetuto il processo.

Abbastanza chiaramente, possiamo continuare indefinitamente (o fino a quando c'è memoria disponibile, comunque) e ogni push-back implicherebbe l'aggiunta di un nuovo elemento e la copia di un vecchio elemento.

Un'implementazione tipica ha esattamente lo stesso numero di copie, ma invece di eseguire le copie una alla volta, copia tutti gli elementi esistenti contemporaneamente. Da un lato, hai ragione: ciò significa che se guardi alle singole invocazioni di push_back, alcune di esse saranno sostanzialmente più lente di altre. Se osserviamo una media a lungo termine, tuttavia, la quantità di copie eseguite per invocazione di push_back rimane costante, indipendentemente dalle dimensioni del vettore.

Anche se è irrilevante per la complessità computazionale, penso che valga la pena sottolineare perché è vantaggioso fare le cose come fanno, invece di copiare un elemento per push_back, quindi il tempo per push_back rimane costante. Ci sono almeno tre ragioni da considerare.

Il primo è semplicemente la disponibilità della memoria. La vecchia memoria può essere liberata per altri usi solo al termine della copia. Se copiassi solo un elemento alla volta, il vecchio blocco di memoria rimarrebbe allocato molto più a lungo. In effetti, avresti un blocco vecchio e uno nuovo allocati essenzialmente per tutto il tempo. Se decidessi un fattore di crescita inferiore a due (che di solito vuoi) avresti bisogno di una quantità di memoria ancora maggiore.

In secondo luogo, se si copiasse solo un vecchio elemento alla volta, l'indicizzazione nell'array sarebbe un po 'più complicata: ogni operazione di indicizzazione avrebbe bisogno di capire se l'elemento in corrispondenza dell'indice dato era attualmente nel vecchio blocco di memoria o nuovo. Non è assolutamente complesso, ma per un'operazione elementare come l'indicizzazione in un array, quasi ogni rallentamento potrebbe essere significativo.

In terzo luogo, copiando tutto in una volta, si sfrutta molto meglio la memorizzazione nella cache. Copiando tutto in una volta, ci si può aspettare che sia l'origine che la destinazione siano nella cache nella maggior parte dei casi, quindi il costo di una mancata cache viene ammortizzato sul numero di elementi che si adatteranno in una riga della cache. Se copi un elemento alla volta, potresti facilmente perdere la cache per ogni elemento copiato. Ciò cambia solo il fattore costante, non la complessità, ma può comunque essere abbastanza significativo: per una macchina tipica, ci si potrebbe facilmente aspettare un fattore da 10 a 20.

Probabilmente vale la pena considerare anche l'altra direzione per un momento: se si stava progettando un sistema con requisiti in tempo reale, potrebbe avere senso copiare solo un elemento alla volta anziché tutti contemporaneamente. Anche se la velocità complessiva potrebbe (o potrebbe non essere) essere inferiore, avresti comunque un limite superiore al tempo impiegato per una singola esecuzione di push_back - presumendo di avere un allocatore in tempo reale (anche se, ovviamente, molti in tempo reale i sistemi proibiscono semplicemente l'allocazione dinamica della memoria, almeno in porzioni con requisiti in tempo reale).


2
+1 Questa è una meravigliosa spiegazione in stile Feynman .
Ripristina Monica il
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.