golangdata structures

Queues

Queues in Go: Managing Your Data in Order


Imagine a line at a checkout counter, a print job spool, or a list of tasks waiting to be processed. In all these scenarios, items are handled in a specific order. This is the essence of a queue: a fundamental data structure designed to manage elements in a precise sequence.

Elements are added to a queue (this is called enqueuing or putting), and then removed from it (this is called dequeuing or getting) based on predefined rules. The most common types of queues include:

  • First-In, First-Out (FIFO): The classic queue. The first element to enter is the first one to leave, just like a line at the store.
  • Last-In, First-Out (LIFO): Also known as a stack. The last element added is the first one to be removed, like a stack of plates.
  • Priority Queue: Elements are processed based on their priority, regardless of their arrival order. Highest priority goes first.

In this guide, we'll focus on implementing FIFO queues in Go, starting with a simple slice-based approach and then moving to a more efficient linked-list implementation.


Simple FIFO Queue with a Go Slice

We can easily build a basic FIFO queue in Go using a slice as the underlying data storage. Our Queue struct will contain this slice, and we'll add methods for putting elements in (Put), getting elements out (Get), and checking if the queue is empty (IsEmpty).

package main

import (
	"errors" // For returning custom error messages
	"fmt"    // For printing
)

// Queue represents a basic FIFO queue using a slice of integers.
type Queue struct {
	Data []int
}

// Put adds an integer to the back of the queue.
func (q *Queue) Put(i int) {
	q.Data = append(q.Data, i) // Append adds to the end of the slice
}

// Get retrieves and removes the first element from the front of the queue.
func (q *Queue) Get() (int, error) {
	if q.IsEmpty() {
		return 0, errors.New("Queue is empty") // Return error if queue is empty
	}
	first := q.Data[0]         // Get the first element
	q.Data = q.Data[1:]        // Slice the array to remove the first element
	return first, nil          // Return the element and no error
}

// IsEmpty checks if the queue contains any elements.
func (q *Queue) IsEmpty() bool {
	return len(q.Data) == 0
}

func main() {
	myQueue := Queue{} // Initialize an empty queue

	fmt.Println("Is queue empty?", myQueue.IsEmpty()) // Output: Is queue empty? true

	fmt.Println("Putting 1, 2, 3 into the queue...")
	myQueue.Put(1)
	myQueue.Put(2)
	myQueue.Put(3)

	fmt.Println("Is queue empty?", myQueue.IsEmpty()) // Output: Is queue empty? false

	val, err := myQueue.Get()
	fmt.Printf("Got: %d, Error: %v\n", val, err) // Output: Got: 1, Error: <nil>

	val, err = myQueue.Get()
	fmt.Printf("Got: %d, Error: %v\n", val, err) // Output: Got: 2, Error: <nil>

	val, err = myQueue.Get()
	fmt.Printf("Got: %d, Error: %v\n", val, err) // Output: Got: 3, Error: <nil>

	val, err = myQueue.Get()
	fmt.Printf("Got: %d, Error: %v\n", val, err) // Output: Got: 0, Error: Queue is empty
}

This slice-based implementation is simple, but repeatedly slicing q.Data = q.Data[1:] to remove the first element can be inefficient for very large queues, as it creates a new underlying array and copies the remaining elements.


Size-Limited Queues

Sometimes you need a queue that can only hold a certain number of elements. We can extend our basic Queue to include a MaxSize attribute and an IsFull method.

package main

import (
	"errors"
	"fmt"
)

type Queue struct {
	Data    []int
	MaxSize int // Add the maximum size attribute
}

// Put adds an integer to the back of the queue, checking for capacity.
func (q *Queue) Put(i int) error {
	if q.IsFull() { // Check if the queue is full before adding
		return errors.New("Queue is full")
	}
	q.Data = append(q.Data, i)
	return nil
}

// Get retrieves and removes the first element from the front of the queue. (Same as before)
func (q *Queue) Get() (int, error) {
	if q.IsEmpty() {
		return 0, errors.New("Queue is empty")
	}
	first := q.Data[0]
	q.Data = q.Data[1:]
	return first, nil
}

// IsFull checks if the queue has reached its maximum capacity.
func (q *Queue) IsFull() bool {
	// A MaxSize of 0 can indicate no size limit, or you could use a separate flag.
	return q.MaxSize != 0 && len(q.Data) == q.MaxSize
}

// IsEmpty checks if the queue contains any elements. (Same as before)
func (q *Queue) IsEmpty() bool {
	return len(q.Data) == 0
}

func main() {
	// Initialize a queue with values 1, 2, 3, and set its max size to 3
	q := Queue{Data: []int{1, 2, 3}, MaxSize: 3}

	fmt.Println("Is queue full?", q.IsFull()) // Output: Is queue full? true

	err := q.Put(4)
	fmt.Println("Attempt to Put 4:", err) // Output: Attempt to Put 4: Queue is full

	v, err := q.Get()
	fmt.Printf("Got: %d, Error: %v\n", v, err) // Output: Got: 1, Error: <nil>

	v, err = q.Get()
	fmt.Printf("Got: %d, Error: %v\n", v, err) // Output: Got: 2, Error: <nil>

	v, err = q.Get()
	fmt.Printf("Got: %d, Error: %v\n", v, err) // Output: Got: 3, Error: <nil>

	fmt.Println("Is queue empty?", q.IsEmpty()) // Output: Is queue empty? true

	v, err = q.Get()
	fmt.Printf("Got: %d, Error: %v\n", v, err) // Output: Got: 0, Error: Queue is empty
}

