← Back to DevBytes

Solving Implement Queue using Stacks in Go: Step-by-Step Guide

Introduction to Implementing a Queue Using Stacks

The "Implement Queue using Stacks" problem is a classic computer science challenge that frequently appears in coding interviews and algorithm courses. At its core, the task asks you to build a First-In-First-Out (FIFO) queue data structure using only two Last-In-First-Out (LIFO) stack operations. While this may sound counterintuitive at first, it is an excellent exercise for understanding how fundamental data structures can be composed to emulate one another.

In Go, a language that favors simplicity and explicit design, implementing this pattern teaches you not only about stacks and queues but also about how to structure clean, idiomatic code using slices and structs. This tutorial walks you through the concept, the underlying theory, multiple implementation strategies, and best practices for writing production-ready code.

What Is a Queue and What Is a Stack?

Before diving into the implementation, it is important to understand the two data structures involved.

The Stack

A stack is a linear data structure that follows the Last-In-First-Out principle. Think of a stack of plates: you add a plate to the top, and you remove the plate from the top. The two primary operations are push, which adds an element, and pop, which removes the most recently added element.

The Queue

A queue, on the other hand, follows the First-In-First-Out principle. Imagine a line of people waiting at a coffee shop: the first person to arrive is the first person served. The two primary operations are enqueue, which adds an element to the back, and dequeue, which removes the element at the front.

The challenge is to simulate the behavior of a queue using only stack operations. Because stacks reverse the order of elements as you pop them, you need a clever approach to preserve the original insertion order.

Why This Problem Matters

You might wonder why anyone would implement a queue using stacks when Go already provides slices that can act as both stacks and queues. The answer lies in several areas.

The Two-Stack Approach

The standard solution uses two stacks. Let us call them inStack and outStack. The idea is simple but powerful.

When you enqueue an element, you push it onto the inStack. When you need to dequeue an element, you pop from the outStack. If the outStack is empty, you transfer all elements from the inStack to the outStack by popping from one and pushing to the other. This transfer reverses the order of the elements, effectively converting the LIFO behavior of the stack into the FIFO behavior of a queue.

There are two common strategies for when to perform this transfer.

Strategy 1: Push Costly

In this approach, every enqueue operation ensures that the newest element is always at the bottom of the stack. This means each push operation is expensive, but each pop operation is cheap.

Strategy 2: Pop Costly (Amortized)

In this approach, enqueue is a simple push onto the inStack, and the expensive transfer only happens during dequeue when the outStack is empty. This gives you an amortized constant time complexity for dequeue operations, which is generally preferred.

This tutorial focuses on the second strategy because it is more efficient in practice and is the approach most interviewers expect.

Implementing the Queue in Go

Let us now write the complete implementation. Go does not have a built-in stack type, but slices work perfectly for this purpose. We will define a struct that holds two slices representing our two stacks.

package main

import (
	"errors"
	"fmt"
)

// MyQueue represents a queue implemented using two stacks.
type MyQueue struct {
	inStack  []int
	outStack []int
}

// NewMyQueue creates and returns a new instance of MyQueue.
func NewMyQueue() *MyQueue {
	return &MyQueue{
		inStack:  make([]int, 0),
		outStack: make([]int, 0),
	}
}

// Push enqueues an element to the back of the queue.
func (q *MyQueue) Push(x int) {
	q.inStack = append(q.inStack, x)
}

// Pop dequeues and returns the element at the front of the queue.
func (q *MyQueue) Pop() (int, error) {
	if err := q.ensureOutStack(); err != nil {
		return 0, err
	}
	val := q.outStack[len(q.outStack)-1]
	q.outStack = q.outStack[:len(q.outStack)-1]
	return val, nil
}

// Peek returns the element at the front without removing it.
func (q *MyQueue) Peek() (int, error) {
	if err := q.ensureOutStack(); err != nil {
		return 0, err
	}
	return q.outStack[len(q.outStack)-1], nil
}

// Empty returns true if the queue contains no elements.
func (q *MyQueue) Empty() bool {
	return len(q.inStack) == 0 && len(q.outStack) == 0
}

// ensureOutStack transfers elements from inStack to outStack
// when outStack is empty. This is the core of the amortized approach.
func (q *MyQueue) ensureOutStack() error {
	if len(q.outStack) == 0 {
		if len(q.inStack) == 0 {
			return errors.New("queue is empty")
		}
		for len(q.inStack) > 0 {
			// Pop from inStack
			top := q.inStack[len(q.inStack)-1]
			q.inStack = q.inStack[:len(q.inStack)-1]
			// Push onto outStack
			q.outStack = append(q.outStack, top)
		}
	}
	return nil
}

