Qual è lo scopo di utilizzare elenchi su vettori, in C ++?


32

Ho eseguito 3 diversi esperimenti che coinvolgono elenchi e vettori C ++.

Quelli con vettori si sono dimostrati più efficienti, anche quando sono stati coinvolti molti inserimenti nel mezzo.

Da qui la domanda: in quale caso le liste hanno più senso dei vettori?

Se i vettori sembrano più efficienti nella maggior parte dei casi, e considerando quanto sono simili i loro membri, quali vantaggi rimangono per le liste?

  1. Genera N numeri interi e inseriscili in un contenitore in modo che il contenitore rimanga ordinato. L'inserimento è stato eseguito in modo ingenuo, leggendo gli elementi uno a uno e inserendo quello nuovo prima del primo più grande.
    Con un elenco, il tempo passa attraverso il tetto quando la dimensione aumenta, rispetto ai vettori.

  2. Inserisci N numeri interi alla fine del contenitore.
    Per elenchi e vettori, il tempo è aumentato dello stesso ordine di grandezza, sebbene con i vettori fosse 3 volte più veloce.

  3. Inserisci N numeri interi in un contenitore.
    Avvia il timer.
    Ordinare il contenitore utilizzando list.sort per gli elenchi e std :: sort per i vettori. Stop timer.
    Ancora una volta, il tempo aumenta dello stesso ordine di grandezza, ma è in media 5 volte più veloce con i vettori.

Potrei continuare a eseguire test e capire un paio di esempi in cui gli elenchi si sarebbero dimostrati migliori.

Ma l'esperienza comune di voi ragazzi che leggete questo messaggio potrebbe fornire risposte più produttive.

Potresti esserti imbattuto in situazioni in cui gli elenchi erano più convenienti da usare o si sono comportati meglio?



1
Ecco un'altra buona risorsa sull'argomento: stackoverflow.com/a/2209564/8360 , inoltre, la maggior parte della guida C ++ che ho sentito è quella di utilizzare il vettore per impostazione predefinita, solo se hai un motivo specifico.
Zachary Yates,

Grazie. Tuttavia, non sono d'accordo con la maggior parte di ciò che viene detto nella risposta preferita. La maggior parte di queste idee preconcette sono state invalidate dai miei esperimenti. Questa persona non ha fatto alcun test e ha applicato la teoria diffusa insegnata nei libri o a scuola.
Marek Stanley,

1
A listprobabilmente fa meglio se stai rimuovendo molti elementi. Non credo che un vectormai restituirà memoria al sistema fino a quando l'intero vettore non viene eliminato. Inoltre, tieni presente che il test n. 1 non sta testando il tempo di inserimento da solo. È un test che combina ricerca e inserimento. È trovare il posto dove inserire dove listè lento. L'inserto effettivo sarà più veloce del vettore.
Gort il robot il

3
È così tipico che questa domanda è descritta in termini di prestazioni (tempo di esecuzione), prestazioni e solo prestazioni. Questo sembra essere un punto cieco di molti programmatori: si concentrano su questo aspetto e dimenticano che ci sono dozzine di altri aspetti che sono spesso molto, molto più importanti.
Doc Brown,

Risposte:


34

La risposta breve è che i casi sembrano pochi e lontani tra loro. Probabilmente ce ne sono alcuni però.

Uno sarebbe quando è necessario memorizzare un piccolo numero di oggetti di grandi dimensioni, in particolare oggetti così grandi che non è pratico allocare spazio anche per alcuni extra. Fondamentalmente non c'è modo di impedire a un vettore o a un deque di allocare spazio per oggetti extra - è come sono definiti (cioè devono allocare spazio extra per soddisfare i loro requisiti di complessità). Se il flat-out non può consentire lo spazio aggiuntivo da allocare, std::listpuò essere l' unico contenitore standard che soddisfa le tue esigenze.

Un altro sarebbe quando / se memorizzerai un iteratore in un punto "interessante" in un elenco per un lungo periodo di tempo, e quando esegui inserimenti e / o eliminazioni, lo fai (quasi) sempre da un punto in cui hai già un iteratore, quindi non scorrere l'elenco per arrivare al punto in cui si intende eseguire l'inserimento o l'eliminazione. Ovviamente lo stesso vale se lavori con più di un posto, ma hai ancora in programma di memorizzare un iteratore in ogni posto con cui probabilmente lavorerai, quindi manipoli la maggior parte dei punti che puoi raggiungere direttamente e solo raramente percorri l'elenco per ottenere a quei punti.

