¿Cuál es el punto de usar listas sobre vectores, en C ++?

32

He realizado 3 experimentos diferentes que involucran listas y vectores de C ++.

Aquellos con vectores demostraron ser más eficientes, incluso cuando estaban involucradas muchas inserciones en el medio.

De ahí la pregunta: ¿en qué caso las listas tienen más sentido que los vectores?

Si los vectores parecen más eficientes en la mayoría de los casos, y considerando cuán parecidos son sus miembros, ¿qué ventajas quedan para las listas?

  1. Genere N enteros y colóquelos en un contenedor para que el contenedor permanezca ordenado. La inserción se ha realizado ingenuamente, leyendo elementos uno por uno e insertando el nuevo justo antes del primero más grande.
    Con una lista, el tiempo pasa por el techo cuando la dimensión aumenta, en comparación con los vectores.

  2. Inserte N enteros al final del contenedor.
    Para las listas y los vectores, el tiempo aumentó en el mismo orden de magnitud, aunque fue 3 veces más rápido con los vectores.

  3. Inserte N enteros en un contenedor.
    Iniciar temporizador.
    Ordene el contenedor usando list.sort para listas y std :: sort para vectores. Parar temporizador.
    Nuevamente, el tiempo aumenta en el mismo orden de magnitud, pero en promedio es 5 veces más rápido con los vectores.

Podría continuar realizando pruebas y descubriendo un par de ejemplos en los que las listas resultarían mejores.

Pero la experiencia conjunta de ustedes al leer este mensaje podría proporcionar respuestas más productivas.

¿Es posible que haya encontrado situaciones en las que las listas fueron más convenientes de usar o tuvieron un mejor rendimiento?

Marek Stanley
fuente
2
Debería echar un vistazo a ¿ Cuándo usar una lista vinculada sobre una matriz / lista de matrices? si aún no lo ha hecho
Karthik T
1
Aquí hay otro buen recurso sobre el tema: stackoverflow.com/a/2209564/8360 también, la mayor parte de la guía de C ++ que he escuchado es usar el vector de forma predeterminada, enumere solo si tiene una razón específica.
Zachary Yates
Gracias. Sin embargo, no estoy de acuerdo con la mayoría de lo que se dice en la respuesta favorita. La mayoría de estas ideas preconcebidas han sido invalidadas por mis experimentos. Esta persona no ha realizado ninguna prueba y ha aplicado teoría generalizada que se enseña en libros o en la escuela.
Marek Stanley
1
A listprobablemente le vaya mejor si está eliminando muchos elementos. No creo vectorque alguna vez regrese la memoria al sistema hasta que se elimine todo el vector. También tenga en cuenta que su prueba n. ° 1 no prueba el tiempo de inserción solo. Es una prueba que combina búsqueda e inserción. Es encontrar el lugar para insertar donde listes lento. La inserción real será más rápida que el vector.
Gort the Robot
3
Es muy típico que esta pregunta se describa en términos de rendimiento (tiempo de ejecución), rendimiento y solo rendimiento. Este parece ser un punto ciego de muchos programadores: se centran en este aspecto y olvidan que hay docenas de otros aspectos que a menudo son mucho, mucho más importantes.
Doc Brown

Respuestas:

34

La respuesta corta es que los casos parecen ser pocos y distantes entre sí. Probablemente hay algunos sin embargo.

Una sería cuando necesita almacenar una pequeña cantidad de objetos grandes, especialmente, objetos que son tan grandes que no es práctico asignar espacio para unos pocos más. Básicamente, no hay forma de evitar que un vector o deque asigne espacio para objetos adicionales: es cómo se definen (es decir, deben asignar espacio adicional para cumplir con sus requisitos de complejidad). Si no puede permitir que se asigne ese espacio adicional, std::listpuede ser el único contenedor estándar que satisfaga sus necesidades.

Otra sería cuando / si almacenas un iterador en un punto "interesante" en una lista durante un período prolongado de tiempo, y cuando haces inserciones y / o eliminaciones, (casi) siempre lo haces desde un punto en el que ya tiene un iterador, por lo que no recorre la lista para llegar al punto donde va a realizar la inserción o eliminación. Obviamente, lo mismo se aplica si trabaja con más de un lugar, pero aún planea almacenar un iterador en cada lugar con el que es probable que trabaje, por lo que manipula la mayoría de los lugares a los que puede llegar directamente y rara vez recorre la lista para obtener a esos puntos

