El arreglo dinámico por dentro
En resumen: Un arreglo dinámico (el array de JavaScript) tiene más espacios que elementos: leer por índice es O(1), añadir al final es O(1) de media y insertar al principio es O(n).
Problema
El carrito que se vuelve lento
La tienda en línea muestra el carrito con el último producto añadido arriba. El código lo hace sin complicaciones: cada línea nueva entra en el índice 0.
const orderLines = ["Harina 25 kg", "Azúcar 10 kg", "Aceite 5 l", "Arroz 10 kg"];const cart = [];for (const line of orderLines) { cart.unshift(line);}console.log("El carrito: " + cart.join(", "));El carrito: Arroz 10 kg, Aceite 5 l, Azúcar 10 kg, Harina 25 kgCon un cliente normal y 5 productos no se nota. Luego llega un cliente mayorista y sube un pedido desde un archivo: 50 000 líneas. La página se congela durante segundos.
La causa: cada inserción en el índice 0 mueve un espacio todos los productos ya añadidos. 50 000 líneas hacen 1 + 2 + … + 49 999 = 1 249 975 000 movimientos.
La idea
Un arreglo que se cuida solo
El array es por dentro un arreglo dinámico: un arreglo normal con más espacios que elementos y un contador de cuántos están ocupados.
- La capacidad es cuántos espacios tiene el arreglo.
- El tamaño es cuántos de ellos están ocupados, de 0 al tamaño menos uno.
Cuando se acaban los espacios, crea un arreglo nuevo, el doble de grande, y copia todo. Por qué eso es O(1) de media se explica en la lección «Complejidad amortizada». Aquí miramos qué pasa dentro del arreglo: al leer, al insertar y al quitar por índice.
Los espacios del arreglo están uno junto a otro en la memoria y son del mismo tamaño. Si el arreglo empieza en la dirección 1000 y cada espacio ocupa 8 bytes, el espacio i está en la dirección 1000 + i × 8. Por eso leer por índice es una multiplicación y una suma: O(1), sin importar cuántos elementos haya.
Míralo
Tu propio arreglo dinámico
Aquí está lo mismo, escrito a mano. El arreglo empieza con 8 espacios:
class DynamicArray { constructor() { this.items = new Array(8); this.length = 0; }
get capacity() { return this.items.length; }
get(index) { this.#check(index, this.length); return this.items[index]; }
set(index, value) { this.#check(index, this.length); this.items[index] = value; }
add(value) { this.insert(this.length, value); }
insert(index, value) { this.#check(index, this.length + 1); if (this.length === this.items.length) { this.#grow(); } for (let i = this.length; i > index; i--) { this.items[i] = this.items[i - 1]; } this.items[index] = value; this.length++; }
removeAt(index) { this.#check(index, this.length); const removed = this.items[index]; for (let i = index; i < this.length - 1; i++) { this.items[i] = this.items[i + 1]; } this.length--; this.items[this.length] = undefined; return removed; }
#grow() { const bigger = new Array(this.items.length * 2); for (let i = 0; i < this.length; i++) { bigger[i] = this.items[i]; } this.items = bigger; }
#check(index, limit) { if (index < 0 || index >= limit) { throw new RangeError(`índice ${index}, tamaño ${this.length}`); } }}Mira qué pasa con los espacios al leer, insertar y quitar:
insert(index, value) { this.#check(index, this.length + 1); if (this.length === this.items.length) { this.#grow(); } for (let i = this.length; i > index; i--) { this.items[i] = this.items[i - 1]; } this.items[index] = value; this.length++;}
removeAt(index) { this.#check(index, this.length); const removed = this.items[index]; for (let i = index; i < this.length - 1; i++) { this.items[i] = this.items[i + 1]; } this.length--; this.items[this.length] = undefined; return removed;}constructor() { this.items = new Array(8); this.length = 0;}
get(index) { this.#check(index, this.length); return this.items[index];}
set(index, value) { this.#check(index, this.length); this.items[index] = value;}constructor() { this.items = new Array(8); this.length = 0;}
insert(index, value) { this.#check(index, this.length + 1); if (this.length === this.items.length) { this.#grow(); } for (let i = this.length; i > index; i--) { this.items[i] = this.items[i - 1]; } this.items[index] = value; this.length++;}constructor() { this.items = new Array(8); this.length = 0;}
removeAt(index) { this.#check(index, this.length); const removed = this.items[index]; for (let i = index; i < this.length - 1; i++) { this.items[i] = this.items[i + 1]; } this.length--; this.items[this.length] = undefined; return removed;}Paso a paso, en palabras
- El carrito es un único bloque de 8 espacios contiguos en la memoria. Hay 5 ocupados: ese es el tamaño. Los 8 son la capacidad.
- get(3): la dirección es inicio + 3 × 8 = 1024. Un salto, sin pasar por los demás: O(1) con cualquier tamaño.
- insert(0, …): el espacio 0 está ocupado. Cada producto se desplaza un espacio a la derecha, de atrás hacia delante, para no borrar nada: 5 desplazamientos.
- Solo ahora «Queso» entra en el espacio 0, y el tamaño pasa a ser 6.
- removeAt(2) saca «Leche». Los 3 siguientes se desplazan un espacio a la izquierda para cerrar el hueco: 3 desplazamientos.
- El tamaño baja a 5, y el último espacio recibe undefined: la referencia antigua mantendría vivo el producto en la memoria.
- get(5): el espacio 5 existe (la capacidad es 8), pero no está en el carrito. La comprobación compara con el tamaño y lanza RangeError.
- Leer por índice es un salto. Insertar y quitar desplazan todo lo que hay después del índice: al principio son todos los n, O(n). Al final no se desplaza nada.
const cart = new DynamicArray();cart.add("Pan");cart.add("Leche");cart.add("Café");cart.add("Mantequilla");cart.add("Manzanas");console.log(cart.get(3));cart.insert(0, "Queso");cart.removeAt(2);for (let i = 0; i < cart.length; i++) { console.log(`${i}: ${cart.get(i)}`);}console.log("Espacios: " + cart.capacity);try { cart.get(5);} catch (e) { console.log("Error: " + e.message);}Mantequilla0: Queso1: Pan2: Café3: Mantequilla4: ManzanasEspacios: 8Error: índice 5, tamaño 5cart[5] fuera del array da undefined, sin error. Nuestra clase comprueba y lanza RangeError, como hacen Java, C# y Python.Pruébalo
Predice la salida
Un arreglo con 8 espacios contiene 8 productos. Insertas uno más al principio:
class DynamicArray { constructor() { this.items = new Array(8); this.length = 0; }
get capacity() { return this.items.length; }
get(index) { this.#check(index, this.length); return this.items[index]; }
set(index, value) { this.#check(index, this.length); this.items[index] = value; }
add(value) { this.insert(this.length, value); }
insert(index, value) { this.#check(index, this.length + 1); if (this.length === this.items.length) { this.#grow(); } for (let i = this.length; i > index; i--) { this.items[i] = this.items[i - 1]; } this.items[index] = value; this.length++; }
removeAt(index) { this.#check(index, this.length); const removed = this.items[index]; for (let i = index; i < this.length - 1; i++) { this.items[i] = this.items[i + 1]; } this.length--; this.items[this.length] = undefined; return removed; }
#grow() { const bigger = new Array(this.items.length * 2); for (let i = 0; i < this.length; i++) { bigger[i] = this.items[i]; } this.items = bigger; }
#check(index, limit) { if (index < 0 || index >= limit) { throw new RangeError(`índice ${index}, tamaño ${this.length}`); } }}
const cart = new DynamicArray();for (let i = 1; i <= 8; i++) { cart.add("P" + i);}cart.insert(0, "P0");console.log("Productos: " + cart.length);console.log("Espacios: " + cart.capacity);console.log(cart.get(8));El arreglo está lleno, así que insert primero lo duplica: 16 espacios. Luego mueve los ocho un espacio a la derecha, y P0 entra en el espacio 0. P8 queda ahora en el índice 8.
La idea
Insertar y quitar cuestan O(n)
Insertar en el índice i mueve todo lo que hay desde i hasta el final un espacio a la derecha, de atrás hacia delante, para no borrar nada. Quitar mueve todo lo que hay después de i un espacio a la izquierda. Cuanto más cerca del principio, más:
- Al final: no se mueve nada. O(1), y con el crecimiento O(1) amortizado.
- Al principio: se mueve todo. O(n).
- En el medio: de media, la mitad. Sigue siendo O(n).
Por eso n inserciones al principio son O(n²), y n adiciones al final, O(n). La solución para el carrito es añadir al final y mostrar de atrás hacia delante. Recorrer desde atrás es el mismo O(n):
const orderLines = ["Harina 25 kg", "Azúcar 10 kg", "Aceite 5 l", "Arroz 10 kg"];const cart = [];for (const line of orderLines) { cart.push(line);}const newestFirst = cart.toReversed();console.log("El carrito: " + newestFirst.join(", "));El carrito: Arroz 10 kg, Aceite 5 l, Azúcar 10 kg, Harina 25 kgMíralo
Mídelo: al final contra al principio
Ejecuta los dos caminos con los mismos productos. Contamos las escrituras de elementos en el arreglo: la del nuevo, cada movimiento y cada copia al crecer. Insertar al principio con 100 000 no se ejecuta de verdad: Bench lo estima a partir de la curva.
- Agregar al final
O(n) - Insertar al principio
O(n²)
| Algoritmo | n = 10 | n = 1000 | n = 10.000 | n = 100.000 |
|---|---|---|---|---|
| Agregar al final | 18 | 2016 | 26.376 | 231.064 |
| Insertar al principio | 63 | 501.516 | 50.021.376 | ≈ 5.002.137.600 |
class DynamicArray { constructor() { this.items = new Array(8); this.length = 0; }
get capacity() { return this.items.length; }
get(index) { this.#check(index, this.length); return this.items[index]; }
set(index, value) { this.#check(index, this.length); this.items[index] = value; }
add(value) { this.insert(this.length, value); }
insert(index, value) { this.#check(index, this.length + 1); if (this.length === this.items.length) { this.#grow(); } for (let i = this.length; i > index; i--) { this.items[i] = this.items[i - 1]; } this.items[index] = value; this.length++; }
removeAt(index) { this.#check(index, this.length); const removed = this.items[index]; for (let i = index; i < this.length - 1; i++) { this.items[i] = this.items[i + 1]; } this.length--; this.items[this.length] = undefined; return removed; }
#grow() { const bigger = new Array(this.items.length * 2); for (let i = 0; i < this.length; i++) { bigger[i] = this.items[i]; } this.items = bigger; }
#check(index, limit) { if (index < 0 || index >= limit) { throw new RangeError(`índice ${index}, tamaño ${this.length}`); } }}Con 1000 productos, añadir al final hace 2016 escrituras: 1000 nuevas y las copias al crecer. Insertar al principio hace 501.516. Con 10 000 la diferencia es 26.376 contra 50.021.376.
Más precisamente, para curiosos
Los mismos números en tu lenguaje, con un contador de escrituras en la clase:
class DynamicArray { constructor() { this.items = new Array(8); this.length = 0; this.writes = 0; }
get capacity() { return this.items.length; }
get(index) { this.#check(index, this.length); return this.items[index]; }
set(index, value) { this.#check(index, this.length); this.items[index] = value; }
add(value) { this.insert(this.length, value); }
insert(index, value) { this.#check(index, this.length + 1); if (this.length === this.items.length) { this.#grow(); } for (let i = this.length; i > index; i--) { this.items[i] = this.items[i - 1]; this.writes++; } this.items[index] = value; this.writes++; this.length++; }
removeAt(index) { this.#check(index, this.length); const removed = this.items[index]; for (let i = index; i < this.length - 1; i++) { this.items[i] = this.items[i + 1]; } this.length--; this.items[this.length] = undefined; return removed; }
#grow() { const bigger = new Array(this.items.length * 2); for (let i = 0; i < this.length; i++) { bigger[i] = this.items[i]; this.writes++; } this.items = bigger; }
#check(index, limit) { if (index < 0 || index >= limit) { throw new RangeError(`índice ${index}, tamaño ${this.length}`); } }}
for (const n of [10, 1000, 100000]) { const cart = new DynamicArray(); for (let i = 0; i < n; i++) { cart.add(String(1001 + i)); } console.log(`al final, n = ${n}: ${cart.writes} escrituras`);}for (const n of [10, 1000]) { const cart = new DynamicArray(); for (let i = 0; i < n; i++) { cart.insert(0, String(1001 + i)); } console.log(`al principio, n = ${n}: ${cart.writes} escrituras`);}al final, n = 10: 18 escriturasal final, n = 1000: 2016 escriturasal final, n = 100000: 231064 escriturasal principio, n = 10: 63 escriturasal principio, n = 1000: 501516 escriturasPruébalo
Encuentra el error
Un compañero ha reescrito insert. Tres productos, luego uno más al principio, y el carrito sale así:
const cart = new DynamicArray();cart.add("Pan");cart.add("Leche");cart.add("Café");cart.insert(0, "Queso");for (let i = 0; i < cart.length; i++) { console.log(`${i}: ${cart.get(i)}`);}0: Queso1: Pan2: Pan3: Pan¿En qué línea está el error?
Elige la línea con el error.
El bucle va de delante hacia atrás. El primer paso escribe el producto del espacio 0 sobre el espacio 1 y borra lo que había. El siguiente copia el mismo producto aún más a la derecha. Así todos los espacios se llenan con el primer producto. Al mover a la derecha, el bucle tiene que empezar por atrás: primero el último va al espacio libre, luego el penúltimo a su lugar, y así hasta el índice.
Pruébalo
Completa
removeAt cierra el hueco moviendo cada elemento posterior un espacio a la izquierda. ¿De dónde viene el elemento del espacio i?
removeAt(index) {
this.#check(index, this.length);
const removed = this.items[index];
for (let i = index; i < this.length - 1; i++) {
this.items[i] = this.items[];
}
this.length--;
this.items[this.length] = undefined;
return removed;
}El espacio i recibe a su vecino de la derecha, i + 1. El bucle va de delante hacia atrás, así que no se borra nada: cada elemento se lee antes de escribir su espacio. Al quitar, la dirección es la contraria a la de insertar.
La idea
Reducir o no reducir
El carrito llegó a tener 10 000 productos, y luego el cliente quitó casi todos. El arreglo aún guarda 16 384 espacios. ¿Debería reducirse?
Si se reduce a la mitad en cuanto el tamaño cae por debajo de la mitad, hay una trampa. Un arreglo con 16 espacios y 9 elementos: un quitar, y pasa a 8 espacios, lleno. Un añadir, y vuelve a 16 espacios. Alterna los dos justo en ese límite, y cada operación copia todo: O(n).
Por eso la reducción espera más: a la mitad, cuando está lleno hasta un cuarto. Tras reducir, el arreglo está medio lleno, y hasta la siguiente copia hay al menos n/4 operaciones. El coste sigue siendo O(1) amortizado.
Las listas integradas deciden de otra forma:
-
Java
ArrayListno se reduce solo, tienetrimToSize(); -
C#
List<T>no se reduce solo, tieneTrimExcess(); - Python (CPython) se reduce solo por debajo de la mitad;
-
JavaScript (V8) reduce la memoria del array cuando una parte grande está vacía, por ejemplo tras
popolength = 0. El lenguaje no lo promete, lo decide el motor.
Reducir ahorra memoria, pero cuesta una copia. Si el arreglo va a volver a crecer pronto, es más barato no reducirlo.
La idea
Por qué el arreglo también es rápido en la práctica: el caché
El procesador no lee la memoria byte a byte. Toma una línea entera de 64 bytes y la guarda en el caché, una memoria pequeña y muy rápida a su lado. Leer del caché es decenas de veces más rápido que leer de la memoria principal.
El arreglo es un solo bloque. Cuando lees el espacio 0, sus vecinos vienen en la misma línea y las siguientes lecturas salen del caché. El procesador también nota que lees en orden, y trae las siguientes líneas por adelantado. Por eso recorrer un arreglo es rápido no solo sobre el papel, sino también en el reloj.
Importa qué hay en los espacios:
- V8 guarda un array solo de enteros pequeños como los propios números uno junto a otro, y un array de objetos o texto como referencias a ellos. Un array con huecos o con tipos mezclados se vuelve más lento. Para números juntos existen Int32Array y Float64Array.
La siguiente lección, «Lista enlazada», es lo contrario: cada elemento es un objeto aparte en algún lugar de la memoria. Allí insertar al principio es O(1), pero el caché no ayuda.
Ejercicio
Un arreglo que se reduce
Resuélvelo en tu editor.
Reducir con un cuarto. Sustituye el crecimiento de la clase por un método #resize(places) que crea un arreglo con esa cantidad de espacios y copia los elementos. insert lo llama con el doble de espacios cuando el arreglo está lleno. removeAt lo llama con la mitad cuando el tamaño llega a un cuarto de los espacios, pero nunca por debajo de 8 espacios.
Añade 20 productos e imprime el tamaño y los espacios. Luego quita desde el final hasta que queden 2, e imprime el tamaño y los espacios en cada cambio de espacios:
20 / 328 / 164 / 8Más ejercicios
- indexOf y remove. Añade
indexOf(value), que devuelve el índice o -1, yremove(value), que quita la primera aparición. ¿Cuánto cuesta cada uno? - Quitar del principio sin mover. Una cola de pedidos quita siempre el primero. Guarda un índice
headdel primer espacio ocupado en lugar de mover todos. ¿Qué pasa cuandoheadllega al final del arreglo? (Pista: continúa desde el principio.) - Llenar por partes. Importa 50 000 líneas de una vez: reúnelas al final y da la vuelta al arreglo una sola vez. ¿Cuántas escrituras son?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Big-O: cómo crece el trabajo · Mejor caso, caso promedio y peor caso · Complejidad en espacio · Complejidad amortizada
- Continúa en: Lista enlazada · Pila y cola · Tabla hash
- Ejercicios sobre el tema: Estructuras de datos