awesome-everything RU
↑ Back to the climb

Algorithms from zero

Queues and deques

Crux A queue: FIFO container where you enqueue at the rear, dequeue from the front. Both O(1) with proper implementation. A deque extends this to support O(1) at both ends, used in BFS and any situation requiring fairness.
◷ 24 min

Last lesson you learned the stack — a LIFO (last-in-first-out) structure. Today you will learn the queue — its opposite, a FIFO (first-in-first-out) structure. A queue is like a line at a checkout: you join the back, and the person at the front gets served first. No line-cutting allowed. In computing, queues model waiting lines, breadth-first search (BFS), print job scheduling, and any situation where fairness (first come, first served) is critical. You will also learn the deque (double-ended queue), which adds the power of both FIFO and the ability to operate at both ends in O(1) time. The key insight: a naive array .shift() is O(n), but a circular buffer or linked list makes both ends O(1).

Goal

After this lesson you can explain the FIFO discipline and the two core queue operations (enqueue, dequeue), implement a queue using a linked list (O(1) for both operations), recognize why a naive array .shift() is O(n), understand the deque (double-ended queue) and why a circular-buffer implementation makes both ends O(1), solve BFS and level-order traversal problems using a queue, and recognize when a queue or deque is the right data structure for a problem.

The idea

The FIFO rule:

A queue is a linear data structure where insertions happen at one end (the rear) and deletions happen at the other (the front). The rule is FIFO (First In, First Out): the element added first is the one removed first.

Imagine a checkout line at a grocery store. Customers join at the back and leave from the front. The first to join is the first to pay and leave. You never skip ahead; no one cuts in front. That is FIFO.

The two operations:

  1. Enqueue: Add an element to the rear of the queue. Returns nothing; updates the queue in-place.
  2. Dequeue: Remove and return the front element of the queue. If the queue is empty, either return null or raise an error (implementation-dependent).

There is also often a front or peek operation to see the front element without removing it.

const queue = [];  // Empty queue

// Enqueue: add 10, 20, 30
queue.push(10);    // Queue: [10]
queue.push(20);    // Queue: [10, 20]
queue.push(30);    // Queue: [10, 20, 30]

// Peek: see the front
console.log(queue[0]);  // 10 (without removing)

// Dequeue: remove the front
// DO NOT use queue.shift() in a real queue! It is O(n).
const front = queue[0];
queue = queue.slice(1);  // Inefficient! Why?

Why .shift() is O(n), and how to fix it:

When you use JavaScript’s .shift() or remove from the front of an array, the language must reindex all remaining elements:

Before:  [10, 20, 30, 40]  (indices 0, 1, 2, 3)
Remove index 0 (10).
After:   [20, 30, 40]      (indices 0, 1, 2)
         ↑  Reindex all!

That reindexing is O(n) work. If you call dequeue n times, it costs O(n²) total — very expensive.

Solution 1: Use front and rear pointers.

Instead of removing, keep a front pointer. Enqueue goes to the rear; dequeue increments the front pointer.

class Queue {
  constructor() {
    this.items = [];
    this.front = 0;  // Pointer to the front
  }
  
  enqueue(value) {
    this.items.push(value);
  }
  
  dequeue() {
    if (this.front >= this.items.length) return null;
    const value = this.items[this.front];
    this.front++;
    return value;
  }
  
  peek() {
    if (this.front >= this.items.length) return null;
    return this.items[this.front];
  }
  
  isEmpty() {
    return this.front >= this.items.length;
  }
}

Now both enqueue and dequeue are O(1). But there is a problem: the front pointer only moves forward. After many dequeues, the front part of the array is wasted space. If you dequeue 1000 items, the first 1000 slots are never reused.

Solution 2: Use a circular buffer.

A circular buffer wraps around. When you reach the end of the array, the next enqueue goes back to index 0 (if that slot is empty). This reuses space and keeps both ends O(1):

class CircularQueue {
  constructor(capacity) {
    this.items = new Array(capacity);
    this.front = 0;
    this.rear = -1;
    this.size = 0;
    this.capacity = capacity;
  }
  
  enqueue(value) {
    if (this.size === this.capacity) {
      // Queue is full. Grow the array.
      this._resize();
    }
    this.rear = (this.rear + 1) % this.capacity;
    this.items[this.rear] = value;
    this.size++;
  }
  
  dequeue() {
    if (this.size === 0) return null;
    const value = this.items[this.front];
    this.front = (this.front + 1) % this.capacity;
    this.size--;
    return value;
  }
  
  peek() {
    if (this.size === 0) return null;
    return this.items[this.front];
  }
  
