Working with data structures is fundamental to being a proficient developer. While many languages provide built-in implementations, understanding how they work under the hood – and how to build them from more primitive structures – deepens your knowledge significantly. One classic problem that often appears in interviews and helps solidify these concepts is implementing a Queue using two Stacks.
This post will break down the problem, walk through the logic, provide a complete Python implementation, and analyze its performance.
The Core Problem: Queue vs. Stack
Before we dive into the solution, let’s quickly recap the fundamental differences between a Queue and a Stack:
- Stack (LIFO - Last-In, First-Out): Think of a stack of plates. You add (push) plates to the top, and you remove (pop) plates from the top. The last plate you put on is the first one you take off.
- Queue (FIFO - First-In, First-Out): Imagine a line at a store. People join (enqueue) at the back, and the person at the front is served (dequeue) first. The first person to join the line is the first one to leave.
The challenge is to replicate FIFO behavior using LIFO data structures.
The Two-Stack Approach: The Brains of the Operation
The elegant solution involves using two stacks, often referred to as input_stack
(or enqueue_stack
) and output_stack
(or dequeue_stack
).
Here’s the fundamental idea:
input_stack
: This stack is primarily used for theenqueue
operation. When you add an element, you simply push it onto this stack. This maintains the order of arrival.output_stack
: This stack is used fordequeue
andpeek
operations. It holds elements in the correct FIFO order.
The magic happens when you need to dequeue
an element. Since input_stack
is LIFO, its top element is the most recently added, not the least recently added (which is what we need for a queue). To get the least recently added element, we need to reverse the order. This is where the output_stack
comes in.
The “Transfer” Operation:
When you need to dequeue
or peek
an element:
- First, check if the
output_stack
is empty. - If
output_stack
is empty, it means we need to transfer elements frominput_stack
tooutput_stack
. We do this by popping elements one by one frominput_stack
and pushing them ontooutput_stack
. This effectively reverses the order of elements. The first element popped frominput_stack
(which was the last one pushed ontoinput_stack
) becomes the last one pushed ontooutput_stack
, and vice-versa. So, the first element pushed ontoinput_stack
will be the first one popped fromoutput_stack
after the transfer. - Once the transfer is complete (or if
output_stack
was already not empty), you can now safely pop/peek fromoutput_stack
to get the true front element of the queue.
Let’s illustrate with an example:
-
Enqueue 1, 2, 3:
input_stack
:[1, 2, 3]
(top is 3)output_stack
:[]
-
Dequeue:
output_stack
is empty.- Transfer from
input_stack
tooutput_stack
:- Pop 3 from
input_stack
, push 3 tooutput_stack
.input_stack: [1, 2]
,output_stack: [3]
- Pop 2 from
input_stack
, push 2 tooutput_stack
.input_stack: [1]
,output_stack: [3, 2]
- Pop 1 from
input_stack
, push 1 tooutput_stack
.input_stack: []
,output_stack: [3, 2, 1]
(top is 1)
- Pop 3 from
- Now,
output_stack
has[3, 2, 1]
. Pop fromoutput_stack
-> returns 1. input_stack
:[]
output_stack
:[3, 2]
This mechanism ensures that the element at the top of output_stack
is always the oldest element that was enqueued, thus mimicking FIFO behavior.
Implementing QueueUsingStacks
in Python
We’ll use Python lists as our underlying stack implementation, as they support append()
for push and pop()
for pop efficiently.
1. Initializing the Queue
We’ll start with a class structure and initialize our two stacks.
class QueueUsingStacks:
def __init__(self):
self.input_stack = []
self.output_stack = []
print("Queue initialized with two empty stacks.")
# Example Usage:
my_queue = QueueUsingStacks()
Queue initialized with two empty stacks.
2. enqueue(item)
: Adding an Element
This is the straightforward part. Just push the new item
onto the input_stack
.
class QueueUsingStacks:
def __init__(self):
self.input_stack = []
self.output_stack = []
def enqueue(self, item):
self.input_stack.append(item)
print(f"Enqueued: {item}. input_stack: {self.input_stack}, output_stack: {self.output_stack}")
# Example Usage:
my_queue = QueueUsingStacks()
my_queue.enqueue(10)
my_queue.enqueue(20)
my_queue.enqueue(30)
Queue initialized with two empty stacks.
Enqueued: 10. input_stack: [10], output_stack: []
Enqueued: 20. input_stack: [10, 20], output_stack: []
Enqueued: 30. input_stack: [10, 20, 30], output_stack: []
3. _transfer_elements()
: The Helper for Dequeue/Peek
This private helper method handles moving elements from input_stack
to output_stack
when output_stack
is empty.
class QueueUsingStacks:
def __init__(self):
self.input_stack = []
self.output_stack = []
def enqueue(self, item):
self.input_stack.append(item)
# print(f"Enqueued: {item}. input_stack: {self.input_stack}, output_stack: {self.output_stack}")
def _transfer_elements(self):
if not self.output_stack: # Only transfer if output_stack is empty
if not self.input_stack: # If both are empty, nothing to transfer
return
print("Transferring elements from input_stack to output_stack...")
while self.input_stack:
self.output_stack.append(self.input_stack.pop())
print(f"Transfer complete. input_stack: {self.input_stack}, output_stack: {self.output_stack}")
# Example Usage (demonstrating transfer):
my_queue = QueueUsingStacks()
my_queue.enqueue(1)
my_queue.enqueue(2)
my_queue.enqueue(3)
# Manually call transfer to see its effect
my_queue._transfer_elements()
my_queue.enqueue(4) # Adding more elements after a transfer
print(f"After enqueueing 4: input_stack: {my_queue.input_stack}, output_stack: {my_queue.output_stack}")
my_queue._transfer_elements() # Demonstrating a second transfer (only transfers 4)
Queue initialized with two empty stacks.
Transferring elements from input_stack to output_stack...
Transfer complete. input_stack: [], output_stack: [3, 2, 1]
After enqueueing 4: input_stack: [4], output_stack: [3, 2, 1]
Transferring elements from input_stack to output_stack...
Transfer complete. input_stack: [], output_stack: [3, 2, 1, 4]
4. dequeue()
: Removing an Element
This method first ensures output_stack
has elements (by calling _transfer_elements
), and then pops from it.
class QueueUsingStacks:
def __init__(self):
self.input_stack = []
self.output_stack = []
def enqueue(self, item):
self.input_stack.append(item)
def _transfer_elements(self):
if not self.output_stack:
while self.input_stack:
self.output_stack.append(self.input_stack.pop())
def dequeue(self):
self._transfer_elements()
if not self.output_stack:
raise IndexError("Dequeue from empty queue")
item = self.output_stack.pop()
print(f"Dequeued: {item}. input_stack: {self.input_stack}, output_stack: {self.output_stack}")
return item
# Example Usage:
my_queue = QueueUsingStacks()
my_queue.enqueue('A')
my_queue.enqueue('B')
my_queue.enqueue('C')
print(f"Initial state before dequeue: input_stack: {my_queue.input_stack}, output_stack: {my_queue.output_stack}")
my_queue.dequeue()
my_queue.dequeue()
my_queue.enqueue('D') # Add an element after some dequeues
print(f"State after some operations: input_stack: {my_queue.input_stack}, output_stack: {my_queue.output_stack}")
my_queue.dequeue()
my_queue.dequeue() # Dequeue the last element, will trigger transfer again
my_queue.dequeue() # This should raise an error
Queue initialized with two empty stacks.
Initial state before dequeue: input_stack: ['A', 'B', 'C'], output_stack: []
Dequeued: A. input_stack: [], output_stack: ['C', 'B']
Dequeued: B. input_stack: [], output_stack: ['C']
State after some operations: input_stack: ['D'], output_stack: ['C']
Dequeued: C. input_stack: ['D'], output_stack: []
Dequeued: D. input_stack: [], output_stack: []
Traceback (most recent call last):
File "<stdin>", line 39, in <module>
File "<stdin>", line 21, in dequeue
IndexError: Dequeue from empty queue
Note: The IndexError
is expected when trying to dequeue from an empty queue, mimicking Python’s list pop()
behavior on an empty list.
5. peek()
: Looking at the Front Element
Similar to dequeue
, but without removing the element.
class QueueUsingStacks:
def __init__(self):
self.input_stack = []
self.output_stack = []
def enqueue(self, item):
self.input_stack.append(item)
def _transfer_elements(self):
if not self.output_stack:
while self.input_stack:
self.output_stack.append(self.input_stack.pop())
def peek(self):
self._transfer_elements()
if not self.output_stack:
raise IndexError("Peek from empty queue")
item = self.output_stack[-1] # Peek without popping
print(f"Peeked: {item}. input_stack: {self.input_stack}, output_stack: {self.output_stack}")
return item
# Example Usage:
my_queue = QueueUsingStacks()
my_queue.enqueue('X')
my_queue.enqueue('Y')
print(f"Current front: {my_queue.peek()}")
my_queue.enqueue('Z')
print(f"Current front after Z: {my_queue.peek()}") # Peek again, should still be X
my_queue.dequeue() # Remove X
print(f"Current front after dequeue: {my_queue.peek()}")
Queue initialized with two empty stacks.
Peeked: X. input_stack: [], output_stack: ['Y', 'X']
Peeked: X. input_stack: ['Z'], output_stack: ['Y', 'X']
Dequeued: X. input_stack: ['Z'], output_stack: ['Y']
Peeked: Y. input_stack: ['Z'], output_stack: ['Y']
6. is_empty()
: Checking for Emptiness
A queue is empty if both its input_stack
and output_stack
are empty.
class QueueUsingStacks:
def __init__(self):
self.input_stack = []
self.output_stack = []
def enqueue(self, item):
self.input_stack.append(item)
def _transfer_elements(self):
if not self.output_stack:
while self.input_stack:
self.output_stack.append(self.input_stack.pop())
def dequeue(self):
self._transfer_elements()
if not self.output_stack:
raise IndexError("Dequeue from empty queue")
return self.output_stack.pop()
def peek(self):
self._transfer_elements()
if not self.output_stack:
raise IndexError("Peek from empty queue")
return self.output_stack[-1]
def is_empty(self):
return not self.input_stack and not self.output_stack
# Example Usage:
my_queue = QueueUsingStacks()
print(f"Is queue empty? {my_queue.is_empty()}")
my_queue.enqueue(100)
print(f"Is queue empty? {my_queue.is_empty()}")
my_queue.dequeue()
print(f"Is queue empty? {my_queue.is_empty()}")
Queue initialized with two empty stacks.
Is queue empty? True
Is queue empty? False
Dequeued: 100. input_stack: [], output_stack: []
Is queue empty? True
Complete QueueUsingStacks
Implementation
Here’s the combined class:
class QueueUsingStacks:
"""
Implements a Queue (FIFO) data structure using two internal Stacks (LIFO).
"""
def __init__(self):
"""
Initializes the queue with two empty lists to serve as stacks.
"""
self.input_stack = []
self.output_stack = []
print("--- QueueUsingStacks Initialized ---")
print(f"Current state: input_stack={self.input_stack}, output_stack={self.output_stack}")
def _transfer_elements(self):
"""
Private helper method to transfer elements from input_stack to output_stack.
This reverses the order, preparing elements for FIFO retrieval.
Only transfers if output_stack is empty to optimize performance.
"""
if not self.output_stack:
if self.input_stack: # Only print and transfer if input_stack has elements
print(f"--- Transferring elements. input_stack={self.input_stack} -> output_stack ---")
while self.input_stack:
self.output_stack.append(self.input_stack.pop())
print(f"--- Transfer complete. input_stack={self.input_stack}, output_stack={self.output_stack} ---")
else:
print("--- Both stacks empty. No transfer needed. ---")
def enqueue(self, item):
"""
Adds an item to the back of the queue.
Operation: Pushes the item onto the input_stack.
Time Complexity: O(1)
"""
self.input_stack.append(item)
print(f"ENQUEUE: {item}. State: input_stack={self.input_stack}, output_stack={self.output_stack}")
def dequeue(self):
"""
Removes and returns the item from the front of the queue.
If output_stack is empty, transfers elements from input_stack first.
Raises IndexError if the queue is empty.
Time Complexity: O(1) amortized (O(N) in worst case when transfer occurs)
"""
self._transfer_elements() # Ensure output_stack is ready
if not self.output_stack:
raise IndexError("Cannot dequeue from an empty queue.")
item = self.output_stack.pop()
print(f"DEQUEUE: {item}. State: input_stack={self.input_stack}, output_stack={self.output_stack}")
return item
def peek(self):
"""
Returns the item at the front of the queue without removing it.
If output_stack is empty, transfers elements from input_stack first.
Raises IndexError if the queue is empty.
Time Complexity: O(1) amortized (O(N) in worst case when transfer occurs)
"""
self._transfer_elements() # Ensure output_stack is ready
if not self.output_stack:
raise IndexError("Cannot peek from an empty queue.")
item = self.output_stack[-1] # Access top element without popping
print(f"PEEK: {item}. State: input_stack={self.input_stack}, output_stack={self.output_stack}")
return item
def is_empty(self):
"""
Checks if the queue is empty.
Time Complexity: O(1)
"""
is_empty_status = not self.input_stack and not self.output_stack
print(f"IS_EMPTY? {is_empty_status}. State: input_stack={self.input_stack}, output_stack={self.output_stack}")
return is_empty_status
def size(self):
"""
Returns the total number of elements in the queue.
Time Complexity: O(1)
"""
current_size = len(self.input_stack) + len(self.output_stack)
print(f"SIZE: {current_size}. State: input_stack={self.input_stack}, output_stack={self.output_stack}")
return current_size
# --- Comprehensive Demonstration ---
print("\n--- Starting QueueUsingStacks Demonstration ---")
q = QueueUsingStacks()
print("\n--- Enqueueing Elements ---")
q.enqueue('Apple')
q.enqueue('Banana')
q.enqueue('Cherry')
q.size()
q.is_empty()
print("\n--- Dequeueing and Peeking ---")
q.peek() # Should trigger transfer
q.dequeue() # Should be 'Apple'
q.size()
q.peek() # Should be 'Banana'
q.dequeue() # Should be 'Banana'
q.size()
q.is_empty()
print("\n--- Enqueueing More, Then Dequeueing ---")
q.enqueue('Date')
q.enqueue('Elderberry')
q.size()
q.peek() # Should be 'Cherry' (no transfer needed yet)
q.dequeue() # Should be 'Cherry'
print("\n--- Dequeueing remaining elements ---")
q.dequeue() # Should trigger transfer for 'Date', 'Elderberry'
q.size()
q.dequeue() # Should be 'Elderberry'
q.is_empty()
q.size()
print("\n--- Attempting to Dequeue from Empty Queue ---")
try:
q.dequeue()
except IndexError as e:
print(f"Caught expected error: {e}")
print("\n--- Attempting to Peek from Empty Queue ---")
try:
q.peek()
except IndexError as e:
print(f"Caught expected error: {e}")
print("\n--- End of Demonstration ---")
--- Starting QueueUsingStacks Demonstration ---
--- QueueUsingStacks Initialized ---
Current state: input_stack=[], output_stack=[]
--- Enqueueing Elements ---
ENQUEUE: Apple. State: input_stack=['Apple'], output_stack=[]
ENQUEUE: Banana. State: input_stack=['Apple', 'Banana'], output_stack=[]
ENQUEUE: Cherry. State: input_stack=['Apple', 'Banana', 'Cherry'], output_stack=[]
SIZE: 3. State: input_stack=['Apple', 'Banana', 'Cherry'], output_stack=[]
IS_EMPTY? False. State: input_stack=['Apple', 'Banana', 'Cherry'], output_stack=[]
--- Dequeueing and Peeking ---
--- Transferring elements. input_stack=['Apple', 'Banana', 'Cherry'] -> output_stack ---
--- Transfer complete. input_stack=[], output_stack=['Cherry', 'Banana', 'Apple'] ---
PEEK: Apple. State: input_stack=[], output_stack=['Cherry', 'Banana', 'Apple']
DEQUEUE: Apple. State: input_stack=[], output_stack=['Cherry', 'Banana']
SIZE: 2. State: input_stack=[], output_stack=['Cherry', 'Banana']
PEEK: Banana. State: input_stack=[], output_stack=['Cherry', 'Banana']
DEQUEUE: Banana. State: input_stack=[], output_stack=['Cherry']
SIZE: 1. State: input_stack=[], output_stack=['Cherry']
IS_EMPTY? False. State: input_stack=[], output_stack=['Cherry']
--- Enqueueing More, Then Dequeueing ---
ENQUEUE: Date. State: input_stack=['Date'], output_stack=['Cherry']
ENQUEUE: Elderberry. State: input_stack=['Date', 'Elderberry'], output_stack=['Cherry']
SIZE: 3. State: input_stack=['Date', 'Elderberry'], output_stack=['Cherry']
PEEK: Cherry. State: input_stack=['Date', 'Elderberry'], output_stack=['Cherry']
DEQUEUE: Cherry. State: input_stack=['Date', 'Elderberry'], output_stack=[]
--- Dequeueing remaining elements ---
--- Transferring elements. input_stack=['Date', 'Elderberry'] -> output_stack ---
--- Transfer complete. input_stack=[], output_stack=['Elderberry', 'Date'] ---
DEQUEUE: Date. State: input_stack=[], output_stack=['Elderberry']
SIZE: 1. State: input_stack=[], output_stack=['Elderberry']
DEQUEUE: Elderberry. State: input_stack=[], output_stack=[]
IS_EMPTY? True. State: input_stack=[], output_stack=[]
SIZE: 0. State: input_stack=[], output_stack=[]
--- Attempting to Dequeue from Empty Queue ---
--- Both stacks empty. No transfer needed. ---
Caught expected error: Cannot dequeue from an empty queue.
--- Attempting to Peek from Empty Queue ---
--- Both stacks empty. No transfer needed. ---
Caught expected error: Cannot peek from an empty queue.
--- End of Demonstration ---
Time and Space Complexity Analysis
Understanding the performance characteristics is key to choosing the right data structure.
Time Complexity:
enqueue(item)
: O(1)- Adding an element to
input_stack
is a constant time operation (Python’slist.append()
).
- Adding an element to
dequeue()
: O(1) Amortized- Worst Case: If
output_stack
is empty, allN
elements frominput_stack
must be transferred. This involvesN
pops andN
pushes, making it O(N) for that specificdequeue
call. - Best Case / Average Case: If
output_stack
is not empty, it’s a simple pop, which is O(1). - Amortized Analysis: Consider a sequence of
K
enqueue andK
dequeue operations. Each element is enqueued once (1 push toinput_stack
), transferred once (1 pop frominput_stack
and 1 push tooutput_stack
), and dequeued once (1 pop fromoutput_stack
). This is a total of 4 operations per element over its lifetime in the queue. Thus,K
operations cost4K
(roughly), making the amortized cost per operation O(1).
- Worst Case: If
peek()
: O(1) Amortized- The logic is identical to
dequeue
regarding the transfer, so its complexity is also O(1) amortized.
- The logic is identical to
is_empty()
: O(1)- Checking if two lists are empty is a constant time operation.
size()
: O(1)- Summing lengths of two lists is constant time.
Space Complexity:
- O(N)
- Where N is the total number of elements currently in the queue. At any given time, all N elements will reside in either
input_stack
oroutput_stack
(or be in the process of transferring). Therefore, the space required grows linearly with the number of elements.
- Where N is the total number of elements currently in the queue. At any given time, all N elements will reside in either
Why Bother with This? Use Cases and Importance
- Interview Questions: This is a very common interview question designed to test your understanding of fundamental data structures, your ability to think algorithmically, and your grasp of concepts like amortized analysis.
- Deepening Understanding: Implementing a queue with stacks forces you to think about how data structures work beyond their abstract definitions. It clarifies the implications of LIFO vs. FIFO.
- Constraint-Based Programming: While high-level languages usually provide optimized queue implementations, imagine a scenario where you only have access to stack-like primitives (e.g., in a highly constrained embedded environment or when working with a specific API that only exposes stack operations). In such cases, this pattern becomes incredibly useful.
- Learning About Trade-offs: It highlights that while
enqueue
is always fast,dequeue
/peek
can occasionally be slow due to the transfer, but over time, they are efficient. This introduces the concept of amortized analysis, which is important for many algorithms (e.g., dynamic arrays like Python lists).
Conclusion
Implementing a Queue using two Stacks is a clever solution to a common data structure problem. It elegantly uses the LIFO property of stacks to achieve FIFO behavior, offering a practical example of how one Abstract Data Type (ADT) can be built from another. Understanding this pattern not only bolsters your algorithmic thinking but also provides valuable insights into time complexity analysis, especially amortized costs. It’s a testament to the power of thoughtful design in computer science.
Happy coding!