Python: crea una lista con capacidad inicial

188

Un código como este sucede a menudo:

l = []
while foo:
    #baz
    l.append(bar)
    #qux

Esto es realmente lento si está a punto de agregar miles de elementos a su lista, ya que la lista tendrá que cambiar constantemente de tamaño para adaptarse a los nuevos elementos.

En Java, puede crear una ArrayList con una capacidad inicial. Si tiene alguna idea de cuán grande será su lista, será mucho más eficiente.

Entiendo que un código como este a menudo se puede refactorizar en una lista de comprensión. Sin embargo, si el ciclo for / while es muy complicado, esto no es factible. ¿Hay algún equivalente para nosotros los programadores de Python?

Claudiu
fuente
12
Hasta donde yo sé, son similares a ArrayLists en que duplican su tamaño cada vez. El tiempo amortizado de esta operación es constante. No es un éxito tan grande como se podría pensar.
mmcdole
Parece que tienes razón!
Claudiu
11
Quizás la preinicialización no sea estrictamente necesaria para el escenario del OP, pero a veces definitivamente es necesaria: tengo una serie de elementos preindexados que deben insertarse en un índice específico, pero están fuera de servicio. Necesito hacer crecer la lista con anticipación para evitar IndexErrors. Gracias por esta pregunta
Neil Traft
1
@Claudiu La respuesta aceptada es engañosa. El comentario más votado debajo explica por qué. ¿Considerarías aceptar una de las otras respuestas?
Neal Gokli

Respuestas:

126
def doAppend( size=10000 ):
    result = []
    for i in range(size):
        message= "some unique object %d" % ( i, )
        result.append(message)
    return result

def doAllocate( size=10000 ):
    result=size*[None]
    for i in range(size):
        message= "some unique object %d" % ( i, )
        result[i]= message
    return result

Resultados . (evalúe cada función 144 veces y promedie la duración)

simple append 0.0102
pre-allocate  0.0098

Conclusión . Apenas importa.

La optimización prematura es la fuente de todos los males.

S.Lott
fuente
18
¿Qué sucede si el método de preasignación (tamaño * [Ninguno]) en sí mismo es ineficiente? ¿La máquina virtual Python realmente asigna la lista de una vez, o la crece gradualmente, tal como lo haría append ()?
haridsv
9
Oye. Presumiblemente se puede expresar en Python, pero nadie lo ha publicado aún aquí. El punto de haridsv fue que estamos asumiendo que 'int * list' no solo se agrega a la lista elemento por elemento. Esa suposición es probablemente válida, pero el punto de haridsv era que deberíamos verificar eso. Si no fuera válido, eso explicaría por qué las dos funciones que mostró toman tiempos casi idénticos, porque debajo de las cubiertas, están haciendo exactamente lo mismo, por lo tanto, no han probado el tema de esta pregunta. ¡Atentamente!
Jonathan Hartley
136
Esto no es valido; está formateando una cadena con cada iteración, lo que lleva una eternidad en relación con lo que está tratando de probar. Además, dado que el 4% aún puede ser significativo dependiendo de la situación, y es una subestimación ...
Philip Guin
40
Como @Philip señala, la conclusión aquí es engañosa. La preasignación no importa aquí porque la operación de formateo de cadenas es costosa. Probé con una operación barata en el bucle y descubrí que la preasignación es casi el doble de rápida.
Keith
12
Las respuestas incorrectas con muchos votos a favor son otra raíz de todo mal.
Hashimoto
80

Las listas de Python no tienen preasignación incorporada. Si realmente necesita hacer una lista, y necesita evitar la sobrecarga de agregar (y debe verificar que lo haga), puede hacer esto:

l = [None] * 1000 # Make a list of 1000 None's
for i in xrange(1000):
    # baz
    l[i] = bar
    # qux

Quizás podría evitar la lista utilizando un generador en su lugar:

def my_things():
    while foo:
        #baz
        yield bar
        #qux

for thing in my_things():
    # do something with thing

De esta manera, la lista no se almacena todo en la memoria, simplemente se genera según sea necesario.

