DSA DSA In Depth
0%
build build-labdequering-buffer

Build Lab: Dynamic Circular Buffer & Amortized O(1) Vector

7 min read Module 1.2: Contiguous Arrays, Bit Manipulation & Pointer Idioms Updated Oct 23, 2026
📄 Cheat Sheet
Circular Queue & Ring Buffer Array Implementation (Modulo Wrap-Around)
Jenny's Lectures CS IT Circular Queue & Ring Buffer Array Implementation (Modulo Wrap-Around) Watch on YouTube ↗

Deep-dive lecture covering Build Lab: Dynamic Circular Buffer & Amortized O(1) Vector.

0:00 / --:--

Ring Buffer Indexing: Power-of-Two Masking & Full vs. Empty Disambiguation

Why do production ring buffers (Rust's std::collections::VecDeque<T>, Java's ArrayDeque<E>, Linux kernel kfifo, and LMAX Disruptor) enforce that capacity $C$ is always a power of two ($C = 2^p$)? Because wrapping an index modulo $C$ normally requires an integer division instruction (div on x86-64 takes 14–26 clock cycles and cannot be pipelined), whereas when $C = 2^p$, modulo reduces to a 1-cycle bitwise AND with $M = C - 1$:

$$\text{physical\_index}(i) = (\text{head} + i) \bmod 2^p = (\text{head} + i) \mathrel{\&} (C - 1)$$

How do we distinguish a completely full ring buffer from a completely empty ring buffer when both states have $\text{head} \equiv \text{tail} \pmod C$? There are two canonical engineering designs:

  1. Explicit head_ + size_ (Rust VecDeque style): Store the physical index head_ $\in [0, C)$ and element count size_ $\in [0, C]$. The tail index is computed on the fly as (head_ + size_) & (cap_ - 1),Empty is size_ == 0, and Full is size_ == cap_.
  2. Unmasked Monotonic read_idx_ & write_idx_ (Linux kfifo style): Store monotonically increasing 64-bit unsigned counters read_idx_ and write_idx_ that are only masked with & (cap_ - 1) at the moment of array indexing! Then size() == write_idx_ - read_idx_, Empty is write_idx_ == read_idx_, and Full is write_idx_ - read_idx_ == cap_ (critical for lock-free Single-Producer Single-Consumer queues).
Structural Invariant: Two-Span Ring Linearization During Reallocation

When a circular deque is full (size_ == cap_) and grows to $2C$, we cannot simply memcpy the underlying array of length $C$ directly into the new buffer if the logical sequence wraps around the physical boundary! Specifically, if $\text{head} > 0$, the logical elements $[0 \dots C-1]$ are physically split into two contiguous spans:

  • First Span (Physical Suffix): data_[head_ .. C - 1] of length $L_1 = C - \text{head}_-$ moves to new_data[0 .. L_1 - 1].
  • Second Span (Physical Prefix): data_[0 .. head_ - 1] of length $L_2 = \text{head}_-$ moves to new_data[L_1 .. C - 1].

After copying both spans, reset head_ = 0 in the new buffer so the resized deque starts completely linearized!

Architectural Comparison of Deque Implementations

Implementation push_front / pop_front Random Access d[i] Cache Misses per Scan Pointer Stability on Push?
FlatCircularDeque (Rust VecDeque / Java ArrayDeque) $O(1)$ amortized $O(1)$ via (head + i) & mask Minimum ($1$ contiguous buffer) No (invalidated on $2\times$ resize)
Segmented Map Deque (C++ std::deque) $O(1)$ amortized $O(1)$ (double dereference: map $ o$ chunk) Higher (512B–4KB chunks scattered in heap) Yes (for push_front / push_back)
Doubly Linked List (std::list) $O(1)$ worst-case $O(N)$ pointer chase Catastrophic (1 miss per element) Yes (all iterators stable)

Visualizing Wrapped Physical Spans & Unwrapping Upon $2\times$ Growth