  _resize() {
    const newCapacity = this.capacity * 2;
    const newItems = new Array(newCapacity);
    for (let i = 0; i < this.size; i++) {
      newItems[i] = this.items[(this.front + i) % this.capacity];
    }
    this.items = newItems;
    this.front = 0;
    this.rear = this.size - 1;
    this.capacity = newCapacity;
  }
}

The modulo % operator wraps the pointers: when rear reaches the end, it wraps to 0.

Solution 3: Use a linked list.

A linked list naturally supports O(1) insertion at the tail and O(1) deletion at the head:

class Node {
  constructor(value, next = null) {
    this.value = value;
    this.next = next;
  }
}

class QueueLinkedList {
  constructor() {
    this.head = null;  // Front of queue
    this.tail = null;  // Rear of queue
  }
  
  enqueue(value) {
    const newNode = new Node(value);
    if (this.tail === null) {
      this.head = this.tail = newNode;
    } else {
      this.tail.next = newNode;
      this.tail = newNode;
    }
  }
  
  dequeue() {
    if (this.head === null) return null;
    const value = this.head.value;
    this.head = this.head.next;
    if (this.head === null) this.tail = null;
    return value;
  }
  
  peek() {
    if (this.head === null) return null;
    return this.head.value;
  }
  
  isEmpty() {
    return this.head === null;
  }
}

The deque: queues on both ends

A deque (double-ended queue) allows insertion and deletion at both ends. You can push and pop from both the front and the rear. A deque implemented with a doubly linked list or a circular buffer supports all four operations in O(1):

class Deque {
  constructor() {
    this.items = [];
    this.front = 0;
    this.rear = -1;
    this.size = 0;
  }
  
  pushBack(value) {
    this.items.push(value);
    this.rear++;
    this.size++;
  }
  
  pushFront(value) {
    this.items.unshift(value);
    this.rear++;
    this.size++;
  }
  
  popBack() {
    if (this.size === 0) return null;
    this.size--;
    this.rear--;
    return this.items.pop();
  }
  
  popFront() {
    if (this.size === 0) return null;
    this.size--;
    this.front++;
    return this.items.shift();  // This is O(n)!
  }
  
  peekFront() {
    if (this.size === 0) return null;
    return this.items[this.front];
  }
  
  peekBack() {
    if (this.size === 0) return null;
    return this.items[this.rear];
  }
}

Note: The above naive deque is inefficient (popFront uses .shift()). A proper deque uses a circular buffer or doubly linked list to ensure O(1) for all operations.

The code

Linked-list-backed queue (O(1) enqueue and dequeue):

class Node {
  constructor(value, next = null) {
    this.value = value;
    this.next = next;
  }
}

class Queue {
  constructor() {
    this.head = null;  // Front
    this.tail = null;  // Rear
  }
  
  enqueue(value) {
    const newNode = new Node(value);
    if (this.tail === null) {
      this.head = this.tail = newNode;
    } else {
      this.tail.next = newNode;
      this.tail = newNode;
    }
  }
  
  dequeue() {
    if (this.head === null) return null;
    const value = this.head.value;
    this.head = this.head.next;
    if (this.head === null) this.tail = null;
    return value;
  }
  
  peek() {
    if (this.head === null) return null;
    return this.head.value;
  }
  
  isEmpty() {
    return this.head === null;
  }
}

Circular-buffer-backed queue (array with front/rear pointers):

class CircularQueue {
  constructor(capacity = 10) {
    this.items = new Array(capacity);
    this.front = 0;
    this.rear = -1;
    this.size = 0;
    this.capacity = capacity;
  }
  
  enqueue(value) {
    if (this.size === this.capacity) {
      this._resize();
    }
    this.rear = (this.rear + 1) % this.capacity;
    this.items[this.rear] = value;
    this.size++;
  }
  
  dequeue() {
    if (this.size === 0) return null;
    const value = this.items[this.front];
    this.front = (this.front + 1) % this.capacity;
    this.size--;
    return value;
  }
  
  peek() {
    if (this.size === 0) return null;
    return this.items[this.front];
  }
  
  isEmpty() {
    return this.size === 0;
  }
  
  _resize() {
    const newCapacity = this.capacity * 2;
    const newItems = new Array(newCapacity);
    for (let i = 0; i < this.size; i++) {
      newItems[i] = this.items[(this.front + i) % this.capacity];
    }
    this.items = newItems;
    this.front = 0;
    this.rear = this.size - 1;
    this.capacity = newCapacity;
  }
}

Try both in the interactive runner:

Output
 
Step-by-step trace

Let us trace a sequence of enqueue and dequeue operations on a queue.

