LeetCode 641 - Design Circular Deque
The problem asks us to design a circular double-ended queue, also called a deque. A deque is a data structure that allows insertion and deletion from both the front and the rear.
Difficulty: 🟡 Medium
Topics: Array, Linked List, Design, Queue
Solution
Problem Understanding
The problem asks us to design a circular double-ended queue, also called a deque. A deque is a data structure that allows insertion and deletion from both the front and the rear. Unlike a normal queue, which only supports enqueue at the back and dequeue at the front, a deque supports operations at both ends efficiently.
The word "circular" is the key part of this problem. Instead of shifting elements when the structure reaches the end of the array, the indices wrap around to the beginning. This allows all operations to remain efficient without reallocating or moving elements.
We must implement a class with the following functionality:
- Insert at the front
- Insert at the rear
- Delete from the front
- Delete from the rear
- Retrieve the front value
- Retrieve the rear value
- Check whether the deque is empty
- Check whether the deque is full
The deque has a fixed capacity k, meaning it cannot grow dynamically. If the deque is already full, insertion operations must fail and return false. Similarly, if the deque is empty, deletion operations must fail and return false.
The constraints are small:
1 <= k <= 1000- At most
2000operations
Even though the constraints are not large, the problem is fundamentally a data structure design problem. The goal is not merely to pass the constraints, but to implement all operations efficiently.
A naive implementation could repeatedly shift elements inside an array when inserting or deleting from the front. That would work functionally, but it would make some operations linear time. The intended solution achieves constant time for every operation.
Several edge cases are important:
- Inserting into a full deque
- Deleting from an empty deque
- Correctly wrapping indices when they move past either end of the array
- Handling the case where the deque contains exactly one element
- Maintaining correct front and rear positions after many wraparounds
The problem guarantees that all method calls follow the required signatures, and values are always within the allowed range.
Approaches
Brute Force Approach
The most straightforward solution is to use a dynamic list or array and manually simulate deque behavior.
For example:
insertFrontcould uselist.insert(0, value)insertLastcould useappenddeleteFrontcould usepop(0)deleteLastcould usepop()
This approach is easy to implement and produces correct results because the list always maintains the correct order of elements.
However, operations at the front of a standard array-based list are expensive. Inserting or deleting at index 0 requires shifting every remaining element one position. That makes those operations O(n).
Even though the constraints are relatively small, this does not satisfy the intended design requirement of efficient deque operations.
Optimal Approach
The optimal solution uses a fixed-size circular array.
The key insight is that we do not need to physically move elements when inserting or deleting from either end. Instead, we maintain:
- A
frontpointer - A
rearpointer - A
sizecounter
The array acts like a ring. When an index moves past the end of the array, it wraps back to the beginning using modulo arithmetic.
For example:
(index + 1) % capacity
This circular behavior allows every operation to be completed in constant time.
The most important observation is that the logical order of the deque does not need to match the physical layout in memory. We only need correct pointer management.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n) per front operation | O(k) | Uses list shifting for front insertions and deletions |
| Optimal | O(1) per operation | O(k) | Uses circular array with modular arithmetic |
Algorithm Walkthrough
- Initialize a fixed-size array of length
k.
This array stores all deque elements. Since the maximum size is fixed, we never need to resize it.
2. Maintain a front index.
The front index always points to the current front element of the deque.
3. Maintain a rear index.
The rear index always points to the position where the next rear insertion will occur.
4. Maintain a size variable.
This tracks the number of elements currently in the deque. It allows us to determine whether the deque is empty or full.
5. For insertFront, first check whether the deque is full.
If full, return False.
Otherwise:
- Move
frontbackward by one position using circular arithmetic. - Store the value at the new front position.
- Increment
size.
- For
insertLast, first check whether the deque is full.
If full, return False.
Otherwise:
- Store the value at the current
rearposition. - Move
rearforward using circular arithmetic. - Increment
size.
- For
deleteFront, first check whether the deque is empty.
If empty, return False.
Otherwise:
- Move
frontforward by one position. - Decrement
size.
- For
deleteLast, first check whether the deque is empty.
If empty, return False.
Otherwise:
- Move
rearbackward by one position. - Decrement
size.
- For
getFront, return the element at thefrontindex if the deque is not empty. - For
getRear, return the element immediately before therearpointer.
Since rear points to the next insertion position, the actual rear element is located at:
(rear - 1 + capacity) % capacity
isEmptyreturns whethersize == 0.isFullreturns whethersize == capacity.
Why it works
The correctness comes from maintaining a consistent invariant:
frontalways points to the first valid elementrearalways points to the next insertion position at the backsizealways equals the number of stored elements
Modulo arithmetic guarantees indices always remain within array bounds while still behaving like a circular structure. Since elements are never shifted, all operations remain constant time.
Python Solution
class MyCircularDeque:
def __init__(self, k: int):
self.capacity = k
self.deque = [0] * k
self.front = 0
self.rear = 0
self.size = 0
def insertFront(self, value: int) -> bool:
if self.isFull():
return False
self.front = (self.front - 1 + self.capacity) % self.capacity
self.deque[self.front] = value
self.size += 1
return True
def insertLast(self, value: int) -> bool:
if self.isFull():
return False
self.deque[self.rear] = value
self.rear = (self.rear + 1) % self.capacity
self.size += 1
return True
def deleteFront(self) -> bool:
if self.isEmpty():
return False
self.front = (self.front + 1) % self.capacity
self.size -= 1
return True
def deleteLast(self) -> bool:
if self.isEmpty():
return False
self.rear = (self.rear - 1 + self.capacity) % self.capacity
self.size -= 1
return True
def getFront(self) -> int:
if self.isEmpty():
return -1
return self.deque[self.front]
def getRear(self) -> int:
if self.isEmpty():
return -1
rear_index = (self.rear - 1 + self.capacity) % self.capacity
return self.deque[rear_index]
def isEmpty(self) -> bool:
return self.size == 0
def isFull(self) -> bool:
return self.size == self.capacity
The implementation starts by allocating a fixed-size array and initializing tracking variables. The front and rear pointers both begin at index 0, while size starts at 0.
The insertion methods first verify that space exists. insertFront moves the front pointer backward before storing the new value, while insertLast stores the value first and then advances the rear pointer.
The deletion methods simply move pointers and decrease the size counter. No element shifting occurs, which is why operations remain constant time.
The retrieval methods rely on the pointer invariant. getFront directly accesses the front index, while getRear computes the previous index before rear.
Modulo arithmetic is essential throughout the implementation because it ensures indices wrap around correctly.
Go Solution
type MyCircularDeque struct {
deque []int
front int
rear int
size int
capacity int
}
func Constructor(k int) MyCircularDeque {
return MyCircularDeque{
deque: make([]int, k),
front: 0,
rear: 0,
size: 0,
capacity: k,
}
}
func (this *MyCircularDeque) InsertFront(value int) bool {
if this.IsFull() {
return false
}
this.front = (this.front - 1 + this.capacity) % this.capacity
this.deque[this.front] = value
this.size++
return true
}
func (this *MyCircularDeque) InsertLast(value int) bool {
if this.IsFull() {
return false
}
this.deque[this.rear] = value
this.rear = (this.rear + 1) % this.capacity
this.size++
return true
}
func (this *MyCircularDeque) DeleteFront() bool {
if this.IsEmpty() {
return false
}
this.front = (this.front + 1) % this.capacity
this.size--
return true
}
func (this *MyCircularDeque) DeleteLast() bool {
if this.IsEmpty() {
return false
}
this.rear = (this.rear - 1 + this.capacity) % this.capacity
this.size--
return true
}
func (this *MyCircularDeque) GetFront() int {
if this.IsEmpty() {
return -1
}
return this.deque[this.front]
}
func (this *MyCircularDeque) GetRear() int {
if this.IsEmpty() {
return -1
}
rearIndex := (this.rear - 1 + this.capacity) % this.capacity
return this.deque[rearIndex]
}
func (this *MyCircularDeque) IsEmpty() bool {
return this.size == 0
}
func (this *MyCircularDeque) IsFull() bool {
return this.size == this.capacity
}
/**
* Your MyCircularDeque object will be instantiated and called as such:
* obj := Constructor(k);
* param_1 := obj.InsertFront(value);
* param_2 := obj.InsertLast(value);
* param_3 := obj.DeleteFront();
* param_4 := obj.DeleteLast();
* param_5 := obj.GetFront();
* param_6 := obj.GetRear();
* param_7 := obj.IsEmpty();
* param_8 := obj.IsFull();
*/
The Go implementation closely mirrors the Python version. The primary difference is that Go requires explicit struct fields and method receivers.
The deque storage uses a slice with fixed length. Pointer arithmetic and modulo operations work the same way as in Python.
Because Go does not have dynamic resizing here, the implementation remains fully constant time for all operations.
Worked Examples
Example 1
Input:
["MyCircularDeque",
"insertLast",
"insertLast",
"insertFront",
"insertFront",
"getRear",
"isFull",
"deleteLast",
"insertFront",
"getFront"]
Capacity:
k = 3
Step-by-step Trace
| Operation | Result | Array State | Front | Rear | Size |
|---|---|---|---|---|---|
| Initialize | null | [0,0,0] | 0 | 0 | 0 |
| insertLast(1) | true | [1,0,0] | 0 | 1 | 1 |
| insertLast(2) | true | [1,2,0] | 0 | 2 | 2 |
| insertFront(3) | true | [1,2,3] | 2 | 2 | 3 |
| insertFront(4) | false | [1,2,3] | 2 | 2 | 3 |
| getRear() | 2 | [1,2,3] | 2 | 2 | 3 |
| isFull() | true | [1,2,3] | 2 | 2 | 3 |
| deleteLast() | true | [1,2,3] | 2 | 1 | 2 |
| insertFront(4) | true | [1,4,3] | 1 | 1 | 3 |
| getFront() | 4 | [1,4,3] | 1 | 1 | 3 |
Logical Deque View
Even though the physical array looks like:
[1,4,3]
The logical deque order after the final step is:
[4,3,1]
This demonstrates why circular indexing is important. The logical order is determined by the front pointer, not by the raw array layout.
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(1) | Every operation performs only constant-time pointer arithmetic |
| Space | O(k) | The deque stores at most k elements |
The implementation never shifts elements or resizes the array. Every insertion, deletion, and retrieval operation modifies only a few indices and counters. Because the underlying array has fixed capacity k, the space complexity is linear in the deque size limit.
Test Cases
# Provided example
deque = MyCircularDeque(3)
assert deque.insertLast(1) is True # insert rear
assert deque.insertLast(2) is True # insert rear again
assert deque.insertFront(3) is True # insert front
assert deque.insertFront(4) is False # full deque
assert deque.getRear() == 2 # rear value
assert deque.isFull() is True # deque should be full
assert deque.deleteLast() is True # delete rear
assert deque.insertFront(4) is True # insert front after delete
assert deque.getFront() == 4 # front value
# Single element capacity
deque = MyCircularDeque(1)
assert deque.isEmpty() is True # initially empty
assert deque.insertLast(10) is True # insert succeeds
assert deque.isFull() is True # now full
assert deque.insertFront(20) is False # cannot insert when full
assert deque.getFront() == 10 # front value
assert deque.getRear() == 10 # rear value
assert deque.deleteFront() is True # remove only element
assert deque.isEmpty() is True # empty again
# Delete from empty deque
deque = MyCircularDeque(2)
assert deque.deleteFront() is False # cannot delete from empty
assert deque.deleteLast() is False # cannot delete from empty
assert deque.getFront() == -1 # no front value
assert deque.getRear() == -1 # no rear value
# Wraparound behavior
deque = MyCircularDeque(3)
assert deque.insertLast(1) is True
assert deque.insertLast(2) is True
assert deque.insertLast(3) is True
assert deque.deleteFront() is True
assert deque.insertLast(4) is True # wraps around
assert deque.getFront() == 2 # correct front after wrap
assert deque.getRear() == 4 # correct rear after wrap
# Multiple front operations
deque = MyCircularDeque(5)
assert deque.insertFront(1) is True
assert deque.insertFront(2) is True
assert deque.insertFront(3) is True
assert deque.getFront() == 3 # newest front
assert deque.getRear() == 1 # oldest rear
assert deque.deleteFront() is True
assert deque.getFront() == 2 # updated front
# Multiple rear deletions
deque = MyCircularDeque(4)
assert deque.insertLast(5) is True
assert deque.insertLast(6) is True
assert deque.insertLast(7) is True
assert deque.deleteLast() is True
assert deque.getRear() == 6 # rear updated correctly
assert deque.deleteLast() is True
assert deque.getRear() == 5 # only one left
assert deque.deleteLast() is True
assert deque.isEmpty() is True # completely empty
| Test | Why |
|---|---|
| Provided example | Validates overall correctness |
| Capacity 1 deque | Tests smallest allowed size |
| Delete from empty | Ensures proper failure handling |
| Wraparound behavior | Verifies circular indexing logic |
| Multiple front insertions | Tests front pointer updates |
| Multiple rear deletions | Tests rear pointer updates |
Edge Cases
One important edge case occurs when inserting into a full deque. Without a proper size check, a new insertion could overwrite existing elements and corrupt the structure. The implementation prevents this by checking isFull() before every insertion operation.
Another critical edge case is deleting from an empty deque. Attempting to move pointers when no elements exist can easily produce invalid states or incorrect retrieval results. The implementation handles this safely by immediately returning False without modifying any internal state.
A particularly subtle case involves circular wraparound. For example, after repeated insertions and deletions, the front pointer may become larger than the rear pointer. A naive implementation might assume elements are always stored contiguously, which is incorrect in a circular buffer. Using modulo arithmetic guarantees indices remain valid and logically connected regardless of how many times they wrap around.
A final edge case occurs when the deque contains exactly one element. In that situation, both front and rear operations interact with the same logical position. Incorrect pointer updates can accidentally skip or duplicate elements. The implementation avoids this problem by relying on the size variable as the authoritative source of how many valid elements exist.