Ned Batchelder
fuente
77
+1 Generadores en lugar de listas. Muchos algoritmos se pueden revisar ligeramente para trabajar con generadores en lugar de listas completamente materializadas.
S.Lott
Los generadores son una buena idea, cierto. Quería una forma general de hacerlo además de la configuración en el lugar. Supongo que la diferencia es menor, thoguh.
Claudiu el
50

Versión corta: uso

pre_allocated_list = [None] * size

para preasignar una lista (es decir, para poder abordar los elementos de "tamaño" de la lista en lugar de formarla gradualmente agregando). Esta operación es MUY rápida, incluso en grandes listas. La asignación de nuevos objetos que luego se asignarán a elementos de la lista llevará MUCHO más tiempo y será EL cuello de botella en su programa, en términos de rendimiento.

Versión larga:

Creo que se debe tener en cuenta el tiempo de inicialización. Dado que en Python todo es una referencia, no importa si establece cada elemento en None o alguna cadena, de cualquier manera es solo una referencia. Aunque llevará más tiempo si desea crear un nuevo objeto para que cada elemento haga referencia.

Para Python 3.2:

import time
import copy

def print_timing (func):
  def wrapper (*arg):
    t1 = time.time ()
    res = func (*arg)
    t2 = time.time ()
    print ("{} took {} ms".format (func.__name__, (t2 - t1) * 1000.0))
    return res

  return wrapper

@print_timing
def prealloc_array (size, init = None, cp = True, cpmethod=copy.deepcopy, cpargs=(), use_num = False):
  result = [None] * size
  if init is not None:
    if cp:
      for i in range (size):
          result[i] = init
    else:
      if use_num:
        for i in range (size):
            result[i] = cpmethod (i)
      else:
        for i in range (size):
            result[i] = cpmethod (cpargs)
  return result

@print_timing
def prealloc_array_by_appending (size):
  result = []
  for i in range (size):
    result.append (None)
  return result

@print_timing
def prealloc_array_by_extending (size):
  result = []
  none_list = [None]
  for i in range (size):
    result.extend (none_list)
  return result

def main ():
  n = 1000000
  x = prealloc_array_by_appending(n)
  y = prealloc_array_by_extending(n)
  a = prealloc_array(n, None)
  b = prealloc_array(n, "content", True)
  c = prealloc_array(n, "content", False, "some object {}".format, ("blah"), False)
  d = prealloc_array(n, "content", False, "some object {}".format, None, True)
  e = prealloc_array(n, "content", False, copy.deepcopy, "a", False)
  f = prealloc_array(n, "content", False, copy.deepcopy, (), False)
  g = prealloc_array(n, "content", False, copy.deepcopy, [], False)

  print ("x[5] = {}".format (x[5]))
  print ("y[5] = {}".format (y[5]))
  print ("a[5] = {}".format (a[5]))
  print ("b[5] = {}".format (b[5]))
  print ("c[5] = {}".format (c[5]))
  print ("d[5] = {}".format (d[5]))
  print ("e[5] = {}".format (e[5]))
  print ("f[5] = {}".format (f[5]))
  print ("g[5] = {}".format (g[5]))

if __name__ == '__main__':
  main()

Evaluación:

prealloc_array_by_appending took 118.00003051757812 ms
prealloc_array_by_extending took 102.99992561340332 ms
prealloc_array took 3.000020980834961 ms
prealloc_array took 49.00002479553223 ms
prealloc_array took 316.9999122619629 ms
prealloc_array took 473.00004959106445 ms
prealloc_array took 1677.9999732971191 ms
prealloc_array took 2729.999780654907 ms
prealloc_array took 3001.999855041504 ms
x[5] = None
y[5] = None
a[5] = None
b[5] = content
c[5] = some object blah
d[5] = some object 5
e[5] = a
f[5] = []
g[5] = ()

Como puede ver, hacer una gran lista de referencias al mismo objeto None toma muy poco tiempo.

Pretender o extender toma más tiempo (no promedié nada, pero después de ejecutar esto varias veces puedo decirle que extender y agregar toma aproximadamente el mismo tiempo).

