From 63e0bed1b743adb7010c95415e26145e55d73bc0 Mon Sep 17 00:00:00 2001 From: alxkm <19151554+alxkm@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:45:07 +0200 Subject: [PATCH] feat: add P2QuantileEstimator, one quantile of a stream in constant memory The P-square algorithm of Jain and Chlamtac keeps five markers instead of the samples, so a quantile of an unbounded stream costs O(1) time per sample and O(1) memory. Each marker is nudged towards its desired position with a piecewise parabolic prediction, falling back to a linear one whenever the parabola would break the ordering of the heights. The first five samples are kept verbatim, so the estimate is exact until the sixth arrives, and the minimum and maximum stay exact for the whole stream. Signed-off-by: alxkm <19151554+alxkm@users.noreply.github.com> --- .../streaming/P2QuantileEstimator.java | 310 ++++++++++++++++++ .../streaming/P2QuantileEstimatorTest.java | 206 ++++++++++++ 2 files changed, 516 insertions(+) create mode 100644 src/main/java/com/thealgorithms/streaming/P2QuantileEstimator.java create mode 100644 src/test/java/com/thealgorithms/streaming/P2QuantileEstimatorTest.java diff --git a/src/main/java/com/thealgorithms/streaming/P2QuantileEstimator.java b/src/main/java/com/thealgorithms/streaming/P2QuantileEstimator.java new file mode 100644 index 000000000000..9fa4bcf701b9 --- /dev/null +++ b/src/main/java/com/thealgorithms/streaming/P2QuantileEstimator.java @@ -0,0 +1,310 @@ +package com.thealgorithms.streaming; + +import java.util.Arrays; + +/** + * The P-square (P²) algorithm of Jain and Chlamtac: it estimates one quantile of a stream + * using five numbers and no memory of the samples themselves. + * + *

Computing an exact quantile requires storing everything seen so far, which is impossible for an + * unbounded stream. P² instead keeps five markers that track the minimum, the quantile + * {@code p/2}, the quantile {@code p} itself, the quantile {@code (1 + p)/2} and the maximum. Each + * marker has a height (its current value) and a position (how many samples are known to be below + * it). Every new sample shifts the positions of the markers it falls below, and each marker is then + * nudged back towards the position it is supposed to occupy. The nudge follows a piecewise + * parabolic prediction fitted through the three neighbouring markers, falling back to a linear one + * whenever the parabola would break the ordering of the heights. + * + *

Accuracy is typically a fraction of a percent in rank for stationary streams and improves as + * more samples arrive. The markers adapt continuously, so a drifting distribution is tracked rather + * than averaged away, but no accuracy bound is guaranteed for adversarial inputs. + * + * + * + * + * + * + * + *
Cost
OperationComplexity
{@link #add(double)}O(1)
{@link #quantile()}O(1)
memoryO(1) - five markers, independent of the stream length
+ * + *

Usage

+ * + *
{@code
+ * P2QuantileEstimator p99 = new P2QuantileEstimator(0.99);
+ * for (double latency : latencies) {
+ *     p99.add(latency);
+ * }
+ * double tail = p99.quantile();
+ * }
+ * + *

The first five samples are stored verbatim, so the estimate is exact until the sixth one + * arrives. This class is not thread-safe. + * + * @see R. Jain, I. Chlamtac, The P-square algorithm (1985) + */ +public final class P2QuantileEstimator { + + private static final int MARKERS = 5; + + private final double probability; + + /** Marker heights, kept in non-decreasing order; also the raw buffer for the first five samples. */ + private final double[] heights = new double[MARKERS]; + + /** One-based rank of each marker, i.e. how many samples are known to sit at or below it. */ + private final int[] positions = new int[MARKERS]; + + /** Position each marker should ideally occupy. */ + private final double[] desiredPositions = new double[MARKERS]; + + /** How much {@link #desiredPositions} grows per sample. */ + private final double[] positionIncrements = new double[MARKERS]; + + private long count; + + /** + * Creates an estimator for a single quantile. + * + * @param probability the quantile to track, strictly between 0 and 1, e.g. {@code 0.5} for the median + * @throws IllegalArgumentException if {@code probability} is outside {@code (0, 1)} + */ + public P2QuantileEstimator(double probability) { + if (!(probability > 0.0) || !(probability < 1.0)) { + throw new IllegalArgumentException("The quantile probability must lie strictly between 0 and 1, but was " + probability); + } + this.probability = probability; + } + + /** + * Incorporates one sample. + * + * @param value the sample to add + * @throws IllegalArgumentException if {@code value} is NaN or infinite + */ + public void add(double value) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("Samples must be finite, but was " + value); + } + if (count < MARKERS) { + collectInitialSample(value); + return; + } + + int cell = locate(value); + for (int i = cell + 1; i < MARKERS; i++) { + positions[i]++; + } + for (int i = 0; i < MARKERS; i++) { + desiredPositions[i] += positionIncrements[i]; + } + for (int i = 1; i < MARKERS - 1; i++) { + adjustMarker(i); + } + count++; + } + + /** + * Incorporates every given sample, in order. + * + * @param values the samples to add + * @throws IllegalArgumentException if any value is NaN or infinite + * @throws NullPointerException if {@code values} is {@code null} + */ + public void addAll(double... values) { + for (double value : values) { + add(value); + } + } + + /** + * Returns the current estimate of the tracked quantile. + * + * @return the estimated quantile, exact while fewer than five samples have been seen + * @throws IllegalStateException if no sample has been added yet + */ + public double quantile() { + requireNonEmpty(); + if (count < MARKERS) { + return exactQuantileOfInitialSamples(); + } + return heights[2]; + } + + /** + * Returns the smallest sample seen so far, which P² tracks exactly. + * + * @return the running minimum + * @throws IllegalStateException if no sample has been added yet + */ + public double min() { + requireNonEmpty(); + return count < MARKERS ? Arrays.stream(heights, 0, (int) count).min().orElse(Double.NaN) : heights[0]; + } + + /** + * Returns the largest sample seen so far, which P² tracks exactly. + * + * @return the running maximum + * @throws IllegalStateException if no sample has been added yet + */ + public double max() { + requireNonEmpty(); + return count < MARKERS ? Arrays.stream(heights, 0, (int) count).max().orElse(Double.NaN) : heights[MARKERS - 1]; + } + + /** + * Returns the quantile this estimator was configured for. + * + * @return the probability given at construction time + */ + public double probability() { + return probability; + } + + /** + * Returns the number of samples seen so far. + * + * @return the sample count + */ + public long count() { + return count; + } + + /** + * Tells whether any sample has been added. + * + * @return {@code true} if the estimator holds no samples + */ + public boolean isEmpty() { + return count == 0; + } + + /** + * Forgets every sample. + */ + public void reset() { + count = 0; + Arrays.fill(heights, 0.0); + Arrays.fill(positions, 0); + Arrays.fill(desiredPositions, 0.0); + Arrays.fill(positionIncrements, 0.0); + } + + @Override + public String toString() { + return "P2QuantileEstimator{p=" + probability + ", count=" + count + ", quantile=" + (count == 0 ? Double.NaN : quantile()) + '}'; + } + + /** + * Stores one of the first five samples and, on the fifth, lays out the markers. + */ + private void collectInitialSample(double value) { + heights[(int) count] = value; + count++; + if (count < MARKERS) { + return; + } + Arrays.sort(heights); + // Marker positions are one-based, exactly as in the paper. Only differences of positions are + // ever used, so the origin is arithmetically irrelevant - but the desired positions are grown + // by repeated addition, and starting them one lower would round differently and flip the + // strict comparisons below on the samples where a desired position lands on a whole number. + for (int i = 0; i < MARKERS; i++) { + positions[i] = i + 1; + } + desiredPositions[0] = 1.0; + desiredPositions[1] = 1.0 + 2.0 * probability; + desiredPositions[2] = 1.0 + 4.0 * probability; + desiredPositions[3] = 3.0 + 2.0 * probability; + desiredPositions[4] = 5.0; + positionIncrements[0] = 0.0; + positionIncrements[1] = probability / 2.0; + positionIncrements[2] = probability; + positionIncrements[3] = (1.0 + probability) / 2.0; + positionIncrements[4] = 1.0; + } + + /** + * Finds the cell the sample falls into, stretching the outer markers when it falls outside the + * range seen so far. + * + * @param value the incoming sample + * @return the index of the marker immediately below the sample + */ + private int locate(double value) { + if (value < heights[0]) { + heights[0] = value; + return 0; + } + for (int i = 1; i < MARKERS - 1; i++) { + if (value < heights[i]) { + return i - 1; + } + } + if (value > heights[MARKERS - 1]) { + heights[MARKERS - 1] = value; + } + return MARKERS - 2; + } + + /** + * Moves one inner marker by a single position if it has drifted too far from where it should be. + */ + private void adjustMarker(int i) { + double drift = desiredPositions[i] - positions[i]; + boolean shiftRight = drift >= 1.0 && positions[i + 1] - positions[i] > 1; + boolean shiftLeft = drift <= -1.0 && positions[i - 1] - positions[i] < -1; + if (!shiftRight && !shiftLeft) { + return; + } + + int direction = shiftRight ? 1 : -1; + double candidate = parabolicPrediction(i, direction); + heights[i] = heights[i - 1] < candidate && candidate < heights[i + 1] ? candidate : linearPrediction(i, direction); + positions[i] += direction; + } + + /** + * Piecewise parabolic prediction: fits a parabola through markers {@code i - 1}, {@code i} and + * {@code i + 1} and evaluates it one position away from the current one. + */ + private double parabolicPrediction(int i, int direction) { + double left = positions[i] - positions[i - 1]; + double right = positions[i + 1] - positions[i]; + // Grouped exactly as in the paper. The result then decides, by a strict comparison against the + // neighbouring heights, whether the parabola is used at all, so regrouping the arithmetic would + // flip that decision on knife-edge inputs and make this estimator drift away from the reference. + return heights[i] + direction / (left + right) * ((left + direction) * (heights[i + 1] - heights[i]) / right + (right - direction) * (heights[i] - heights[i - 1]) / left); + } + + /** + * Linear fallback used whenever the parabolic prediction would violate the ordering of the + * marker heights. + */ + private double linearPrediction(int i, int direction) { + int neighbour = i + direction; + return heights[i] + direction * (heights[neighbour] - heights[i]) / (positions[neighbour] - positions[i]); + } + + /** + * Exact quantile of the fewer than five samples buffered so far, by linear interpolation between + * the two order statistics surrounding the requested rank. + */ + private double exactQuantileOfInitialSamples() { + double[] sorted = Arrays.copyOf(heights, (int) count); + Arrays.sort(sorted); + if (sorted.length == 1) { + return sorted[0]; + } + double rank = probability * (sorted.length - 1); + int lower = (int) Math.floor(rank); + int upper = Math.min(lower + 1, sorted.length - 1); + return sorted[lower] + (rank - lower) * (sorted[upper] - sorted[lower]); + } + + private void requireNonEmpty() { + if (count == 0) { + throw new IllegalStateException("The estimator has not seen any sample yet"); + } + } +} diff --git a/src/test/java/com/thealgorithms/streaming/P2QuantileEstimatorTest.java b/src/test/java/com/thealgorithms/streaming/P2QuantileEstimatorTest.java new file mode 100644 index 000000000000..777e00d62d66 --- /dev/null +++ b/src/test/java/com/thealgorithms/streaming/P2QuantileEstimatorTest.java @@ -0,0 +1,206 @@ +package com.thealgorithms.streaming; + +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.Arrays; +import java.util.Random; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class P2QuantileEstimatorTest { + + /** + * Exact quantile of a sample, by linear interpolation between the surrounding order statistics. + */ + private static double exactQuantile(double[] sortedValues, double probability) { + double rank = probability * (sortedValues.length - 1); + int lower = (int) Math.floor(rank); + int upper = Math.min(lower + 1, sortedValues.length - 1); + return sortedValues[lower] + (rank - lower) * (sortedValues[upper] - sortedValues[lower]); + } + + private static double[] shuffledUniformSample(int count, long seed) { + Random random = new Random(seed); + double[] values = new double[count]; + for (int i = 0; i < count; i++) { + values[i] = random.nextDouble(); + } + return values; + } + + @ParameterizedTest + @ValueSource(doubles = {0.0, 1.0, -0.1, 1.5, Double.NaN}) + void rejectsInvalidProbabilities(double probability) { + assertThrows(IllegalArgumentException.class, () -> new P2QuantileEstimator(probability)); + } + + @Test + void queriesBeforeTheFirstSampleFail() { + P2QuantileEstimator estimator = new P2QuantileEstimator(0.5); + assertTrue(estimator.isEmpty()); + assertEquals(0L, estimator.count()); + assertEquals(0.5, estimator.probability()); + assertThrows(IllegalStateException.class, estimator::quantile); + assertThrows(IllegalStateException.class, estimator::min); + assertThrows(IllegalStateException.class, estimator::max); + } + + @ParameterizedTest + @ValueSource(doubles = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) + void rejectsNonFiniteSamples(double value) { + P2QuantileEstimator estimator = new P2QuantileEstimator(0.5); + assertThrows(IllegalArgumentException.class, () -> estimator.add(value)); + } + + @Test + @DisplayName("the first five samples are answered exactly") + void isExactDuringWarmUp() { + P2QuantileEstimator median = new P2QuantileEstimator(0.5); + median.add(3.0); + assertEquals(3.0, median.quantile()); + median.add(1.0); + assertEquals(2.0, median.quantile(), 1e-12); + median.add(2.0); + assertEquals(2.0, median.quantile(), 1e-12); + median.add(10.0); + assertEquals(2.5, median.quantile(), 1e-12); + assertEquals(1.0, median.min()); + assertEquals(10.0, median.max()); + assertEquals(4L, median.count()); + } + + @Test + void tracksTheExtremesExactly() { + P2QuantileEstimator estimator = new P2QuantileEstimator(0.5); + double[] values = shuffledUniformSample(5_000, 7L); + estimator.addAll(values); + + double[] sorted = values.clone(); + Arrays.sort(sorted); + assertEquals(sorted[0], estimator.min(), 0.0); + assertEquals(sorted[sorted.length - 1], estimator.max(), 0.0); + assertEquals(5_000L, estimator.count()); + } + + @Test + void aConstantStreamHasAConstantQuantile() { + P2QuantileEstimator estimator = new P2QuantileEstimator(0.9); + for (int i = 0; i < 1_000; i++) { + estimator.add(7.0); + } + assertEquals(7.0, estimator.quantile(), 1e-12); + } + + @ParameterizedTest + @ValueSource(doubles = {0.05, 0.25, 0.5, 0.75, 0.9, 0.99}) + @DisplayName("estimates a uniform stream within a percent of the exact quantile") + void approximatesUniformQuantiles(double probability) { + double[] values = shuffledUniformSample(100_000, 20240517L); + P2QuantileEstimator estimator = new P2QuantileEstimator(probability); + estimator.addAll(values); + + double[] sorted = values.clone(); + Arrays.sort(sorted); + assertEquals(exactQuantile(sorted, probability), estimator.quantile(), 0.01); + } + + @Test + @DisplayName("estimates the tail of a skewed stream") + void approximatesTheTailOfAnExponentialStream() { + Random random = new Random(31337L); + double[] values = new double[100_000]; + for (int i = 0; i < values.length; i++) { + values[i] = -Math.log(1.0 - random.nextDouble()) * 10.0; + } + + P2QuantileEstimator estimator = new P2QuantileEstimator(0.95); + estimator.addAll(values); + + double[] sorted = values.clone(); + Arrays.sort(sorted); + double expected = exactQuantile(sorted, 0.95); + assertEquals(expected, estimator.quantile(), 0.05 * expected); + } + + @Test + @DisplayName("stays sane even when the stream arrives already sorted, the worst case for P-square") + void handlesMonotonicInput() { + P2QuantileEstimator estimator = new P2QuantileEstimator(0.5); + for (int i = 1; i <= 10_000; i++) { + estimator.add(i); + } + assertEquals(5_000.0, estimator.quantile(), 500.0); + assertEquals(1.0, estimator.min()); + assertEquals(10_000.0, estimator.max()); + } + + @Test + void estimatesOfDifferentQuantilesStayOrdered() { + double[] values = shuffledUniformSample(50_000, 99L); + P2QuantileEstimator low = new P2QuantileEstimator(0.25); + P2QuantileEstimator middle = new P2QuantileEstimator(0.5); + P2QuantileEstimator high = new P2QuantileEstimator(0.75); + low.addAll(values); + middle.addAll(values); + high.addAll(values); + + assertTrue(low.quantile() < middle.quantile()); + assertTrue(middle.quantile() < high.quantile()); + } + + @Test + void resetForgetsEverything() { + P2QuantileEstimator estimator = new P2QuantileEstimator(0.5); + estimator.addAll(shuffledUniformSample(1_000, 5L)); + estimator.reset(); + + assertTrue(estimator.isEmpty()); + assertThrows(IllegalStateException.class, estimator::quantile); + + estimator.addAll(1.0, 2.0, 3.0); + assertEquals(2.0, estimator.quantile(), 1e-12); + } + + @Test + void toStringMentionsTheEstimate() { + P2QuantileEstimator estimator = new P2QuantileEstimator(0.5); + assertTrue(estimator.toString().contains("count=0"), estimator.toString()); + estimator.addAll(1.0, 2.0, 3.0, 4.0, 5.0, 6.0); + assertFalse(estimator.isEmpty()); + assertTrue(estimator.toString().contains("quantile="), estimator.toString()); + } + + @Test + @DisplayName("violent jumps force the parabolic prediction to fall back to a linear one") + void survivesWildlyJumpingInput() { + P2QuantileEstimator estimator = new P2QuantileEstimator(0.5); + double value = 1.0; + for (int i = 0; i < 5_000; i++) { + value = i % 2 == 0 ? value * 3.0 + 1.0 : 1.0 / (i + 1); + estimator.add(value); + assertTrue(estimator.quantile() >= estimator.min(), "the estimate fell below the minimum"); + assertTrue(estimator.quantile() <= estimator.max(), "the estimate rose above the maximum"); + } + } + + @ParameterizedTest + @ValueSource(doubles = {0.01, 0.5, 0.99}) + @DisplayName("lands exactly on the textbook quantile of a ramp, which pins the marker convention") + void isExactOnARamp(double probability) { + // The markers are numbered from one, as in the paper. That origin is arithmetically irrelevant + // - only differences of positions are ever used - but the desired positions are grown by + // repeated addition, so a different origin rounds differently and flips the strict comparisons + // that pick between the parabolic and the linear prediction. On a ramp, whose quantiles are + // whole numbers, that shows up as an answer off by one. + P2QuantileEstimator estimator = new P2QuantileEstimator(probability); + for (int i = 0; i <= 1_700; i++) { + estimator.add(i); + } + assertEquals(probability * 1_700, estimator.quantile(), 1e-9); + } +}