Sumas prefijas
En resumen: Las sumas prefijas se calculan una vez en O(n), y después la suma de cualquier rango de a a b es una sola resta de dos sumas prefijas.
Problema
El panel que suma cada vez
El panel de la tienda muestra los ingresos de un periodo elegido: «del día 120 al día 340», «los últimos 30 días», «esta semana frente a la semana pasada». Los gerentes lo abren muchas veces al día, y cada gráfico pregunta por docenas de periodos. El código suma de nuevo los días de cada periodo:
function rangeSum(revenue, a, b) { let sum = 0; for (let day = a; day <= b; day++) { sum += revenue[day]; } return sum;}Mil días de ingresos y mil consultas:
const revenue = revenueOf(1000);const queries = queriesOf(1000);let total = 0;let additions = 0;for (const [a, b] of queries) { total += rangeSum(revenue, a, b); additions += b - a + 1;}console.log(`consultas: ${queries.length}`);console.log(`suma de las respuestas: ${total}`);console.log(`sumas: ${additions}`);consultas: 1000suma de las respuestas: 68412493sumas: 248794248 794 sumas para mil respuestas. Los días no cambian entre dos consultas, y aun así el código los suma una y otra vez. Cuanto más largos son los periodos y más consultas hay, más lento se vuelve el panel.
De dónde vienen los días y las consultas
Los ingresos y las consultas siguen una fórmula, para que los programas en los cuatro lenguajes y la comparación del navegador de más abajo construyan exactamente los mismos:
function revenueOf(n) { return Array.from({ length: n }, (_, i) => 50 + ((i * 37) % 451));}
function queriesOf(n) { const queries = []; for (let k = 0; k < n; k++) { const a = (k * 7919) % n; queries.push([a, a + ((k * 4231) % (n - a))]); } return queries;}La idea
La suma de todo lo que hay a la izquierda
Calcula una vez, para cada día, los ingresos desde el principio hasta ese día. Esto es una suma prefija: prefix[k] es la suma de los primeros k días.
prefix[0] = 0: la suma de ningún día;prefix[k + 1] = prefix[k] + revenue[k]: cada lugar es el anterior más un día.
El arreglo tiene un lugar más que días. Después, cada periodo es la diferencia de dos números. Los ingresos del día a al día b son los días 0 a b menos los días 0 a a − 1:
prefix[b + 1] − prefix[a]
Construirlo son n sumas, una vez. Cada consulta después son dos lecturas y una resta, sea el periodo de dos días o de dos años: O(1) por consulta en lugar de O(n).
Míralo
Las sumas prefijas, día a día
function buildPrefix(revenue) { const prefix = new Array(revenue.length + 1).fill(0); for (let i = 0; i < revenue.length; i++) { prefix[i + 1] = prefix[i] + revenue[i]; } return prefix;}
function rangeSum(prefix, a, b) { return prefix[b + 1] - prefix[a];}const revenue = [120, 80, 150, 90, 200, 60, 110, 140];const prefix = buildPrefix(revenue);console.log(`prefix: [${prefix.join(", ")}]`);console.log(`días 2–5: ${rangeSum(prefix, 2, 5)}`);prefix: [0, 120, 200, 350, 440, 640, 700, 810, 950]días 2–5: 500function buildPrefix(revenue) { const prefix = new Array(revenue.length + 1).fill(0); for (let i = 0; i < revenue.length; i++) { prefix[i + 1] = prefix[i] + revenue[i]; } return prefix;}
function rangeSum(prefix, a, b) { return prefix[b + 1] - prefix[a];}Paso a paso, en palabras
- 8 días de ingresos. prefix tiene 9 lugares: prefix[0] = 0 es la suma de ningún día.
- prefix[1] = prefix[0] + revenue[0] = 0 + 120 = 120: días 0–0.
- prefix[2] = prefix[1] + revenue[1] = 120 + 80 = 200: días 0–1.
- prefix[3] = prefix[2] + revenue[2] = 200 + 150 = 350: días 0–2.
- prefix[4] = prefix[3] + revenue[3] = 350 + 90 = 440: días 0–3.
- prefix[5] = prefix[4] + revenue[4] = 440 + 200 = 640: días 0–4.
- prefix[6] = prefix[5] + revenue[5] = 640 + 60 = 700: días 0–5.
- prefix[7] = prefix[6] + revenue[6] = 700 + 110 = 810: días 0–6.
- prefix[8] = prefix[7] + revenue[7] = 810 + 140 = 950: días 0–7.
- 8 sumas, una vez. Ahora la consulta: los ingresos del día 2 al día 5. prefix[6] = 700 € son los días 0–5.
- prefix[2] = 200 € son los días 0–1, la parte anterior al periodo. La quitamos.
- 700 − 200 = 500 €: los días 2–5 con dos lecturas y una resta, por largo que sea el periodo.
- Construirlo cuesta n sumas una vez; cada consulta después cuesta una resta.
En la escena prefix[k] está en el límite entre el día k − 1 y el día k. Eso muestra que es la suma de todo lo que hay a la izquierda del límite, y que un periodo es la diferencia entre dos límites.
Más precisamente, para curiosos
Lo que te dan los lenguajes.
JavaScript no tiene sumas prefijas ya hechas. El bucle de arriba es la forma habitual. Conreduce cabe en una línea, pero no se vuelve más claro.Desbordamiento. Los números de JavaScript son exactos hasta 2⁵³, unos 9 · 10¹⁵. Los ingresos en céntimos solo llegan ahí con cantidades increíbles. En Java y C# el arreglo es long, porque allí int termina en 2 147 483 647.
Pruébalo
Predice prefix
Tres días de ingresos: 30 €, 50 € y 20 €.
const revenue = [30, 50, 20];const prefix = buildPrefix(revenue);console.log(`prefix: [${prefix.join(", ")}]`);prefix[0] es 0. Luego 0 + 30, 30 + 50 y 80 + 20. El arreglo tiene cuatro lugares para tres días.
Pruébalo
Completa
Los ingresos del día a al día b inclusive:
return prefix[] - prefix[a];prefix[b + 1] es la suma de los días 0 a b, y prefix[a] es la suma de los días 0 a a − 1. La diferencia es el periodo con sus dos extremos.
La idea
El cero delante
¿Por qué el arreglo tiene un lugar más? Se puede prescindir de él: prefix[k] podría ser la suma de los días 0 a k inclusive. Entonces el periodo de a a b es prefix[b] − prefix[a − 1]. Pero para a = 0 no existe el lugar a − 1, y cada consulta necesita una comprobación «si a es 0».
El cero delante elimina esa comprobación. El precio es un lugar en el arreglo, y la trampa es que los índices se desplazan en uno: prefix[k] es la suma de los días antes de k, no hasta k. Por eso un error de uno de más o de menos (off-by-one) es el fallo más común aquí, como en la búsqueda binaria.
Pruébalo
Encuentra el error
Alguien escribió las sumas prefijas sin el cero delante. La consulta de los días 2–5 debería dar 500 €:
const revenue = [120, 80, 150, 90, 200, 60, 110, 140];const prefix = buildPrefix(revenue);console.log(`días 2–5: ${rangeSum(prefix, 2, 5)}`);días 2–5: 350Elige la línea con el error.
Aquí prefix[k] es la suma de los días 0 a k inclusive. Entonces prefix[5] − prefix[2] son los días 3 a 5, sin el día 2: 350 en lugar de 500. Sin el cero delante necesitas prefix[b] − prefix[a − 1] y un caso aparte para a = 0. Con el cero delante (new long[n + 1]) sigue siendo prefix[b + 1] − prefix[a] sin casos especiales.
Míralo
Mídelo: sumar de nuevo frente a las sumas prefijas
Las mismas mil consultas, con las sumas prefijas:
const revenue = revenueOf(1000);const queries = queriesOf(1000);const prefix = buildPrefix(revenue);let operations = revenue.length;let total = 0;for (const [a, b] of queries) { total += rangeSum(prefix, a, b); operations++;}console.log(`consultas: ${queries.length}`);console.log(`suma de las respuestas: ${total}`);console.log(`sumas y restas: ${operations}`);consultas: 1000suma de las respuestas: 68412493sumas y restas: 2000La suma de las respuestas es la misma, y el trabajo son 2000 operaciones en lugar de 248 794: mil sumas para prefix y una resta por consulta. Ejecuta las dos en el navegador. n es tanto el número de días como el número de consultas:
- Volver a sumar en cada consulta
O(n²) - Sumas de prefijos
O(n)
| Algoritmo | n = 10 | n = 1000 | n = 100.000 |
|---|---|---|---|
| Volver a sumar en cada consulta | 10 | 248.794 | ≈ 2.487.940.000 |
| Sumas de prefijos | 20 | 2000 | 200.000 |
function buildPrefix(revenue) { const prefix = new Array(revenue.length + 1).fill(0); for (let i = 0; i < revenue.length; i++) { prefix[i + 1] = prefix[i] + revenue[i]; } return prefix;}
function rangeSum(prefix, a, b) { return prefix[b + 1] - prefix[a];}Sumar de nuevo es O(n) por consulta, O(n²) para n consultas. Con las sumas prefijas es O(n) una vez y O(1) por consulta. El precio es la memoria: un arreglo con n + 1 números.
Sumas prefijas bidimensionales: pedidos por día y hora
El mapa de calor de los pedidos es una tabla: las filas son días de la semana, las columnas son horas. La pregunta es «cuántos pedidos hay el martes y el miércoles de 10:00 a 13:00 h», es decir, la suma dentro de un rectángulo.
p[r][c] es la suma del rectángulo desde la esquina superior izquierda hasta la fila r − 1 y la columna c − 1, de nuevo con ceros delante (fila 0 y columna 0). Cada lugar es la celda más el rectángulo de arriba y el de la izquierda. La esquina entre ellos se contó dos veces, así que se resta una vez:
function buildPrefix2D(orders) { const rows = orders.length; const cols = orders[0].length; const p = Array.from({ length: rows + 1 }, () => new Array(cols + 1).fill(0)); for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { p[r + 1][c + 1] = orders[r][c] + p[r][c + 1] + p[r + 1][c] - p[r][c]; } } return p;}
function rect(p, r1, c1, r2, c2) { return p[r2 + 1][c2 + 1] - p[r1][c2 + 1] - p[r2 + 1][c1] + p[r1][c1];}const orders = [ [3, 8, 12, 9, 5, 4], [2, 7, 14, 11, 6, 3], [4, 9, 15, 10, 7, 5], [1, 6, 11, 8, 4, 2],];const p = buildPrefix2D(orders);console.log(`pedidos mar–mié de 10 a 13 h: ${rect(p, 1, 1, 2, 3)}`);pedidos mar–mié de 10 a 13 h: 66La consulta es la misma idea: todo el rectángulo hasta la esquina inferior derecha, menos la franja de arriba, menos la franja de la izquierda, más la esquina que se restó dos veces. Cuatro lecturas para un rectángulo de cualquier tamaño.
La idea
Cuántos periodos suman exactamente 50 €
La tienda tiene días con pérdida: los pedidos devueltos se descuentan de los ingresos. La pregunta es cuántos periodos de días consecutivos tienen una ganancia de exactamente 50 €.
Cada periodo del día i al día j es la diferencia de dos sumas prefijas: prefix[j + 1] − prefix[i]. Es 50 exactamente cuando prefix[i] = prefix[j + 1] − 50. Así que recorres los días, guardas la suma prefija actual y preguntas: ¿cuántas veces he visto hasta ahora una suma prefija igual a la actual menos 50? La respuesta se guarda en un mapa de suma prefija a cuántas veces se vio:
function countPeriods(profit, target) { const seen = new Map([[0, 1]]); let prefix = 0; let count = 0; for (const p of profit) { prefix += p; count += seen.get(prefix - target) ?? 0; seen.set(prefix, (seen.get(prefix) ?? 0) + 1); } return count;}const profit = [40, -10, 30, 20, -30, 50, -20, 30, 0, 20];console.log(`periodos con 50 € de ganancia: ${countPeriods(profit, 50)}`);periodos con 50 € de ganancia: 8Una pasada por los días y una búsqueda en una tabla hash por día: O(n). Dos bucles anidados sobre todos los periodos serían O(n²). El mapa aquí es un contador: cuántas veces se vio cada valor. Esa es una técnica aparte, y es el tema de la próxima lección.
Más precisamente, para curiosos
seen empieza con 0 → 1, porque la suma prefija antes del primer día es 0. Sin ella no se cuentan los periodos que empiezan el día 0.
Si todos los días dieran ganancia, también funcionaría una ventana deslizante: crecerla por la derecha mientras la suma sea menor que 50, reducirla por la izquierda cuando se pase. Con números negativos eso falla: reducir puede aumentar la suma, y la ventana no sabe en qué dirección ir. Las sumas prefijas con un mapa funcionan con cualquier número.
Pruébalo
Predice el conteo
Cuatro días de ganancia: 20 €, 30 €, −30 € y 30 €. ¿Cuántos periodos de días consecutivos suman exactamente 50 €?
const profit = [20, 30, -30, 30];console.log(`periodos con 50 € de ganancia: ${countPeriods(profit, 50)}`);Las sumas prefijas son 0, 20, 50, 20, 50. Una diferencia de 50 sale de 50 − 0 (días 0–1) y 50 − 0 (días 0–3, porque 20 + 30 − 30 + 30 = 50). La suma prefija 20 necesitaría una suma prefija posterior 70, y no hay ninguna.
Ejercicio
El almacén, día a día
Resuelve la tarea en tu propio editor.
Entregado, vendido, en existencia. El almacén empieza con 40 unidades de un producto. Para diez días tienes dos arreglos:
- entregado: 0, 25, 0, 30, 10, 0, 40, 0, 15, 0;
- vendido: 12, 8, 15, 9, 20, 11, 7, 18, 10, 14.
Construye las sumas prefijas de ambos arreglos. Para los periodos días 2–5, 0–9 y 6–8 imprime cuánto se entregó, cuánto se vendió y las existencias al final del último día. Cada respuesta debe salir de unas pocas lecturas de las sumas prefijas, sin un bucle sobre los días.
días 2–5: entregados 40, vendidos 55, existencias al final 30días 0–9: entregados 120, vendidos 124, existencias al final 36días 6–8: entregados 55, vendidos 35, existencias al final 50Más tareas
- El pedido promedio en un periodo. Además de los ingresos tienes el número de pedidos por día. Un valor medio de pedido no se suma, pero las dos sumas sí. ¿Cómo respondes «el pedido promedio del día a al día b»?
- La mejor semana. Con las sumas prefijas, encuentra los siete días consecutivos con mayores ingresos. ¿Cuántas operaciones necesita eso para 365 días?
- El mapa de calor. Añade el viernes y el sábado a las sumas bidimensionales y encuentra la ventana de dos horas con más pedidos de toda la semana.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Mejor caso, caso promedio y peor caso · Complejidad en espacio · Búsqueda lineal · Tabla hash
- Continúa en: Una tabla hash como contador
- Ejercicios sobre el tema: Técnicas para resolver problemas