bigonotes
DSA: Patterns & PythonDesign Problems

Sliding Time Window: Deque, Ring Buffer and Buckets

Answer 'how many in the last N seconds' with a pruned deque, a fixed ring buffer or time buckets, and know what each costs.

9 min readLast updated

Many design problems only care about recent events: hits in the last 5 minutes, prints in the last 10 seconds, tokens that have not expired, the average of the last n values. The trap is a dict that remembers everything forever, which is a memory leak. The pattern is to keep only the window, using one of three structures: a deque you prune from the front, a fixed ring buffer that overwrites itself, or coarse time buckets.

The idea#

Events arrive with non-decreasing timestamps, so anything that falls out of the window falls out at the old end. That gives three options.

Structure How old data leaves Memory Exact?
Deque of events pop from the left while expired O(events in window)O(\text{events in window}) ✓
Ring buffer of [second, count] slot is overwritten when its second is stale O(window)O(\text{window}), fixed ✓ for in-order data
Buckets per minute or hour whole bucket overwritten or dropped O(window/bucket)O(\text{window} / \text{bucket}) to bucket size

Why the deque is fast: each event is appended once and popped once, so pruning is amortized O(1)O(1) per event, even if one call pops many.

Why the ring buffer needs no cleanup: slot t % W is reused every W seconds. Each slot remembers which second it holds. On a hit, if the stored second differs, the slot is reset and reused. Old data is overwritten, so there is no background cleanup thread and memory never grows.

A 300-slot ring buffer for a hit counter: slot index is t mod 300, each slot stores a second and a count, a hit at second 301 finds slot 1 holding second 1 and resets it

How to spot it#

The problem talks about time or count windows over a stream.

  • "in the past 300 seconds", "within the last 10 seconds", "per minute"
  • "at most K requests per window", "should this message print"
  • "expires after ttl", "count unexpired tokens"
  • "moving average of the last size values"
  • timestamps are given in non-decreasing order (so the oldest is always at the front)

Template#

Two building blocks cover most problems. First, the operations of a ring-buffer counter: hit is O(1)O(1), get_hits is O(W)O(W), and memory is fixed at W slots.

init(W):
    times = W zeros                  # which second each slot belongs to
    counts = W zeros
hit(t):
    i = t % W
    if times[i] != t:                # slot holds an old second: reuse it
        times[i] = t; counts[i] = 0
    counts[i] += 1
get_hits(t):
    return the sum of counts[i] over slots where t - times[i] < W

Second, a deque of expiries with tombstones: stale entries you leave in place instead of deleting. Renewing a key appends a new entry, and the old one becomes a tombstone. The dict is the source of truth, and a stale entry is checked against it only when it reaches the front.

init(ttl):
    expiry = empty dict              # key -> expiry time (the truth)
    log = empty deque                # (expiry, key), sorted: time only grows
add_or_renew(key, now):
    expiry[key] = now + ttl
    append (now + ttl, key) to log   # any older entry is now a tombstone
count_live(now):
    while log is not empty and its front has expired:
        exp, key = pop from the left of log
        if expiry[key] == exp: delete key from expiry   # skip tombstones
    return the size of expiry

A heap of expiries would also work, but every expiry is now + ttl with now only growing, so entries arrive already sorted. A deque gives O(1)O(1) appends instead of O(log⁡n)O(\log n) pushes. That is the key observation: when keys arrive in order, you don't need a heap.

Before you commit to a ring buffer, compare it with its main alternative: a sorted list of timestamps plus binary search, covered in Sorted Timestamps + Binary Search. That approach answers any window size in O(log⁡n)O(\log n), but stores every timestamp unless you prune.

Binary search on timestamps Ring buffer
get_hits O(log⁡n)O(\log n) O(W)O(W)
Memory grows with every hit fixed WW slots
Best when windows vary, or traffic is sparse window is fixed, traffic is high

Choose the ring buffer when memory and stability matter most, and binary search when lookup speed and flexible windows matter most.

Rate limiters: three algorithms on one trace#

