← Home

learnDataStructures

CSC/CPE 202

Deques (Double-ended queues)

A deque lets you add/remove items at both the front and the back in amortized O(1) time.

How it works (array-backed circular buffer): we keep two indices, head (first element) and tail (one past the last element). The elements logically occupy size contiguous positions in a circular index space. When we push_front, we decrement the head index modulo capacity and write there; for push_back, we write at tail and then increment tail modulo capacity. Pops adjust the corresponding index in the opposite direction. We shift the indices, not the elements—so no O(n) moves. If the buffer is full and resizing is enabled, we allocate a larger array and copy the size items once, re-laying them out from index 0..size-1.

Real-life intuition: a line where VIPs can enter at the front, a work queue that prioritizes urgent tasks, or sliding-window algorithms that push/pop from both ends.

Core functions

  • push_front(x), push_back(x)
  • pop_front(), pop_back()
  • front(), back()
  • isEmpty(), size()

Deque — tiny reference snippets



        

Operations (pseudocode)


        
Pseudocode mirrors the simulator (circular buffer; auto-resize optional).

Interactive deque simulator

capacity: 8 · size: 0 · head: 0 · tail: 0
Double-ended Circular buffer Amortized O(1)

Deques

Array-backed circular buffer model (what this page simulates)

We store elements in a fixed-size array and track three values: head (index of the front element), tail (index one past the back element), and size (number of elements). The array is treated circularly, so when an index reaches the end it wraps to 0 using modulo arithmetic.

Invariant: tail = (head + size) mod capacity

Why it’s O(1): shift indices, not elements

  • push_back(x): write to A[tail], then tail ← (tail + 1) mod C, size++.
  • push_front(x): head ← (head − 1 + C) mod C, write to A[head], size++.
  • pop_front(): read/clear A[head], then head ← (head + 1) mod C, size--.
  • pop_back(): tail ← (tail − 1 + C) mod C, read/clear A[tail], size--.
  • front()/back() read ends in O(1).

No bulk shifting is needed; only head/tail move. That’s why end operations stay O(1).

Full vs empty

  • With stored size (used here): empty ⇔ size == 0, full ⇔ size == capacity.
  • Without size (alternative): keep one slot empty — empty ⇔ head == tail, full ⇔ (tail + 1) mod C == head.

Auto-resize (optional)

When full, we can double capacity: allocate a larger array, copy the size items in logical order to indices 0..size−1, then set head = 0 and tail = size. A single push may cost O(n), but over many pushes the cost is still amortized O(1).

Core functions

  • push_front(x), push_back(x)
  • pop_front(), pop_back()
  • peek_front(), peek_back()
  • isEmpty(), size()

Implementations & trade-offs

  • Array deque: great cache locality and constants; occasional resize cost; capacity bound at any instant.
  • Linked-list deque: true O(1) ends without resizing; worse locality and pointer overhead; naturally unbounded.

Typical uses

  • Flexible producer/consumer buffers that sometimes prioritize at the front.
  • Sliding-window algorithms (e.g., monotonic deque for window min/max).
  • Back/forward navigation, undo/redo, and general worklists that add/remove at both ends.

Complexity, Pros/Cons, and Implementations

Time complexity

OperationArray/Circular-buffer dequeLinked-list dequeNotes
push_front(x)Amortized O(1)O(1)Array may occasionally resize; circular indices
push_back(x)Amortized O(1)O(1)Same as above
pop_front()O(1)O(1)Advance head
pop_back()O(1)O(1)Retreat tail
front()/back()O(1)O(1)Read ends
isEmpty()/size()O(1)O(1)Maintain a count

Implementations by language — how to create & use

Python

Using collections.deque (standard library)
from collections import deque

dq = deque()
dq.append(10)          # push_back
dq.appendleft(5)       # push_front
x = dq.pop()           # pop_back
y = dq.popleft()       # pop_front
f, b = dq[0], dq[-1]   # front, back
n = len(dq)
Educational circular-array deque
class ArrayDeque:
    def __init__(self, capacity=8):
        self._a = [None]*capacity
        self._cap = capacity
        self._head = 0; self._tail = 0; self._size = 0

    def _grow(self):
        b = [None]*(self._cap*2)
        for i in range(self._size):
            b[i] = self._a[(self._head + i) % self._cap]
        self._a, self._cap = b, self._cap*2
        self._head, self._tail = 0, self._size

    def push_back(self, x):
        if self._size == self._cap: self._grow()
        self._a[self._tail] = x
        self._tail = (self._tail + 1) % self._cap
        self._size += 1

    def push_front(self, x):
        if self._size == self._cap: self._grow()
        self._head = (self._head - 1 + self._cap) % self._cap
        self._a[self._head] = x
        self._size += 1

    def pop_front(self):
        if self._size == 0: raise IndexError("empty")
        v = self._a[self._head]; self._a[self._head] = None
        self._head = (self._head + 1) % self._cap
        self._size -= 1; return v

    def pop_back(self):
        if self._size == 0: raise IndexError("empty")
        self._tail = (self._tail - 1 + self._cap) % self._cap
        v = self._a[self._tail]; self._a[self._tail] = None
        self._size -= 1; return v

    def front(self): 
        if self._size == 0: raise IndexError("empty")
        return self._a[self._head]

    def back(self):
        if self._size == 0: raise IndexError("empty")
        return self._a[(self._tail - 1 + self._cap) % self._cap]

    def isEmpty(self): return self._size == 0
    def size(self):    return self._size

