¿Agregar un objeto a una lista en R en tiempo constante amortizado, O (1)?

Resuelto Nick asked hace 54 años • 17 respuestas

Si tengo alguna lista R mylist, puedes agregarle un elemento objde 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 ( lstse copia efectivamente al llamar, por lo que los cambios lstno 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.

Nick avatar Jan 01 '70 08:01 Nick
Aceptado

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 listtipos:

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.

Dirk is no longer here avatar Mar 13 '2010 01:03 Dirk is no longer here

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 listsolución: O(1) (tiempo constante)

Veamos primero el crecimiento de la listsolució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 listsolució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 listsolució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 listsolució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_indexsolució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_indexsolució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_indexsolución es O (norte). No se enumeran soluciones que muestren un crecimiento O(n 2 ) al agregar un solo objeto.

phonetagger avatar Apr 25 '2015 21:04 phonetagger

En las otras respuestas, solo el listenfoque 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_containery 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 linkedListy expandingListuna versión incorporada de ambos. Es inlinedLinkedListbá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 expandingListy linkedListmuestran el rendimiento de anexos O(1), y el tiempo de referencia se escala linealmente con el número de elementos agregados. linkedListes más lento que expandingListy 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_containerque he agregado es muy similar env_as_container_pero sin el deparsematerial. 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 linkedListenfoque 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 expandingListenfoque 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 envuso de la memoria, pero creo que estará más cerca linkedList.

JanKanis avatar Sep 30 '2015 15:09 JanKanis

¿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.

Ken Williams avatar Mar 15 '2010 17:03 Ken Williams