Before Resize (cap = 8, size = 8, head = 5): Logical [A, B, C, D, E, F, G, H] Wraps Around Span 2 [0..4]: D, E, F, G, H (tail) Span 1 [5..7]: A, B, C (head) After 2x Resize (new_cap = 16, head = 0): Unwrapped into Linear Order via Two Block Moves [0..2]: A, B, C [3..7]: D, E, F, G, H [8..15]: Free Slack Capacity (8 slots) Bitmask Wrap: push_front decrements head = (head - 1) & (cap - 1); push_back writes at (head + size) & (cap - 1)

Circular Deque Operations & Two-Span Reallocation Flow

flowchart LR A["push_front / push_back"] --> B{"size_ == cap_?"} B -- "Yes (Full)" --> C["Allocate new_cap = 2 * cap_
Move Span 1 [head..cap)
Move Span 2 [0..head)
Reset head_ = 0"] B -- "No" --> D["push_front: head_ = (head_ - 1) & mask
push_back: idx = (head_ + size_) & mask"] C --> D
Hardware Reality: Why Rust's VecDeque Beats C++ libstdc++ std::deque in Cache Speed

Because the C++ standard mandates that std::deque::push_back and push_front must never invalidate references to existing elements, std::deque cannot use a single resizable ring buffer. Instead, it uses a pointer-array directory ("map") pointing to fixed-size heap chunks (512 bytes in libstdc++, or absurdly just 16 bytes / 1 element in MSVC!). Every random access dq[i] executes a double pointer dereference, and iterating across chunk boundaries stalls hardware stride prefetchers. A single-buffer power-of-two ring deque (like Rust's VecDeque or the CircularDeque below) keeps all elements in one contiguous allocation, running $2\times$ to $5\times$ faster for sliding-window and BFS workloads.

Dynamic Array Geometric Doubling & Potential Φ = 2n − m
Amortized O(1)

Step through push(x) operations as capacity doubles (1 → 2 → 4 → 8 → 16). Watch Tarjan’s potential function Φ = 2n − m bank 2 credits on cheap inserts to pay for O(n) reallocation copies.

Step 1 / 10
Custom Input:
Timeline

Invariant & State

Φ(D_i) = 2n − m = 2(1) − 1 = 1 ≥ 0 | ĉ_i = c_i + ΔΦ = 1 + (1 − 0) = 2
🔗 Live Pointer & State Transition Graph — Dynamic Array Geometric Doubling & Potential Φ = 2n − m Active Pointer Jump/Transition Matched/Optimal
NEW (+1 10 [0]
Buffer Capacity m = 1 | Size n = 1 | Load Factor α = 100%
[0] 10 NEW (+1)
Size / Cap (n / m)
1 / 1
Actual Cost c_i
1 (1 write)
Potential Φ = 2n−m
1
Amortized Cost ĉ_i
2 (Avg 1.00)
[Step 1] Inserted 10 at index 0. Actual cost = 1, banked +2 potential (Φ = 1), Amortized cost = 2.

Production Implementation: Power-of-Two Dynamic Circular Deque

#include <cassert>
#include <cstddef>
#include <memory>
#include <new>
#include <utility>

template <typename T>
class CircularDeque {
  T* data_ = nullptr;
  std::size_t head_ = 0;
  std::size_t size_ = 0;
  std::size_t cap_ = 0; // Always a power of 2 when allocated (0 initially)

  std::size_t mask() const noexcept { return cap_ - 1; }

  void grow() {
    const std::size_t new_cap = (cap_ == 0) ? 8 : (cap_ * 2);
    T* new_data = static_cast<T*>(::operator new(new_cap * sizeof(T), std::align_val_t{alignof(T)}));

    if (size_ > 0) {
      // Unwrap the two physical spans [head_ .. cap_) and [0 .. head_) into [0 .. size_)
      const std::size_t span1 = cap_ - head_;
      if (span1 >= size_) {
        std::uninitialized_move_n(data_ + head_, size_, new_data);
      } else {
        std::uninitialized_move_n(data_ + head_, span1, new_data);
        std::uninitialized_move_n(data_, size_ - span1, new_data + span1);
      }
      for (std::size_t i = 0; i < size_; ++i) {
        std::destroy_at(data_ + ((head_ + i) & mask()));
      }
    }
    if (data_) {
      ::operator delete(data_, std::align_val_t{alignof(T)});
    }
    data_ = new_data;
    head_ = 0;
    cap_ = new_cap;
  }

public:
  CircularDeque() = default;

  ~CircularDeque() {
    for (std::size_t i = 0; i < size_; ++i) {
      std::destroy_at(data_ + ((head_ + i) & mask()));
    }
    if (data_) ::operator delete(data_, std::align_val_t{alignof(T)});
  }

  void push_back(T value) {
    if (size_ == cap_) grow();
    const std::size_t tail = (head_ + size_) & mask();
    std::construct_at(data_ + tail, std::move(value));
    ++size_;
  }

  void push_front(T value) {
    if (size_ == cap_) grow();
    // Unsigned wrap-around: (0 - 1) & (cap_ - 1) == cap_ - 1!
    head_ = (head_ - 1) & mask();
    std::construct_at(data_ + head_, std::move(value));
    ++size_;
  }

  void pop_front() noexcept {
    assert(size_ > 0);
    std::destroy_at(data_ + head_);
    head_ = (head_ + 1) & mask();
    --size_;
  }

  void pop_back() noexcept {
    assert(size_ > 0);
    --size_;
    const std::size_t tail = (head_ + size_) & mask();
    std::destroy_at(data_ + tail);
  }

  T& operator[](std::size_t i) noexcept {
    return data_[(head_ + i) & mask()];
  }

  const T& operator[](std::size_t i) const noexcept {
    return data_[(head_ + i) & mask()];
  }

  std::size_t size() const noexcept { return size_; }
  std::size_t capacity() const noexcept { return cap_; }
};
Production Pitfall: Bitmasking Non-Power-of-Two Capacities & Naive Copy on Resize

Two catastrophic bugs plague custom circular buffers:

  1. Using & (cap - 1) when cap is not a power of two: Bitwise AND x & (cap - 1) equals x % cap if and only if cap is an exact power of 2 ($2^p$). If you grow capacity by $1.5\times$ (e.g., $C = 12, C - 1 = 11 = ext{0b1011}$), bit 2 is $0$, so (head + i) & 11 silently skips indices $4, 5, 6, 7$ and overwrites existing elements!
  2. Copying data_[0 .. size_] without unwrapping on resize: When head_ > 0 and the buffer wraps around, doubling capacity changes the modulus from $C$ to $2C$. If you leave elements at their old physical indices without unwrapping Span 1 and Span 2 into contiguous order, (head_ + i) & (2C - 1) will read uninitialized garbage slots in $[C, C + \text{head}_-)$.
Check yourself Question 1 of 4
Concept Check Bitwise Masking Prerequisite in Power-of-Two Ring Buffers recall

In a circular deque of capacity `cap_`, we replace the modulo operator `(head_ + i) % cap_` with the bitwise AND expression `(head_ + i) & (cap_ - 1)`.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Which C++ Standard specification requirement forces `std::deque` to use a segmented chunk directory instead of a single flat ring buffer?

Key Takeaways

Enforcing a power-of-two capacity C = 2^p replaces slow 20-cycle modulo division (% C) with a single-cycle bitwise AND (& (C - 1)).
Storing explicit (head_, size_) or unmasked monotonic 64-bit (read_idx_, write_idx_) cleanly distinguishes a full ring (size == cap) from an empty ring (size == 0) without wasting a sentinel slot.
When doubling a wrapped circular buffer's capacity, you must linearize the two physical spans [head..cap) and [0..head) into [0..size) and reset head = 0.
A single-buffer circular deque (like Rust's VecDeque) eliminates the double-dereference and chunk-boundary cache misses of C++ std::deque.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue