Go Data Structures & Algorithms

code

Linked Lists in Go

Linked lists are fundamental data structures that provide dynamic, flexible storage for collections of data. Unlike arrays and slices, which store elements in contiguous memory locations, linked lists use nodes that reference each other, allowing for efficient insertions and deletions. In this comprehensive guide, we'll explore linked lists in Go, from basic concepts to advanced implementations and practical applications.

What is a Linked List?

A linked list is a linear data structure consisting of a sequence of elements, called nodes. Each node contains data and a reference (or link) to the next node in the sequence. Unlike arrays, linked lists don't require contiguous memory allocation, making them more flexible for dynamic data storage.

The key components of a linked list are:

  • Node: The basic unit of a linked list, containing data and a reference to the next node
  • Head: A reference to the first node in the list
  • Tail (optional): A reference to the last node in the list

Linked lists offer several advantages:

  • Dynamic size (can grow and shrink during execution)
  • Efficient insertions and deletions (when you have a reference to the position)
  • No need for contiguous memory allocation
  • Efficient implementation of other data structures like stacks, queues, and graphs

However, they also have some disadvantages:

  • No random access (must traverse from the beginning to reach a specific position)
  • Extra memory overhead for storing references
  • Poor cache locality compared to arrays
  • More complex implementation than arrays and slices

Types of Linked Lists

There are several types of linked lists, each with its own characteristics and use cases:

Singly Linked List

In a singly linked list, each node contains data and a reference to the next node in the sequence. The last node points to nil (or null), indicating the end of the list.

type Node struct {
    Data int
    Next *Node
}

type SinglyLinkedList struct {
    Head *Node
    Size int
}

Singly linked lists are simple and memory-efficient, but they only allow traversal in one direction (from head to tail).

Doubly Linked List

In a doubly linked list, each node contains data and references to both the next and previous nodes. This allows for bidirectional traversal.

type Node struct {
    Data int
    Next *Node
    Prev *Node
}

type DoublyLinkedList struct {
    Head *Node
    Tail *Node
    Size int
}

Doubly linked lists are more versatile than singly linked lists, as they allow traversal in both directions and make certain operations (like deletion) more efficient. However, they require more memory due to the additional reference in each node.

Circular Linked List

A circular linked list is a variation where the last node points back to the first node, creating a circle. This can be implemented with either singly or doubly linked lists.

// Circular Singly Linked List
type CircularSinglyLinkedList struct {
    Head *Node
    Size int
}

// The last node's Next points to Head instead of nil

// Circular Doubly Linked List
type CircularDoublyLinkedList struct {
    Head *Node
    Size int
}

// The last node's Next points to Head, and Head's Prev points to the last node

Circular linked lists are useful for applications where you need to cycle through elements repeatedly, such as round-robin scheduling.

Implementing a Singly Linked List

Let's implement a basic singly linked list in Go, with methods for common operations:

package main

import (
    "fmt"
)

// Node represents a node in a singly linked list
type Node struct {
    Data interface{}
    Next *Node
}

// SinglyLinkedList represents a singly linked list
type SinglyLinkedList struct {
    Head *Node
    Tail *Node
    Size int
}

// NewSinglyLinkedList creates a new empty singly linked list
func NewSinglyLinkedList() *SinglyLinkedList {
    return &SinglyLinkedList{}
}

// IsEmpty checks if the list is empty
func (list *SinglyLinkedList) IsEmpty() bool {
    return list.Size == 0
}

// Append adds a new node with the given data to the end of the list
func (list *SinglyLinkedList) Append(data interface{}) {
    newNode := &Node{Data: data, Next: nil}
    
    if list.IsEmpty() {
        list.Head = newNode
        list.Tail = newNode
    } else {
        list.Tail.Next = newNode
        list.Tail = newNode
    }
    
    list.Size++
}

// Prepend adds a new node with the given data to the beginning of the list
func (list *SinglyLinkedList) Prepend(data interface{}) {
    newNode := &Node{Data: data, Next: list.Head}
    
    if list.IsEmpty() {
        list.Tail = newNode
    }
    
    list.Head = newNode
    list.Size++
}

// InsertAt inserts a new node with the given data at the specified position
func (list *SinglyLinkedList) InsertAt(data interface{}, position int) error {
    if position < 0 || position > list.Size {
        return fmt.Errorf("position out of bounds")
    }
    
    if position == 0 {
        list.Prepend(data)
        return nil
    }
    
    if position == list.Size {
        list.Append(data)
        return nil
    }
    
    current := list.Head
    for i := 0; i < position-1; i++ {
        current = current.Next
    }
    
    newNode := &Node{Data: data, Next: current.Next}
    current.Next = newNode
    list.Size++
    
    return nil
}

// RemoveAt removes the node at the specified position
func (list *SinglyLinkedList) RemoveAt(position int) (interface{}, error) {
    if list.IsEmpty() {
        return nil, fmt.Errorf("list is empty")
    }
    
    if position < 0 || position >= list.Size {
        return nil, fmt.Errorf("position out of bounds")
    }
    
    var data interface{}
    
    if position == 0 {
        data = list.Head.Data
        list.Head = list.Head.Next
        
        if list.Size == 1 {
            list.Tail = nil
        }
    } else {
        current := list.Head
        for i := 0; i < position-1; i++ {
            current = current.Next
        }
        
        data = current.Next.Data
        
        if current.Next == list.Tail {
            list.Tail = current
        }
        
        current.Next = current.Next.Next
    }
    
    list.Size--
    return data, nil
}

// Get returns the data at the specified position
func (list *SinglyLinkedList) Get(position int) (interface{}, error) {
    if list.IsEmpty() {
        return nil, fmt.Errorf("list is empty")
    }
    
    if position < 0 || position >= list.Size {
        return nil, fmt.Errorf("position out of bounds")
    }
    
    current := list.Head
    for i := 0; i < position; i++ {
        current = current.Next
    }
    
    return current.Data, nil
}

// IndexOf returns the position of the first occurrence of the specified data
func (list *SinglyLinkedList) IndexOf(data interface{}) int {
    if list.IsEmpty() {
        return -1
    }
    
    current := list.Head
    position := 0
    
    for current != nil {
        if current.Data == data {
            return position
        }
        current = current.Next
        position++
    }
    
    return -1
}

// Contains checks if the list contains the specified data
func (list *SinglyLinkedList) Contains(data interface{}) bool {
    return list.IndexOf(data) != -1
}

// Clear removes all nodes from the list
func (list *SinglyLinkedList) Clear() {
    list.Head = nil
    list.Tail = nil
    list.Size = 0
}

// ToSlice converts the list to a slice
func (list *SinglyLinkedList) ToSlice() []interface{} {
    result := make([]interface{}, list.Size)
    
    current := list.Head
    for i := 0; i < list.Size; i++ {
        result[i] = current.Data
        current = current.Next
    }
    
    return result
}

// String returns a string representation of the list
func (list *SinglyLinkedList) String() string {
    if list.IsEmpty() {
        return "[]"
    }
    
    result := "["
    
    current := list.Head
    for current != nil {
        result += fmt.Sprintf("%v", current.Data)
        
        if current.Next != nil {
            result += " -> "
        }
        
        current = current.Next
    }
    
    result += "]"
    return result
}

func main() {
    list := NewSinglyLinkedList()
    
    // Append elements
    list.Append(1)
    list.Append(2)
    list.Append(3)
    
    fmt.Println("List after appending 1, 2, 3:", list)
    
    // Prepend an element
    list.Prepend(0)
    fmt.Println("List after prepending 0:", list)
    
    // Insert at position
    list.InsertAt(1.5, 2)
    fmt.Println("List after inserting 1.5 at position 2:", list)
    
    // Remove at position
    removed, _ := list.RemoveAt(2)
    fmt.Println("Removed:", removed)
    fmt.Println("List after removing element at position 2:", list)
    
    // Get element at position
    element, _ := list.Get(2)
    fmt.Println("Element at position 2:", element)
    
    // Check if list contains an element
    fmt.Println("List contains 3:", list.Contains(3))
    fmt.Println("List contains 5:", list.Contains(5))
    
    // Convert to slice
    slice := list.ToSlice()
    fmt.Println("List as slice:", slice)
    
    // Clear the list
    list.Clear()
    fmt.Println("List after clearing:", list)
}