Asignar un nuevo objeto para cada elemento: eso es lo que lleva más tiempo. Y la respuesta de S.Lott hace eso: formatea una nueva cadena cada vez. Lo cual no es estrictamente necesario: si desea preasignar algo de espacio, simplemente haga una lista de Ninguno y luego asigne datos a los elementos de la lista a voluntad. De cualquier manera, lleva más tiempo generar datos que agregar / extender una lista, ya sea que la genere al crear la lista o después de eso. Pero si desea una lista escasamente poblada, comenzar con una lista de Ninguno es definitivamente más rápido.

LRN
fuente
mmmm interesante. así que la respuesta puede ser: realmente no importa si está haciendo alguna operación para poner elementos en una lista, pero si realmente solo quiere una gran lista de todos los mismos elementos, debe usar el []*enfoque
Claudiu
26

La forma pitónica para esto es:

x = [None] * numElements

o cualquier valor predeterminado con el que desee completar, p. ej.

bottles = [Beer()] * 99
sea = [Fish()] * many
vegetarianPizzas = [None] * peopleOrderingPizzaNotQuiche

[EDITAR: Caveat Emptor La [Beer()] * 99sintaxis crea uno Beer y luego llena una matriz con 99 referencias a la misma instancia]

El enfoque predeterminado de Python puede ser bastante eficiente, aunque esa eficiencia disminuye a medida que aumenta el número de elementos.

Comparar

import time

class Timer(object):
    def __enter__(self):
        self.start = time.time()
        return self

    def __exit__(self, *args):
        end = time.time()
        secs = end - self.start
        msecs = secs * 1000  # millisecs
        print('%fms' % msecs)

Elements   = 100000
Iterations = 144

print('Elements: %d, Iterations: %d' % (Elements, Iterations))


def doAppend():
    result = []
    i = 0
    while i < Elements:
        result.append(i)
        i += 1

def doAllocate():
    result = [None] * Elements
    i = 0
    while i < Elements:
        result[i] = i
        i += 1

def doGenerator():
    return list(i for i in range(Elements))


def test(name, fn):
    print("%s: " % name, end="")
    with Timer() as t:
        x = 0
        while x < Iterations:
            fn()
            x += 1


test('doAppend', doAppend)
test('doAllocate', doAllocate)
test('doGenerator', doGenerator)

con

#include <vector>
typedef std::vector<unsigned int> Vec;

static const unsigned int Elements = 100000;
static const unsigned int Iterations = 144;

void doAppend()
{
    Vec v;
    for (unsigned int i = 0; i < Elements; ++i) {
        v.push_back(i);
    }
}

void doReserve()
{
    Vec v;
    v.reserve(Elements);
    for (unsigned int i = 0; i < Elements; ++i) {
        v.push_back(i);
    }
}

void doAllocate()
{
    Vec v;
    v.resize(Elements);
    for (unsigned int i = 0; i < Elements; ++i) {
        v[i] = i;
    }
}

#include <iostream>
#include <chrono>
using namespace std;

void test(const char* name, void(*fn)(void))
{
    cout << name << ": ";

    auto start = chrono::high_resolution_clock::now();
    for (unsigned int i = 0; i < Iterations; ++i) {
        fn();
    }
    auto end = chrono::high_resolution_clock::now();

    auto elapsed = end - start;
    cout << chrono::duration<double, milli>(elapsed).count() << "ms\n";
}

int main()
{
    cout << "Elements: " << Elements << ", Iterations: " << Iterations << '\n';

    test("doAppend", doAppend);
    test("doReserve", doReserve);
    test("doAllocate", doAllocate);
}

En mi Windows 7 i7, Python de 64 bits da

Elements: 100000, Iterations: 144
doAppend: 3587.204933ms
doAllocate: 2701.154947ms
doGenerator: 1721.098185ms

Mientras que C ++ da (construido con MSVC, 64 bits, optimizaciones habilitadas)

Elements: 100000, Iterations: 144
doAppend: 74.0042ms
doReserve: 27.0015ms
doAllocate: 5.0003ms

La compilación de depuración de C ++ produce:

Elements: 100000, Iterations: 144
doAppend: 2166.12ms
doReserve: 2082.12ms
doAllocate: 273.016ms

