Gleitendes Fenster
Kurz gesagt: Ein gleitendes Fenster berechnet etwas für jeden zusammenhängenden Bereich, indem es bei jedem Schritt das neue Element addiert und das alte entfernt: O(n) statt O(n · k).
Problem
Der Umsatz der letzten 365 Tage
Das Dashboard des Shops zeigt für jeden Tag den Umsatz der letzten 365 Tage. Die Historie umfasst 10 Jahre: 3650 Tage. Der Code berechnet jedes Fenster von Grund auf: Für jeden Tag addiert er die 365 Tage davor.
let adds = 0;
function recompute(sales, k) { const sums = []; for (let start = 0; start + k <= sales.length; start++) { let sum = 0; for (let d = start; d < start + k; d++) { adds++; sum += sales[d]; } sums.push(sum); } return sums;}const sales = Array.from({ length: 3650 }, (_, d) => (d * 37) % 101 * 10 + 500);const sums = recompute(sales, 365);console.log(`Fenster: ${sums.length}`);console.log(`Additionen: ${adds}`);console.log(`Die besten 365 Tage: ${Math.max(...sums)} €`);Fenster: 3286Additionen: 1199390Die besten 365 Tage: 366050 €3286 Fenster mit je 365 Additionen: 1.199.390. Das ist n·k: Bei 1000 Produkten mit je einem Diagramm werden daraus jede Nacht 1,2 Milliarden Additionen.
Dabei unterscheiden sich zwei benachbarte Fenster nur in zwei Tagen. Die anderen 364 werden immer wieder addiert.
Die Idee
Das Fenster gleiten lassen: +neuer Tag, −alter Tag
Die Summe für die Tage 2–8 ist die Summe für die Tage 1–7, plus Tag 8, minus Tag 1. Tag 8 kommt ins Fenster, Tag 1 geht hinaus. Die Tage dazwischen werden nicht angefasst.
Also:
- das erste Fenster wird einmal addiert: k Additionen;
- jedes weitere ist das vorige + der neue Tag − der älteste Tag: zwei Operationen, egal wie groß das Fenster ist.
Das heißt gleitendes Fenster: ein Fenster fester Länge, das sich um ein Element bewegt, während sein Ergebnis aus dem vorigen aktualisiert wird. Statt (n − k + 1) · k Operationen sind es k + 2(n − k). Das ist O(n), nicht O(n·k).
Das Fenster sind zwei Zeiger, die sich in dieselbe Richtung bewegen, wie in der vorigen Lektion: sein Anfang und sein Ende. Hier ist der Abstand zwischen ihnen immer k.
Sieh es dir an
Die Woche gleitet
Der Umsatz für 12 Tage und die Summe für je 7 aufeinanderfolgende Tage. Der Durchschnitt der Woche ist die Summe geteilt durch 7.
function slide(sales, k) { let sum = 0; for (let i = 0; i < k; i++) { sum += sales[i]; } const sums = [sum]; for (let i = k; i < sales.length; i++) { sum += sales[i]; sum -= sales[i - k]; sums.push(sum); } return sums;}const sales = [820, 640, 910, 700, 580, 1020, 760, 890, 610, 980, 720, 850];const sums = slide(sales, 7);sums.forEach((sum, d) => { console.log(`Tage ${d + 1}–${d + 7}: ${sum} €`);});Tage 1–7: 5430 €Tage 2–8: 5500 €Tage 3–9: 5470 €Tage 4–10: 5540 €Tage 5–11: 5560 €Tage 6–12: 5830 €function slide(sales, k) { let sum = 0; for (let i = 0; i < k; i++) { sum += sales[i]; } const sums = [sum]; for (let i = k; i < sales.length; i++) { sum += sales[i]; sum -= sales[i - k]; sums.push(sum); } return sums;}Schritt für Schritt, in Worten
- Das erste Fenster: Tage 1–7. Seine 7 Tage werden einzeln addiert: 7 Additionen, 5430 €.
- Tag 8 kommt hinein: +890 €. Tag 1 geht hinaus: −820 €. Tage 2–8: 5500 €, mit zwei Operationen statt 7.
- Tag 9 kommt hinein: +610 €. Tag 2 geht hinaus: −640 €. Tage 3–9: 5470 €, mit zwei Operationen statt 7.
- Tag 10 kommt hinein: +980 €. Tag 3 geht hinaus: −910 €. Tage 4–10: 5540 €, mit zwei Operationen statt 7.
- Tag 11 kommt hinein: +720 €. Tag 4 geht hinaus: −700 €. Tage 5–11: 5560 €, mit zwei Operationen statt 7.
- Tag 12 kommt hinein: +850 €. Tag 5 geht hinaus: −580 €. Tage 6–12: 5830 €, mit zwei Operationen statt 7.
- 6 Fenster für 17 Operationen: 7 für das erste und je 2 für jedes weitere. Jedes Mal von Grund auf wären es 6 · 7 = 42.
push, und forEach liefert sowohl die Summe als auch ihre Nummer.Probier es aus
Sag voraus
Der Umsatz für fünf Tage und ein Fenster von 3 Tagen. Was gibt das Programm aus?
function slide(sales, k) { let sum = 0; for (let i = 0; i < k; i++) { sum += sales[i]; } const sums = [sum]; for (let i = k; i < sales.length; i++) { sum += sales[i]; sum -= sales[i - k]; sums.push(sum); } return sums;}
const sales = [820, 640, 910, 700, 580];const sums = slide(sales, 3);console.log(sums.at(-1));Das erste Fenster ist 820 + 640 + 910 = 2370. Dann +700 −820 = 2250, dann +580 −640 = 2190. Die letzte Summe gilt für die Tage 3–5: 910 + 700 + 580 = 2190.
Probier es aus
Ergänze
Tag i kommt ins Fenster. Welcher Tag geht hinaus?
function slide(sales, k) {
let sum = 0;
for (let i = 0; i < k; i++) {
sum += sales[i];
}
const sums = [sum];
for (let i = k; i < sales.length; i++) {
sum += sales[i];
sum -= sales[];
sums.push(sum);
}
return sums;
}Das Fenster, das an Tag i endet, reicht von i - k + 1 bis i. Das vorige reicht von i - k bis i - 1. Der Unterschied ist Tag i - k: Er geht hinaus.
Probier es aus
Finde den Fehler
Die erste Summe stimmt, und danach springen die Zahlen auf und ab:
const sales = [820, 640, 910, 700, 580, 1020, 760, 890, 610, 980, 720, 850];const sums = slide(sales, 7);sums.forEach((sum, d) => { console.log(`Tage ${d + 1}–${d + 7}: ${sum} €`);});Tage 1–7: 5430 €Tage 2–8: 5560 €Tage 3–9: 5280 €Tage 4–10: 5650 €Tage 5–11: 5390 €Tage 6–12: 5520 €Wähle die Zeile mit dem Fehler.
Der Code zieht gestern ab, sales[i - 1], und nicht den Tag, der das Fenster verlässt, sales[i - k]. Gestern ist noch im Fenster. Also ist jede Summe falsch außer der ersten: Für die Tage 2–8 ergibt er 5430 + 890 − 760 = 5560, während die richtige Antwort 5500 ist.
Sieh es dir an
Miss es: von Grund auf gegen Gleiten
Dieselben 10 Jahre und ein Fenster von 365 Tagen, und die Funktion zählt die Additionen und Subtraktionen:
let ops = 0;
function slide(sales, k) { let sum = 0; for (let i = 0; i < k; i++) { ops++; sum += sales[i]; } const sums = [sum]; for (let i = k; i < sales.length; i++) { ops++; sum += sales[i]; ops++; sum -= sales[i - k]; sums.push(sum); } return sums;}const sales = Array.from({ length: 3650 }, (_, d) => (d * 37) % 101 * 10 + 500);const sums = slide(sales, 365);console.log(`Fenster: ${sums.length}`);console.log(`Operationen: ${ops}`);console.log(`Die besten 365 Tage: ${Math.max(...sums)} €`);Fenster: 3286Operationen: 6935Die besten 365 Tage: 366050 €6935 Operationen statt 1.199.390, und die besten 365 Tage sind dieselben. Lass beide Wege im Browser laufen:
- Jedes Fenster von vorn (K = 365)
O(n) - Gleitend: +neu −alt
O(n)
| Algorithmus | n = 1.000 | n = 3.650 | n = 10.000 | n = 100.000 |
|---|---|---|---|---|
| Jedes Fenster von vorn (K = 365) | 232.140 | 1.199.390 | 3.517.140 | 36.367.140 |
| Gleitend: +neu −alt | 1.635 | 6.935 | 19.635 | 199.635 |
function slide(sales, k) { let sum = 0; for (let i = 0; i < k; i++) { sum += sales[i]; } const sums = [sum]; for (let i = k; i < sales.length; i++) { sum += sales[i]; sum -= sales[i - k]; sums.push(sum); } return sums;}Die zwei Kurven verlaufen parallel: Das Fenster ist immer 365 Tage, also wachsen beide mit n. Der Unterschied ist der Faktor. Von Grund auf kostet k pro Tag, Gleiten kostet 2 pro Tag, egal wie groß das Fenster ist. Bei einem Fenster von 30 Tagen ist das Gleiten 15-mal schneller, bei 365 Tagen etwa 180-mal.
Die Idee
Ein Fenster mit veränderlicher Länge
Das Marketing fragt: Was ist der längste Zeitraum aufeinanderfolgender Tage, in dem die Werbeausgaben insgesamt höchstens 400 € betragen? Jetzt ist die Länge des Fensters nicht vorgegeben. Sie ist die Antwort.
Das Fenster hat zwei Kanten, left und right:
rightgeht vorwärts und fügt einen neuen Tag hinzu;- geht die Summe über das Budget, geht
leftvorwärts und entfernt links Tage, bis die Summe wieder im Budget ist; - danach ist das Fenster erlaubt, und seine Länge ist ein Kandidat für die längste.
function longestWithin(spend, budget) { let best = 0; let sum = 0; let left = 0; for (let right = 0; right < spend.length; right++) { sum += spend[right]; while (sum > budget) { sum -= spend[left]; left++; } best = Math.max(best, right - left + 1); } return best;}const spend = [120, 80, 200, 40, 60, 90, 30, 150, 70, 20, 250, 60];console.log(`Längster Zeitraum bis 400 €: ${longestWithin(spend, 400)} Tage`);Längster Zeitraum bis 400 €: 5 TageEs gibt eine Schleife in einer Schleife, aber das ist O(n), nicht O(n²). left geht nie zurück: Jeder Tag kommt einmal ins Fenster und verlässt es höchstens einmal. Insgesamt höchstens 2n Bewegungen.
Das funktioniert, weil die Ausgaben nicht negativ sind: Ein längeres Fenster ist nie günstiger. Gibt es Erstattungen (negative Zahlen), kann das Entfernen eines Tages links die Summe erhöhen, und die Regel „verkleinern, bis es im Budget ist“ stimmt nicht mehr.
Sieh es dir an
Kein wiederholtes Zeichen: ein Fenster mit einem Set
Ein weiteres Fenster mit veränderlicher Länge: das längste Stück einer Logzeile, in dem sich kein Zeichen wiederholt. Solche Stücke verraten zufällige Tokens, die nicht im Log stehen sollten. Das Fenster hält seine Zeichen in einem Set. Ist das neue Zeichen schon drin, entfernt left links Zeichen, bis die Wiederholung weg ist:
function longestUnique(line) { const seen = new Set(); let bestStart = 0; let bestLen = 0; let left = 0; for (let right = 0; right < line.length; right++) { const c = line[right]; while (seen.has(c)) { seen.delete(line[left]); left++; } seen.add(c); if (right - left + 1 > bestLen) { bestLen = right - left + 1; bestStart = left; } } return line.slice(bestStart, bestStart + bestLen);}const line = "GET /cart?token=x9Kq2Lm7Pz&page=2";const part = longestUnique(line);console.log(`Kein Zeichen doppelt: ${part} (${part.length})`);Kein Zeichen doppelt: ?token=x9Kq2Lm7Pz&pag (21)Die Prüfung „ist es da“ im Set ist im Durchschnitt O(1), mit einer Hashtabelle. Auch hier ist die Summe also O(n).
line[right] ist eine Einheit des UTF-16-Texts. Bei Text mit Emoji ist es ein halbes Zeichen: siehe „Genauer, für Neugierige“.Genauer, für Neugierige
Was ein „Zeichen“ ist. In Java, C# und JavaScript besteht ein String aus 16-Bit-Einheiten (UTF-16). Ein Emoji wie 🛒 sind zwei solche Einheiten, und das Fenster zählt sie getrennt. In Python besteht ein String aus Codepoints, und 🛒 ist ein Zeichen. Ein Log mit lateinischen Buchstaben und Ziffern ist in allen vier Sprachen gleich.
Die Summe darf nicht überlaufen. In JavaScript sind Zahlen bis 2⁵³ exakt, die Summe in Cent läuft nicht über. In Java und C# reicht int bis 2.147.483.647, und Umsatz in Cent braucht ein long.
Die Summe darf nicht driften. Sind die Verkäufe in Euro mit Cent in einem double, fügt jedes +neu −alt einen winzigen Rundungsfehler hinzu, und die Fehler häufen sich: Nach Tausenden von Schritten ist die Summe des Fensters nicht mehr genau die Summe seiner Tage. Halte Geld in Cent, als ganze Zahlen. Sind die Zahlen von Natur aus Gleitkommazahlen (Temperaturen, Zeiten), berechne das Fenster alle paar Tausend Schritte von Grund auf neu.
Der Durchschnitt und das Größte. Der gleitende Durchschnitt ist die Summe geteilt durch k. Der größte Wert im Fenster funktioniert nicht mit +neu −alt: Ein Maximum lässt sich nicht „abziehen“. Dafür gibt es ein gleitendes Fenster mit einer doppelt-endenden Warteschlange (Deque), die die Kandidaten für das Maximum behält.
Übung
Der kürzeste Zeitraum mit mindestens 3500 € Umsatz
Löse die Aufgabe in deinem Editor.
Der kürzeste Zeitraum. Der Umsatz für 12 Tage ist 820 €, 640 €, 910 €, 700 €, 580 €, 1020 €, 760 €, 890 €, 610 €, 980 €, 720 € und 850 €. Was ist der kürzeste Zeitraum aufeinanderfolgender Tage mit mindestens 3500 € Umsatz? Das Fenster wächst nach rechts, und solange die Summe mindestens 3500 € beträgt, merke dir seine Länge, wenn sie kleiner ist, und verkleinere es von links.
Kürzester Zeitraum mit mindestens 3500 €: 5 Tage (von Tag 1 bis Tag 5)Weitere Aufgaben
- Die beste Woche. Finde mit dem Gleiten heraus, welche 7 aufeinanderfolgenden Tage den größten Umsatz haben, und gib sie aus.
- Ein gleitender Durchschnitt. Gib den durchschnittlichen Umsatz für je 7 Tage aus, auf den Cent gerundet.
- Verschiedene Kunden pro Stunde. Die Bestellungen sind nach Minute sortiert, jede mit einer Kundennummer. Wie viele verschiedene Kunden gibt es in jedem 60-Minuten-Fenster? Halte in einer Map, wie viele Bestellungen jeder Kunde im Fenster hat.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Big-O: wie die Arbeit wächst · Bester, durchschnittlicher und schlechtester Fall · Hashtabelle · Zwei Zeiger
- Aufgaben zum Thema: Techniken zum Lösen von Aufgaben