Rate limiters are the most common place these structures show up. A per-user limiter of limit requests per window seconds can be built three ways.

  • Fixed window: window_id = t // window plus a counter. Two integers, O(1)O(1) space. When window_id changes, reset the counter.
  • Sliding window log: a deque of accepted timestamps. Prune the front, then accept if fewer than limit remain. O(limit)O(\text{limit}) space per user, amortized O(1)O(1) time.
  • Token bucket: a float tokens, refilled at limit / window tokens per second since the last call, capped at limit. Accept if at least one token is left. O(1)O(1) space.

The fixed window has a boundary burst: with 2 per 60 s, requests at 58, 59, 60 and 61 are all accepted, because 60 starts a new window. That is 4 requests in 4 seconds. The sliding log rejects 60 and 61.

To compare all three side by side, here is one trace with limit 2 per 60 seconds (the log treats an entry exactly 60 s old as expired):

Request at Fixed window Sliding log Token bucket (tokens left)
1 ✓ ✓ ✓ (1.00)
30 ✓ ✓ ✓ (0.97)
50 ✗ ✗ ✓ (0.63)
70 ✓ ✓ ✓ (0.30)
100 ✓ ✓ ✓ (0.30)

The sliding log is the "honest" one: at t = 50 there were 2 requests in the last 60 seconds. The token bucket is "forgiving": it refills continuously, so evenly spread requests get slightly more throughput, while a concentrated burst is still capped at limit. The full design (per-user state, retry-after, locking, distributed limits) is in the Rate Limiter LLD.

Pitfalls#

Each of these costs you memory, speed or correctness.

  • Unbounded dicts. "Last time seen" maps grow forever unless something removes old keys.
  • Off-by-one at the window edge. Decide whether an event exactly window seconds old is inside, and use < or <= to match. Read the problem's exact range.
  • list.pop(0) instead of deque.popleft(). The list version is O(n)O(n) per pop.
  • Deleting a renewed key. When you pop an old entry, check that the dict still holds that same time before deleting.
  • Pruning in only one method. Every method that reads the state must prune first, or expired data leaks into answers.
  • Late events in a ring buffer. A late hit can land in a slot that a newer second has already reset, so the ring buffer assumes roughly in-order data.

Worth exploring: the sliding window counter, which weights the previous fixed window's count by how much of it still overlaps the sliding window. It gets close to the log's accuracy in O(1)O(1) memory. The LLD rate limiter note compares it with the others.

Problems in this pattern#

These problems use this pattern. Solve them before you open the hints below. Start with Logger Rate Limiter and Design Hit Counter.

Hints#

Open a hint only after you have tried the problem for a while.

Hint: Logger Rate Limiter

The base answer is a dict message -> last printed time. Print if the message is new or last + 10 <= t. That dict leaks: a million one-off messages stay forever.

Hint: Logger Rate Limiter: bounding memory

Keep (time, message) in a deque, pop entries older than t - 10, and delete the dict entry only if its stored time still equals the popped time. Or rotate two maps, current and previous, every 10 seconds, so expiry is one swap. An OrderedDict with a maximum size gives a hard bound but loses the exact rule; Redis keys with a 10-second TTL also work across servers.

Hint: Logger Rate Limiter: 5 prints per 10 seconds

One timestamp per message is no longer enough. The value becomes a deque of timestamps, which is the sliding-window log.

Hint: Design Hit Counter

A defaultdict(int) keyed by second grows forever, and summing it loops over the whole window (86,400 iterations for 24 hours). The ring buffer fixes memory. LeetCode counts hits in (t - 300, t], so check your <.

Hint: Design Hit Counter: follow-ups

Many hits in one second: the slot's second matches, so the count increments. A 24-hour window: bucket by minute, 1,440 slots. Late hits land in slot t % W only if a newer second hasn't reset it. Concurrent hits need a lock or atomic counters, and 100 servers need a shared store such as Redis with atomic increments.

Hint: Moving Average from Data Stream

Keep a deque of the last size values and a running sum. Append and add; if the deque is longer than size, pop the left and subtract. One if is enough, because the window can only overflow by one. A fixed array plus an index also works.