This implementation provides a basic singly linked list with common operations. It uses the interface{} type for data, making it generic and able to store any type of data.

Implementing a Doubly Linked List

Now, let's implement a doubly linked list, which allows for bidirectional traversal:

package main

import (
    "fmt"
)

// Node represents a node in a doubly linked list
type Node struct {
    Data interface{}
    Next *Node
    Prev *Node
}

// DoublyLinkedList represents a doubly linked list
type DoublyLinkedList struct {
    Head *Node
    Tail *Node
    Size int
}

// NewDoublyLinkedList creates a new empty doubly linked list
func NewDoublyLinkedList() *DoublyLinkedList {
    return &DoublyLinkedList{}
}

// IsEmpty checks if the list is empty
func (list *DoublyLinkedList) IsEmpty() bool {
    return list.Size == 0
}

// Append adds a new node with the given data to the end of the list
func (list *DoublyLinkedList) Append(data interface{}) {
    newNode := &Node{Data: data, Next: nil, Prev: list.Tail}
    
    if list.IsEmpty() {
        list.Head = newNode
    } else {
        list.Tail.Next = newNode
    }
    
    list.Tail = newNode
    list.Size++
}

// Prepend adds a new node with the given data to the beginning of the list
func (list *DoublyLinkedList) Prepend(data interface{}) {
    newNode := &Node{Data: data, Next: list.Head, Prev: nil}
    
    if list.IsEmpty() {
        list.Tail = newNode
    } else {
        list.Head.Prev = newNode
    }
    
    list.Head = newNode
    list.Size++
}

// InsertAt inserts a new node with the given data at the specified position
func (list *DoublyLinkedList) InsertAt(data interface{}, position int) error {
    if position < 0 || position > list.Size {
        return fmt.Errorf("position out of bounds")
    }
    
    if position == 0 {
        list.Prepend(data)
        return nil
    }
    
    if position == list.Size {
        list.Append(data)
        return nil
    }
    
    // Determine whether to start from head or tail based on position
    var current *Node
    if position <= list.Size/2 {
        // Start from head
        current = list.Head
        for i := 0; i < position; i++ {
            current = current.Next
        }
    } else {
        // Start from tail
        current = list.Tail
        for i := list.Size - 1; i > position; i-- {
            current = current.Prev
        }
    }
    
    newNode := &Node{Data: data, Next: current, Prev: current.Prev}
    current.Prev.Next = newNode
    current.Prev = newNode
    list.Size++
    
    return nil
}

// RemoveAt removes the node at the specified position
func (list *DoublyLinkedList) RemoveAt(position int) (interface{}, error) {
    if list.IsEmpty() {
        return nil, fmt.Errorf("list is empty")
    }
    
    if position < 0 || position >= list.Size {
        return nil, fmt.Errorf("position out of bounds")
    }
    
    var current *Node
    
    if position == 0 {
        // Remove head
        current = list.Head
        list.Head = current.Next
        
        if list.Head == nil {
            // List becomes empty
            list.Tail = nil
        } else {
            list.Head.Prev = nil
        }
    } else if position == list.Size-1 {
        // Remove tail
        current = list.Tail
        list.Tail = current.Prev
        list.Tail.Next = nil
    } else {
        // Determine whether to start from head or tail based on position
        if position <= list.Size/2 {
            // Start from head
            current = list.Head
            for i := 0; i < position; i++ {
                current = current.Next
            }
        } else {
            // Start from tail
            current = list.Tail
            for i := list.Size - 1; i > position; i-- {
                current = current.Prev
            }
        }
        
        current.Prev.Next = current.Next
        current.Next.Prev = current.Prev
    }
    
    list.Size--
    return current.Data, nil
}