El punto aquí es que con Python puede lograr una mejora del rendimiento del 7-8%, y si cree que está escribiendo una aplicación de alto rendimiento (o si está escribiendo algo que se utiliza en un servicio web o algo así) eso no debe ser rastreado, pero es posible que deba repensar su elección de idioma.

Además, el código de Python aquí no es realmente el código de Python. Cambiar a código verdaderamente Pythonesque aquí ofrece un mejor rendimiento:

import time

class Timer(object):
    def __enter__(self):
        self.start = time.time()
        return self

    def __exit__(self, *args):
        end = time.time()
        secs = end - self.start
        msecs = secs * 1000  # millisecs
        print('%fms' % msecs)

Elements   = 100000
Iterations = 144

print('Elements: %d, Iterations: %d' % (Elements, Iterations))


def doAppend():
    for x in range(Iterations):
        result = []
        for i in range(Elements):
            result.append(i)

def doAllocate():
    for x in range(Iterations):
        result = [None] * Elements
        for i in range(Elements):
            result[i] = i

def doGenerator():
    for x in range(Iterations):
        result = list(i for i in range(Elements))


def test(name, fn):
    print("%s: " % name, end="")
    with Timer() as t:
        fn()


test('doAppend', doAppend)
test('doAllocate', doAllocate)
test('doGenerator', doGenerator)

Lo que da

Elements: 100000, Iterations: 144
doAppend: 2153.122902ms
doAllocate: 1346.076965ms
doGenerator: 1614.092112ms

(en 32 bits doGenerator funciona mejor que doAllocate).

Aquí la brecha entre doAppend y doAllocate es significativamente mayor.

Obviamente, las diferencias aquí solo se aplican si está haciendo esto más de un puñado de veces o si está haciendo esto en un sistema muy cargado donde esos números van a ser escalados por órdenes de magnitud, o si está tratando con Listas considerablemente más grandes.

El punto aquí: hágalo de la manera pitónica para obtener el mejor rendimiento.

