diff --git a/src/main/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregator.java b/src/main/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregator.java new file mode 100644 index 000000000000..ae20c88cd334 --- /dev/null +++ b/src/main/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregator.java @@ -0,0 +1,447 @@ +package com.thealgorithms.datastructures.buffers; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.ConcurrentModificationException; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.function.BinaryOperator; +import java.util.function.Function; + +/** + * A fixed-capacity ring (circular) buffer that additionally keeps an aggregate of every element it + * currently holds, i.e. a sliding window with aggregation. + * + *

The window slides automatically: once the buffer is full, adding a new element evicts the + * oldest one, and the aggregate is kept in sync without rescanning the window. + * + *

How the aggregate is maintained

+ * + *

The naive way to keep a windowed sum is "add the new value, subtract the evicted one". That + * trick only works for invertible operators: there is no way to subtract a value from a + * minimum. This class instead uses the classic two-stack sliding window aggregation + * algorithm, which needs nothing but associativity: + * + *

+ * + *

A pleasant side effect is numerical stability: because aggregates are recomputed from scratch + * on every flip, floating-point error cannot accumulate indefinitely the way it does with the + * add-then-subtract approach. + * + *

Complexity

+ * + * + * + * + * + * + * + * + * + *
Time and space complexity
OperationComplexity
{@link #add(Object)}O(1) amortised, O(n) worst case
{@link #removeOldest()}O(1) amortised, O(n) worst case
{@link #aggregate()}O(1)
{@link #get(int)}, {@link #peekOldest()}, {@link #peekNewest()}O(1)
memoryO(capacity), no allocation after construction
+ * + *

Usage

+ * + *
{@code
+ * // Maximum of the last three readings.
+ * SlidingWindowAggregator window = SlidingWindowAggregator.maximum(3, Comparator.naturalOrder());
+ * for (int value : new int[] {1, 3, 2, 5, 0}) {
+ *     window.add(value);
+ *     System.out.println(window.aggregate());
+ * }
+ * // prints 1, 3, 3, 5, 5
+ *
+ * // Moving average of the last 100 samples: the window knows both the sum and its own size.
+ * SlidingWindowAggregator sum = SlidingWindowAggregator.sumOfDoubles(100);
+ * sum.add(sample);
+ * double movingAverage = sum.aggregate() / sum.size();
+ * }
+ * + *

Contract

+ * + * + * + * @param type of the elements stored in the window + * @param type of the aggregate; use {@code A == E} for operators such as min, max or sum + * @see CircularBuffer + * @see Circular buffer + * @see K. Tangwongsan, M. Hirzel, S. Schneider, K.-L. Wu, General incremental sliding-window aggregation (VLDB 2015) + */ +public final class SlidingWindowAggregator implements Iterable { + + private final Object[] elements; + + /** + * Parallel ring holding one aggregate per occupied slot. The meaning depends on the section the + * slot belongs to: inside the front section it is the aggregate of the element and every younger + * element of that section, inside the back section it is simply the mapped element itself. + */ + private final Object[] aggregates; + + private final Function mapper; + private final BinaryOperator combiner; + private final int capacity; + + /** Absolute index of the oldest element; {@code head <= flip <= tail} always holds. */ + private long head; + + /** Absolute index of the boundary between the front and the back section. */ + private long flip; + + /** Absolute index one past the newest element. */ + private long tail; + + /** Aggregate of the back section, or {@code null} when that section is empty. */ + private A backAggregate; + + private int modCount; + + /** + * Creates an empty window. + * + * @param capacity maximum number of elements held at once; adding beyond it evicts the oldest + * @param mapper turns an element into the value the aggregate is computed over + * @param combiner associative operator used to merge two aggregates + * @throws IllegalArgumentException if {@code capacity} is not positive + * @throws NullPointerException if {@code mapper} or {@code combiner} is {@code null} + */ + public SlidingWindowAggregator(int capacity, Function mapper, BinaryOperator combiner) { + if (capacity <= 0) { + throw new IllegalArgumentException("Window capacity must be positive, but was " + capacity); + } + this.capacity = capacity; + this.elements = new Object[capacity]; + this.aggregates = new Object[capacity]; + this.mapper = Objects.requireNonNull(mapper, "mapper must not be null"); + this.combiner = Objects.requireNonNull(combiner, "combiner must not be null"); + } + + /** + * Creates a window whose aggregate has the same type as its elements. + * + * @param capacity maximum number of elements held at once + * @param combiner associative operator used to merge two elements + * @param type of the elements and of the aggregate + * @return a new window + */ + public static SlidingWindowAggregator of(int capacity, BinaryOperator combiner) { + return new SlidingWindowAggregator<>(capacity, Function.identity(), combiner); + } + + /** + * Creates a window that keeps the sum of the last {@code capacity} values. + * + * @param capacity maximum number of elements held at once + * @return a new window aggregating with {@link Long#sum(long, long)} + */ + public static SlidingWindowAggregator sumOfLongs(int capacity) { + return of(capacity, Long::sum); + } + + /** + * Creates a window that keeps the sum of the last {@code capacity} values. Combined with + * {@link #size()} this is the cheapest way to obtain a moving average. + * + * @param capacity maximum number of elements held at once + * @return a new window aggregating with {@link Double#sum(double, double)} + */ + public static SlidingWindowAggregator sumOfDoubles(int capacity) { + return of(capacity, Double::sum); + } + + /** + * Creates a window that keeps the smallest of the last {@code capacity} values. + * + * @param capacity maximum number of elements held at once + * @param comparator ordering used to pick the minimum + * @param type of the elements + * @return a new window aggregating with {@link BinaryOperator#minBy(Comparator)} + */ + public static SlidingWindowAggregator minimum(int capacity, Comparator comparator) { + return of(capacity, BinaryOperator.minBy(comparator)); + } + + /** + * Creates a window that keeps the largest of the last {@code capacity} values. + * + * @param capacity maximum number of elements held at once + * @param comparator ordering used to pick the maximum + * @param type of the elements + * @return a new window aggregating with {@link BinaryOperator#maxBy(Comparator)} + */ + public static SlidingWindowAggregator maximum(int capacity, Comparator comparator) { + return of(capacity, BinaryOperator.maxBy(comparator)); + } + + /** + * Appends an element, evicting the oldest one if the window is already full. + * + * @param element the element to append + * @return the evicted element, or {@code null} if the window was not full + * @throws NullPointerException if {@code element} is {@code null} or the mapper returns {@code null} + */ + public E add(E element) { + Objects.requireNonNull(element, "This window does not accept null elements"); + E evicted = isFull() ? removeOldest() : null; + + int slot = slotOf(tail); + elements[slot] = element; + A mapped = requireNonNullResult(mapper.apply(element), "mapper"); + aggregates[slot] = mapped; + backAggregate = tail == flip ? mapped : requireNonNullResult(combiner.apply(backAggregate, mapped), "combiner"); + tail++; + modCount++; + return evicted; + } + + /** + * Appends every given element in order. The insertion is not atomic: if an element turns out to be + * {@code null}, the ones before it are already in the window. + * + * @param items the elements to append + * @throws NullPointerException if {@code items} or any of its elements is {@code null} + */ + public void addAll(Iterable items) { + Objects.requireNonNull(items, "items must not be null"); + for (E item : items) { + add(item); + } + } + + /** + * Removes the oldest element, shrinking the window. + * + * @return the removed element + * @throws NoSuchElementException if the window is empty + */ + public E removeOldest() { + if (isEmpty()) { + throw new NoSuchElementException("The window is empty"); + } + if (head == flip) { + rotateBackSectionToFront(); + } + + int slot = slotOf(head); + E oldest = elementAt(slot); + elements[slot] = null; + aggregates[slot] = null; + head++; + modCount++; + return oldest; + } + + /** + * Returns the aggregate of every element currently in the window, combined from the oldest to + * the newest. + * + * @return the aggregate of the window + * @throws NoSuchElementException if the window is empty + */ + public A aggregate() { + if (isEmpty()) { + throw new NoSuchElementException("The aggregate of an empty window is undefined"); + } + if (head == flip) { + return backAggregate; + } + A frontAggregate = aggregateAt(slotOf(head)); + return tail == flip ? frontAggregate : requireNonNullResult(combiner.apply(frontAggregate, backAggregate), "combiner"); + } + + /** + * Returns the element at the given position, counting from the oldest one. + * + * @param index zero-based position, {@code 0} being the oldest element in the window + * @return the element at that position + * @throws IndexOutOfBoundsException if {@code index} is negative or not smaller than {@link #size()} + */ + public E get(int index) { + if (index < 0 || index >= size()) { + throw new IndexOutOfBoundsException("Index " + index + " is out of bounds for a window of size " + size()); + } + return elementAt(slotOf(head + index)); + } + + /** + * Returns the oldest element without removing it. + * + * @return the element that would be evicted next + * @throws NoSuchElementException if the window is empty + */ + public E peekOldest() { + if (isEmpty()) { + throw new NoSuchElementException("The window is empty"); + } + return elementAt(slotOf(head)); + } + + /** + * Returns the most recently added element without removing it. + * + * @return the newest element + * @throws NoSuchElementException if the window is empty + */ + public E peekNewest() { + if (isEmpty()) { + throw new NoSuchElementException("The window is empty"); + } + return elementAt(slotOf(tail - 1)); + } + + /** + * Returns the number of elements currently in the window. + * + * @return the current size, never greater than {@link #capacity()} + */ + public int size() { + return (int) (tail - head); + } + + /** + * Returns the maximum number of elements the window can hold. + * + * @return the capacity given at construction time + */ + public int capacity() { + return capacity; + } + + /** + * Tells whether the window holds no elements. + * + * @return {@code true} if the window is empty + */ + public boolean isEmpty() { + return head == tail; + } + + /** + * Tells whether the next insertion will evict the oldest element. + * + * @return {@code true} if the window is saturated + */ + public boolean isFull() { + return size() == capacity; + } + + /** + * Discards every element, leaving the window as if freshly constructed. + */ + public void clear() { + Arrays.fill(elements, null); + Arrays.fill(aggregates, null); + head = 0; + flip = 0; + tail = 0; + backAggregate = null; + modCount++; + } + + /** + * Returns a snapshot of the window, ordered from the oldest element to the newest one. The list + * is detached from the window: later insertions do not affect it. + * + * @return a new list holding the current contents of the window + */ + public List toList() { + List snapshot = new ArrayList<>(size()); + for (long i = head; i < tail; i++) { + snapshot.add(elementAt(slotOf(i))); + } + return snapshot; + } + + /** + * Returns a fail-fast iterator walking the window from the oldest element to the newest one. + * + * @return an iterator over the current contents of the window + */ + @Override + public Iterator iterator() { + return new WindowIterator(); + } + + @Override + public String toString() { + return toList().toString(); + } + + /** + * Turns the back section into the front section by walking it backwards and storing, for every + * slot, the aggregate of that element and all younger ones. Called only when the front section is + * exhausted, so each element takes part in at most one such pass. + */ + private void rotateBackSectionToFront() { + A suffix = null; + for (long i = tail - 1; i >= head; i--) { + int slot = slotOf(i); + A own = aggregateAt(slot); + suffix = suffix == null ? own : requireNonNullResult(combiner.apply(own, suffix), "combiner"); + aggregates[slot] = suffix; + } + flip = tail; + backAggregate = null; + } + + private int slotOf(long absoluteIndex) { + return (int) (absoluteIndex % capacity); + } + + @SuppressWarnings("unchecked") + private E elementAt(int slot) { + return (E) elements[slot]; + } + + @SuppressWarnings("unchecked") + private A aggregateAt(int slot) { + return (A) aggregates[slot]; + } + + private static T requireNonNullResult(T value, String producer) { + return Objects.requireNonNull(value, "The " + producer + " of a SlidingWindowAggregator must not return null"); + } + + private final class WindowIterator implements Iterator { + private long cursor = head; + private final int expectedModCount = modCount; + + @Override + public boolean hasNext() { + return cursor < tail; + } + + @Override + public E next() { + if (expectedModCount != modCount) { + throw new ConcurrentModificationException(); + } + if (!hasNext()) { + throw new NoSuchElementException("The iterator has been exhausted"); + } + return elementAt(slotOf(cursor++)); + } + } +} diff --git a/src/test/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregatorTest.java b/src/test/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregatorTest.java new file mode 100644 index 000000000000..2595678961ed --- /dev/null +++ b/src/test/java/com/thealgorithms/datastructures/buffers/SlidingWindowAggregatorTest.java @@ -0,0 +1,449 @@ +package com.thealgorithms.datastructures.buffers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.ConcurrentModificationException; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class SlidingWindowAggregatorTest { + + private static final Comparator NATURAL = Comparator.naturalOrder(); + + private static SlidingWindowAggregator sumWindow(int capacity) { + return SlidingWindowAggregator.of(capacity, Integer::sum); + } + + @Nested + @DisplayName("construction") + class Construction { + + @ParameterizedTest + @ValueSource(ints = {0, -1, Integer.MIN_VALUE}) + void rejectsNonPositiveCapacity(int capacity) { + assertThrows(IllegalArgumentException.class, () -> sumWindow(capacity)); + } + + @Test + void rejectsNullMapper() { + assertThrows(NullPointerException.class, () -> new SlidingWindowAggregator(4, null, Integer::sum)); + } + + @Test + void rejectsNullCombiner() { + assertThrows(NullPointerException.class, () -> new SlidingWindowAggregator(4, Function.identity(), null)); + } + + @Test + void startsEmpty() { + SlidingWindowAggregator window = sumWindow(3); + assertTrue(window.isEmpty()); + assertFalse(window.isFull()); + assertEquals(0, window.size()); + assertEquals(3, window.capacity()); + assertTrue(window.toList().isEmpty()); + assertEquals("[]", window.toString()); + } + } + + @Nested + @DisplayName("empty window") + class EmptyWindow { + + private final SlidingWindowAggregator window = sumWindow(3); + + @Test + void aggregateThrows() { + assertThrows(NoSuchElementException.class, window::aggregate); + } + + @Test + void peeksThrow() { + assertThrows(NoSuchElementException.class, window::peekOldest); + assertThrows(NoSuchElementException.class, window::peekNewest); + } + + @Test + void removeThrows() { + assertThrows(NoSuchElementException.class, window::removeOldest); + } + + @Test + void getThrows() { + assertThrows(IndexOutOfBoundsException.class, () -> window.get(0)); + } + + @Test + void iteratorIsExhausted() { + Iterator iterator = window.iterator(); + assertFalse(iterator.hasNext()); + assertThrows(NoSuchElementException.class, iterator::next); + } + } + + @Nested + @DisplayName("aggregation") + class Aggregation { + + @Test + void aggregatesWhileFilling() { + SlidingWindowAggregator window = sumWindow(4); + window.add(1); + assertEquals(1, window.aggregate()); + window.add(2); + assertEquals(3, window.aggregate()); + window.add(3); + assertEquals(6, window.aggregate()); + window.add(4); + assertEquals(10, window.aggregate()); + assertTrue(window.isFull()); + } + + @Test + void slidesOnceFull() { + SlidingWindowAggregator window = sumWindow(3); + window.addAll(List.of(1, 2, 3)); + assertEquals(6, window.aggregate()); + + assertEquals(1, window.add(4)); + assertEquals(9, window.aggregate()); + assertEquals(List.of(2, 3, 4), window.toList()); + + assertEquals(2, window.add(5)); + assertEquals(12, window.aggregate()); + } + + @Test + void addReturnsNullWhileTheWindowIsNotFull() { + SlidingWindowAggregator window = sumWindow(2); + Assertions.assertNull(window.add(1)); + Assertions.assertNull(window.add(2)); + assertEquals(1, window.add(3)); + } + + @Test + @DisplayName("keeps the maximum of the last three readings") + void tracksMaximum() { + SlidingWindowAggregator window = SlidingWindowAggregator.maximum(3, NATURAL); + List observed = new ArrayList<>(); + for (int value : new int[] {1, 3, 2, 5, 0, 0, 0}) { + window.add(value); + observed.add(window.aggregate()); + } + assertEquals(List.of(1, 3, 3, 5, 5, 5, 0), observed); + } + + @Test + void tracksMinimum() { + SlidingWindowAggregator window = SlidingWindowAggregator.minimum(3, NATURAL); + window.addAll(List.of(5, 4, 6, 7, 8)); + assertEquals(6, window.aggregate()); + } + + @Test + @DisplayName("combines oldest first, so non-commutative operators work") + void preservesOrderForNonCommutativeOperators() { + SlidingWindowAggregator window = SlidingWindowAggregator.of(3, (a, b) -> a + b); + window.addAll(List.of("a", "b", "c", "d", "e")); + assertEquals("cde", window.aggregate()); + window.removeOldest(); + assertEquals("de", window.aggregate()); + } + + @Test + void supportsAggregatesOfADifferentType() { + SlidingWindowAggregator lengths = new SlidingWindowAggregator<>(3, String::length, Integer::sum); + lengths.addAll(List.of("a", "bb", "ccc", "dddd")); + assertEquals(2 + 3 + 4, lengths.aggregate()); + } + + @Test + void movingAverageIsSumOverSize() { + SlidingWindowAggregator window = SlidingWindowAggregator.sumOfDoubles(3); + window.addAll(List.of(1.0, 2.0, 3.0, 6.0)); + assertEquals(11.0 / 3.0, window.aggregate() / window.size(), 1e-12); + } + + @Test + void sumOfLongsFactoryWorks() { + SlidingWindowAggregator window = SlidingWindowAggregator.sumOfLongs(2); + window.addAll(List.of(1L, 2L, 3L)); + assertEquals(5L, window.aggregate()); + } + + @Test + @DisplayName("the mapper runs exactly once per element") + void mapperIsCalledOncePerElement() { + AtomicInteger calls = new AtomicInteger(); + SlidingWindowAggregator window = new SlidingWindowAggregator<>(3, value -> { + calls.incrementAndGet(); + return value; + }, Integer::sum); + for (int i = 0; i < 100; i++) { + window.add(i); + window.aggregate(); + } + assertEquals(100, calls.get()); + } + } + + @Nested + @DisplayName("null handling") + class NullHandling { + + @Test + void rejectsNullElements() { + SlidingWindowAggregator window = sumWindow(2); + assertThrows(NullPointerException.class, () -> window.add(null)); + } + + @Test + @DisplayName("a null hidden inside a collection is rejected, and the elements before it stay") + void rejectsNullElementsInsideACollection() { + SlidingWindowAggregator window = sumWindow(2); + List items = new ArrayList<>(); + items.add(1); + items.add(null); + + assertThrows(NullPointerException.class, () -> window.addAll(items)); + assertEquals(1, window.size()); + assertEquals(1, window.aggregate()); + } + + @Test + void rejectsMappersReturningNull() { + SlidingWindowAggregator window = new SlidingWindowAggregator<>(2, value -> null, Integer::sum); + assertThrows(NullPointerException.class, () -> window.add(1)); + } + + @Test + void rejectsCombinersReturningNull() { + SlidingWindowAggregator window = SlidingWindowAggregator.of(2, (a, b) -> null); + window.add(1); + assertThrows(NullPointerException.class, () -> window.add(2)); + } + } + + @Nested + @DisplayName("access and iteration") + class Access { + + @Test + void indexedAccessStartsAtTheOldestElement() { + SlidingWindowAggregator window = sumWindow(3); + window.addAll(List.of(10, 20, 30, 40)); + assertEquals(20, window.get(0)); + assertEquals(30, window.get(1)); + assertEquals(40, window.get(2)); + assertEquals(20, window.peekOldest()); + assertEquals(40, window.peekNewest()); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 3, 100}) + void rejectsOutOfBoundsIndices(int index) { + SlidingWindowAggregator window = sumWindow(5); + window.addAll(List.of(1, 2, 3)); + assertThrows(IndexOutOfBoundsException.class, () -> window.get(index)); + } + + @Test + void iteratesFromOldestToNewest() { + SlidingWindowAggregator window = sumWindow(3); + window.addAll(List.of(1, 2, 3, 4, 5)); + List seen = new ArrayList<>(); + for (int value : window) { + seen.add(value); + } + assertEquals(List.of(3, 4, 5), seen); + assertEquals("[3, 4, 5]", window.toString()); + } + + @Test + void iteratorIsFailFast() { + SlidingWindowAggregator window = sumWindow(4); + window.addAll(List.of(1, 2, 3)); + Iterator iterator = window.iterator(); + assertEquals(1, iterator.next()); + window.add(4); + assertThrows(ConcurrentModificationException.class, iterator::next); + } + + @Test + void toListIsASnapshot() { + SlidingWindowAggregator window = sumWindow(3); + window.addAll(List.of(1, 2, 3)); + List snapshot = window.toList(); + window.add(4); + assertEquals(List.of(1, 2, 3), snapshot); + } + } + + @Nested + @DisplayName("removal and reuse") + class Removal { + + @Test + void removeOldestShrinksTheWindow() { + SlidingWindowAggregator window = sumWindow(4); + window.addAll(List.of(1, 2, 3, 4)); + assertEquals(1, window.removeOldest()); + assertEquals(9, window.aggregate()); + assertEquals(3, window.size()); + assertFalse(window.isFull()); + + assertEquals(2, window.removeOldest()); + assertEquals(3, window.removeOldest()); + assertEquals(4, window.aggregate()); + assertEquals(4, window.removeOldest()); + assertTrue(window.isEmpty()); + assertThrows(NoSuchElementException.class, window::aggregate); + } + + @Test + void clearRestoresTheInitialState() { + SlidingWindowAggregator window = sumWindow(3); + window.addAll(List.of(1, 2, 3, 4, 5)); + window.clear(); + assertTrue(window.isEmpty()); + assertEquals(0, window.size()); + assertThrows(NoSuchElementException.class, window::aggregate); + + window.addAll(List.of(7, 8)); + assertEquals(15, window.aggregate()); + assertEquals(List.of(7, 8), window.toList()); + } + + @Test + @DisplayName("a capacity of one keeps only the latest element") + void capacityOfOne() { + SlidingWindowAggregator window = sumWindow(1); + window.add(1); + assertEquals(1, window.aggregate()); + assertEquals(1, window.add(2)); + assertEquals(2, window.aggregate()); + assertEquals(2, window.add(3)); + assertEquals(3, window.aggregate()); + assertEquals(1, window.size()); + } + + @Test + @DisplayName("survives far more insertions than its capacity") + void survivesManyRotations() { + SlidingWindowAggregator window = sumWindow(3); + for (int i = 1; i <= 100_000; i++) { + window.add(i); + } + assertEquals(99_998 + 99_999 + 100_000, window.aggregate()); + assertEquals(3, window.size()); + } + } + + @Nested + @DisplayName("agreement with a brute force reference") + class BruteForceAgreement { + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 5, 8, 13}) + void matchesBruteForceSum(int capacity) { + assertMatchesBruteForce(capacity, Integer::sum); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 5, 8, 13}) + void matchesBruteForceMaximum(int capacity) { + assertMatchesBruteForce(capacity, BinaryOperator.maxBy(NATURAL)); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 5, 8, 13}) + void matchesBruteForceMinimum(int capacity) { + assertMatchesBruteForce(capacity, BinaryOperator.minBy(NATURAL)); + } + + private void assertMatchesBruteForce(int capacity, BinaryOperator combiner) { + SlidingWindowAggregator window = SlidingWindowAggregator.of(capacity, combiner); + Deque reference = new ArrayDeque<>(); + Random random = new Random(20240517L + capacity); + + for (int step = 0; step < 2_000; step++) { + int value = random.nextInt(2_000) - 1_000; + window.add(value); + reference.addLast(value); + if (reference.size() > capacity) { + reference.removeFirst(); + } + assertEquals(fold(reference, combiner), window.aggregate(), "after step " + step); + } + } + + @Test + @DisplayName("interleaved insertions and removals stay in sync with the reference") + void matchesBruteForceUnderInterleavedOperations() { + int capacity = 7; + SlidingWindowAggregator window = SlidingWindowAggregator.of(capacity, Integer::sum); + Deque reference = new ArrayDeque<>(); + Random random = new Random(987654321L); + + for (int step = 0; step < 20_000; step++) { + if (reference.isEmpty() || random.nextInt(3) > 0) { + int value = random.nextInt(100); + window.add(value); + reference.addLast(value); + if (reference.size() > capacity) { + reference.removeFirst(); + } + } else { + assertEquals(reference.removeFirst(), window.removeOldest()); + } + + assertEquals(reference.size(), window.size()); + assertEquals(new ArrayList<>(reference), window.toList()); + if (reference.isEmpty()) { + assertThrows(NoSuchElementException.class, window::aggregate); + } else { + assertEquals(fold(reference, Integer::sum), window.aggregate(), "after step " + step); + } + } + } + + private Integer fold(Iterable values, BinaryOperator combiner) { + Integer accumulator = null; + for (Integer value : values) { + accumulator = accumulator == null ? value : combiner.apply(accumulator, value); + } + return accumulator; + } + } + + @Test + @DisplayName("the documented example from the class javadoc") + void documentedExample() { + SlidingWindowAggregator window = SlidingWindowAggregator.maximum(3, Comparator.naturalOrder()); + List printed = new ArrayList<>(); + for (int value : new int[] {1, 3, 2, 5, 0}) { + window.add(value); + printed.add(window.aggregate()); + } + assertEquals(List.of(1, 3, 3, 5, 5), printed); + Assertions.assertDoesNotThrow(window::clear); + } +}