Loading learning content...
Here's a puzzle that has confused countless programmers implementing circular queues for the first time:
Consider a circular queue with capacity 4. In what state are the pointers when the queue is empty? And when it's full?
If you've been following along, you might answer:
Wait—both states have the same pointer configuration? How can we tell them apart?
This is the full vs empty detection problem, and it's the central challenge of circular queue implementation. This page explores multiple solutions, each with distinct trade-offs.
By the end of this page, you will understand: (1) Why the full/empty ambiguity exists, (2) Three distinct solutions (count variable, sacrificed slot, wrap flag), (3) Trade-offs between memory, performance, and complexity, and (4) How to implement each approach correctly.
Let's trace through operations to see exactly when and why the ambiguity arises.
Setup: Circular queue with capacity 4, using Convention 2 (rear points to next slot).
Initial State (Empty):
front = 0, rear = 0
[_] [_] [_] [_]
↑
f,r
Condition: front == rear → EMPTY
After 4 Enqueues (Full):
enqueue(A): rear = 1 → [A] [_] [_] [_]
enqueue(B): rear = 2 → [A] [B] [_] [_]
enqueue(C): rear = 3 → [A] [B] [C] [_]
enqueue(D): rear = 0 → [A] [B] [C] [D] (rear wrapped!)
front = 0, rear = 0
[A] [B] [C] [D]
↑
f,r
Condition: front == rear → FULL
The Paradox:
Both empty and full have front == rear! The pointers alone cannot distinguish these states.
With only two pointer variables, we can represent capacity + 1 distinct states (0 elements, 1 element, ..., capacity elements). But two indices can only uniquely identify capacity × capacity configurations, and capacity of those configurations are used for regular states, leaving no unique configuration for 'full' vs 'empty'. We need additional information.
Mathematical Perspective:
For a queue of capacity N:
We have N + 1 states but only N configurations. By the pigeonhole principle, at least two states must share a configuration. In circular queues, these are the empty (size = 0) and full (size = N) states.
Three Solutions:
The most straightforward solution is to maintain an explicit counter that tracks the number of elements in the queue. This completely eliminates the ambiguity.
How It Works:
count variable initialized to 0count on each enqueuecount on each dequeuecount == 0; Full when count == capacity1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
/** * Circular Queue with Count Variable * The cleanest and most intuitive solution */class CircularQueueWithCount<T> { private data: (T | undefined)[]; private front: number; private rear: number; private capacity: number; private count: number; // ← The key addition constructor(capacity: number) { this.capacity = capacity; this.data = new Array(capacity); this.front = 0; this.rear = 0; this.count = 0; } // Empty and Full are now unambiguous isEmpty(): boolean { return this.count === 0; } isFull(): boolean { return this.count === this.capacity; } size(): number { return this.count; // O(1) size query as a bonus! } enqueue(element: T): void { if (this.isFull()) { throw new Error("Queue Overflow"); } this.data[this.rear] = element; this.rear = (this.rear + 1) % this.capacity; this.count++; // ← Track addition } dequeue(): T { if (this.isEmpty()) { throw new Error("Queue Underflow"); } const element = this.data[this.front] as T; this.data[this.front] = undefined; this.front = (this.front + 1) % this.capacity; this.count--; // ← Track removal return element; } peek(): T { if (this.isEmpty()) { throw new Error("Queue is empty"); } return this.data[this.front] as T; }}The count variable approach is our recommended solution for most use cases. The 'extra memory' of one integer is trivial (4-8 bytes), and the 'extra operations' are single cycle instructions. The clarity and robustness it provides far outweigh these minimal costs.
The sacrificed slot approach solves the ambiguity without additional variables by intentionally leaving one slot always empty. This ensures that full and empty states have different pointer configurations.
How It Works:
(rear + 1) % capacity == front (next enqueue would reach front)rear == frontSince we never fill the last slot, rear == front can only mean empty.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
/** * Circular Queue with Sacrificed Slot * Uses N-1 of N slots to distinguish empty from full */class CircularQueueSacrificedSlot<T> { private data: (T | undefined)[]; private front: number; private rear: number; private capacity: number; // Total array size constructor(usableCapacity: number) { // Allocate one extra slot that will always remain empty this.capacity = usableCapacity + 1; this.data = new Array(this.capacity); this.front = 0; this.rear = 0; } // Empty: front and rear are at the same position isEmpty(): boolean { return this.front === this.rear; } // Full: rear is exactly one position behind front (circularly) // Adding another element would make rear == front (ambiguous!) isFull(): boolean { return (this.rear + 1) % this.capacity === this.front; } // Size must be computed from pointers size(): number { return (this.rear - this.front + this.capacity) % this.capacity; } // Maximum usable elements (not the array size) maxSize(): number { return this.capacity - 1; } enqueue(element: T): void { if (this.isFull()) { throw new Error("Queue Overflow"); } this.data[this.rear] = element; this.rear = (this.rear + 1) % this.capacity; } dequeue(): T { if (this.isEmpty()) { throw new Error("Queue Underflow"); } const element = this.data[this.front] as T; this.data[this.front] = undefined; this.front = (this.front + 1) % this.capacity; return element; } peek(): T { if (this.isEmpty()) { throw new Error("Queue is empty"); } return this.data[this.front] as T; }}Visual Explanation:
Capacity = 5 (usable = 4)
Empty: front = rear = 0
[_] [_] [_] [_] [_]
↑
f,r
1 element: front = 0, rear = 1
[A] [_] [_] [_] [_]
↑ ↑
f r
4 elements (FULL): front = 0, rear = 4
[A] [B] [C] [D] [_]
↑ ↑
f r
isFull(): (4 + 1) % 5 = 0 == front ✓
The slot at index 4 is kept empty!
If we tried to enqueue, rear would
become 0, matching front (ambiguous).
By never allowing rear to catch up to front, we preserve the distinction: front == rear always means empty, never full.
The sacrificed slot approach is ideal when: (1) Memory is extremely constrained (no room for count), (2) You rarely need size queries, (3) You're interfacing with hardware that expects this convention, or (4) N is large so losing one slot is negligible.
The wrap flag approach uses a boolean to track whether the rear pointer has 'wrapped around' and caught up to the front. This provides full capacity usage with minimal additional memory.
How It Works:
wrapped boolean flag, initially falsewrapped = true when rear wraps and reaches frontwrapped = false when front advances past where rear wasfront == rear && !wrappedfront == rear && wrapped12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
/** * Circular Queue with Wrap Flag * Uses a boolean to track full/empty distinction */class CircularQueueWithWrapFlag<T> { private data: (T | undefined)[]; private front: number; private rear: number; private capacity: number; private wrapped: boolean; // True if rear has caught up to front constructor(capacity: number) { this.capacity = capacity; this.data = new Array(capacity); this.front = 0; this.rear = 0; this.wrapped = false; } isEmpty(): boolean { return this.front === this.rear && !this.wrapped; } isFull(): boolean { return this.front === this.rear && this.wrapped; } size(): number { if (this.wrapped) { return this.capacity; // Full } return (this.rear - this.front + this.capacity) % this.capacity; } enqueue(element: T): void { if (this.isFull()) { throw new Error("Queue Overflow"); } this.data[this.rear] = element; this.rear = (this.rear + 1) % this.capacity; // If rear caught up to front, we're now full if (this.rear === this.front) { this.wrapped = true; } } dequeue(): T { if (this.isEmpty()) { throw new Error("Queue Underflow"); } const element = this.data[this.front] as T; this.data[this.front] = undefined; this.front = (this.front + 1) % this.capacity; // After removing, we're definitely not full anymore this.wrapped = false; return element; } peek(): T { if (this.isEmpty()) { throw new Error("Queue is empty"); } return this.data[this.front] as T; }}State Transitions:
Initial (Empty):
front=0, rear=0, wrapped=false
isEmpty: front==rear && !wrapped ✓
After filling all 4 slots:
front=0, rear=0, wrapped=true
isFull: front==rear && wrapped ✓
After one dequeue:
front=1, rear=0, wrapped=false
(wrapped cleared on dequeue)
After one more enqueue:
front=1, rear=1, wrapped=true
(rear caught front again)
The wrapped flag acts as a 'tie-breaker' when pointers are equal.
The wrap flag must be managed precisely. Setting it too early or clearing it at the wrong time causes incorrect full/empty detection. This approach is less forgiving of bugs than the count variable method.
Let's systematically compare the three solutions across key dimensions:
| Criterion | Count Variable | Sacrificed Slot | Wrap Flag |
|---|---|---|---|
| Extra Memory | 1 integer (4-8 bytes) | None | 1 boolean (1 byte) |
| Usable Capacity | N (full) | N-1 (lose one) | N (full) |
| isEmpty() | O(1), trivial | O(1), pointer comparison | O(1), pointer + flag |
| isFull() | O(1), trivial | O(1), modular comparison | O(1), pointer + flag |
| size() | O(1), return count | O(1), pointer arithmetic | O(1), but complex |
| Implementation Complexity | Low | Medium | High |
| Bug Risk | Low | Medium (off-by-one) | High (flag sync) |
| Common Use Case | General purpose | Hardware, embedded | Rare, specialized |
Decision Framework:
For most applications: Use the count variable approach. The minimal memory cost is negligible, and the implementation clarity prevents bugs.
For embedded/hardware with byte-level constraints: Use the sacrificed slot approach. The lost capacity is often acceptable when N is large.
For specialized low-level systems: Consider the wrap flag approach only if you need full capacity AND cannot afford an integer counter.
When in doubt: Choose count variable. It's the industry standard for software implementations.
Most production queue implementations (Java's ArrayDeque, C++ STL queue, Python's deque, etc.) use either the count variable or sacrificed slot approach. The wrap flag is mainly seen in hardware FIFO implementations and academic exercises.
Regardless of which approach you choose, certain edge cases require careful handling. Let's examine each:
Edge Case 1: Queue of Capacity 1
123456789101112131415
// Capacity 1 is a degenerate case for sacrificed slot!const queue = new CircularQueueSacrificedSlot(1); // Actually allocates: capacity = 1 + 1 = 2// Usable: 1 element (array has 2 slots, one always empty) // For count variable approach: works fine, can hold 1 element // For wrap flag: works, but the flag flips on every enqueue/dequeue // Test thoroughly!queue.enqueue("only");console.log(queue.isFull()); // Should be trueconsole.log(queue.dequeue()); // "only"console.log(queue.isEmpty()); // Should be trueEdge Case 2: Repeated Fill-Empty Cycles
123456789101112131415161718192021
// Test that multiple fill/empty cycles work correctlyconst queue = new CircularQueue(3); for (let cycle = 0; cycle < 100; cycle++) { // Fill completely for (let i = 0; i < 3; i++) { queue.enqueue(`cycle${cycle}-item${i}`); } if (!queue.isFull()) { throw new Error(`Should be full at cycle ${cycle}`); } // Empty completely for (let i = 0; i < 3; i++) { queue.dequeue(); } if (!queue.isEmpty()) { throw new Error(`Should be empty at cycle ${cycle}`); }}console.log("100 cycles completed successfully");Edge Case 3: Alternating Operations
1234567891011121314151617181920
// Alternating enqueue/dequeue stresses pointer managementconst queue = new CircularQueue(4); // Add 2, remove 1, repeatedlyfor (let i = 0; i < 20; i++) { queue.enqueue(`item-${i * 2}`); queue.enqueue(`item-${i * 2 + 1}`); const removed = queue.dequeue(); console.log(`Removed: ${removed}, Size: ${queue.size()}`); // Queue should never overflow (we add 2, remove 1, net +1) // But it will eventually fill up! if (queue.isFull()) { console.log("Queue filled, draining..."); while (!queue.isEmpty()) { queue.dequeue(); } }}Edge Case 4: Operations on Empty/Full Queue
123456789101112131415161718192021222324252627282930313233
// Verify error handling at boundariesconst queue = new CircularQueue(2); // Operations on empty queuetry { queue.dequeue(); throw new Error("Should have thrown Underflow");} catch (e) { console.log("✓ Dequeue on empty throws correctly");} try { queue.peek(); throw new Error("Should have thrown Empty error");} catch (e) { console.log("✓ Peek on empty throws correctly");} // Fill the queuequeue.enqueue("A");queue.enqueue("B"); // Operations on full queuetry { queue.enqueue("C"); throw new Error("Should have thrown Overflow");} catch (e) { console.log("✓ Enqueue on full throws correctly");} // Verify queue still works after failed operationsconsole.log(queue.peek()); // Should be "A"console.log(queue.size()); // Should be 2Always test these edge cases when implementing a circular queue. Many subtle bugs only manifest under specific sequences of operations. Automated tests with random operation sequences can help catch rare bugs.
Here's a comprehensive, production-ready circular queue implementation using the recommended count variable approach, with all edge cases handled and thorough documentation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
/** * A generic circular queue (ring buffer) implementation. * Uses a count variable for unambiguous full/empty detection. * All operations are O(1) time complexity. * * @template T The type of elements stored in the queue */class CircularQueue<T> { private readonly data: (T | undefined)[]; private front: number; private rear: number; private readonly capacity: number; private count: number; /** * Creates a new circular queue with the specified capacity. * * @param capacity Maximum number of elements the queue can hold * @throws Error if capacity is not a positive integer */ constructor(capacity: number) { if (!Number.isInteger(capacity) || capacity <= 0) { throw new Error("Capacity must be a positive integer"); } this.capacity = capacity; this.data = new Array(capacity); this.front = 0; this.rear = 0; this.count = 0; } /** * Returns true if the queue contains no elements. * Time: O(1) */ isEmpty(): boolean { return this.count === 0; } /** * Returns true if the queue is at maximum capacity. * Time: O(1) */ isFull(): boolean { return this.count === this.capacity; } /** * Returns the number of elements currently in the queue. * Time: O(1) */ size(): number { return this.count; } /** * Returns the maximum capacity of the queue. * Time: O(1) */ getCapacity(): number { return this.capacity; } /** * Adds an element to the rear of the queue. * Time: O(1) * * @param element The element to add * @throws Error if the queue is full */ enqueue(element: T): void { if (this.isFull()) { throw new Error("Queue Overflow: Cannot enqueue to a full queue"); } this.data[this.rear] = element; this.rear = (this.rear + 1) % this.capacity; this.count++; } /** * Removes and returns the element at the front of the queue. * Time: O(1) * * @returns The element that was at the front * @throws Error if the queue is empty */ dequeue(): T { if (this.isEmpty()) { throw new Error("Queue Underflow: Cannot dequeue from an empty queue"); } const element = this.data[this.front] as T; this.data[this.front] = undefined; // Help garbage collection this.front = (this.front + 1) % this.capacity; this.count--; return element; } /** * Returns the element at the front without removing it. * Time: O(1) * * @returns The element at the front * @throws Error if the queue is empty */ peek(): T { if (this.isEmpty()) { throw new Error("Queue Empty: No element to peek"); } return this.data[this.front] as T; } /** * Returns the element at the rear without removing it. * Time: O(1) * * @returns The element at the rear * @throws Error if the queue is empty */ peekRear(): T { if (this.isEmpty()) { throw new Error("Queue Empty: No element to peek"); } // Rear points to next empty slot, so go back one position const rearIndex = (this.rear - 1 + this.capacity) % this.capacity; return this.data[rearIndex] as T; } /** * Removes all elements from the queue. * Time: O(n) */ clear(): void { // Clear references for garbage collection for (let i = 0; i < this.capacity; i++) { this.data[i] = undefined; } this.front = 0; this.rear = 0; this.count = 0; } /** * Returns the queue contents as an array in front-to-rear order. * Time: O(n) * * @returns A new array containing all queue elements */ toArray(): T[] { const result: T[] = []; let current = this.front; for (let i = 0; i < this.count; i++) { result.push(this.data[current] as T); current = (current + 1) % this.capacity; } return result; } /** * Creates an iterator over queue elements (front to rear). * Allows use in for...of loops. */ *[Symbol.iterator](): Iterator<T> { let current = this.front; for (let i = 0; i < this.count; i++) { yield this.data[current] as T; current = (current + 1) % this.capacity; } } /** * Returns a string representation of the queue for debugging. */ toString(): string { return `CircularQueue(size=${this.count}, capacity=${this.capacity}): [${this.toArray().join(', ')}]`; }} // Export for use as a moduleexport { CircularQueue };This implementation includes: input validation in constructor, proper error messages, garbage collection hints (clearing slots), iterator support for for...of loops, toString for debugging, and comprehensive JSDoc documentation.
This page addressed the critical full vs empty detection problem that challenges circular queue implementations.
Module Complete:
You have now completed Module 4: Array-Based Queue & Circular Queue Implementation. You understand:
This knowledge equips you to implement efficient, correct queue data structures and understand existing implementations in libraries and codebases.
Congratulations! You've mastered the circular queue—one of the most elegant examples of how a simple insight (treating arrays as rings) transforms an impractical data structure into an efficient one. This pattern of using modular arithmetic for cyclic behaviors appears throughout computer science: in hash tables, caches, scheduling algorithms, and network buffers.