Para un ejemplo del primero, considere un navegador web. Puede mantener una lista vinculada de Tabobjetos, con cada objeto de pestaña representando en la pestaña abierta en el navegador. Cada pestaña puede tener unas pocas docenas de megabytes de datos (o más, especialmente si se trata de un video). Su número típico de pestañas abiertas podría ser fácilmente inferior a una docena, y 100 probablemente esté cerca del extremo superior.

Para un ejemplo del segundo, considere un procesador de texto que almacena texto como una lista vinculada de capítulos, cada uno de los cuales puede contener una lista vinculada de (digamos) párrafos. Cuando el usuario está editando, generalmente van a encontrar un lugar en particular donde van a editar, y luego harán una buena cantidad de trabajo en ese lugar (o dentro de ese párrafo, de todos modos). Sí, se moverán de un párrafo a otro de vez en cuando, pero en la mayoría de los casos será un párrafo cerca de donde ya estaban trabajando.

De vez en cuando (cosas como buscar y reemplazar globalmente) terminas recorriendo todos los elementos en todas las listas, pero es bastante poco común, e incluso cuando lo haces, probablemente harás suficiente trabajo buscando dentro de un elemento en la lista, que el tiempo para recorrer la lista es casi intrascendente.

Tenga en cuenta que, en un caso típico, es probable que esto también se ajuste al primer criterio: un capítulo contiene un número bastante pequeño de párrafos, cada uno de los cuales es bastante grande (al menos en relación con el tamaño de los punteros en el nodo, y tal). Del mismo modo, tiene un número relativamente pequeño de capítulos, cada uno de los cuales puede ser de varios kilobytes más o menos.

Dicho esto, tengo que admitir que ambos ejemplos son probablemente un poco inventados, y aunque una lista vinculada podría funcionar perfectamente bien para ambos, probablemente tampoco proporcionaría una gran ventaja en ninguno de los casos. En ambos casos, por ejemplo, es poco probable que la asignación de espacio adicional en un vector para algunas páginas web / pestañas (vacías) o algunos capítulos vacíos sea un problema real.

Jerry Coffin
fuente
44
+1, pero: el primer caso desaparece cuando usas punteros, que siempre debes usar con objetos grandes. Las listas enlazadas tampoco son adecuadas para el ejemplo de la segunda; Las matrices son propias de todas las operaciones cuando son tan cortas.
amara
2
El caso de objeto grande no funciona en absoluto. El uso de un std::vectorpuntero será más eficiente que todos los objetos de nodo de lista enlazados.
Winston Ewert
Las listas vinculadas tienen muchos usos: es solo que no son tan comunes como las matrices dinámicas. Un caché LRU es un uso común de una lista vinculada.
Charles Salvia
Además, a std::vector<std::unique_ptr<T>>podría ser una buena alternativa.
Deduplicador el
24

Según el propio Bjarne Stroustrup, los vectores siempre deben ser la colección predeterminada para las secuencias de datos. Puede elegir la lista si desea optimizar para la inserción y eliminación de elementos, pero normalmente no debería hacerlo. El costo de la lista es un recorrido lento y el uso de memoria.

Él habla sobre esto en esta presentación .

Aproximadamente a las 0:44 habla sobre vectores vs. listas en general.

La compacidad importa. Los vectores son más compactos que las listas. Y los patrones de uso predecibles son muy importantes. Con los vectores tienes que pasar muchos elementos, pero los cachés son muy, muy buenos en eso. ... Las listas no tienen acceso aleatorio. Pero cuando atraviesas una lista, sigues haciendo acceso aleatorio. Hay un nodo aquí, y va a ese nodo, en la memoria. Por lo tanto, en realidad tiene acceso aleatorio a su memoria y está maximizando sus errores de caché, que es exactamente lo contrario de lo que desea.

Alrededor de la 1:08, se le hace una pregunta sobre este tema.

Lo que deberíamos ver es que necesitamos una secuencia de elementos. Y la secuencia predeterminada de elementos en C ++ es el vector. Ahora, porque eso es compacto y eficiente. Implementación, mapeo a hardware, asuntos. Ahora, si desea optimizar para la inserción y eliminación, diga: 'bueno, no quiero la versión predeterminada de una secuencia. Quiero el especializado, que es una lista '. Y si hace eso, debe saber lo suficiente como para decir: "Estoy aceptando algunos costos y algunos problemas, como los recorridos lentos y el uso de más memoria".

