La lección anterior dejó dos preguntas pendientes: ¿por qué acceder a lista[i] cuesta O(1), y qué reorganización oculta hace que append sea O(1) solo amortizado? Para responderlas hay que bajar un nivel: entender cómo se organiza físicamente la memoria del ordenador y qué es un array, la estructura más elemental de todas y el cimiento sobre el que se construyen casi todas las demás. Al terminar sabrás exactamente qué hace la list de Python por dentro, qué operaciones de TaskFlow resuelve bien un array... y cuáles no, lo que nos abrirá la puerta del módulo 2.

Contenido

  1. La memoria como una hilera de casillas numeradas
  2. Arrays estáticos: contigüidad y acceso O(1)
  3. El precio de la contigüidad: insertar y borrar en medio
  4. Arrays dinámicos y redimensionado amortizado
  5. La list de Python por dentro (y una mención a array y NumPy)
  6. TaskFlow: cuándo basta un array y cuándo se queda corto

La memoria como una hilera de casillas numeradas

Puedes imaginar la memoria RAM de un ordenador como una hilera gigantesca de casillas del mismo tamaño, numeradas consecutivamente. El número de cada casilla es su dirección de memoria, y el procesador puede leer o escribir cualquier casilla con solo conocer su dirección, tarde lo que tarde de encontrarse "lejos": ir a la casilla 4 y a la casilla 40.000.000 cuesta lo mismo. Por eso a esta memoria se la llama RAM: Random Access Memory, memoria de acceso aleatorio (directo).

Dirección:   ... 1000  1001  1002  1003  1004  1005  1006 ...
             ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐
Contenido:   │ ... │ ... │ ... │ ... │ ... │ ... │ ... │
             └─────┴─────┴─────┴─────┴─────┴─────┴─────┘

Dos ideas clave de este modelo:

  • Todo dato vive en alguna dirección. Cuando en Python escribes x = 42, en algún lugar de esa hilera queda almacenado el 42, y x es la forma humana de referirse a su dirección. Python te oculta las direcciones (en C se manejan a mano, con punteros), pero puedes asomarte con id(x), que devuelve un identificador basado en la dirección del objeto.
  • Acceder por dirección es O(1). Es una operación de hardware: el procesador calcula la dirección y va. No hay búsqueda, no hay recorrido.

Sobre este modelo hay una decisión fundamental que separa dos mundos: guardar los datos contiguos (en casillas seguidas) o dispersos (cada uno donde haya sitio, conectados por referencias). Los arrays apuestan por la contigüidad; las listas enlazadas del módulo 2 apostarán por la dispersión. Toda la lección gira alrededor de las consecuencias de esa apuesta.

Arrays estáticos: contigüidad y acceso O(1)

Un array estático es un bloque de casillas contiguas reservado de una vez, con un tamaño fijo, donde cada casilla ocupa lo mismo. Es la estructura nativa de lenguajes como C o Java.

La contigüidad tiene un premio enorme. Si el array empieza en la dirección 1000 y cada elemento ocupa 8 bytes, ¿dónde está el elemento de índice i? No hace falta buscarlo: se calcula.

dirección(elemento i) = dirección_base + i × tamaño_elemento

Elemento 0 → 1000 + 0×8 = 1000
Elemento 1 → 1000 + 1×8 = 1008
Elemento 5 → 1000 + 5×8 = 1040

Una multiplicación y una suma, sea i 3 o 3 millones: por eso el acceso por índice es O(1). Esta fórmula es la respuesta a la primera pregunta pendiente de la lección 01-04, y es de las ideas más importantes del curso: el array no busca el elemento i, calcula dónde tiene que estar.

Simulemos un array estático en Python para tocar sus límites (Python no los tiene nativos, así que imponemos las restricciones nosotros):

class ArrayEstatico:
    """Simulación de un array estático de tamaño fijo."""

    def __init__(self, capacidad):
        self._capacidad = capacidad
        self._datos = [None] * capacidad   # reservamos TODAS las casillas ya
        self._usados = 0                   # cuántas están ocupadas de verdad

    def obtener(self, i):
        if not 0 <= i < self._usados:
            raise IndexError("índice fuera de rango")
        return self._datos[i]              # acceso directo: O(1)

    def anadir(self, elemento):
        if self._usados == self._capacidad:
            raise OverflowError("array lleno: no hay más casillas")
        self._datos[self._usados] = elemento
        self._usados += 1                  # escribir en la primera libre: O(1)

