Two-Stack Sliding-Window Aggregation

Two-Stack Sliding-Window Aggregation

Two-Stack Sliding-Window Aggregation 2026-10-02

An aggregation is some kind of summary of a set of data. This can be the sum, length, minimum, etc. It is quite common to want to calculate such a summary repeatedly, e.g. “the maximum noise level in dB for the past 30 seconds” for a nuisance detector. In such a case we say there is a sliding window over our data, and we want to aggregate over our window.

聚合是对一组数据的某种总结。这可以是总和、长度、最小值等。我们经常需要重复计算这种总结,例如噪声检测器中“过去 30 秒内的最大噪声分贝值”。在这种情况下,我们称数据上存在一个滑动窗口,并且我们希望对窗口内的数据进行聚合。

If our aggregation is a binary operator with an inverse, like integer sums, there is a very easy solution using a double-ended queue:

如果我们的聚合是一个带有逆运算的二元运算符(例如整数求和),那么使用双端队列(deque)可以非常简单地解决这个问题:

from collections import deque

class SlidingWindowSum:
    def __init__(self):
        self.sum = 0
        self.elems = deque()

    def push(self, x):
        self.sum += x
        self.elems.append(x)

    def pop(self):
        self.sum -= self.elems.popleft()

    def eval(self):
        return self.sum

But what if our operator has no inverse? This is actually the case for most interesting summaries such as minimum, quantile, approximate unique count (for example using HyperLogLog), etc. In fact, even something as simple as a floating-point sum suffers from the fact that floating-point addition is not invertible. For example, if you ever have a NaN in your input data with the above naive algorithm your sum will forever remain NaN, even long after the bad value has left your window.

但如果我们的运算符没有逆运算怎么办?事实上,大多数有趣的总结(如最小值、分位数、近似唯一计数(例如使用 HyperLogLog)等)都是这种情况。甚至像浮点数求和这样简单的事情,也会因为浮点加法不可逆而遇到问题。例如,如果你在上述朴素算法的输入数据中遇到了 NaN,那么即使坏值早已离开窗口,你的总和也将永远保持为 NaN。

Six years ago I came up with an algorithm for maintaining just the minimum/maximum in a sliding window and posted it to cs.stackexchange. I now consider this algorithm pointless, because it turns out there is a simple and efficient algorithm that solves this problem for a very wide class of aggregations. I’m writing this blog post to spread the word, because I feel it should be more widely known.

六年前,我想出了一种仅用于维护滑动窗口中最小值/最大值的算法,并将其发布到了 cs.stackexchange 上。我现在认为该算法毫无意义,因为事实证明,有一种简单且高效的算法可以解决这一类广泛的聚合问题。我写这篇博文是为了传播这个方法,因为我觉得它应该被更多人所知。

Folklore

民间传说

I came across this algorithm while reading a far more advanced paper, Low-Latency Sliding-Window Aggregation in Worst-Case Constant Time by Tangwongsan et al. Why is this paper titled low-latency? Because it does the same as what I’m about to describe, but in O(1) time for each step. However, in it they also described a “two-stack” algorithm, which does it in amortized O(1), and is far, far simpler.

我在阅读 Tangwongsan 等人撰写的一篇更高级的论文《最坏情况下常数时间的低延迟滑动窗口聚合》(Low-Latency Sliding-Window Aggregation in Worst-Case Constant Time)时发现了这个算法。为什么这篇论文标题叫“低延迟”?因为它实现了我即将描述的功能,但每一步的时间复杂度为 O(1)。然而,他们在文中也描述了一种“双栈”算法,其摊销时间复杂度为 O(1),而且要简单得多。

Funnily enough that paper attributes this algorithm to “adamax” from a 2011 Stack Overflow post. They in turn credit a 2001 lecture note by D. Sleator for the inspiration. However, this lecture note does not describe a sliding window aggregate, it describes the classical two-stack algorithm for implementing a FIFO queue and does amortized analysis on it. Ultimately I would not be surprised to find that this algorithm was already described in an obscure paper from the 1970s, seeing how simple and brilliant it is.

有趣的是,该论文将此算法归功于 2011 年 Stack Overflow 上的一位用户“adamax”。而他们又将灵感归功于 D. Sleator 2001 年的讲义。然而,那份讲义并没有描述滑动窗口聚合,它描述的是实现 FIFO 队列的经典双栈算法,并对其进行了摊销分析。最终,如果发现这个算法早在 20 世纪 70 年代的一篇晦涩论文中就已经被描述过,我也不会感到惊讶,因为它实在是太简单且精妙了。

Two stacks

双栈

Like the authors of the paper, I will generalize the two-stack algorithm to arbitrary associative aggregation functions. By abstracting the aggregation as a set of functions, empty(), unit(x), combine(x, y) and finalize(x), you can describe many possible aggregations, for example a mean:

像论文作者一样,我将把双栈算法推广到任意结合性聚合函数。通过将聚合抽象为一组函数 empty()、unit(x)、combine(x, y) 和 finalize(x),你可以描述许多可能的聚合,例如平均值:

empty = lambda: (0, 0)
unit = lambda x: (x, 1)
combine = lambda x, y: (x[0] + y[0], x[1] + y[1])
finalize = lambda x: x[0] / x[1] if x[1] else None