Pete
fuente
1
¿Le importaría escribir brevemente lo que se dice en la presentación a la que se vincula "aproximadamente a las 0:44 y 1:08"?
mosquito
2
@gnat, ciertamente. He tratado de citar las cosas que tienen sentido por separado, y eso necesita el contexto de las diapositivas.
Pete
11

El único lugar donde generalmente uso listas es donde necesito borrar elementos y no invalidar iteradores. std::vectorinvalida todos los iteradores al insertar y borrar. std::listgarantiza que los iteradores de los elementos existentes sigan siendo válidos después de insertar o eliminar.

UldisK
fuente
4

Además de las otras respuestas ya proporcionadas, las listas tienen ciertas características que no existen en los vectores (porque serían increíblemente costosas). Las operaciones de empalme y fusión son las más significativas. Si con frecuencia tiene un montón de listas que deben agregarse o fusionarse, una lista es probablemente una buena opción.

Pero si no necesita realizar estas operaciones, entonces probablemente no.

David C.
fuente
3

La falta de memoria caché inherente a las páginas vinculadas tiende a hacer que sean descartadas por completo por muchos desarrolladores de C ++, y con una buena justificación en esa forma predeterminada.

Las listas enlazadas aún pueden ser maravillosas

Sin embargo, las listas vinculadas pueden ser maravillosas cuando están respaldadas por un asignador fijo que les devuelve esa localidad espacial de la que carecen intrínsecamente.

Donde se destacan es que podemos dividir una lista en dos listas, por ejemplo, simplemente almacenando un nuevo puntero y manipulando uno o dos punteros. Podemos mover nodos de una lista a otra en tiempo constante mediante la simple manipulación del puntero, y una lista vacía simplemente puede tener el costo de memoria de un solo headpuntero.

Acelerador de cuadrícula simple

Como ejemplo práctico, considere una simulación visual 2D. Tiene una pantalla de desplazamiento con un mapa que abarca 400x400 (160,000 celdas de cuadrícula) que se usa para acelerar cosas como la detección de colisión entre millones de partículas que se mueven en cada cuadro (evitamos los árboles cuádruples aquí, ya que en realidad tienden a funcionar peor con este nivel de datos dinámicos). Una gran cantidad de partículas se mueven constantemente en cada cuadro, lo que significa que pasan de residir en una celda de la rejilla a otra constantemente.

En este caso, si cada partícula es un nodo de lista enlazado individualmente, cada celda de la cuadrícula puede comenzar como un headpuntero que apunta nullptr. Cuando nace una nueva partícula, simplemente la colocamos en la celda de la cuadrícula donde reside configurando el headpuntero de esa celda para que apunte a este nodo de partículas. Cuando una partícula se mueve de una celda a la siguiente, simplemente manipulamos punteros.

Esto puede ser mucho más eficiente que almacenar 160,000 vectorspara cada celda de la cuadrícula y empujar hacia atrás y borrar desde el medio todo el tiempo cuadro por cuadro.

std :: list

Sin embargo, esto es para listas enrolladas a mano, intrusivas y enlazadas individualmente respaldadas por un asignador fijo. std::listrepresenta una lista doblemente vinculada y puede que no sea tan compacta cuando está vacía como un puntero único (varía según la implementación del proveedor), además es un poco difícil implementar asignadores personalizados en std::allocatorforma.

Debo admitir que nunca uso en listabsoluto. ¡Pero las listas enlazadas pueden seguir siendo maravillosas! Sin embargo, no son maravillosas por las razones por las que las personas a menudo se sienten tentadas a usarlas, y no son tan maravillosas a menos que estén respaldadas por un asignador fijo muy eficiente que mitigue al menos muchas fallas de página obligatorias y errores de caché asociados.


fuente
1
Hay una lista simplemente enlazada estándar ya que C ++ 11, std::forward_list.
sharyex
2

Debe considerar el tamaño de los elementos en el contenedor.

int El vector de elementos es muy rápido ya que la mayoría de los datos se ajustan dentro del caché de la CPU (y las instrucciones SIMD probablemente se pueden usar para copiar datos).

Si el tamaño del elemento es mayor, entonces el resultado de las pruebas 1 y 3 podría cambiar significativamente.

De una comparación de rendimiento muy completa :

