Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 9

Linked List Implementation and Performance Analysis

Recommended resources

Introduction

In our last lesson, we mastered the dynamic array, appreciating its amortized cost for appends and its random access. However, we also identified its key weakness: inserting or deleting an element at the beginning or in the middle is a slow operation due to the need to shift all subsequent elements. Today, we explore a data structure designed to solve this very problem: the linked list.

This lesson addresses the learning outcome: Implement singly and doubly linked lists, analyzing the trade-offs for insertion, deletion, and traversal operations. We will deconstruct linked lists to understand their non-contiguous memory model, implement them from scratch in Python, and analyze where they shine and where they falter compared to arrays. This fundamental trade-off is at the heart of countless system design decisions.



Concept 1: The Node and the Chain - Singly Linked List Fundamentals

Unlike arrays, which store elements in a single, contiguous block of memory, linked lists store elements in individual Node objects scattered throughout memory, connected by pointers.

Technical Explanation

A singly linked list is a collection of Node objects. Each Node contains two pieces of information:

  1. value: The actual data element we want to store.
  2. next: A reference (or "pointer") to the next Node in the sequence.

The list is maintained by keeping a reference to the very first node, called the head. The next reference of the last node in the chain points to None (or null), signifying the end of the list. This structure of disconnected nodes linked by pointers is the key to its flexibility.

Memory Representation of a Singly Linked List
This image illustrates the "under the hood" memory layout. A 'start' pointer (our `head`) on the stack holds the memory address of the first node, which resides in the heap. Each node in the heap then contains its data and the address of the next node, forming a chain. Notice how the nodes (at addresses 150, 250, 350, 450) are not sequential in memory.

Metaphor: A Treasure Hunt

Think of a linked list as a treasure hunt. You start with a single clue (the head) that tells you the location of the first treasure chest. Inside that chest, along with some treasure (the value), you find the clue to the next chest's location (the next pointer). You follow this chain of clues until you open a chest that contains no next clue (next is None). The key insight is that the chests can be buried anywhere in the world (scattered in memory); it's the clues that give them their order.

Python Code Snippet

Let's start by defining the Node itself.

class SinglyNode:
    """A node in a singly linked list."""
    def __init__(self, value, next_node=None):
        self.value = value
        self.next = next_node

    def __repr__(self):
        return f"SinglyNode({self.value})"

# Let's manually create a small linked list: 10 -> 20 -> 30
node3 = SinglyNode(30)
node2 = SinglyNode(20, node3)
head = SinglyNode(10, node2)

# We can traverse it by following the 'next' pointers
print(f"Head: {head}")
print(f"Next from head: {head.next}")
print(f"Next from that: {head.next.next}")
print(f"End of the list: {head.next.next.next}")

This manual creation shows the core concept, but it's cumbersome. We need a class to manage the list for us.



Concept 2: Managing the Chain - Traversal and Basic Operations

A LinkedList class acts as a wrapper that holds the head and provides methods to interact with the list. The most fundamental operation is traversal.

Technical Explanation

To traverse a linked list, you initialize a temporary pointer, often called current, to the head. Then, you loop as long as current is not None. Inside the loop, you process the current node's value and then advance the pointer by setting current = current.next. This simple loop is the basis for nearly every operation that needs to find a specific position in the list, such as searching, getting length, or inserting at an index.

The time complexity for traversal is always , where is the number of nodes. This is because, unlike an array, you cannot jump to the i-th element; you must visit all i-1 nodes before it.

Linked List - Data Structures & Algorithms Tutorials in Python #4

To see this concept in action, the following video 'Linked List - Data Structures & Algorithms' provides a great visual explanation of the array's limitations and introduces the linked list structure.

Watch the first part of the video (00:00 - 05:54). Focus on how the pointer-based structure of a linked list overcomes the costly shifting required for insertions in an array.

Python Code Snippet

Let's create a SinglyLinkedList class with methods that rely on traversal.

class SinglyLinkedList:
    def __init__(self):
        self.head = None
        self._size = 0

    def __len__(self):
        # We can maintain a size variable for O(1) length check
        return self._size

    def __str__(self):
        """Allows us to print the list, e.g., print(my_list)"""
        if self.head is None:
            return "[]"
        
        nodes = []
        current = self.head
        while current:
            nodes.append(str(current.value))
            current = current.next
        return "[" + " -> ".join(nodes) + "]"

    # We will implement insertion/deletion methods next

Maintaining a _size variable is a common optimization to make length checks instead of requiring an traversal every time.



Concept 3: The Core Trade-off - Insertion and Deletion

