DEV Community

Cover image for Introduction to Linked Lists in Golang: A Practical Guide
Wycliffe A. Onyango
Wycliffe A. Onyango

Posted on

Introduction to Linked Lists in Golang: A Practical Guide

Linked lists are a foundational data structure in computer science, widely used in scenarios where dynamic memory allocation and efficient insertion/deletion operations are critical. Understanding linked lists is essential for solving problems that involve managing data in a flexible and scalable way. In this article, we’ll explore where linked lists are applicable, why they are important, and how to implement a linked list in Go (Golang) with both a head and a tail pointer.

Where Are Linked Lists Applicable?

Linked lists are particularly useful in the following scenarios:

1. Dynamic Memory Allocation: Linked lists are ideal when the size of the data structure is not known in advance or changes frequently. Unlike arrays, linked lists do not require contiguous memory allocation, making them more flexible.

2. Efficient Insertions and Deletions: Linked lists excel in situations where frequent insertions and deletions are required. For example:

  • Implementing undo/redo functionality in text editors.

  • Managing browser history (back and forward navigation).

  • Handling task scheduling in operating systems.

3. Implementation of Other Data Structures: Linked lists serve as the building blocks for more complex data structures like:

  • Stacks and queues.

  • Hash tables (for collision resolution using chaining).

  • Graphs (adjacency list representation).

4. Memory-Constrained Environments: In systems with limited memory, linked lists can be more efficient than arrays because they allocate memory only when needed.

Therefore, by understanding linked lists, you gain a powerful tool for solving real-world problems efficiently.

What is a Linked List?

A linked list is a linear data structure where each element, called a node, contains two parts:

  1. Data: The value or information stored in the node.

  2. Pointer: A reference (or link) to the next node in the sequence.

Unlike arrays, linked lists do not require contiguous memory allocation. Instead, each node is dynamically allocated in memory, and the nodes are connected via pointers. This makes linked lists more flexible in terms of size and efficient for certain operations like insertion and deletion.

Types of Linked Lists

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

  1. Singly Linked List: Each node points only to the next node in the sequence. The last node points to nil, indicating the end of the list.

  2. Doubly Linked List: Each node has two pointers: one pointing to the next node and another pointing to the previous node. This allows traversal in both directions.

  3. Circular Linked List: Similar to a singly linked list, but the last node points back to the first node, forming a loop.

In this article, we will focus on implementing a singly linked list in Go with both a head and a tail pointer.

Define the Node Structure

In Go, we can define a node using a struct. Each node will have a data field to store the value and a next field to point to the next node.

package main

import "fmt"

// Node represents a single node in the linked list
type Node struct {
    data int
    next *Node
}
Enter fullscreen mode Exit fullscreen mode

Define the Linked List Structure

The linked list will now have two pointers: head (points to the first node) and tail (points to the last node).

// LinkedList represents the linked list
type LinkedList struct {
    head *Node
    tail *Node
}
Enter fullscreen mode Exit fullscreen mode

Insertion at the Beginning

Inserting a new node at the beginning of the list involves updating the head pointer. If the list is empty, both head and tail will point to the new node.

// InsertAtBeginning inserts a new node at the beginning of the list
func (ll *LinkedList) InsertAtBeginning(data int) {
    newNode := &Node{data: data}
    if ll.head == nil {
        // If the list is empty, set both head and tail to the new node
        ll.head = newNode
        ll.tail = newNode
    } else {
        newNode.next = ll.head
        ll.head = newNode
    }
}
Enter fullscreen mode Exit fullscreen mode

Insertion at the End

With the tail pointer, inserting a new node at the end of the list becomes efficient. We simply update the tail pointer to point to the new node.

// InsertAtEnd inserts a new node at the end of the list
func (ll *LinkedList) InsertAtEnd(data int) {
    newNode := &Node{data: data}
    if ll.head == nil {
        // If the list is empty, set both head and tail to the new node
        ll.head = newNode
        ll.tail = newNode
    } else {
        ll.tail.next = newNode
        ll.tail = newNode
    }
}
Enter fullscreen mode Exit fullscreen mode

Traversal Operation

To traverse the linked list, we start from the head and follow the next pointers until we reach nil

// Display prints the elements of the linked list
func (ll *LinkedList) Display() {
    current := ll.head
    for current != nil {
        fmt.Printf("%d -> ", current.data)
        current = current.next
    }
    fmt.Println("nil")
}
Enter fullscreen mode Exit fullscreen mode

Deletion Operation

Deleting a node involves updating the next pointer of the previous node to skip the node to be deleted. If the node to be deleted is the head or tail, we update the respective pointers.

// DeleteNode deletes the first occurrence of a node with the given data
func (ll *LinkedList) DeleteNode(data int) {
    if ll.head == nil {
        return
    }

    // If the node to be deleted is the head
    if ll.head.data == data {
        ll.head = ll.head.next
        if ll.head == nil {
            // If the list becomes empty, update the tail
            ll.tail = nil
        }
        return
    }

    current := ll.head
    for current.next != nil {
        if current.next.data == data {
            current.next = current.next.next
            if current.next == nil {
                // If the deleted node was the tail, update the tail
                ll.tail = current
            }
            return
        }
        current = current.next
    }
}
Enter fullscreen mode Exit fullscreen mode

Example Usage

Let’s see how to use the linked list with both head and tail pointers.

func main() {
    ll := LinkedList{}

    // Insert elements at the beginning
    ll.InsertAtBeginning(10)
    ll.InsertAtBeginning(20)
    ll.InsertAtBeginning(30)

    // Insert elements at the end
    ll.InsertAtEnd(40)
    ll.InsertAtEnd(50)

    // Display the list
    fmt.Println("Linked List:")
    ll.Display() // Output: 30 -> 20 -> 10 -> 40 -> 50 -> nil

    // Delete a node
    ll.DeleteNode(10)
    fmt.Println("Linked List after deleting 10:")
    ll.Display() // Output: 30 -> 20 -> 40 -> 50 -> nil

    // Delete the head
    ll.DeleteNode(30)
    fmt.Println("Linked List after deleting 30:")
    ll.Display() // Output: 20 -> 40 -> 50 -> nil

    // Delete the tail
    ll.DeleteNode(50)
    fmt.Println("Linked List after deleting 50:")
    ll.Display() // Output: 20 -> 40 -> nil
}
Enter fullscreen mode Exit fullscreen mode

Advantages of Using a Tail Pointer

Efficient Insertions at the End: With a tail pointer, inserting elements at the end of the list becomes an O(1) operation.

Simplified Code: The tail pointer eliminates the need to traverse the entire list for insertions at the end, making the code cleaner and more efficient.

Conclusion

Linked lists are a versatile and powerful data structure that can be applied in various real-world scenarios, from managing dynamic data to implementing complex systems. By enhancing the linked list with a tail pointer, we can further optimize operations like insertions at the end.

In this article, we explored the importance of linked lists, their applications, and implemented a singly linked list in Go with both headand tail pointers. This implementation provides a solid foundation for understanding more advanced data structures and algorithms.

Top comments (0)