Esto saca conclusiones simples sobre el uso de cada estructura de datos:

  • Crujido de números: use std::vectorostd::deque
  • Búsqueda lineal: use std::vectorostd::deque
  • Insertar / Eliminar al azar:
    • Tamaño de datos pequeño: uso std::vector
    • Tamaño de elemento grande: uso std::list(a menos que esté destinado principalmente a la búsqueda)
  • Tipo de datos no trivial: utilícelo a std::listmenos que necesite el contenedor especialmente para la búsqueda. Pero para múltiples modificaciones del contenedor, será muy lento.
  • Empuje hacia el frente: use std::dequeostd::list

(como nota al margen std::dequees una estructura de datos muy subestimada).

Desde un punto de vista conveniente, std::listgarantiza que los iteradores nunca se invaliden al insertar y eliminar otros elementos. A menudo es un aspecto clave.

manlio
fuente
2

La razón más destacada para usar listas en mi opinión es la invalidación de iteradores : si agrega / elimina elementos a un vector, todos los punteros, referencias, iteradores que mantuvo a elementos particulares de este vector pueden invalidarse y provocar errores sutiles. . o fallas de segmentación.

Este no es el caso con las listas.

Las reglas precisas para todos los contenedores estándar se dan en esta publicación de StackOverflow .

Jean-Michaël Celerier
fuente
0

En resumen, no hay una buena razón para usar std::list<>:

  • Si necesita un contenedor sin clasificar, std::vector<>reglas.
    (Eliminar elementos reemplazándolos con el último elemento del vector).

  • Si necesita un contenedor ordenado, std::vector<shared_ptr<>>reglas.

  • Si necesita un índice escaso, std::unordered_map<>reglas.

Eso es.

Me parece que solo hay una situación en la que tiendo a usar una lista vinculada: cuando tengo objetos preexistentes que necesitan estar conectados de alguna manera para implementar alguna lógica de aplicación adicional. Sin embargo, en ese caso, nunca uso std::list<>, sino que recurro al siguiente puntero (inteligente) dentro del objeto, especialmente porque la mayoría de los casos de uso dan como resultado un árbol en lugar de una lista lineal. En algunos casos, la estructura resultante es una lista vinculada, en otros, es un árbol o un gráfico acíclico dirigido. El objetivo principal de estos punteros es siempre construir una estructura lógica, nunca administrar objetos. Tenemos std::vector<>para eso.

cmaster
fuente
-1

Debe mostrar cómo estaba haciendo los insertos en su primera prueba. Su segunda y tercera prueba, el vector ganará fácilmente.

Un uso significativo de las listas es cuando debe admitir la eliminación de elementos mientras itera. Cuando se modifica el vector, todos los iteradores son (potencialmente) inválidos. Con una lista, solo un iterador del elemento eliminado no es válido. Todos los demás iteradores siguen siendo válidos.

El orden típico de uso para contenedores es vector, deque, luego lista. La elección del contenedor generalmente se basa en push_back elegir vector, pop_front elegir deque, insertar elegir lista.

Bill Door
fuente
3
al eliminar elementos mientras se itera, generalmente es mejor usar un vector y simplemente crear un nuevo vector para los resultados
amara
-1

Un factor en el que puedo pensar es que a medida que crece un vector, la memoria libre se fragmentará a medida que el vector desasigne su memoria y asigne un bloque más grande una y otra vez. Esto no será un problema con las listas.

Esto se suma al hecho de que una gran cantidad de push_backs sin reserva también causará una copia durante cada cambio de tamaño, lo que lo hace ineficiente. Insertar en el medio de manera similar provoca un movimiento de todos los elementos hacia la derecha, y es aún peor.

Sin embargo, no sé si esto es una preocupación importante, pero fue la razón que se me dio en mi trabajo (desarrollo de juegos móviles), para evitar vectores.

Karthik T
fuente
1
no, el vector se copiará y eso es costoso. Pero atravesar la lista vinculada (para descubrir dónde insertar) también es costoso. La clave es medir
Kate Gregory el
@KateGregory quise decir además de eso, déjame editar en consecuencia
Karthik T
3
Correcto, pero lo creas o no (y la mayoría de la gente no lo cree) el costo que no mencionaste, recorriendo la lista vinculada para encontrar dónde insertar OUTWEIGHS esas copias (especialmente si los elementos son pequeños (o móviles, porque entonces son movimientos)) y el vector es a menudo (o incluso generalmente) más rápido. Por extraño que parezca.
Kate Gregory