func main() {
	queue := NewMyQueue()

	queue.Push(10)
	queue.Push(20)
	queue.Push(30)

	if val, err := queue.Peek(); err == nil {
		fmt.Printf("Peek: %d\n", val) // Expected: 10
	}

	if val, err := queue.Pop(); err == nil {
		fmt.Printf("Pop: %d\n", val) // Expected: 10
	}

	if val, err := queue.Pop(); err == nil {
		fmt.Printf("Pop: %d\n", val) // Expected: 20
	}

	fmt.Printf("Empty: %v\n", queue.Empty()) // Expected: false

	queue.Push(40)
	if val, err := queue.Pop(); err == nil {
		fmt.Printf("Pop: %d\n", val) // Expected: 30
	}

	if val, err := queue.Pop(); err == nil {
		fmt.Printf("Pop: %d\n", val) // Expected: 40
	}

	fmt.Printf("Empty: %v\n", queue.Empty()) // Expected: true
}

When you run this program, the output will be:

Peek: 10
Pop: 10
Pop: 20
Empty: false
Pop: 30
Pop: 40
Empty: true

Understanding the Transfer Logic

The heart of this implementation is the ensureOutStack helper method. Let us trace through what happens when you push 10, 20, and 30, and then pop twice.

After the three push operations, the internal state looks like this:

inStack:  [10, 20, 30]
outStack: []

When you call Pop() for the first time, ensureOutStack notices that outStack is empty. It then pops each element from inStack and pushes it onto outStack. Because stacks reverse order, the result is:

inStack:  []
outStack: [30, 20, 10]

Now the front of the queue, which is 10, sits at the top of outStack. Popping from outStack returns 10, exactly as a queue should. The next pop returns 20, and so on. If you push more elements in the meantime, they go onto inStack and wait patiently until outStack is drained.

Time Complexity Analysis

Understanding the time complexity of this implementation is crucial, especially for interviews.

The key insight is that the expensive transfer operation is rare. It only happens when outStack is completely empty, and when it does happen, it processes every element in inStack exactly once. No element is ever transferred more than once, which is why the amortized cost remains constant.

Alternative: Push-Costly Implementation

For completeness, here is the push-costly variant. In this version, every push operation ensures that the newest element is at the bottom of the stack, so pop and peek are always O(1).

package main

import (
	"errors"
	"fmt"
)

type PushCostlyQueue struct {
	stack []int
}

func NewPushCostlyQueue() *PushCostlyQueue {
	return &PushCostlyQueue{
		stack: make([]int, 0),
	}
}

func (q *PushCostlyQueue) Push(x int) {
	// Use a temporary stack to reverse the order
	temp := make([]int, 0)
	for len(q.stack) > 0 {
		top := q.stack[len(q.stack)-1]
		q.stack = q.stack[:len(q.stack)-1]
		temp = append(temp, top)
	}
	q.stack = append(q.stack, x)
	for len(temp) > 0 {
		top := temp[len(temp)-1]
		temp = temp[:len(temp)-1]
		q.stack = append(q.stack, top)
	}
}

func (q *PushCostlyQueue) Pop() (int, error) {
	if len(q.stack) == 0 {
		return 0, errors.New("queue is empty")
	}
	val := q.stack[len(q.stack)-1]
	q.stack = q.stack[:len(q.stack)-1]
	return val, nil
}

func (q *PushCostlyQueue) Peek() (int, error) {
	if len(q.stack) == 0 {
		return 0, errors.New("queue is empty")
	}
	return q.stack[len(q.stack)-1], nil
}

func (q *PushCostlyQueue) Empty() bool {
	return len(q.stack) == 0
}

func main() {
	q := NewPushCostlyQueue()
	q.Push(1)
	q.Push(2)
	q.Push(3)

	val, _ := q.Pop()
	fmt.Println(val) // 1
	val, _ = q.Pop()
	fmt.Println(val) // 2
}

In this version, Push is O(n) because it must reverse the entire stack each time, while Pop and Peek are O(1). This is generally less efficient than the amortized approach unless your workload is dominated by reads.

Best Practices

When implementing a queue using stacks in Go, keep the following best practices in mind.

Prefer the Amortized Approach

The two-stack amortized approach is almost always the better choice. It keeps the common operation, pushing, fast and predictable, and spreads the cost of the expensive transfer across many operations. This matches the real-world usage pattern of most queues, where elements are added and removed in roughly equal numbers.

Handle Empty Queues Explicitly

Always check for empty conditions and return meaningful errors. In Go, the idiomatic way to signal failure is to return an error value alongside the result. This is cleaner than panicking and gives callers the flexibility to handle the situation appropriately.

Avoid Premature Optimization with Slices

Go slices grow dynamically, and the runtime handles capacity expansion efficiently. Unless you know the exact size of your queue in advance, there is no need to pre-allocate capacity. If you do know the size, you can pass a capacity hint to make to avoid reallocations:

func NewMyQueueWithCapacity(capacity int) *MyQueue {
	return &MyQueue{
		inStack:  make([]int, 0, capacity),
		outStack: make([]int, 0, capacity),
	}
}

Encapsulate Internal State

Keep the inStack and outStack fields unexported (lowercase) so that external code cannot manipulate them directly. This preserves the invariants of your queue and makes the code easier to maintain. Only expose the standard queue operations: Push, Pop, Peek, and Empty.

Write Table-Driven Tests

Go's testing framework is well suited for table-driven tests. Here is an example test file that validates the behavior of the queue:

package main

import "testing"

func TestMyQueue(t *testing.T) {
	q := NewMyQueue()

	if !q.Empty() {
		t.Error("expected queue to be empty initially")
	}

	q.Push(1)
	q.Push(2)
	q.Push(3)

	if q.Empty() {
		t.Error("expected queue to be non-empty after pushes")
	}

	val, err := q.Peek()
	if err != nil || val != 1 {
		t.Errorf("expected peek to return 1, got %d, err %v", val, err)
	}

	val, err = q.Pop()
	if err != nil || val != 1 {
		t.Errorf("expected pop to return 1, got %d, err %v", val, err)
	}

	val, err = q.Pop()
	if err != nil || val != 2 {
		t.Errorf("expected pop to return 2, got %d, err %v", val, err)
	}

	q.Push(4)

	val, err = q.Pop()
	if err != nil || val != 3 {
		t.Errorf("expected pop to return 3, got %d, err %v", val, err)
	}

	val, err = q.Pop()
	if err != nil || val != 4 {
		t.Errorf("expected pop to return 4, got %d, err %v", val, err)
	}

	if !q.Empty() {
		t.Error("expected queue to be empty after all pops")
	}

	_, err = q.Pop()
	if err == nil {
		t.Error("expected error when popping from empty queue")
	}
}

Consider Concurrency Carefully

The implementation shown here is not safe for concurrent use. If you need a thread-safe queue, wrap the operations with a sync.Mutex or use channels, which are Go's native concurrency primitive. Here is a simple concurrent wrapper:

package main

import (
	"errors"
	"sync"
)

type ConcurrentQueue struct {
	mu       sync.Mutex
	inStack  []int
	outStack []int
}

func NewConcurrentQueue() *ConcurrentQueue {
	return &ConcurrentQueue{
		inStack:  make([]int, 0),
		outStack: make([]int, 0),
	}
}

func (q *ConcurrentQueue) Push(x int) {
	q.mu.Lock()
	defer q.mu.Unlock()
	q.inStack = append(q.inStack, x)
}

func (q *ConcurrentQueue) Pop() (int, error) {
	q.mu.Lock()
	defer q.mu.Unlock()
	if len(q.outStack) == 0 {
		if len(q.inStack) == 0 {
			return 0, errors.New("queue is empty")
		}
		for len(q.inStack) > 0 {
			top := q.inStack[len(q.inStack)-1]
			q.inStack = q.inStack[:len(q.inStack)-1]
			q.outStack = append(q.outStack, top)
		}
	}
	val := q.outStack[len(q.outStack)-1]
	q.outStack = q.outStack[:len(q.outStack)-1]
	return val, nil
}

func (q *ConcurrentQueue) Empty() bool {
	q.mu.Lock()
	defer q.mu.Unlock()
	return len(q.inStack) == 0 && len(q.outStack) == 0
}

Note that for high-throughput concurrent scenarios, Go channels are usually a better choice than a mutex-protected queue. However, understanding how to make this structure thread-safe is still a valuable exercise.

Common Pitfalls to Avoid

As you work through this problem, watch out for these common mistakes.

Conclusion

Implementing a queue using two stacks is a deceptively simple problem that reveals deep truths about data structure composition and amortized analysis. By using an input stack for enqueue operations and an output stack for dequeue operations, you can achieve amortized constant time performance while staying within the constraints of stack-only primitives. In Go, slices provide a natural and efficient foundation for building stacks, and the language's error-handling conventions encourage you to write robust, production-quality code. Whether you are preparing for an interview or simply sharpening your algorithmic thinking, mastering this pattern will strengthen your understanding of how fundamental data structures relate to one another and how thoughtful design can turn expensive worst-case operations into efficient amortized ones.

— Ad —

Google AdSense will appear here after approval

← Back to all articles