// Get returns the data at the specified position
func (list *DoublyLinkedList) Get(position int) (interface{}, error) {
    if list.IsEmpty() {
        return nil, fmt.Errorf("list is empty")
    }
    
    if position < 0 || position >= list.Size {
        return nil, fmt.Errorf("position out of bounds")
    }
    
    var current *Node
    
    // Determine whether to start from head or tail based on position
    if position <= list.Size/2 {
        // Start from head
        current = list.Head
        for i := 0; i < position; i++ {
            current = current.Next
        }
    } else {
        // Start from tail
        current = list.Tail
        for i := list.Size - 1; i > position; i-- {
            current = current.Prev
        }
    }
    
    return current.Data, nil
}

// IndexOf returns the position of the first occurrence of the specified data
func (list *DoublyLinkedList) IndexOf(data interface{}) int {
    if list.IsEmpty() {
        return -1
    }
    
    current := list.Head
    position := 0
    
    for current != nil {
        if current.Data == data {
            return position
        }
        current = current.Next
        position++
    }
    
    return -1
}

// Contains checks if the list contains the specified data
func (list *DoublyLinkedList) Contains(data interface{}) bool {
    return list.IndexOf(data) != -1
}

// Clear removes all nodes from the list
func (list *DoublyLinkedList) Clear() {
    list.Head = nil
    list.Tail = nil
    list.Size = 0
}

// ToSlice converts the list to a slice
func (list *DoublyLinkedList) ToSlice() []interface{} {
    result := make([]interface{}, list.Size)
    
    current := list.Head
    for i := 0; i < list.Size; i++ {
        result[i] = current.Data
        current = current.Next
    }
    
    return result
}

// ToSliceReverse converts the list to a slice in reverse order
func (list *DoublyLinkedList) ToSliceReverse() []interface{} {
    result := make([]interface{}, list.Size)
    
    current := list.Tail
    for i := 0; i < list.Size; i++ {
        result[i] = current.Data
        current = current.Prev
    }
    
    return result
}

// String returns a string representation of the list
func (list *DoublyLinkedList) String() string {
    if list.IsEmpty() {
        return "[]"
    }
    
    result := "["
    
    current := list.Head
    for current != nil {
        result += fmt.Sprintf("%v", current.Data)
        
        if current.Next != nil {
            result += " <-> "
        }
        
        current = current.Next
    }
    
    result += "]"
    return result
}

func main() {
    list := NewDoublyLinkedList()
    
    // Append elements
    list.Append(1)
    list.Append(2)
    list.Append(3)
    
    fmt.Println("List after appending 1, 2, 3:", list)
    
    // Prepend an element
    list.Prepend(0)
    fmt.Println("List after prepending 0:", list)
    
    // Insert at position
    list.InsertAt(1.5, 2)
    fmt.Println("List after inserting 1.5 at position 2:", list)
    
    // Remove at position
    removed, _ := list.RemoveAt(2)
    fmt.Println("Removed:", removed)
    fmt.Println("List after removing element at position 2:", list)
    
    // Get element at position
    element, _ := list.Get(2)
    fmt.Println("Element at position 2:", element)
    
    // Check if list contains an element
    fmt.Println("List contains 3:", list.Contains(3))
    fmt.Println("List contains 5:", list.Contains(5))
    
    // Convert to slice
    slice := list.ToSlice()
    fmt.Println("List as slice:", slice)
    
    // Convert to slice in reverse order
    sliceReverse := list.ToSliceReverse()
    fmt.Println("List as slice (reverse):", sliceReverse)
    
    // Clear the list
    list.Clear()
    fmt.Println("List after clearing:", list)
}

The doubly linked list implementation is similar to the singly linked list but includes bidirectional links and optimizations for operations that can benefit from traversing from the tail.

Common Operations

Let's explore some common operations on linked lists and their implementations:

Traversal

Traversing a linked list involves visiting each node in sequence:

// Traverse a singly linked list
func (list *SinglyLinkedList) Traverse(callback func(data interface{})) {
    current := list.Head
    for current != nil {
        callback(current.Data)
        current = current.Next
    }
}

// Traverse a doubly linked list in reverse
func (list *DoublyLinkedList) TraverseReverse(callback func(data interface{})) {
    current := list.Tail
    for current != nil {
        callback(current.Data)
        current = current.Prev
    }
}

Searching

Searching for a specific value in a linked list:

// Search for a value in a linked list
func (list *SinglyLinkedList) Search(data interface{}) *Node {
    current := list.Head
    for current != nil {
        if current.Data == data {
            return current
        }
        current = current.Next
    }
    return nil
}

Reversing

Reversing a linked list is a common interview question and a useful operation:

// Reverse a singly linked list
func (list *SinglyLinkedList) Reverse() {
    if list.IsEmpty() || list.Size == 1 {
        return
    }
    
    var prev, next *Node
    current := list.Head
    
    // Swap head and tail
    list.Tail = list.Head
    
    for current != nil {
        next = current.Next
        current.Next = prev
        prev = current
        current = next
    }
    
    list.Head = prev
}

// Reverse a doubly linked list
func (list *DoublyLinkedList) Reverse() {
    if list.IsEmpty() || list.Size == 1 {
        return
    }
    
    current := list.Head
    
    // Swap head and tail
    list.Head, list.Tail = list.Tail, list.Head
    
    for current != nil {
        // Swap Next and Prev pointers
        current.Next, current.Prev = current.Prev, current.Next
        
        // Move to the next node (which is now current.Prev due to the swap)
        current = current.Prev
    }
}

Detecting Cycles

Detecting cycles in a linked list using Floyd's Cycle-Finding Algorithm (also known as the "tortoise and hare" algorithm):

// Detect a cycle in a singly linked list
func (list *SinglyLinkedList) HasCycle() bool {
    if list.IsEmpty() || list.Size == 1 {
        return false
    }
    
    slow := list.Head
    fast := list.Head
    
    for fast != nil && fast.Next != nil {
        slow = slow.Next          // Move one step
        fast = fast.Next.Next     // Move two steps
        
        if slow == fast {
            return true  // Cycle detected
        }
    }
    
    return false  // No cycle
}

Finding the Middle Node

Finding the middle node of a linked list using the "slow and fast pointers" technique:

// Find the middle node of a singly linked list
func (list *SinglyLinkedList) FindMiddle() *Node {
    if list.IsEmpty() {
        return nil
    }
    
    slow := list.Head
    fast := list.Head
    
    for fast != nil && fast.Next != nil {
        slow = slow.Next          // Move one step
        fast = fast.Next.Next     // Move two steps
    }
    
    return slow  // Slow pointer will be at the middle when fast reaches the end
}

Merging Two Sorted Lists

Merging two sorted linked lists into a single sorted list:

// Merge two sorted singly linked lists
func MergeSortedLists(list1, list2 *SinglyLinkedList) *SinglyLinkedList {
    result := NewSinglyLinkedList()
    
    current1 := list1.Head
    current2 := list2.Head
    
    for current1 != nil && current2 != nil {
        if current1.Data.(int) <= current2.Data.(int) {
            result.Append(current1.Data)
            current1 = current1.Next
        } else {
            result.Append(current2.Data)
            current2 = current2.Next
        }
    }
    
    // Append remaining elements from list1
    for current1 != nil {
        result.Append(current1.Data)
        current1 = current1.Next
    }
    
    // Append remaining elements from list2
    for current2 != nil {
        result.Append(current2.Data)
        current2 = current2.Next
    }
    
    return result
}

Performance Analysis

Understanding the performance characteristics of linked lists is crucial for choosing the right data structure for your application.

Time Complexity

Operation Singly Linked List Doubly Linked List Explanation
Access by Index O(n) O(n) Must traverse from the beginning (or end for doubly linked lists) to reach the desired position
Insert at Beginning O(1) O(1) Only need to update head pointer and a few references
Insert at End O(1) with tail pointer, O(n) without O(1) With a tail pointer, insertion at the end is constant time
Insert at Middle O(n) O(n) Must traverse to find the insertion point
Delete at Beginning O(1) O(1) Only need to update head pointer
Delete at End O(n) O(1) For singly linked lists, must traverse to find the node before the tail; for doubly linked lists, can use the tail's prev pointer
Delete at Middle O(n) O(n) Must traverse to find the deletion point
Search O(n) O(n) Must traverse to find the element

Space Complexity

The space complexity of linked lists is O(n), where n is the number of elements. However, linked lists have additional overhead compared to arrays:

  • Singly linked lists: Each node requires extra space for the next pointer
  • Doubly linked lists: Each node requires extra space for both next and prev pointers