Pero si le preocupa el rendimiento general de alto nivel, Python es el lenguaje incorrecto. El problema más fundamental es que las llamadas a funciones de Python han sido tradicionalmente hasta 300 veces más lentas que otros lenguajes debido a características de Python como decoradores, etc. ( https://wiki.python.org/moin/PythonSpeed/PerformanceTips#Data_Aggregation#Data_Aggregation ).

kfsone
fuente
@NilsvonBarth C ++ no tienetimeit
kfsone
Python tiene timeit, que debe usar al cronometrar su código Python; No estoy hablando de C ++, obviamente.
Nils von Barth
44
Esta no es la respuesta correcta. bottles = [Beer()] * 99no crea 99 objetos Beer. En su lugar, crea un objeto Beer con 99 referencias a él. Si lo mutas, todos los elementos en la lista serán mutados, causa (bottles[i] is bootles[j]) == Truede cada uno i != j. 0<= i, j <= 99.
erhesto
@erhesto ¿Consideró que la respuesta no era correcta, porque el autor utilizó referencias como ejemplo para completar una lista? Primero, nadie está obligado a crear 99 objetos Beer (en comparación con un objeto y 99 referencias). En el caso de la prepoblación (de lo que habló), más rápido es mejor, ya que el valor se reemplazará más adelante. En segundo lugar, la respuesta no se trata de referencias o mutaciones en absoluto. Te estás perdiendo el panorama general.
Yongwei Wu
@YongweiWu Tienes razón, en realidad tienes razón. Este ejemplo no hace que la respuesta completa sea incorrecta, puede ser simplemente engañosa y simplemente vale la pena mencionarla.
erhesto
8

Como otros han mencionado, la forma más simple de pre-sembrar una lista con NoneType objetos.

Dicho esto, debe comprender la forma en que funcionan realmente las listas de Python antes de decidir que esto es necesario. En la implementación de CPython de una lista, la matriz subyacente siempre se crea con espacio superior, en tamaños progresivamente más grandes ( 4, 8, 16, 25, 35, 46, 58, 72, 88, 106, 126, 148, 173, 201, 233, 269, 309, 354, 405, 462, 526, 598, 679, 771, 874, 990, 1120, etc), de modo que el cambio de tamaño de la lista no ocurre con tanta frecuencia.

Debido a este comportamiento, la mayoría de las list.append() funciones son O(1)complejas para los anexos, solo que tienen una mayor complejidad al cruzar uno de estos límites, en cuyo punto la complejidad seráO(n) . Este comportamiento es lo que conduce al aumento mínimo en el tiempo de ejecución en la respuesta de S. Lott.

Fuente: http://www.laurentluce.com/posts/python-list-implementation/

Russell Troxel
fuente
4

ejecuté el código de @ s.lott y produje el mismo aumento de rendimiento del 10% mediante preasignación. Probé la idea de @Jeremy usando un generador y pude ver el rendimiento del gen mejor que el del doAllocate. Para mi proyecto, la mejora del 10% es importante, así que gracias a todos, ya que esto ayuda mucho.

def doAppend( size=10000 ):
    result = []
    for i in range(size):
        message= "some unique object %d" % ( i, )
        result.append(message)
    return result

def doAllocate( size=10000 ):
    result=size*[None]
    for i in range(size):
        message= "some unique object %d" % ( i, )
        result[i]= message
    return result

def doGen( size=10000 ):
    return list("some unique object %d" % ( i, ) for i in xrange(size))

size=1000
@print_timing
def testAppend():
    for i in xrange(size):
        doAppend()

@print_timing
def testAlloc():
    for i in xrange(size):
        doAllocate()

@print_timing
def testGen():
    for i in xrange(size):
        doGen()


testAppend()
testAlloc()
testGen()

testAppend took 14440.000ms
testAlloc took 13580.000ms
testGen took 13430.000ms
Jason Wiener
fuente
55
"Para mi proyecto, ¿importa la mejora del 10%"? De Verdad? ¿Puede probar que la asignación de la lista es el cuello de botella? Me gustaría ver más sobre eso. ¿Tienes un blog donde podrías explicar cómo esto realmente ayudó?
S.Lott
2
@ S.Lott intenta aumentar el tamaño en un orden de magnitud; el rendimiento se reduce en 3 órdenes de magnitud (en comparación con C ++ donde el rendimiento se reduce un poco más de un solo orden de magnitud).
kfsone
2
Este podría ser el caso porque a medida que crece una matriz, es posible que tenga que moverse en la memoria. (Piense en cómo se almacenan los objetos allí, uno tras otro.)
Evgeni Sergeev
3

Las preocupaciones sobre la preasignación en Python surgen si está trabajando con numpy, que tiene más matrices tipo C. En este caso, las preocupaciones previas a la asignación son sobre la forma de los datos y el valor predeterminado.

Considere numpy si está haciendo un cálculo numérico en listas masivas y desea rendimiento.

J450n
fuente
0

Para algunas aplicaciones, un diccionario puede ser lo que está buscando. Por ejemplo, en el método find_totient, me pareció más conveniente usar un diccionario ya que no tenía un índice cero.

def totient(n):
    totient = 0

    if n == 1:
        totient = 1
    else:
        for i in range(1, n):
            if math.gcd(i, n) == 1:
                totient += 1
    return totient

def find_totients(max):
    totients = dict()
    for i in range(1,max+1):
        totients[i] = totient(i)

    print('Totients:')
    for i in range(1,max+1):
        print(i,totients[i])

Este problema también podría resolverse con una lista preasignada:

def find_totients(max):
    totients = None*(max+1)
    for i in range(1,max+1):
        totients[i] = totient(i)

    print('Totients:')
    for i in range(1,max+1):
        print(i,totients[i])

Siento que esto no es tan elegante y propenso a errores porque no estoy almacenando Ninguno, lo que podría arrojar una excepción si accidentalmente los uso incorrectamente, y porque necesito pensar en casos extremos que el mapa me permite evitar.

Es cierto que el diccionario no será tan eficiente, pero como otros han comentado, pequeñas diferencias en la velocidad no siempre valen riesgos de mantenimiento significativos .

Josiah Yoder
fuente