Hint: Moving Average from Data Stream: follow-ups

For money, float drift builds up, so use decimal.Decimal or integer cents. Sensor gaps are a requirements question: forward-fill, or switch to a time-based window. For a huge window, an exponential moving average avg = a * x + (1 - a) * avg needs O(1)O(1) memory, but every past value still counts with shrinking weights.

Hint: Design Circular Queue

This is the ring buffer itself: a fixed array, front, rear and a size counter, each pointer moving with (p + 1) % k. When front == rear the queue could be full or empty, and size settles it without wasting a slot. The last item is at (rear - 1) % k, which is correct even when rear is 0, because Python's % is non-negative for a positive divisor.

Hint: Design Authentication Manager

Scanning the whole dict in countUnexpiredTokens is O(n)O(n) per call. The tombstone deque from the template makes the count amortized O(1)O(1) per expired entry, while generate and renew stay O(1)O(1). Renew only a token that is still live.

Key takeaways#

  • Keep only the window. Anything that stores all history needs a pruning plan.
  • A deque of in-order events prunes in amortized O(1)O(1): each item enters and leaves once.
  • A ring buffer of [second, count] gives fixed memory with no cleanup thread; coarser buckets shrink it further.
  • In-order timestamps make a deque enough; a heap is only needed when keys arrive out of order.
  • Tombstones (leave stale entries, validate against the dict at the front) make updates O(1)O(1).
  • Fixed window, sliding log and token bucket trade memory against accuracy at window boundaries.
Revision notes · 2 min

Core idea#

Keep only the recent window, never all history. A dict that remembers everything is a memory leak. Use a pruned deque, a fixed ring buffer or coarse time buckets.

Key points#

  • Timestamps arrive in order, so expired data always leaves from the old end.
  • A deque prunes in amortized O(1)O(1): each event enters once and leaves once.
  • Ring buffer: slot t % W stores [second, count]. A stale second means reset and reuse. No cleanup thread is needed.
  • Tombstones are stale entries left in place. Renew by appending a new entry. Delete from the dict only if its stored time matches the popped entry.
  • In-order keys need no heap. A deque gives O(1)O(1) appends.
  • Every method that reads the state must prune first.
  • Moving Average: a deque plus a running sum. Pop once when it grows past size.
  • Circular Queue: front, rear and a size counter tell full from empty.

Structure trade-offs#

Structure Memory Exact?
Deque of events events in window ✓
Ring buffer fixed W slots ✓ for in-order data
Buckets per minute or hour window / bucket to bucket size
Sorted timestamps + binary search every hit ✓, any window in O(log⁡n)O(\log n)

Rate limiter algorithms#

Algorithm Space Behavior
Fixed window O(1)O(1) Bursts at the boundary: 58, 59, 60, 61 all pass at 2 per 60 s
Sliding window log O(limit)O(\text{limit}) Exact; prune the front, accept if fewer than limit
Token bucket O(1)O(1) Refills continuously; bursts capped at limit

Template#

init(W): times = W zeros; counts = W zeros   # second held by each slot
hit(t):
    i = t % W
    if times[i] != t:                         # stale slot: reuse it
        times[i] = t; counts[i] = 0
    counts[i] += 1
get_hits(t):
    return the sum of counts[i] where t - times[i] < W

Pitfalls#

  • Unbounded "last seen" dicts. Logger Rate Limiter needs deque cleanup, two-bucket rotation, an LRU cap or a TTL store.
  • Off-by-one at the edge. Design Hit Counter counts hits in (t - 300, t].
  • list.pop(0) instead of deque.popleft().
  • Late events in a ring buffer can land in a slot a newer second already reset.
  • Money in a moving average. Use decimal.Decimal or integer cents.

Say it in an interview#

  • "Each event enters and leaves the deque once, so pruning is amortized O(1)."
  • "For a fixed window under heavy traffic, a ring buffer keeps memory constant."
  • "Across 100 servers, counts move to Redis with atomic increments."

Found a mistake? Email ask@bigonotes.com