Puntos del código que merecen atención:

  • [None] * capacidad reserva todas las casillas en el momento de la creación: eso es lo que significa "estático". La memoria queda comprometida aunque no se use.
  • anadir es O(1)... hasta que el array se llena. Entonces no hay nada que hacer: OverflowError. Un array estático no crece.
  • Distinguimos _capacidad (casillas reservadas) de _usados (casillas con datos reales). Esta distinción parece menor ahora, pero es la clave del apartado de arrays dinámicos.
tablero = ArrayEstatico(3)
tablero.anadir("Diseñar logo")
tablero.anadir("Escribir informe")
tablero.anadir("Enviar factura")
print(tablero.obtener(1))        # Escribir informe (calculado, no buscado)
tablero.anadir("Llamar cliente") # OverflowError: array lleno

Ventaja adicional de la contigüidad que conviene conocer: los procesadores modernos leen la memoria por bloques (caché), de modo que recorrer datos contiguos es aún más rápido en la práctica que recorrer datos dispersos: al leer el elemento 0, los siguientes ya vienen "de regalo" en el mismo bloque.

El precio de la contigüidad: insertar y borrar en medio

Todo lo que la contigüidad regala en el acceso lo cobra en las modificaciones. Piensa en una fila de asientos llenos en un cine: si alguien quiere sentarse en medio, todos los de un lado tienen que correrse un asiento.

Insertar en la posición i de un array exige desplazar una casilla a la derecha todos los elementos desde i hasta el final:

Insertar "X" en el índice 1:

Antes:    [ A ][ B ][ C ][ D ][ · ]
                └────┴────┴─── todos estos se desplazan →
Después:  [ A ][ X ][ B ][ C ][ D ]

Borrar la posición i es el movimiento inverso: los elementos posteriores se desplazan a la izquierda para no dejar hueco (la contigüidad no admite agujeros).

El coste, con el vocabulario de la lección anterior:

Operación sobre un array Coste Motivo
Acceder al índice i O(1) Dirección calculada con la fórmula
Modificar el índice i O(1) Ídem
Añadir al final (con hueco libre) O(1) Se escribe en la primera casilla libre
Insertar en el índice i O(n) Desplazar hasta n elementos a la derecha
Borrar el índice i O(n) Desplazar hasta n elementos a la izquierda
Insertar/borrar al principio O(n) El peor caso del anterior: se desplaza todo
Buscar por valor (sin ordenar) O(n) No hay fórmula para valores: hay que mirar

Fíjate en la asimetría que define al array: espléndido leyendo y modificando por posición, caro reorganizando. Insertar al principio es el caso más doloroso: todos los elementos se mueven.

Arrays dinámicos y redimensionado amortizado

El array estático tiene un problema práctico evidente: hay que adivinar el tamaño por adelantado. ¿Cuántas tareas tendrá un usuario de TaskFlow? ¿100? ¿100.000? Quedarse corto es fatal (OverflowError) y pasarse desperdicia memoria.

El array dinámico resuelve el dilema con una estrategia elegante:

  1. Empieza con un array estático de cierta capacidad.
  2. Mantiene la cuenta de casillas usadas frente a capacidad total (nuestros _usados y _capacidad).
  3. Cuando un append encuentra el array lleno: reserva un array nuevo más grande (típicamente el doble o similar), copia todos los elementos al nuevo y libera el viejo.
graph TB
    A["Array lleno (cap. 4): [A][B][C][D]"] -->|append E| B["1. Reservar array de capacidad 8"]
    B --> C["2. Copiar A,B,C,D al nuevo — O(n)"]
    C --> D["3. Escribir E: [A][B][C][D][E][·][·][·]"]
    D --> E["Los próximos 3 append serán O(1)"]

El paso de copia cuesta O(n), y aquí aparece la respuesta a la segunda pregunta pendiente de la lección 01-04. ¿Por qué append es O(1) amortizado y no O(n)? Por la política de duplicar la capacidad: tras una copia de n elementos, quedan n casillas libres, es decir, los siguientes n append serán O(1) garantizado. El coste O(n) de la copia, repartido entre esos n append baratos, sale a O(1) extra por operación.