Here we see why linked lists exist. They trade fast random access for fast insertion and deletion at the beginning of the list.

Technical Explanation

Let's analyze the pointer manipulation for different cases in a singly linked list.

  1. Insertion at the beginning (insert_at_beginning):

    • Create a new_node.
    • Set new_node.next to point to the current head.
    • Update the list's head to be the new_node.
    • This is a constant number of operations, so its complexity is .
  2. Deletion at the beginning (remove_from_beginning):

    • Check if the list is empty.
    • Set the list's head to head.next.
    • This is also .
  3. Insertion/Deletion in the middle or end:

    • To insert or delete at index i, you must first traverse the list to find the node at index i-1. This traversal takes time.
    • Once you're at the correct position, the pointer re-wiring takes time.
    • The total complexity is dominated by the traversal, making it in the worst case (e.g., operating at the end of the list).

Linked List - Data Structures & Algorithms Tutorials in Python #4

The 'codebasics' video provides an excellent, step-by-step implementation of these core insertion and removal methods in Python.

Watch the segments covering insert_at_beginning (08:39), insert_at_end (12:21), remove_at (16:33), and insert_at (21:22). Pay close attention to how the current.next pointers are manipulated to add or bypass nodes.

Python Code Snippet

Let's add these methods to our SinglyLinkedList class.

# (Continuing the SinglyLinkedList class)
class SinglyLinkedList:
    # ... (__init__, __len__, __str__) ...
    def __init__(self):
        self.head = None
        self._size = 0

    def __len__(self):
        return self._size

    def __str__(self):
        if not self.head: return "[]"
        nodes = []
        current = self.head
        while current:
            nodes.append(str(current.value))
            current = current.next
        return "[" + " -> ".join(nodes) + "]"

    def insert_at_beginning(self, value):
        """Inserts a new node at the head of the list. O(1)"""
        new_node = SinglyNode(value, self.head)
        self.head = new_node
        self._size += 1

    def insert_at_end(self, value):
        """Inserts a new node at the tail of the list. O(N)"""
        if self.head is None:
            self.head = SinglyNode(value)
        else:
            current = self.head
            while current.next: # Traverse to the last node
                current = current.next
            current.next = SinglyNode(value)
        self._size += 1

    def remove_at(self, index):
        """Removes a node at a given index. O(N)"""
        if not 0 <= index < self._size:
            raise IndexError("Index out of bounds")
        
        if index == 0: # Special case: removing the head
            self.head = self.head.next
            self._size -= 1
            return

        count = 0
        current = self.head
        # Traverse to the node *before* the one to be removed
        while count < index - 1:
            current = current.next
            count += 1
        
        # Bypass the node to be removed
        current.next = current.next.next
        self._size -= 1


Concept 4: The Doubly Linked List - Going Both Ways

A singly linked list has a limitation: from a given node, you can only move forward. What if you need to go backward? This is where the doubly linked list comes in.

Technical Explanation

A doubly linked list node stores three pieces of information:

  1. value: The data element.
  2. next: A reference to the next node.
  3. prev: A reference to the previous node.

A doubly linked list class typically maintains pointers to both the head and the tail of the list. This prev pointer is a game-changer for certain operations:

  • Bidirectional Traversal: You can now traverse the list from tail to head using the prev pointers.
  • Tail Insertion/Deletion: With a tail pointer, adding or removing a node at the end of the list becomes an operation, a major improvement over the singly linked list's cost.
  • Deletion with a Node Reference: If you have a direct reference to a node you want to delete (e.g., from a hash map), you can delete it in . You can access node.prev and node.next to re-wire the list. In a singly linked list, this would require an traversal to find the previous node.

Metaphor: A Train with Two-Way Connectors

If a singly linked list is a train where each car is only hitched to the one in front of it, a doubly linked list is a modern train where cars are connected at both the front and back. From any car, you can walk forward or backward. The conductor also has radios to immediately contact the engine (head) and the caboose (tail).

Python Code Snippet

Let's implement a DoublyLinkedList and see the tail insertion.

class DoublyNode:
    def __init__(self, value, prev_node=None, next_node=None):
        self.value = value
        self.prev = prev_node
        self.next = next_node

