← Back to DevBytes

Double-Ended Queues: Implementation and Time Complexity Analysis

Double-Ended Queues: Core Concepts and Practical Implementation

A double-ended queue, commonly called a deque (pronounced “deck”), is a linear data structure that allows insertion and deletion of elements from both ends — the front and the rear. It generalizes both stacks (LIFO) and queues (FIFO), offering a flexible tool for scenarios where elements need to be added or removed from either side efficiently.

In this tutorial we will explore what deques are, why they are indispensable in modern software development, how to implement them from scratch with optimal time complexity, and how to leverage them effectively in real-world code. We will also cover best practices and common pitfalls.

1. What Is a Double-Ended Queue (Deque)?

Formally, a deque supports the following fundamental operations:

Unlike a standard queue that restricts access to one end (FIFO) or a stack that restricts access to one end (LIFO), a deque removes both restrictions. This makes it a versatile building block for higher-level algorithms and data structures.

2. Why Deques Matter in Software Development

Deques shine whenever you need a dynamic collection that must grow and shrink from both sides. Here are several practical applications:

In all these cases, the ability to efficiently add or remove from either end is critical, often reducing algorithmic complexity from O(n) to O(1) per operation compared to naive array-based approaches.

3. Implementation Strategies

There are two mainstream ways to implement a deque with guaranteed efficient operations: a circular dynamic array and a doubly linked list. Each has its trade-offs in memory overhead, cache locality, and constant factors.

3.1 Array-Based Circular Deque (Most Common in Practice)

This approach uses a contiguous block of memory (a dynamic array) and treats it as circular. We maintain a front index, a rear index, and a count of elements. When the array is full, we allocate a larger block and copy elements in order. All core operations run in amortized O(1) time.

Below is a complete, production-style Python implementation of a circular deque using a resizing list. It supports all fundamental operations and includes detailed comments.