Per un esempio del primo, considera un browser web. Potrebbe mantenere un elenco collegato di Taboggetti, con ciascun oggetto scheda rappresentato su una scheda aperta nel browser. Ogni scheda potrebbe contenere alcune decine di megabyte di dati (di più, specialmente se è coinvolto qualcosa come un video). Il tuo numero tipico di schede aperte potrebbe essere facilmente inferiore a una dozzina e 100 è probabilmente vicino all'estremo superiore.

Per un esempio del secondo, considera un elaboratore di testi che memorizza il testo come un elenco collegato di capitoli, ognuno dei quali potrebbe contenere un elenco collegato di (diciamo) paragrafi. Quando l'utente sta modificando, in genere troverà un punto particolare in cui modificherà, quindi eseguirà una buona dose di lavoro in quel punto (o all'interno di quel paragrafo, comunque). Sì, passeranno da un paragrafo all'altro di tanto in tanto, ma nella maggior parte dei casi sarà un paragrafo vicino a dove stavano già lavorando.

Di tanto in tanto (cose come la ricerca globale e la sostituzione) finisci per scorrere tutti gli elementi in tutte le liste, ma è abbastanza raro, e anche quando lo fai, probabilmente farai abbastanza lavoro cercando all'interno di un elemento in l'elenco, che il tempo di attraversare l'elenco è quasi irrilevante.

Si noti che in un caso tipico, questo probabilmente si adatta anche al primo criterio: un capitolo contiene un numero abbastanza piccolo di paragrafi, ognuno dei quali è probabilmente abbastanza grande (almeno relativamente alla dimensione dei puntatori nel nodo e così via). Allo stesso modo, hai un numero relativamente piccolo di capitoli, ognuno dei quali potrebbe essere di diversi kilobyte o giù di lì.

Detto questo, devo ammettere che entrambi questi esempi sono probabilmente un po 'inventati, e mentre un elenco collegato potrebbe funzionare perfettamente per entrambi, probabilmente non fornirebbe un enorme vantaggio in entrambi i casi. In entrambi i casi, ad esempio, è improbabile che l'allocazione di spazio extra in un vettore per alcune pagine / schede (vuote) Web o alcuni capitoli vuoti sia un vero problema.


4
+1, ma: il primo caso scompare quando si utilizzano i puntatori, che si dovrebbe sempre usare con oggetti di grandi dimensioni. Le liste collegate non sono adatte neanche per l'esempio del secondo; gli array sono propri di tutte le operazioni quando sono così brevi.
amara,

2
La custodia per oggetti di grandi dimensioni non funziona affatto. L'uso di un std::vectorpuntatore sarà più efficiente di tutti quegli oggetti nodo elenco collegati.
Winston Ewert,

Ci sono molti usi per gli elenchi collegati - è solo che non sono così comuni come gli array dinamici. Una cache LRU è un uso comune di un elenco collegato.
Charles Salvia,

Inoltre, std::vector<std::unique_ptr<T>>potrebbe essere una buona alternativa.
Deduplicatore

24

Secondo lo stesso Bjarne Stroustrup, i vettori dovrebbero sempre essere la raccolta predefinita per sequenze di dati. Puoi scegliere l'elenco se desideri ottimizzare per l'inserimento e la cancellazione di elementi, ma normalmente non dovresti. I costi dell'elenco sono attraversamenti lenti e utilizzo della memoria.

Ne parla in questa presentazione .

Verso le 0:44 parla di vettori e liste in generale.

La compattezza conta. I vettori sono più compatti delle liste. E i modelli di utilizzo prevedibili contano enormemente. Con i vettori devi inserire molti elementi, ma le cache sono davvero molto brave. ... Le liste non hanno accesso casuale. Ma quando attraversi un elenco, continui a fare l'accesso casuale. C'è un nodo qui, e va a quel nodo, in memoria. Quindi stai effettivamente accedendo casualmente alla tua memoria e stai massimizzando i tuoi errori nella cache, che è esattamente l'opposto di quello che vuoi.

Verso le 1:08, gli viene fatta una domanda su questo problema.

Quello che dovremmo vedere è che abbiamo bisogno di una sequenza di elementi. E la sequenza predefinita di elementi in C ++ è il vettore. Ora, perché è compatto ed efficiente. Implementazione, mappatura su hardware, questioni. Ora, se si desidera ottimizzare per l'inserimento e l'eliminazione, si dice 'bene, non voglio la versione predefinita di una sequenza. Voglio quello specializzato, che è un elenco '. E se lo fai, dovresti sapere abbastanza per dire: "Accetto alcuni costi e alcuni problemi, come spostamenti lenti e maggiore utilizzo della memoria".