Contémoslo con números para que no quede en palabras: partiendo de capacidad 1, hacer 8 append provoca copias de 1, 2 y 4 elementos (al llenar las capacidades 1, 2 y 4): 7 copias en total para 8 inserciones, menos de una copia por append. Con 1.024 append, las copias suman 1.023: sigue saliendo a ~1 por operación. El promedio no crece con n: eso es O(1) amortizado, exactamente la definición que dimos en la lección anterior.

La contrapartida honesta: un append concreto (el que dispara la copia) sí puede ser lento, y el array dinámico mantiene casillas de sobra reservadas (memoria extra de hasta el doble de lo usado). Flexibilidad a cambio de picos puntuales y algo de memoria: otro intercambio de los que ya sabes reconocer.

La list de Python por dentro (y una mención a array y NumPy)

La gran revelación de esta lección: la list de Python es exactamente eso, un array dinámico. No es una "lista enlazada" pese a su nombre (ese será justo el tema del módulo 2, y la confusión de nombres es una trampa clásica). Ahora toda la tabla de costes que dimos en la lección 01-04 tiene explicación física:

Operación en Python Coste Explicación desde el array
lista[i] O(1) Fórmula base + i × tamaño
lista.append(x) O(1) amortizado Escribe en la primera casilla libre; a veces redimensiona
lista.insert(0, x) O(n) Desplaza todos los elementos a la derecha
lista.pop() (final) O(1) Vacía la última casilla usada, sin desplazar nada
lista.pop(0) (principio) O(n) Desplaza todos los elementos a la izquierda
x in lista O(n) Búsqueda por valor: no hay fórmula, hay recorrido

Un matiz técnico que conviene conocer: como una list puede mezclar tipos y los objetos de Python tienen tamaños distintos, las casillas del array no contienen los objetos en sí, sino referencias (direcciones) a ellos, todas del mismo tamaño; por eso la fórmula del acceso sigue funcionando. Y la política de crecimiento de CPython no es duplicar exactamente, sino crecer ~1,125× con un margen adicional; el análisis amortizado se mantiene con cualquier crecimiento proporcional.

Podemos incluso observar el redimensionado desde fuera con sys.getsizeof, que da los bytes que ocupa el objeto:

import sys

lista = []
anterior = sys.getsizeof(lista)
for i in range(40):
    lista.append(i)
    actual = sys.getsizeof(lista)
    if actual != anterior:                 # ¡acaba de redimensionar!
        print(f"Con {i + 1:>2} elementos: {anterior} -> {actual} bytes")
        anterior = actual

Salida típica (varía según la versión de Python):

Con  1 elementos: 56 -> 88 bytes
Con  5 elementos: 88 -> 120 bytes
Con  9 elementos: 120 -> 184 bytes
Con 17 elementos: 184 -> 248 bytes
Con 25 elementos: 248 -> 312 bytes
Con 33 elementos: 312 -> 376 bytes

El tamaño no crece con cada append, sino a saltos: cada salto es una reserva de capacidad extra, y entre salto y salto los append no piden memoria. Es el redimensionado amortizado visto en directo.

Dos parientes de la list que conviene conocer, solo como mención:

  • array.array (módulo de la biblioteca estándar): array dinámico homogéneo que guarda los números en sí (no referencias), ahorrando bastante memoria cuando tienes millones de valores del mismo tipo.
  • NumPy (numpy.ndarray): la librería estándar del cálculo numérico; arrays homogéneos, compactos y con operaciones vectorizadas enormemente rápidas. Si algún día TaskFlow calculara estadísticas sobre millones de registros de tiempos, NumPy sería la herramienta. Queda fuera de este curso, pero debes saber que existe y que por dentro es... un array contiguo, como todo lo de esta lección.

TaskFlow: cuándo basta un array y cuándo se queda corto

Cerremos aplicando el criterio a nuestro proyecto. El tablero de TaskFlow, versión array (list):

tablero = []                                   # array dinámico vacío