Queue: initially empty. Operations: enqueue(10), enqueue(20), enqueue(30), dequeue(), dequeue(), enqueue(40), dequeue().

1 while (ops.length > 0) {
2 const op = ops.shift();
3 if (op.type === 'enqueue') {
4 queue.enqueue(op.value);
5 } else if (op.type === 'dequeue') {
6 queue.dequeue();
7 }
8 }

Complexity

Queue operations (linked-list implementation):

OperationTimeSpace
EnqueueO(1)O(1)
DequeueO(1)O(1)
PeekO(1)O(1)
isEmptyO(1)O(1)

All operations run in O(1) constant time because you only touch the head and tail pointers. No traversal is needed; no loop iterates over the structure.

Queue operations (circular-buffer implementation with pointers):

Same as above: O(1) for all operations. The modulo % operator is O(1), so pointer arithmetic is O(1).

Naive array with .shift() (DO NOT USE):

  • Enqueue: O(1)
  • Dequeue: O(n) — because .shift() must reindex all remaining elements.
  • If you enqueue m items then dequeue m items, total cost is O(m²). Avoid this.

Deque operations (circular-buffer implementation):

OperationTimeSpace
PushFrontO(1)O(1)
PushBackO(1)O(1)
PopFrontO(1)O(1)
PopBackO(1)O(1)
PeekFrontO(1)O(1)
PeekBackO(1)O(1)

All six operations are O(1) constant time with a proper circular-buffer or doubly linked-list implementation.

Practice 0 / 5

You enqueue 10, 20, and 30 (in that order) into a queue, then dequeue twice. What is the front element now?

Why is array `.shift()` not suitable for implementing a queue dequeue operation?

A circular queue wraps the front and rear pointers using modulo (%). What is the main advantage?

A deque supports O(1) operations at both the front and rear. Which data structure is best suited to implement it efficiently?

You perform 1000 enqueues and 1000 dequeues on a naive array queue (using `.shift()`). What is the total time complexity?

Edge cases

Empty queue operations: When the queue is empty and you try to dequeue or peek, what should happen? Different languages differ: some return null, some raise an exception. In JavaScript, a LinkedList queue returns null. A circular queue also returns null. Decide upfront: return null for an empty dequeue, or throw an error? For BFS and level-order traversal, we return null and check for it.

Common mistake

“A queue is just an array.” No. A queue is an abstract discipline (FIFO with restricted access to front and rear), not a data structure. An array is a data structure (random access, indexing). You can implement a queue using an array (with front/rear pointers or circular buffer), but the queue hides the array’s ability to access the middle. Do not confuse the abstract idea (queue) with the implementation (array, linked list, circular buffer).

More practice

Follow-up: breadth-first search (BFS). Queues are the heart of BFS traversal on graphs and trees. You start at a root node, enqueue it, then repeatedly dequeue a node, visit it, and enqueue its unvisited neighbors. The FIFO order ensures you visit nodes level by level. Deques also allow two-way exploration, useful in certain advanced graph algorithms.

Check yourself
Quiz

Why is a queue (not a stack) the ideal data structure for breadth-first search (BFS) traversal?

Recap

The queue: FIFO and the front/rear discipline

  1. FIFO rule: The first element enqueued is the first one dequeued. Think of a checkout line.

  2. Two core operations: enqueue (add to rear, O(1)), dequeue (remove from front, O(1)). Both are constant time with proper implementation.

  3. Three implementations:

    • Linked list: O(1) for both, simple, no wasted space.
    • Circular buffer: O(1) for both, space-efficient (reuses slots), requires modulo arithmetic.
    • Naive array with .shift(): O(n) for dequeue. Avoid.
  4. The deque (double-ended queue): Allows O(1) operations (push, pop) at both the front and rear. Implemented with a doubly linked list or circular buffer.

  5. Why circular buffers are clever: The modulo % operator wraps pointers. When rear reaches the end of the array, it wraps to 0. Dequeued slots are reused; no space is wasted.

  6. Real-world uses: BFS traversal, print job queues, level-order tree traversal, message queues, load balancing.

  7. Key insight: A queue is an abstract discipline. You choose the implementation (linked list, circular buffer, etc.) based on memory constraints and use case. FIFO semantics are what matter.

Next, you will learn the monotonic stack — a stack paired with a value constraint to solve problems like “next greater element” and “largest rectangle in histogram” efficiently.

Continue the climb ↑The monotonic stack
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources5
expand
  1. 01
  2. 02
  3. 03
  4. 04
  5. 05

Trademarks belong to their respective owners. Editorial reference only.