Java

Using ArrayDeque (standard library)
import java.util.ArrayDeque;
import java.util.Deque;

Deque<Integer> dq = new ArrayDeque<>();
dq.addLast(10);     // push_back
dq.addFirst(5);     // push_front
int x = dq.removeLast();   // pop_back
int y = dq.removeFirst();  // pop_front
int f = dq.getFirst();     // front
int b = dq.getLast();      // back
int n = dq.size();
Minimal linked-list deque (educational)
public class LinkedDeque<T> {
  private static class Node<T>{ T v; Node<T> p,n; Node(T v){this.v=v;} }
  private Node<T> head, tail; private int size;
  public void pushFront(T x){ Node<T> a=new Node<>(x);
    if(head==null){ head=tail=a; } else { a.n=head; head.p=a; head=a; } size++; }
  public void pushBack(T x){ Node<T> a=new Node<>(x);
    if(tail==null){ head=tail=a; } else { a.p=tail; tail.n=a; tail=a; } size++; }
  public T popFront(){ if(size==0) throw new java.util.NoSuchElementException();
    T v=head.v; head=head.n; if(head!=null) head.p=null; else tail=null; size--; return v; }
  public T popBack(){ if(size==0) throw new java.util.NoSuchElementException();
    T v=tail.v; tail=tail.p; if(tail!=null) tail.n=null; else head=null; size--; return v; }
  public T front(){ if(size==0) throw new java.util.NoSuchElementException(); return head.v; }
  public T back(){ if(size==0) throw new java.util.NoSuchElementException(); return tail.v; }
  public int size(){ return size; } public boolean isEmpty(){ return size==0; }
}

C++

Using std::deque (standard library)
#include <deque>
#include <iostream>
int main(){
  std::deque<int> dq;
  dq.push_back(10); dq.push_front(5);
  std::cout << dq.front() << " " << dq.back() << "\n";
  dq.pop_front(); dq.pop_back();
}
Minimal circular-buffer deque (educational)
#include <vector>
#include <stdexcept>
template<typename T> class ArrayDeque {
  std::vector<T> a; size_t head=0, tail=0, cnt=0;
public:
  explicit ArrayDeque(size_t cap=8): a(cap) {}
  size_t capacity() const { return a.size(); }
  size_t size() const { return cnt; }
  bool empty() const { return cnt==0; }
  void grow(){ std::vector<T> b(a.size()*2);
    for(size_t i=0;i<cnt;i++) b[i]=a[(head+i)%a.size()];
    a.swap(b); head=0; tail=cnt; }
  void push_back(const T& x){ if(cnt==a.size()) grow(); a[tail]=x; tail=(tail+1)%a.size(); ++cnt; }
  void push_front(const T& x){ if(cnt==a.size()) grow(); head=(head-1+a.size())%a.size(); a[head]=x; ++cnt; }
  T pop_front(){ if(!cnt) throw std::runtime_error("empty"); T v=a[head]; head=(head+1)%a.size(); --cnt; return v; }
  T pop_back(){ if(!cnt) throw std::runtime_error("empty"); tail=(tail-1+a.size())%a.size(); T v=a[tail]; --cnt; return v; }
  const T& front() const { if(!cnt) throw std::runtime_error("empty"); return a[head]; }
  const T& back()  const { if(!cnt) throw std::runtime_error("empty"); return a[(tail-1+a.size())%a.size()]; }
};

Conceptual Questions

  1. In a circular-buffer deque with capacity C, why does tail = (head + size) mod C always hold?
  2. Would it be helpful to have head and tail start in the middle? Why or why not?
  3. If C = 8, head = 6, tail = 2, what is size?
  4. Compare handling a full deque by (a) rejecting, (b) resizing (doubling). What are the time/space trade-offs?
  5. Why can array-backed deques beat linked-list deques in practice despite occasional resizing?
  6. How would you distinguish “empty” vs “full” without a stored size? (Hint: sentinel gap or version bit.)