Memory Considerations

Linked lists have several memory-related characteristics to consider:

  • Dynamic allocation: Nodes are allocated individually, which can lead to fragmentation
  • Pointer overhead: Each node requires additional memory for pointers
  • Cache locality: Nodes may be scattered in memory, leading to poor cache performance compared to arrays
  • Garbage collection: In languages with garbage collection (like Go), removing nodes from a linked list doesn't immediately free memory

Use Cases

Linked lists are well-suited for certain applications:

When to Use Linked Lists

  • Frequent insertions and deletions: When you need to frequently insert or delete elements, especially at the beginning or middle of the collection
  • Unknown size: When the number of elements is not known in advance and may change significantly
  • Implementation of other data structures: As a building block for stacks, queues, and certain types of graphs
  • Memory allocation concerns: When memory is fragmented and contiguous allocation (as required by arrays) is not possible
  • Circular buffers: When implementing circular data structures, such as a circular buffer or round-robin scheduler

Real-World Applications

  • Browser history: Implementing forward and backward navigation (doubly linked list)
  • Music playlists: Managing songs with previous and next functionality
  • Undo functionality: Maintaining a history of operations that can be undone
  • Memory management: Managing free blocks of memory in operating systems
  • Hash tables with chaining: Using linked lists to handle collisions in hash tables
  • Polynomial representation: Representing polynomials with linked lists where each node contains a coefficient and exponent

Linked Lists vs. Arrays and Slices

Choosing between linked lists and arrays/slices depends on your specific requirements:

Feature Linked Lists Arrays/Slices
Memory allocation Dynamic, non-contiguous Static (arrays) or dynamic but contiguous (slices)
Random access O(n) O(1)
Insertion/deletion at beginning O(1) O(n)
Insertion/deletion at end O(1) with tail pointer O(1) amortized for slices
Insertion/deletion in middle O(n) to find position, O(1) to insert/delete O(n)
Memory overhead Higher (extra pointers) Lower
Cache performance Poor (scattered memory) Good (contiguous memory)
Implementation complexity Higher Lower

When to Choose Arrays/Slices Over Linked Lists

  • When you need fast random access
  • When the size is known and relatively stable
  • When memory usage is a concern
  • When cache performance is important
  • When you need simple, straightforward code

When to Choose Linked Lists Over Arrays/Slices

  • When you need frequent insertions and deletions, especially at the beginning or middle
  • When the size is unknown or highly variable
  • When you need to implement certain data structures (e.g., certain types of queues)
  • When memory fragmentation is a concern

Best Practices

Here are some best practices to follow when working with linked lists in Go:

Use Appropriate List Type

Choose the right type of linked list for your needs:

  • Use singly linked lists when you only need to traverse in one direction and memory efficiency is important
  • Use doubly linked lists when you need bidirectional traversal or efficient operations at both ends
  • Use circular linked lists when you need to cycle through elements repeatedly

Maintain Both Head and Tail Pointers

Keeping a reference to both the head and tail of the list allows for O(1) operations at both ends.

Handle Edge Cases

Always handle edge cases carefully:

  • Empty lists
  • Single-element lists
  • Operations at the beginning or end of the list
  • Invalid indices or positions

Use Generic Types

In Go, you can use interface{} to create generic linked lists, but be aware of type assertions when retrieving data.

Consider Thread Safety

If your linked list will be accessed by multiple goroutines, implement proper synchronization using mutexes or channels.

Implement Standard Interfaces

Implement standard Go interfaces like fmt.Stringer for better integration with the language.

Use Existing Libraries

Consider using existing libraries like Gods (Go Data Structures) instead of implementing linked lists from scratch for production code.

Conclusion

Linked lists are versatile data structures that excel in scenarios requiring frequent insertions and deletions. While they have some disadvantages compared to arrays and slices, they remain an important tool in a programmer's toolkit. By understanding their characteristics, operations, and best practices, you can effectively use linked lists in your Go programs.

In the next section, we'll explore stacks and queues, which are often implemented using linked lists as their underlying data structure.