class DoublyLinkedList:
    def __init__(self):
        self.head = None
        self.tail = None
        self._size = 0

    def __len__(self):
        return self._size

    def __str__(self):
        if not self.head: return "[]"
        nodes = []
        current = self.head
        while current:
            nodes.append(str(current.value))
            current = current.next
        return "[" + " <-> ".join(nodes) + "]"

    def insert_at_end(self, value):
        """Inserts a new node at the tail. O(1)"""
        new_node = DoublyNode(value)
        if self.tail is None: # List is empty
            self.head = new_node
            self.tail = new_node
        else:
            self.tail.next = new_node
            new_node.prev = self.tail
            self.tail = new_node
        self._size += 1

    def remove_node(self, node_to_remove):
        """Removes a given node reference in O(1)."""
        if node_to_remove.prev:
            node_to_remove.prev.next = node_to_remove.next
        else: # It's the head
            self.head = node_to_remove.next

        if node_to_remove.next:
            node_to_remove.next.prev = node_to_remove.prev
        else: # It's the tail
            self.tail = node_to_remove.prev
        
        self._size -= 1
Test your understanding!

Given a direct reference (pointer) to a node in the middle of a large list, what is the time complexity to delete that node if it's in a singly linked list vs. a doubly linked list? Explain why.

Show answer
  • Singly Linked List: . Even though you have the node, you cannot delete it without a reference to the previous node to update its next pointer. To get the previous node, you must traverse the list from the head, which takes time in the worst case.
  • Doubly Linked List: . With a reference to the node, you can access both node.prev and node.next. You can update node.prev.next to point to node.next, and node.next.prev to point to node.prev, completely bypassing the node in constant time. This is a critical advantage.


Concept 5: The Final Showdown & A Real-World Use Case

Let's summarize the trade-offs and look at a killer application where the doubly linked list's properties are essential.

Trade-off Analysis

Operation Dynamic Array Singly Linked List Doubly Linked List Why?
Access element by index i Arrays have contiguous memory; lists require traversal.
Insert/Delete at start Array requires shifting; lists just update head.
Insert/Delete at end * ** * DLL's tail pointer avoids traversal. SLL must traverse.
Insert/Delete middle All require finding the position () before a quick modification.
Space Complexity DLLs store two pointers (next, prev) per node, increasing overhead.

* Amortized cost for dynamic array.
** Can be if a tail pointer is maintained, but deletion is still tricky.
*** Assumes a tail pointer is maintained.

Real-World Application: The LRU Cache

A Least Recently Used (LRU) Cache is a high-performance cache that evicts the item that hasn't been accessed for the longest time when it's full. This is a classic component in databases, operating systems, and web servers. The requirements are demanding: both lookup and update operations should be as close to as possible.

How can we build this? By combining a hash map and a doubly linked list.

  • Hash Map (Dictionary in Python): Provides average time lookup. It stores a key and a reference to the node in the doubly linked list.
  • Doubly Linked List: Maintains the order of use. We keep the most recently used item at the head and the least recently used at the tail.

When an item is accessed (a "cache hit"):

  1. Use the hash map to find the node in .
  2. Move this node to the head of the list. This move is an operation in a doubly linked list because we can instantly access the node's prev and next pointers to patch the list.
  3. When the cache is full and a new item is added, we simply evict the tail of the list, which is an operation.
Data Structures for LRU Cache Implementation
This diagram shows the combined structure. The HashMap allows for O(1) lookups of keys (P1, P2...), which point directly to nodes in the DoublyLinkedList. The list's structure (with Start/Head and End/Tail) makes moving nodes to the front or removing them from the back an O(1) operation, which is critical for the LRU logic.


Conclusion

Today you've dissected one of the most foundational data structures. You've seen that the choice between an array and a linked list is not about which is "better," but about understanding the specific performance characteristics of your access patterns.

Key Takeaways:

  • Core Idea: Linked lists consist of nodes scattered in memory, connected by pointers, freeing them from the contiguous block requirement of arrays.
  • Singly vs. Doubly: A singly linked list has a next pointer for forward traversal. A doubly linked list adds a prev pointer, enabling bidirectional traversal and crucial deletions given a node reference.
  • The Fundamental Trade-off: Linked lists excel at insertions/deletions at the head (and tail for DLLs), but sacrifice the indexed access of arrays, making searches and indexed operations .
  • Practical Application: The combination of a hash map and a doubly linked list to build an efficient LRU cache is a canonical example of leveraging data structures' strengths to solve a complex problem, and a favorite in top-tier technical interviews.

Preview of the Next Lesson

We've now covered two ways to implement a linear sequence: dynamic arrays and linked lists. Both can be used to build other, more abstract data structures. In our next lesson, we will use these building blocks to implement stacks and queues, two "Last-In, First-Out" (LIFO) and "First-In, First-Out" (FIFO) structures that are fundamental to solving a huge class of algorithmic problems, from parsing expressions to managing breadth-first searches in graphs.