1
ti dispiacerebbe scrivere in breve cosa si dice nella presentazione a cui ti colleghi "a circa 0:44 e 1:08"?
moscerino

2
@gnat - certamente. Ho cercato di citare le cose che hanno senso separatamente e che hanno bisogno del contesto delle diapositive.
Pete,

11

L'unico posto in cui di solito uso le liste è dove ho bisogno di cancellare elementi e non invalidare iteratori. std::vectorinvalida tutti gli iteratori su inserisci e cancella. std::listgarantisce che gli iteratori di elementi esistenti siano ancora validi dopo l'inserimento o l'eliminazione.


4

Oltre alle altre risposte già fornite, gli elenchi hanno alcune caratteristiche che non esistono nei vettori (perché sarebbero follemente costose). Le operazioni di giunzione e unione sono le più significative. Se hai spesso un sacco di elenchi che devono essere aggiunti o uniti, un elenco è probabilmente una buona scelta.

Ma se non hai bisogno di eseguire queste operazioni, probabilmente no.


3

La mancanza di intrinseca cache / ottimizzazione della pagina degli elenchi collegati tende a renderli quasi completamente respinti da molti sviluppatori C ++ e con una buona giustificazione in quella forma predefinita.

Le liste collegate possono ancora essere meravigliose

Tuttavia, gli elenchi collegati possono essere meravigliosi se supportati da un allocatore fisso che restituisce loro quella località spaziale di cui intrinsecamente manca.

Dove eccellono è che possiamo dividere un elenco in due elenchi, ad esempio, semplicemente memorizzando un nuovo puntatore e manipolando un puntatore o due. Possiamo spostare i nodi da una lista all'altra in tempo costante semplicemente manipolando il puntatore e una lista vuota può semplicemente avere il costo di memoria di un singolo headpuntatore.

Acceleratore a griglia semplice

Come esempio pratico, considera una simulazione visiva 2D. Ha una schermata a scorrimento con una mappa che si estende su 400x400 (160.000 celle a griglia) utilizzata per accelerare cose come il rilevamento delle collisioni tra milioni di particelle che si muovono intorno a ciascun frame (evitiamo qui i quad-alberi poiché in realtà tendono a peggiorare con questo livello di dati dinamici). Un intero gruppo di particelle si muove costantemente in ogni frame, il che significa che risiedono costantemente in una cella della griglia.

In questo caso, se ogni particella è un nodo elenco collegato singolarmente, ogni cella della griglia può iniziare come un headpuntatore a cui punta nullptr. Quando nasce una nuova particella, la inseriamo semplicemente nella cella della griglia in cui risiede impostando il headpuntatore di quella cella in modo che punti a questo nodo di particelle. Quando una particella si sposta da una cella della griglia alla successiva, manipoliamo semplicemente i puntatori.

Questo può essere di gran lunga più efficiente rispetto alla memorizzazione di 160.000 vectorsper ogni cella della griglia e all'indietro e alla cancellazione dal centro continuamente su una base per trama.

std :: list