# Caso de uso 1: añadir tareas nuevas al final — O(1) amortizado
tablero.append({"id": 1, "titulo": "Diseñar logo"})
tablero.append({"id": 2, "titulo": "Escribir informe"})

# Caso de uso 2: mostrar el tablero en orden — O(n), inevitable y óptimo
for posicion, tarea in enumerate(tablero, start=1):
    print(f"{posicion}. {tarea['titulo']}")

# Caso de uso 3: acceder a la tarea en una posición — O(1)
print(tablero[0]["titulo"])

Para estos tres usos —añadir al final, listar en orden, acceder por posición— la list es la elección correcta, y ninguna estructura del curso la superará en ellos. Que sea la primera opción por defecto en Python está justificado.

Pero mira lo que pasa con otros dos casos de uso reales de TaskFlow:

# Caso de uso 4: el usuario arrastra una tarea nueva a lo ALTO del tablero
tablero.insert(0, {"id": 3, "titulo": "¡Urgente!"})   # O(n): desplaza todo

# Caso de uso 5: completar la tarea 2, esté donde esté
for i, tarea in enumerate(tablero):                   # O(n): buscarla...
    if tarea["id"] == 2:
        del tablero[i]                                # ...y O(n): desplazar el resto
        break

Con 50 tareas, nada de esto importa. Pero imagina la lista de actividad global de un TaskFlow corporativo con cientos de miles de entradas donde lo normal es insertar por delante y borrar por el medio: cada operación desplazaría cientos de miles de referencias. El diagnóstico, con el vocabulario que ya dominas:

Patrón de uso en TaskFlow ¿Array (list)? Motivo
Añadir al final, listar, leer por posición Sí, ideal O(1) / O(n) óptimo / O(1)
Insertar y borrar constantemente al principio o en medio Se queda corto Cada operación es O(n) por los desplazamientos
Buscar por id continuamente Se queda corto O(n); ya vimos que un índice lo hace O(1) (módulo 5)

¿Y si existiera una estructura donde insertar o borrar en medio no desplazara nada, porque los elementos no viven contiguos sino enlazados unos a otros, cada uno donde le haya tocado en memoria? Existe: es la lista enlazada, la otra gran apuesta —dispersión en lugar de contigüidad— y el tema del módulo 2. Como es de justicia, tendrá sus propios costes: al perder la contigüidad se pierde la fórmula mágica del acceso O(1). No hay almuerzo gratis; hay elecciones informadas.

Errores Comunes y Consejos

  • Creer que la list de Python es una lista enlazada. El nombre engaña: es un array dinámico. Este malentendido lleva a asumir que insertar al principio es barato, cuando es O(n). En el módulo 2 la comparación quedará cristalina.
  • Usar lista.insert(0, x) o lista.pop(0) dentro de bucles. Es el O(n²) accidental más común de Python: n operaciones O(n). Si necesitas añadir y quitar por delante habitualmente, existe una estructura pensada para ello (collections.deque, que estudiaremos en el módulo 4).
  • Alarmarse por el pico del redimensionado. Que un append ocasional cueste O(n) casi nunca es un problema real: el amortizado O(1) es lo que cuenta en la práctica. Solo en sistemas con restricciones estrictas de latencia importan los picos.
  • Ignorar la memoria de reserva. Un array dinámico puede tener hasta el doble de casillas de las que usa. Con millones de elementos numéricos, array.array o NumPy reducen drásticamente el consumo frente a list.
  • Consejo: ante cualquier estructura nueva, pregunta siempre "¿es contigua o dispersa por dentro?". La respuesta te dirá, sin mirar documentación, qué operaciones serán baratas (acceso si es contigua; reorganización si es dispersa) y cuáles caras.

Ejercicios

Ejercicio 1: la fórmula del acceso

Un array de enteros de 8 bytes comienza en la dirección 5000. (a) ¿En qué dirección está el elemento de índice 12? (b) Si un elemento está en la dirección 5096, ¿qué índice tiene? (c) Explica en una frase por qué esta fórmula deja de funcionar si los elementos ocuparan tamaños distintos.

Ejercicio 2: contar desplazamientos