More Efficient Queues with Doubly Linked Lists

For applications that involve frequent Get (dequeue) operations on large queues, the slice-based approach can be slow due to the repeated re-slicing and memory copying. A more efficient way to implement a queue is by using a doubly linked list as the underlying data structure.

A doubly linked list allows for constant-time ($O(1)$) insertions and deletions at both ends because each node has pointers to both the next and previous nodes. By maintaining pointers to both the Head (front of the queue) and the Tail (back of the queue) of the linked list, we can perform Put and Get operations very efficiently.

Let's assume we have a List and Node generic type for a doubly linked list, similar to what you might have seen in a "Linked Lists" article:

// Assuming these are defined elsewhere or inline for this example.
// For a fully working example, you'd include the full List and Node definitions.
type List[T comparable] struct {
	Head *Node[T]
	Tail *Node[T]
}

type Node[T comparable] struct {
	Prev  *Node[T]
	Next  *Node[T]
	Value T
}

Now, let's implement our queue using this doubly linked list:

package main

import (
	"errors"
	"fmt"
)

// Node and List structs for a generic doubly linked list (as above)
type List[T comparable] struct {
	Head *Node[T]
	Tail *Node[T]
}

type Node[T comparable] struct {
	Prev  *Node[T]
	Next  *Node[T]
	Value T
}

// Queue now uses a doubly linked list for its Data storage.
type Queue[T comparable] struct { // Make the Queue itself generic
	Data List[T]
	// MaxSize int // Can still add MaxSize logic if needed
}

// Put adds an element to the back (tail) of the queue.
func (q *Queue[T]) Put(val T) {
	newNode := &Node[T]{Value: val} // Create a new node

	if q.Data.Tail == nil { // If the queue is empty, this is the first node
		q.Data.Head = newNode
		q.Data.Tail = newNode
	} else { // Otherwise, append to the current tail
		q.Data.Tail.Next = newNode // Old tail points to new node
		newNode.Prev = q.Data.Tail // New node points back to old tail
		q.Data.Tail = newNode      // New node becomes the new tail
	}
}

// Get retrieves and removes the element from the front (head) of the queue.
func (q *Queue[T]) Get() (T, error) {
	var zeroValue T // Get the zero value for type T
	if q.IsEmpty() {
		return zeroValue, errors.New("Queue is empty")
	}

	val := q.Data.Head.Value // Get the value from the head node
	q.Data.Head = q.Data.Head.Next // Move head to the next node

	if q.Data.Head != nil {
		q.Data.Head.Prev = nil // New head has no previous node
	} else {
		q.Data.Tail = nil // If head became nil, queue is empty, so tail is also nil
	}
	return val, nil
}

// IsEmpty checks if the queue is empty.
func (q *Queue[T]) IsEmpty() bool {
	return q.Data.Head == nil
}

func main() {
	stringQueue := Queue[string]{}
	fmt.Println("Is string queue empty?", stringQueue.IsEmpty()) // Output: Is string queue empty? true

	stringQueue.Put("First Task")
	stringQueue.Put("Second Task")
	stringQueue.Put("Third Task")

	fmt.Println("Is string queue empty?", stringQueue.IsEmpty()) // Output: Is string queue empty? false

	task, err := stringQueue.Get()
	fmt.Printf("Processed: %s, Error: %v\n", task, err) // Output: Processed: First Task, Error: <nil>

	task, err = stringQueue.Get()
	fmt.Printf("Processed: %s, Error: %v\n", task, err) // Output: Processed: Second Task, Error: <nil>

	task, err = stringQueue.Get()
	fmt.Printf("Processed: %s, Error: %v\n", task, err) // Output: Processed: Third Task, Error: <nil>

	task, err = stringQueue.Get()
	fmt.Printf("Processed: %s, Error: %v\n", task, err) // Output: Processed: , Error: Queue is empty
}

This linked-list based queue, made generic with [T comparable], offers robust performance for Put and Get operations, especially with large datasets, as it avoids the overhead of copying elements inherent in slice re-slicing.


Practical Use Cases for Queues

Queues are incredibly versatile and are found everywhere in software systems:

  • Task Scheduling and Management:

    • Operating Systems: Queues manage processes waiting for CPU time, I/O operations, or other resources.
    • Background Jobs: Web applications often put long-running tasks (like sending emails, image processing, or report generation) into a queue to be processed by workers asynchronously, improving responsiveness.
    • Print Spoolers: Documents waiting to be printed are typically held in a queue.
  • Breadth-First Search (BFS): This fundamental graph traversal algorithm uses a queue to explore nodes level by level. When visiting a node, its unvisited neighbors are added to the queue, ensuring that all nodes at the current depth are explored before moving to the next depth.

  • Buffering Data: Queues can act as buffers in data pipelines, smoothing out differences in processing speeds between producers and consumers of data.

  • Message Queues: In distributed systems, dedicated message queue systems (like RabbitMQ, Kafka, AWS SQS) use queue principles to enable asynchronous communication between different services.

Queues are a cornerstone of efficient and organized data processing. Understanding them thoroughly will undoubtedly bolster your Go programming skills. What kind of ordered data challenges are you looking to solve next?