Questo è per elenchi fatti a mano, invadenti, collegati singolarmente e supportati da un allocatore fisso. std::listrappresenta un elenco doppiamente collegato e potrebbe non essere altrettanto compatto se vuoto come un singolo puntatore (varia in base all'implementazione del fornitore), inoltre è un po 'problematico implementare allocatori personalizzati nella std::allocatorforma.

Devo ammettere di non aver mai usato listnulla. Ma le liste collegate possono essere ancora meravigliose! Eppure non sono meravigliosi per i motivi per cui le persone sono spesso tentate di usarli, e non così meravigliosi a meno che non siano supportati da un allocatore fisso molto efficiente che mitiga molti errori di pagina obbligatori e mancati cache associati.


1
C'è un elenco standard singolarmente legata dal C ++ 11, std::forward_list.
Sharyex,

2

È necessario considerare la dimensione degli elementi nel contenitore.

int elements vector è molto veloce poiché la maggior parte dei dati si inserisce nella cache della CPU (e le istruzioni SIMD possono probabilmente essere utilizzate per la copia dei dati).

Se la dimensione dell'elemento è maggiore, il risultato dei test 1 e 3 potrebbe cambiare in modo significativo.

Da un confronto delle prestazioni molto completo :

Questo trae semplici conclusioni sull'uso di ciascuna struttura di dati:

  • Scricchiolio dei numeri: usare std::vectorostd::deque
  • Ricerca lineare: utilizzare std::vectorostd::deque
  • Inserisci / Rimuovi casuale:
    • Piccola dimensione dei dati: utilizzare std::vector
    • Grande dimensione dell'elemento: uso std::list(a meno che non sia destinato principalmente alla ricerca)
  • Tipo di dati non banale: utilizzare a std::listmeno che non sia necessario il contenitore soprattutto per la ricerca. Ma per più modifiche del contenitore, sarà molto lento.
  • Spingere in avanti: usare std::dequeostd::list

(come nota a margine std::dequeè una struttura di dati molto sottovalutata).

Da un punto di vista della convenienza std::listoffre la garanzia che gli iteratori non vengano mai invalidati durante l'inserimento e la rimozione di altri elementi. È spesso un aspetto chiave.


2

Il motivo più importante per usare gli elenchi secondo me è l' invalidazione dell'iteratore : se aggiungi / rimuovi elementi a un vettore, tutti i puntatori, i riferimenti, gli iteratori che hai tenuto a particolari elementi di questo vettore potrebbero essere invalidati e portare a bug sottili. o errori di segmentazione.

Questo non è il caso delle liste.

Le regole precise per tutti i contenitori standard sono riportate in questo post StackOverflow .


0

In breve, non ci sono buoni motivi per usare std::list<>:

  • Se hai bisogno di un contenitore non ordinato, std::vector<>regole.
    (Elimina gli elementi sostituendoli con l'ultimo elemento del vettore.)

  • Se è necessario un contenitore ordinato, std::vector<shared_ptr<>>regole.

  • Se hai bisogno di un indice sparso, std::unordered_map<>regole.

Questo è tutto.

Trovo che ci sia solo una situazione in cui tendo a usare un elenco collegato: quando ho oggetti preesistenti che devono essere collegati in qualche modo per implementare qualche logica applicativa aggiuntiva. Tuttavia, in quel caso non uso mai std::list<>, piuttosto ricorro a un puntatore (intelligente) successivo all'interno dell'oggetto, soprattutto perché la maggior parte dei casi d'uso genera un albero anziché un elenco lineare. In alcuni casi, la struttura risultante è un elenco collegato, in altri è un albero o un grafico aciclico diretto. Lo scopo principale di questi puntatori è sempre quello di costruire una struttura logica, mai di gestire oggetti. Abbiamo std::vector<>per quello.


-1

Devi mostrare come stavi facendo gli inserti nel tuo primo test. Il tuo secondo e terzo test, il vettore vincerà facilmente.

Un uso significativo degli elenchi è quando è necessario supportare la rimozione di elementi durante l'iterazione. Quando il vettore viene modificato, tutti gli iteratori sono (potenzialmente) non validi. Con un elenco, solo un iteratore per l'elemento rimosso non è valido. Tutti gli altri iteratori rimangono validi.

L'ordine di utilizzo tipico per i contenitori è vettore, deque, quindi elenco. La scelta del contenitore è di solito basata sul push_back scegli vettore, pop_front scegli deque, inserisci scegli lista.


3
quando si rimuovono elementi durante l'iterazione, di solito è meglio usare un vettore e creare semplicemente un nuovo vettore per i risultati
amara

-1

Un fattore a cui riesco a pensare è che man mano che un vettore cresce, la memoria libera si frammenta man mano che il vettore disloca la sua memoria e alloca un blocco più grande ancora e ancora. Questo non sarà un problema con le liste.

Questo è in aggiunta al fatto che un gran numero di push_backs senza riserva provocherà anche una copia durante ogni ridimensionamento che lo rende inefficiente. L'inserimento nel mezzo in modo simile provoca uno spostamento di tutti gli elementi a destra, ed è anche peggio.

Non so se questa sia una delle maggiori preoccupazioni, ma è stata la ragione che mi è stata data nel mio lavoro (sviluppo di giochi per dispositivi mobili), per evitare i vettori.


1
no, il vettore verrà copiato ed è costoso. Ma attraversare l'elenco collegato (per capire dove inserire) è anche costoso. La chiave è davvero misurare
Kate Gregory

@KateGregory Volevo dire in aggiunta a ciò, fammi modificare di conseguenza
Karthik T

3
Giusto, ma credici o no (e la maggior parte delle persone non ci crede) il costo che non hai menzionato, attraversando l'elenco collegato per trovare dove inserire OUTWEIGHS quelle copie (soprattutto se gli elementi sono piccoli (o mobili, perché poi sono mosse)) e il vettore è spesso (o addirittura di solito) più veloce. Credici o no.
Kate Gregory,
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.