Partiendo del array [10, 20, 30, 40, 50] (capacidad 8, 5 casillas usadas), indica cuántos elementos se desplazan en cada operación y el estado final del array tras aplicarlas en orden: (a) insert(0, 5); (b) append(60); (c) borrar el elemento de índice 2; (d) insert(3, 35).

Ejercicio 3: observar el coste O(n) de insertar por delante

Escribe un programa que compare con timeit construir una lista de 50.000 elementos de dos maneras: (a) con append (añadiendo al final) y (b) con insert(0, x) (añadiendo al principio). Predice antes de ejecutar cuál será más lenta y por qué, y comprueba si la diferencia crece al pasar a 100.000 elementos.

Soluciones

Solución 1:

  • (a) 5000 + 12 × 8 = 5096.
  • (b) (5096 − 5000) / 8 = 12: el mismo elemento del apartado anterior, calculado a la inversa.
  • (c) La fórmula multiplica el índice por un tamaño fijo; con tamaños variables no se puede calcular dónde empieza el elemento i sin recorrer y sumar los tamaños de todos los anteriores (por eso la list de Python guarda referencias de tamaño fijo a los objetos, no los objetos en sí).

Solución 2:

  • (a) insert(0, 5): se desplazan los 5 elementos → [5, 10, 20, 30, 40, 50].
  • (b) append(60): se desplazan 0 (hay hueco libre, capacidad 8) → [5, 10, 20, 30, 40, 50, 60].
  • (c) borrar índice 2 (el valor 20): se desplazan a la izquierda los 4 posteriores → [5, 10, 30, 40, 50, 60].
  • (d) insert(3, 35): se desplazan los 3 elementos desde el índice 3 → [5, 10, 30, 35, 40, 50, 60].

Moral del ejercicio: el coste de cada operación depende de cuántos elementos quedan a la derecha del punto de modificación; por eso el peor caso es siempre el principio.

Solución 3:

Predicción: la versión (b) será mucho más lenta, porque cada insert(0, x) desplaza todos los elementos ya presentes: el coste total es 0 + 1 + 2 + ... + (n−1) ≈ n²/2 desplazamientos, es decir, O(n²); la versión (a) es n append de O(1) amortizado, o sea O(n) total.

import timeit

def con_append(n):
    lista = []
    for i in range(n):
        lista.append(i)
    return lista

def con_insert_delante(n):
    lista = []
    for i in range(n):
        lista.insert(0, i)
    return lista

for n in (50_000, 100_000):
    t_a = timeit.timeit(lambda: con_append(n), number=3)
    t_b = timeit.timeit(lambda: con_insert_delante(n), number=3)
    print(f"n={n}: append {t_a:.3f} s | insert(0) {t_b:.3f} s")

Resultado típico: con 50.000 elementos, append tarda milésimas y insert(0, ...) del orden de segundos; al duplicar a 100.000, append se duplica (lineal) pero insert(0, ...) se cuadruplica (cuadrática), confirmando la predicción. Es la física del array —los desplazamientos— manifestándose exactamente como la teoría anuncia.

Conclusión

Has llegado al fondo del asunto: la memoria es una hilera de casillas numeradas de acceso directo, y el array —datos contiguos, casillas iguales— explota esa organización para ofrecer acceso por índice O(1) mediante una simple fórmula, pagándolo con desplazamientos O(n) al insertar o borrar en medio. Los arrays dinámicos añaden crecimiento automático con redimensionado proporcional, cuyo coste repartido da el famoso append O(1) amortizado; y la list de Python es exactamente eso, con array.array y NumPy como variantes compactas para datos numéricos masivos. Para TaskFlow, el array es perfecto como tablero que crece por el final y se lee en orden, y se queda corto cuando abundan las inserciones y borrados por delante o por el medio.

Con esto se cierra el módulo 1: ya sabes qué es una estructura de datos, por qué importa elegirla bien, qué familias existen, cómo medir su eficiencia con Big O y sobre qué base física se construye todo. En el módulo 2 empezamos a construir de verdad: la lista enlazada, la primera estructura que fabricaremos desde cero, que renuncia a la contigüidad precisamente para hacer baratas las operaciones donde el array flaquea, y que convertiremos en el tablero de tareas definitivo de TaskFlow.

© Copyright 2026. Todos los derechos reservados