¿Agregar un objeto a una lista en R en tiempo constante amortizado, O (1)?
Si tengo alguna lista R mylist
, puedes agregarle un elemento obj
de esta manera:
mylist[[length(mylist)+1]] <- obj
Pero seguramente hay alguna forma más compacta. Cuando era nuevo en R, intenté escribir lappend()
así:
lappend <- function(lst, obj) {
lst[[length(lst)+1]] <- obj
return(lst)
}
pero, por supuesto, eso no funciona debido a la semántica de llamada por nombre de R ( lst
se copia efectivamente al llamar, por lo que los cambios lst
no son visibles fuera del alcance de lappend()
. Sé que puedes piratear el entorno en una función de R para llegar fuera del alcance de su función y mutar el entorno de llamada, pero eso parece un gran martillo para escribir una función de adición simple.
¿Alguien puede sugerir una forma más hermosa de hacer esto? Puntos de bonificación si funciona tanto para vectores como para listas.
Si es una lista de cadenas, simplemente use la c()
función:
R> LL <- list(a="tom", b="dick")
R> c(LL, c="harry")
$a
[1] "tom"
$b
[1] "dick"
$c
[1] "harry"
R> class(LL)
[1] "list"
R>
Eso también funciona con vectores, entonces ¿obtendré puntos de bonificación?
Editar (01 de febrero de 2015): esta publicación se acerca al quinto aniversario. Algunos lectores amables siguen repitiendo cualquier deficiencia, así que, por supuesto, vean también algunos de los comentarios a continuación. Una sugerencia para list
tipos:
newlist <- list(oldlist, list(someobj))
En general, los tipos R pueden hacer que sea difícil tener un solo idioma para todos los tipos y usos.
El OP (en la revisión actualizada de la pregunta en abril de 2012) está interesado en saber si hay una manera de agregar a una lista en tiempo constante amortizado, como se puede hacer, por ejemplo, con un vector<>
contenedor C++. Las mejores respuestas aquí hasta ahora solo muestran los tiempos de ejecución relativos para varias soluciones dado un problema de tamaño fijo, pero no abordan directamente la eficiencia algorítmica de ninguna de las diversas soluciones . Los comentarios a continuación de muchas de las respuestas analizan la eficiencia algorítmica de algunas de las soluciones, pero en todos los casos hasta la fecha (a abril de 2015) llegan a una conclusión equivocada.
La eficiencia algorítmica captura las características de crecimiento, ya sea en el tiempo (tiempo de ejecución) o en el espacio (cantidad de memoria consumida) a medida que crece el tamaño del problema . Realizar una prueba de rendimiento para varias soluciones dado un problema de tamaño fijo no aborda la tasa de crecimiento de las distintas soluciones. El OP está interesado en saber si hay una manera de agregar objetos a una lista R en "tiempo constante amortizado". ¿Qué significa eso? Para explicarlo, primero déjame describir el "tiempo constante":
Crecimiento constante u O(1) :
Si el tiempo necesario para realizar una tarea determinada sigue siendo el mismo a medida que se duplica el tamaño del problema , entonces decimos que el algoritmo muestra un crecimiento temporal constante o, expresado en notación "Big O", muestra un crecimiento temporal O(1). Cuando el OP dice tiempo constante "amortizado", simplemente quiere decir "a largo plazo"... es decir, si realizar una sola operación ocasionalmente lleva mucho más tiempo de lo normal (por ejemplo, si un buffer preasignado se agota y ocasionalmente requiere cambiar el tamaño a un tamaño mayor). tamaño del búfer), siempre que el rendimiento promedio a largo plazo sea constante, lo llamaremos O(1).
A modo de comparación, también describiré "tiempo lineal" y "tiempo cuadrático":
Crecimiento lineal u O(n) :
Si el tiempo necesario para realizar una tarea determinada se duplica a medida que se duplica el tamaño del problema , entonces decimos que el algoritmo presenta un tiempo lineal , o un crecimiento O(n) .
Crecimiento cuadrático u O(n 2 ) :
Si el tiempo necesario para realizar una tarea determinada aumenta en el cuadrado del tamaño del problema , decimos que el algoritmo presenta un tiempo cuadrático , o crecimiento O(n 2 ) .
Hay muchas otras clases de algoritmos de eficiencia; Me remito al artículo de Wikipedia para una mayor discusión.
Agradezco a @CronAcronis por su respuesta, ya que soy nuevo en R y fue bueno tener un bloque de código completamente construido para realizar un análisis de rendimiento de las diversas soluciones presentadas en esta página. Estoy tomando prestado su código para mi análisis, que duplico (envuelto en una función) a continuación:
library(microbenchmark)
### Using environment as a container
lPtrAppend <- function(lstptr, lab, obj) {lstptr[[deparse(substitute(lab))]] <- obj}
### Store list inside new environment
envAppendList <- function(lstptr, obj) {lstptr$list[[length(lstptr$list)+1]] <- obj}
runBenchmark <- function(n) {
microbenchmark(times = 5,
env_with_list_ = {
listptr <- new.env(parent=globalenv())
listptr$list <- NULL
for(i in 1:n) {envAppendList(listptr, i)}
listptr$list
},
c_ = {
a <- list(0)
for(i in 1:n) {a = c(a, list(i))}
},
list_ = {
a <- list(0)
for(i in 1:n) {a <- list(a, list(i))}
},
by_index = {
a <- list(0)
for(i in 1:n) {a[length(a) + 1] <- i}
a
},
append_ = {
a <- list(0)
for(i in 1:n) {a <- append(a, i)}
a
},
env_as_container_ = {
listptr <- new.env(parent=globalenv())
for(i in 1:n) {lPtrAppend(listptr, i, i)}
listptr
}
)
}
Los resultados publicados por @CronAcronis definitivamente parecen sugerir que el a <- list(a, list(i))
método es más rápido, al menos para un tamaño de problema de 10000, pero los resultados para un tamaño de problema único no abordan el crecimiento de la solución. Para ello, necesitamos ejecutar un mínimo de dos pruebas de creación de perfiles, con diferentes tamaños de problemas:
> runBenchmark(2e+3)
Unit: microseconds
expr min lq mean median uq max neval
env_with_list_ 8712.146 9138.250 10185.533 10257.678 10761.33 12058.264 5
c_ 13407.657 13413.739 13620.976 13605.696 13790.05 13887.738 5
list_ 854.110 913.407 1064.463 914.167 1301.50 1339.132 5
by_index 11656.866 11705.140 12182.104 11997.446 12741.70 12809.363 5
append_ 15986.712 16817.635 17409.391 17458.502 17480.55 19303.560 5
env_as_container_ 19777.559 20401.702 20589.856 20606.961 20939.56 21223.502 5
> runBenchmark(2e+4)
Unit: milliseconds
expr min lq mean median uq max neval
env_with_list_ 534.955014 550.57150 550.329366 553.5288 553.955246 558.636313 5
c_ 1448.014870 1536.78905 1527.104276 1545.6449 1546.462877 1558.609706 5
list_ 8.746356 8.79615 9.162577 8.8315 9.601226 9.837655 5
by_index 953.989076 1038.47864 1037.859367 1064.3942 1065.291678 1067.143200 5
append_ 1634.151839 1682.94746 1681.948374 1689.7598 1696.198890 1706.683874 5
env_as_container_ 204.134468 205.35348 208.011525 206.4490 208.279580 215.841129 5
>
En primer lugar, unas palabras sobre los valores min/lq/mean/median/uq/max: dado que estamos realizando exactamente la misma tarea para cada una de las 5 ejecuciones, en un mundo ideal, podríamos esperar que tomaría exactamente lo mismo. cantidad de tiempo para cada ejecución. Pero la primera ejecución normalmente se inclina hacia tiempos más largos debido al hecho de que el código que estamos probando aún no está cargado en la memoria caché de la CPU. Después de la primera ejecución, esperaríamos que los tiempos fueran bastante consistentes, pero ocasionalmente nuestro código puede ser expulsado del caché debido a interrupciones del temporizador u otras interrupciones de hardware que no están relacionadas con el código que estamos probando. Al probar los fragmentos de código 5 veces, permitimos que el código se cargue en la memoria caché durante la primera ejecución y luego le damos a cada fragmento 4 oportunidades para que se ejecute hasta su finalización sin interferencia de eventos externos. Por esta razón, y porque en realidad estamos ejecutando exactamente el mismo código bajo las mismas condiciones de entrada cada vez, consideraremos que solo los tiempos "mínimos" son suficientes para la mejor comparación entre las distintas opciones de código.
Tenga en cuenta que elegí ejecutar primero con un tamaño de problema de 2000 y luego 20000, por lo que el tamaño de mi problema aumentó en un factor de 10 desde la primera ejecución hasta la segunda.
Rendimiento de la list
solución: O(1) (tiempo constante)
Veamos primero el crecimiento de la list
solución, ya que podemos decir de inmediato que es la solución más rápida en ambas ejecuciones de creación de perfiles: en la primera ejecución, tomó 854 microsegundos (0,854 milisegundos ) para realizar 2000 tareas "añadidas". En la segunda ejecución, se necesitaron 8,746 milisegundos para realizar 20.000 tareas "añadidas". Un observador ingenuo diría: "Ah, la list
solución muestra un crecimiento O(n), ya que a medida que el tamaño del problema crecía en un factor de diez, también lo hacía el tiempo necesario para ejecutar la prueba". El problema con ese análisis es que lo que el OP quiere es la tasa de crecimiento de la inserción de un solo objeto , no la tasa de crecimiento del problema general. Sabiendo eso, queda claro que la list
solución proporciona exactamente lo que quiere el OP: un método para agregar objetos a una lista en tiempo O(1).
Rendimiento de las otras soluciones.
Ninguna de las otras soluciones se acerca siquiera a la velocidad de la list
solución, pero es informativo examinarlas de todos modos:
La mayoría de las otras soluciones parecen tener un rendimiento O(n). Por ejemplo, la by_index
solución, una solución muy popular basada en la frecuencia con la que la encuentro en otras publicaciones de SO, tomó 11,6 milisegundos para agregar 2000 objetos y 953 milisegundos para agregar diez veces esa cantidad de objetos. El tiempo total del problema aumentó en un factor de 100, por lo que un observador ingenuo podría decir: "Ah, la by_index
solución muestra un crecimiento O(n 2 ), ya que a medida que el tamaño del problema creció en un factor de diez, el tiempo requerido para ejecutar la prueba aumentó". por un factor de 100." Como antes, este análisis es defectuoso, ya que el OP está interesado en el crecimiento de la inserción de un solo objeto. Si dividimos el crecimiento del tiempo total por el crecimiento del tamaño del problema, encontramos que el crecimiento del tiempo de los objetos añadidos aumentó en un factor de sólo 10, no en un factor de 100, lo que coincide con el crecimiento del tamaño del problema, por lo que la by_index
solución es O (norte). No se enumeran soluciones que muestren un crecimiento O(n 2 ) al agregar un solo objeto.
En las otras respuestas, solo el list
enfoque da como resultado O(1) adjuntos, pero da como resultado una estructura de lista profundamente anidada, y no una lista única y simple. He utilizado las siguientes estructuras de datos, admiten anexos O(1) (amortizados) y permiten que el resultado se vuelva a convertir en una lista simple.
expandingList <- function(capacity = 10) {
buffer <- vector('list', capacity)
length <- 0
methods <- list()
methods$double.size <- function() {
buffer <<- c(buffer, vector('list', capacity))
capacity <<- capacity * 2
}
methods$add <- function(val) {
if(length == capacity) {
methods$double.size()
}
length <<- length + 1
buffer[[length]] <<- val
}
methods$as.list <- function() {
b <- buffer[0:length]
return(b)
}
methods
}
y
linkedList <- function() {
head <- list(0)
length <- 0
methods <- list()
methods$add <- function(val) {
length <<- length + 1
head <<- list(head, val)
}
methods$as.list <- function() {
b <- vector('list', length)
h <- head
for(i in length:1) {
b[[i]] <- head[[2]]
head <- head[[1]]
}
return(b)
}
methods
}
Úselos de la siguiente manera:
> l <- expandingList()
> l$add("hello")
> l$add("world")
> l$add(101)
> l$as.list()
[[1]]
[1] "hello"
[[2]]
[1] "world"
[[3]]
[1] 101
Estas soluciones podrían ampliarse a objetos completos que admitan todas las operaciones relacionadas con listas por sí mismos, pero eso quedará como un ejercicio para el lector.
Otra variante para una lista con nombre:
namedExpandingList <- function(capacity = 10) {
buffer <- vector('list', capacity)
names <- character(capacity)
length <- 0
methods <- list()
methods$double.size <- function() {
buffer <<- c(buffer, vector('list', capacity))
names <<- c(names, character(capacity))
capacity <<- capacity * 2
}
methods$add <- function(name, val) {
if(length == capacity) {
methods$double.size()
}
length <<- length + 1
buffer[[length]] <<- val
names[length] <<- name
}
methods$as.list <- function() {
b <- buffer[0:length]
names(b) <- names[0:length]
return(b)
}
methods
}
Puntos de referencia
Comparación de rendimiento utilizando el código de @phonetagger (que se basa en el código de @Cron Arconis). También agregué better_env_as_container
y cambié un env_as_container_
poco. El original env_as_container_
estaba roto y en realidad no almacena todos los números.
library(microbenchmark)
lPtrAppend <- function(lstptr, lab, obj) {lstptr[[deparse(lab)]] <- obj}
### Store list inside new environment
envAppendList <- function(lstptr, obj) {lstptr$list[[length(lstptr$list)+1]] <- obj}
env2list <- function(env, len) {
l <- vector('list', len)
for (i in 1:len) {
l[[i]] <- env[[as.character(i)]]
}
l
}
envl2list <- function(env, len) {
l <- vector('list', len)
for (i in 1:len) {
l[[i]] <- env[[paste(as.character(i), 'L', sep='')]]
}
l
}
runBenchmark <- function(n) {
microbenchmark(times = 5,
env_with_list_ = {
listptr <- new.env(parent=globalenv())
listptr$list <- NULL
for(i in 1:n) {envAppendList(listptr, i)}
listptr$list
},
c_ = {
a <- list(0)
for(i in 1:n) {a = c(a, list(i))}
},
list_ = {
a <- list(0)
for(i in 1:n) {a <- list(a, list(i))}
},
by_index = {
a <- list(0)
for(i in 1:n) {a[length(a) + 1] <- i}
a
},
append_ = {
a <- list(0)
for(i in 1:n) {a <- append(a, i)}
a
},
env_as_container_ = {
listptr <- new.env(hash=TRUE, parent=globalenv())
for(i in 1:n) {lPtrAppend(listptr, i, i)}
envl2list(listptr, n)
},
better_env_as_container = {
env <- new.env(hash=TRUE, parent=globalenv())
for(i in 1:n) env[[as.character(i)]] <- i
env2list(env, n)
},
linkedList = {
a <- linkedList()
for(i in 1:n) { a$add(i) }
a$as.list()
},
inlineLinkedList = {
a <- list()
for(i in 1:n) { a <- list(a, i) }
b <- vector('list', n)
head <- a
for(i in n:1) {
b[[i]] <- head[[2]]
head <- head[[1]]
}
},
expandingList = {
a <- expandingList()
for(i in 1:n) { a$add(i) }
a$as.list()
},
inlineExpandingList = {
l <- vector('list', 10)
cap <- 10
len <- 0
for(i in 1:n) {
if(len == cap) {
l <- c(l, vector('list', cap))
cap <- cap*2
}
len <- len + 1
l[[len]] <- i
}
l[1:len]
}
)
}
# We need to repeatedly add an element to a list. With normal list concatenation
# or element setting this would lead to a large number of memory copies and a
# quadratic runtime. To prevent that, this function implements a bare bones
# expanding array, in which list appends are (amortized) constant time.
expandingList <- function(capacity = 10) {
buffer <- vector('list', capacity)
length <- 0
methods <- list()
methods$double.size <- function() {
buffer <<- c(buffer, vector('list', capacity))
capacity <<- capacity * 2
}
methods$add <- function(val) {
if(length == capacity) {
methods$double.size()
}
length <<- length + 1
buffer[[length]] <<- val
}
methods$as.list <- function() {
b <- buffer[0:length]
return(b)
}
methods
}
linkedList <- function() {
head <- list(0)
length <- 0
methods <- list()
methods$add <- function(val) {
length <<- length + 1
head <<- list(head, val)
}
methods$as.list <- function() {
b <- vector('list', length)
h <- head
for(i in length:1) {
b[[i]] <- head[[2]]
head <- head[[1]]
}
return(b)
}
methods
}
# We need to repeatedly add an element to a list. With normal list concatenation
# or element setting this would lead to a large number of memory copies and a
# quadratic runtime. To prevent that, this function implements a bare bones
# expanding array, in which list appends are (amortized) constant time.
namedExpandingList <- function(capacity = 10) {
buffer <- vector('list', capacity)
names <- character(capacity)
length <- 0
methods <- list()
methods$double.size <- function() {
buffer <<- c(buffer, vector('list', capacity))
names <<- c(names, character(capacity))
capacity <<- capacity * 2
}
methods$add <- function(name, val) {
if(length == capacity) {
methods$double.size()
}
length <<- length + 1
buffer[[length]] <<- val
names[length] <<- name
}
methods$as.list <- function() {
b <- buffer[0:length]
names(b) <- names[0:length]
return(b)
}
methods
}
resultado:
> runBenchmark(1000)
Unit: microseconds
expr min lq mean median uq max neval
env_with_list_ 3128.291 3161.675 4466.726 3361.837 3362.885 9318.943 5
c_ 3308.130 3465.830 6687.985 8578.913 8627.802 9459.252 5
list_ 329.508 343.615 389.724 370.504 449.494 455.499 5
by_index 3076.679 3256.588 5480.571 3395.919 8209.738 9463.931 5
append_ 4292.321 4562.184 7911.882 10156.957 10202.773 10345.177 5
env_as_container_ 24471.511 24795.849 25541.103 25486.362 26440.591 26511.200 5
better_env_as_container 7671.338 7986.597 8118.163 8153.726 8335.659 8443.493 5
linkedList 1700.754 1755.439 1829.442 1804.746 1898.752 1987.518 5
inlineLinkedList 1109.764 1115.352 1163.751 1115.631 1206.843 1271.166 5
expandingList 1422.440 1439.970 1486.288 1519.728 1524.268 1525.036 5
inlineExpandingList 942.916 973.366 1002.461 1012.197 1017.784 1066.044 5
> runBenchmark(10000)
Unit: milliseconds
expr min lq mean median uq max neval
env_with_list_ 357.760419 360.277117 433.810432 411.144799 479.090688 560.779139 5
c_ 685.477809 734.055635 761.689936 745.957553 778.330873 864.627811 5
list_ 3.257356 3.454166 3.505653 3.524216 3.551454 3.741071 5
by_index 445.977967 454.321797 515.453906 483.313516 560.374763 633.281485 5
append_ 610.777866 629.547539 681.145751 640.936898 760.570326 763.896124 5
env_as_container_ 281.025606 290.028380 303.885130 308.594676 314.972570 324.804419 5
better_env_as_container 83.944855 86.927458 90.098644 91.335853 92.459026 95.826030 5
linkedList 19.612576 24.032285 24.229808 25.461429 25.819151 26.223597 5
inlineLinkedList 11.126970 11.768524 12.216284 12.063529 12.392199 13.730200 5
expandingList 14.735483 15.854536 15.764204 16.073485 16.075789 16.081726 5
inlineExpandingList 10.618393 11.179351 13.275107 12.391780 14.747914 17.438096 5
> runBenchmark(20000)
Unit: milliseconds
expr min lq mean median uq max neval
env_with_list_ 1723.899913 1915.003237 1921.23955 1938.734718 1951.649113 2076.910767 5
c_ 2759.769353 2768.992334 2810.40023 2820.129738 2832.350269 2870.759474 5
list_ 6.112919 6.399964 6.63974 6.453252 6.910916 7.321647 5
by_index 2163.585192 2194.892470 2292.61011 2209.889015 2436.620081 2458.063801 5
append_ 2832.504964 2872.559609 2983.17666 2992.634568 3004.625953 3213.558197 5
env_as_container_ 573.386166 588.448990 602.48829 597.645221 610.048314 642.912752 5
better_env_as_container 154.180531 175.254307 180.26689 177.027204 188.642219 206.230191 5
linkedList 38.401105 47.514506 46.61419 47.525192 48.677209 50.952958 5
inlineLinkedList 25.172429 26.326681 32.33312 34.403442 34.469930 41.293126 5
expandingList 30.776072 30.970438 34.45491 31.752790 38.062728 40.712542 5
inlineExpandingList 21.309278 22.709159 24.64656 24.290694 25.764816 29.158849 5
He agregado linkedList
y expandingList
una versión incorporada de ambos. Es inlinedLinkedList
básicamente una copia de list_
, pero también convierte la estructura anidada nuevamente en una lista simple. Más allá de eso, la diferencia entre las versiones integradas y no integradas se debe a la sobrecarga de las llamadas a funciones.
Todas las variantes de expandingList
y linkedList
muestran el rendimiento de anexos O(1), y el tiempo de referencia se escala linealmente con el número de elementos agregados. linkedList
es más lento que expandingList
y la sobrecarga de llamada a la función también es visible. Entonces, si realmente necesita toda la velocidad posible (y desea ceñirse al código R), use una versión incorporada de expandingList
.
También eché un vistazo a la implementación C de R, y ambos enfoques deben agregarse O(1) para cualquier tamaño hasta que se quede sin memoria.
También cambié env_as_container_
, la versión original almacenaría cada elemento en el índice "i", sobrescribiendo el elemento agregado anteriormente. El better_env_as_container
que he agregado es muy similar env_as_container_
pero sin el deparse
material. Ambos exhiben un rendimiento O(1), pero tienen una sobrecarga que es bastante mayor que las listas enlazadas/en expansión.
Sobrecarga de memoria
En la implementación de CR hay una sobrecarga de 4 palabras y 2 entradas por objeto asignado. El linkedList
enfoque asigna una lista de longitud dos por anexo, para un total de (4*8+4+4+2*8=) 56 bytes por elemento agregado en computadoras de 64 bits (excluyendo la sobrecarga de asignación de memoria, por lo que probablemente esté más cerca de 64 bytes). El expandingList
enfoque utiliza una palabra por elemento añadido, más una copia al duplicar la longitud del vector, por lo que el uso total de memoria es de hasta 16 bytes por elemento. Dado que toda la memoria está en uno o dos objetos, la sobrecarga por objeto es insignificante. No he analizado en profundidad el env
uso de la memoria, pero creo que estará más cerca linkedList
.
¿Quieres algo como esto tal vez?
> push <- function(l, x) {
lst <- get(l, parent.frame())
lst[length(lst)+1] <- x
assign(l, lst, envir=parent.frame())
}
> a <- list(1,2)
> push('a', 6)
> a
[[1]]
[1] 1
[[2]]
[1] 2
[[3]]
[1] 6
No es una función muy educada (asignar parent.frame()
es un poco grosero) pero IIUYC es lo que estás pidiendo.