I’d like to note here that these functions have the following signatures: fn empty() -> Agg; fn unit(x: Value) -> Agg; fn combine(x: Agg, y: Agg) -> Agg; fn finalize(x: Agg) -> Out; I’m making a distinction here between Value, Agg and Out because while they seem superficially similar for something like an integer sum, for an approximate unique count on strings you would have (Value, Agg, Out) = (String, HyperLogLogSketch, u64), three wildly different types.

在此我想指出,这些函数具有以下签名:fn empty() -> Agg; fn unit(x: Value) -> Agg; fn combine(x: Agg, y: Agg) -> Agg; fn finalize(x: Agg) -> Out; 我在这里区分了 Value、Agg 和 Out,因为虽然对于整数求和这类问题它们看起来表面上相似,但对于字符串的近似唯一计数,你会有 (Value, Agg, Out) = (String, HyperLogLogSketch, u64),这是三种截然不同的类型。

Without further ado, the algorithm:

废话不多说,算法如下:

class TwoStackAgg:
    def __init__(self):
        self.values = []
        self.values_agg = empty()
        self.cum_aggs = []

    def push(self, x):
        self.values.append(x)
        self.values_agg = combine(self.values_agg, unit(x))

    def pop(self):
        if not self.cum_aggs:
            cum_agg = empty()
            while self.values:
                cum_agg = combine(unit(self.values.pop()), cum_agg)
                self.cum_aggs.append(cum_agg)
            self.values_agg = empty()
        self.cum_aggs.pop()

    def eval(self):
        return finalize(
            combine(self.cum_aggs[-1], self.values_agg) 
            if self.cum_aggs else self.values_agg
        )

That’s it, the entire algorithm. There’s two stacks (values and cum_aggs) and one more aggregate, values_agg. At any point in time values_agg holds the aggregate of values, and cum_aggs contains the cumulative aggregates of all values in our window that aren’t in values, in reverse order. From this we can get the aggregate over our entire window in constant time by combining the last value of cum_aggs with values_agg.

这就是整个算法。它有两个栈(values 和 cum_aggs)以及一个额外的聚合变量 values_agg。在任何时间点,values_agg 保存了 values 的聚合结果,而 cum_aggs 以相反的顺序包含了窗口中不在 values 内的所有值的累积聚合。由此,我们可以通过将 cum_aggs 的最后一个值与 values_agg 结合,在常数时间内获得整个窗口的聚合结果。

The neat part is that (assuming w is our window size) every wth operation we drain all of values and maintain a running aggregate while pushing the partial cumulative aggregates onto cum_aggs. This is what makes it amortized O(1), doing O(w) internal operations every wth pop bounds the total amount of work per element to O(1), even though a singular operation might not be constant time.

巧妙之处在于(假设 w 是窗口大小),每进行 w 次操作,我们就会清空 values 并维护一个运行中的聚合,同时将部分累积聚合推入 cum_aggs。这就是它摊销时间复杂度为 O(1) 的原因:每第 w 次 pop 操作执行 O(w) 的内部操作,将每个元素的总工作量限制在 O(1),尽管单次操作可能不是常数时间。

I think this is best visualized. Suppose we sum [1, 2, ..., 10] with a fixed-size sliding window of four elements, then the state on each eval() call would look like this (values_agg not shown as it is simply the aggregate of the values):

我认为最好通过可视化来理解。假设我们对 [1, 2, ..., 10] 进行求和,滑动窗口大小固定为 4,那么每次 eval() 调用时的状态如下(values_agg 未显示,因为它只是 values 的聚合):

cum_aggsvaluesout
[][]0
[][1]1
[][1, 2]1 + 2
[][1, 2, 3]1 + 2 + 3
[][1, 2, 3, 4]1 + 2 + 3 + 4
[4, 3 + 4, 2 + 3 + 4][5]2 + 3 + 4 + 5
[4, 3 + 4][5, 6]3 + 4 + 5 + 6
[4][5, 6, 7]4 + 5 + 6 + 7
[][5, 6, 7, 8]5 + 6 + 7 + 8
[8, 7 + 8, 6 + 7 + 8][9]6 + 7 + 8 + 9
[8, 7 + 8][9, 10]7 + 8 + 9 + 10
[8][9, 10]8 + 9 + 10
[][9, 10]9 + 10
[10][]10
[][]0

In total the memory usage is O(w), where w is your maximum window size. Note that for simplicity of analysis and the example I assumed a fixed-size window w, but there is nothing about the two-stack algorithm that requires this. You can call push(x) and pop() as many times as you’d like between each eval(), growing and shrinking the window size as needed.

总内存使用量为 O(w),其中 w 是最大窗口大小。请注意,为了分析和示例的简单起见,我假设了一个固定大小的窗口 w,但双栈算法本身并不要求这一点。你可以在每次 eval() 之间根据需要多次调用 push(x) 和 pop(),从而根据需要增大或缩小窗口大小。

Floating-point non-associativity

浮点数的非结合性

Note that we required above that our aggregate combine is associative, meaning: combine(combine(x, y), z) == combine(x, combine(y, z)).

请注意,我们在上面要求聚合函数 combine 必须满足结合律,即:combine(combine(x, y), z) == combine(x, combine(y, z))。