diff --git a/README.md b/README.md index 47ec122..98a8e8b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Provided `Gatherers`: - takes elements until a change is detected - `MoreGatherers.distinctUntilChanged(Function)` - takes elements until a change is detected based on a key extractor function +- `MoreGatherers.windowSliding(int, int)` + - creates a sliding window of a fixed size with a fixed step, extends `Gatherers.windowSliding(int)` by adding a step parameter ### Philosophy diff --git a/src/main/java/com/pivovarit/gatherers/MoreGatherers.java b/src/main/java/com/pivovarit/gatherers/MoreGatherers.java index 0aea9c6..6d1fcca 100644 --- a/src/main/java/com/pivovarit/gatherers/MoreGatherers.java +++ b/src/main/java/com/pivovarit/gatherers/MoreGatherers.java @@ -1,6 +1,8 @@ package com.pivovarit.gatherers; +import java.util.Arrays; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.BiFunction; @@ -217,4 +219,28 @@ private MoreGatherers() { public static Gatherer> zipWithIndex() { return new ZipWithIndexGatherer<>(); } + + /** + * Creates a {@link Gatherer} that collects elements into sliding windows of a specified size. + * Each window captures a subset of elements from the input, and windows slide by a specified step. + * + *

For example, if the window size is 3 and the step is 1, the gatherer will collect + * windows of size 3, sliding by 1 element at a time. This means each subsequent window overlaps + * with the previous one by two elements.

+ * + *

Common use cases include moving averages, trend analysis, and any scenario requiring + * overlapping or rolling window operations on a list of elements.

+ * + * @param the type of elements in the input and output list + * @param windowSize the size of each window (must be a positive integer) + * @param step the number of elements to slide the window by (must be a positive integer) + * + * @return a {@link Gatherer} that collects elements into sliding windows + * + * @throws IllegalArgumentException if {@code windowSize} is less than one or {@code step} is less than zero, or greater than {@code windowSize} + * @apiNote this {@link Gatherer} extends {@link java.util.stream.Gatherers#windowSliding(int)} by allowing to customize the step + */ + public static Gatherer> windowSliding(int windowSize, int step) { + return new WindowSlidingGatherer<>(windowSize, step); + } } diff --git a/src/main/java/com/pivovarit/gatherers/WindowSlidingGatherer.java b/src/main/java/com/pivovarit/gatherers/WindowSlidingGatherer.java new file mode 100644 index 0000000..236e8e7 --- /dev/null +++ b/src/main/java/com/pivovarit/gatherers/WindowSlidingGatherer.java @@ -0,0 +1,70 @@ +package com.pivovarit.gatherers; + +import java.util.Arrays; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Supplier; +import java.util.stream.Gatherer; + +record WindowSlidingGatherer(int windowSize, int step) + implements Gatherer> { + WindowSlidingGatherer { + if (windowSize < 1) { + throw new IllegalArgumentException("'windowSize' must be greater than zero"); + } + + if (step < 0) { + throw new IllegalArgumentException("'step' must be greater than or equal to zero"); + } + + if (step > windowSize) { + throw new IllegalArgumentException("'step' must be less than or equal to 'windowSize'"); + } + } + + @Override + public Supplier initializer() { + return WindowSlidingGatherer.SlidingWindow::new; + } + + @Override + public Integrator> integrator() { + return Integrator.ofGreedy((state, e, downstream) -> state.integrate(e, downstream)); + } + + @Override + public BiConsumer>> finisher() { + return SlidingWindow::finish; + } + + class SlidingWindow { + Object[] window = new Object[windowSize]; + int at = 0; + boolean firstWindow = true; + + boolean integrate(T element, Downstream> downstream) { + window[at++] = element; + if (at < windowSize) { + return true; + } else { + final var oldWindow = window; + final var newWindow = new Object[windowSize]; + System.arraycopy(oldWindow, step, newWindow, 0, windowSize - step); + window = newWindow; + at -= step; + firstWindow = false; + return downstream.push((List) Arrays.asList(oldWindow)); + } + } + + void finish(Downstream> downstream) { + if (firstWindow && at > 0 && !downstream.isRejecting()) { + var lastWindow = new Object[at]; + System.arraycopy(window, 0, lastWindow, 0, at); + window = null; + at = 0; + downstream.push((List) Arrays.asList(lastWindow)); + } + } + } +} diff --git a/src/test/java/com/pivovarit/gatherers/blackbox/WindowSlidingTest.java b/src/test/java/com/pivovarit/gatherers/blackbox/WindowSlidingTest.java new file mode 100644 index 0000000..8403612 --- /dev/null +++ b/src/test/java/com/pivovarit/gatherers/blackbox/WindowSlidingTest.java @@ -0,0 +1,44 @@ +package com.pivovarit.gatherers.blackbox; + +import com.pivovarit.gatherers.MoreGatherers; +import org.junit.jupiter.api.Test; + +import java.util.stream.Stream; + +import static java.util.List.of; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WindowSlidingTest { + + @Test + void shouldRejectInvalidWindowSize() { + assertThatThrownBy(() -> MoreGatherers.windowSliding(0, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("'windowSize' must be greater than zero"); + } + + @Test + void shouldRejectInvalidStep() { + assertThatThrownBy(() -> MoreGatherers.windowSliding(1, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("'step' must be greater than or equal to zero"); + } + + @Test + void shouldWindowSlidingEmpty() { + assertThat(Stream.empty().gather(MoreGatherers.windowSliding(2, 1))).isEmpty(); + } + + @Test + void shouldWindowSlidingWithStep1() { + assertThat(Stream.of(1, 2, 3, 4, 5).gather(MoreGatherers.windowSliding(2, 1))) + .containsExactly(of(1, 2), of(2, 3), of(3, 4), of(4, 5)); + } + + @Test + void shouldWindowSlidingWithStep2() { + assertThat(Stream.of(1, 2, 3, 4, 5).gather(MoreGatherers.windowSliding(2, 2))) + .containsExactly(of(1, 2), of(3, 4)); + } +}