class CircularDeque:
    """
    A double-ended queue implemented with a circular dynamic array.
    All operations are amortized O(1) time.
    """

    def __init__(self, initial_capacity=4):
        if initial_capacity < 1:
            raise ValueError("Initial capacity must be positive")
        self._data = [None] * initial_capacity
        self._front = 0       # index of the first element
        self._rear = 0        # index of the next free slot after the last element
        self._size = 0
        self._capacity = initial_capacity

    def __len__(self):
        """Return the number of elements in the deque."""
        return self._size

    def is_empty(self):
        """Check if the deque has no elements."""
        return self._size == 0

    def add_first(self, item):
        """Insert item at the front of the deque."""
        if self._size == self._capacity:
            self._resize(self._capacity * 2)
        # Move front pointer backward one position (modular arithmetic)
        self._front = (self._front - 1) % self._capacity
        self._data[self._front] = item
        self._size += 1

    def add_last(self, item):
        """Insert item at the rear of the deque."""
        if self._size == self._capacity:
            self._resize(self._capacity * 2)
        # Place item at current rear and advance rear pointer
        self._data[self._rear] = item
        self._rear = (self._rear + 1) % self._capacity
        self._size += 1

    def remove_first(self):
        """Remove and return the first element.
        Raises IndexError if the deque is empty."""
        if self.is_empty():
            raise IndexError("Deque is empty")
        item = self._data[self._front]
        self._data[self._front] = None  # help garbage collection
        self._front = (self._front + 1) % self._capacity
        self._size -= 1
        # Shrink if necessary (avoid oscillation)
        if 0 < self._size <= self._capacity // 4 and self._capacity > 4:
            self._resize(self._capacity // 2)
        return item

    def remove_last(self):
        """Remove and return the last element.
        Raises IndexError if the deque is empty."""
        if self.is_empty():
            raise IndexError("Deque is empty")
        # Rear pointer points one past the last element; move it back
        self._rear = (self._rear - 1) % self._capacity
        item = self._data[self._rear]
        self._data[self._rear] = None
        self._size -= 1
        if 0 < self._size <= self._capacity // 4 and self._capacity > 4:
            self._resize(self._capacity // 2)
        return item

    def first(self):
        """Return (but do not remove) the first element."""
        if self.is_empty():
            raise IndexError("Deque is empty")
        return self._data[self._front]

    def last(self):
        """Return (but do not remove) the last element."""
        if self.is_empty():
            raise IndexError("Deque is empty")
        last_index = (self._rear - 1) % self._capacity
        return self._data[last_index]

    def _resize(self, new_capacity):
        """Resize the underlying array to a new capacity."""
        old_data = self._data
        new_data = [None] * new_capacity
        # Copy existing elements in order starting from the front
        for i in range(self._size):
            new_data[i] = old_data[(self._front + i) % self._capacity]
        self._data = new_data
        self._front = 0
        self._rear = self._size  # next free slot after last element
        self._capacity = new_capacity

    def __iter__(self):
        """Iterate over elements from front to rear."""
        for i in range(self._size):
            yield self._data[(self._front + i) % self._capacity]

    def __str__(self):
        return "[" + ", ".join(str(e) for e in self) + "]"

How the circular logic works: Indices wrap around using modulo arithmetic. When adding to the front, we decrement _front (mod capacity). When adding to the rear, we write at _rear and then increment it. The element count _size tracks the number of valid items. The resize method linearizes the circular order into a new, larger array with _front = 0 and _rear = _size, restoring a simple layout.

3.2 Doubly Linked List Implementation

A doubly linked list with head and tail pointers naturally supports O(1) insertions and deletions at both ends. Each node stores the element and references to the next and previous node. No resizing is ever needed, and memory is allocated on demand. However, pointer overhead per element is higher, and cache performance is worse due to non-contiguous memory.

A minimal Python implementation using sentinel nodes can be written as follows:


class _Node:
    __slots__ = ('value', 'next', 'prev')
    def __init__(self, value=None, prev=None, next=None):
        self.value = value
        self.prev = prev
        self.next = next

class LinkedListDeque:
    def __init__(self):
        # Sentinel nodes: head and tail are never removed
        self._head = _Node()   # dummy node before the first element
        self._tail = _Node()   # dummy node after the last element
        self._head.next = self._tail
        self._tail.prev = self._head
        self._size = 0

    def __len__(self):
        return self._size

    def is_empty(self):
        return self._size == 0

    def add_first(self, item):
        node = _Node(item, self._head, self._head.next)
        self._head.next.prev = node
        self._head.next = node
        self._size += 1

    def add_last(self, item):
        node = _Node(item, self._tail.prev, self._tail)
        self._tail.prev.next = node
        self._tail.prev = node
        self._size += 1

    def remove_first(self):
        if self.is_empty():
            raise IndexError("Deque is empty")
        node = self._head.next
        self._head.next = node.next
        node.next.prev = self._head
        self._size -= 1
        return node.value

    def remove_last(self):
        if self.is_empty():
            raise IndexError("Deque is empty")
        node = self._tail.prev
        self._tail.prev = node.prev
        node.prev.next = self._tail
        self._size -= 1
        return node.value

    def first(self):
        if self.is_empty():
            raise IndexError("Deque is empty")
        return self._head.next.value

    def last(self):
        if self.is_empty():
            raise IndexError("Deque is empty")
        return self._tail.prev.value

The sentinel nodes eliminate edge cases when adding to an empty list, making the code cleaner and faster.

4. Time Complexity Analysis

Understanding the asymptotic performance of deque operations is essential for choosing the right implementation. The table below summarizes the complexities for both approaches discussed.

Amortized analysis for the circular array: Resizing occurs only when the array is full (doubling) or sparsely populated (halving). Using the accounting method, we can assign a constant extra cost to each insertion/removal to “prepay” for future resizing. Consequently, the average cost per operation is O(1). This makes the circular deque ideal for most practical workloads where cache efficiency and low memory overhead matter.

5. Using Deques in Practice: Code Examples

Python’s built-in collections.deque is implemented as a doubly linked list of blocks (a hybrid approach) to provide O(1) operations from both ends and near-array cache behavior. It is the recommended choice for production code unless you have very specific requirements.

Here are some practical examples demonstrating the versatility of deques.

5.1 Sliding Window Maximum

Given an array nums and window size k, find the maximum in each sliding window. Using a deque, we maintain indices of potential maximums in descending order. The solution runs in O(n) time.


from collections import deque

def sliding_window_max(nums, k):
    dq = deque()
    result = []
    for i, num in enumerate(nums):
        # Remove indices that are out of the current window
        while dq and dq[0] <= i - k:
            dq.popleft()
        # Remove smaller elements from the back, they will never be maximum
        while dq and nums[dq[-1]] <= num:
            dq.pop()
        dq.append(i)
        # The front of deque holds the index of the maximum for current window
        if i >= k - 1:
            result.append(nums[dq[0]])
    return result

# Example usage
print(sliding_window_max([1,3,-1,-3,5,3,6,7], 3))
# Output: [3,3,5,5,6,7]

5.2 Level-Order Traversal with Zigzag (Binary Tree)

A deque can alternate between popping from the front and rear to achieve a zigzag (spiral) traversal of a binary tree in O(n) time without needing to reverse lists.


from collections import deque

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def zigzag_level_order(root):
    if not root:
        return []
    dq = deque([root])
    result = []
    left_to_right = True
    while dq:
        level_vals = []
        for _ in range(len(dq)):
            if left_to_right:
                node = dq.popleft()
            else:
                node = dq.pop()
            level_vals.append(node.val)
            # Always enqueue children normally (left then right)
            # The deque order will be reversed for the next level
            if left_to_right:
                if node.left:
                    dq.append(node.left)
                if node.right:
                    dq.append(node.right)
            else:
                # When popping from rear, we must push children in reverse order
                # to maintain correct left-to-right traversal later
                if node.right:
                    dq.appendleft(node.right)
                if node.left:
                    dq.appendleft(node.left)
        result.append(level_vals)
        left_to_right = not left_to_right
    return result

5.3 Implementing a Simple Undo Stack

An undo feature in a text editor can use a deque with a fixed capacity to store recent actions. When the deque is full, the oldest action is dropped from the front.


from collections import deque

class UndoManager:
    def __init__(self, max_history=50):
        self.history = deque(maxlen=max_history)

    def perform_action(self, action):
        self.history.append(action)   # newest at the rear

    def undo(self):
        if self.history:
            return self.history.pop()   # remove most recent
        return None

6. Best Practices and Pitfalls

7. Conclusion

Double-ended queues are a fundamental data structure that bridges the gap between stacks and queues, enabling efficient O(1) operations on both ends. Their flexibility underpins countless algorithms — from sliding window problems to work-stealing schedulers. By understanding the implementation trade-offs between circular arrays and linked lists, and by knowing when to leverage built-in deques, you can choose the right tool for your performance and memory requirements. Whether you build one from scratch or use a production-grade library, mastering deques will sharpen your algorithmic thinking and expand your problem-solving toolkit.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles