What is a Linked List?
A linked list is a series of nodes linked together in a linear sequence. Linked lists are a null-terminated data structure, meaning the final node points to null.
This guide walks you through everything you need to know about the linked list data structure, with clear, commented code samples.
- When to use a linked list
- Big O time complexity of operations
- Singly linked lists
- Implement a singly linked list in Python
- Doubly linked lists
- Singly vs. doubly linked lists
- Solving linked list problems
- Sample linked list problems
When to Use a Linked List
To understand when to use a linked list, we must understand its structure. There is order to a linked list, unlike hash tables, which means they should be considered when a problem requires the preservation of element order.
Adding and deleting from the start of a singly linked list can also be done in constant time O(1), but search is slower, at O(n). Therefore, if we need to access specific items on a regular basis, arrays may be a better option, as the time complexity to access their items is O(1).
Main advantages over arrays:
- Lets you insert to the middle using pointers
- Only change that happens is in the middle. Same for deletes
- These are faster operations than inserts and deletes in arrays, which can require shifting. O(n)
- Static arrays also only have a certain amount of memory allocation. They can double in size when the limit is reached, but costs O(n)
Big O Time Complexity of Operations
- Prepend: O(1)
- Append: O(1)
- Lookup: O(n)
- Insert: O(n)
- Delete: O(n)
Singly Linked Lists
Each node is represented as a unique object. A node stores two references, one being its data and the other being a pointer to the next node (or None, if node is the tail).
In a linked list, the first node is called the head and the last node is called the tail. The tail can be identified by its pointer to null.
Pros
- O(1) time complexity to add an element at either end, provided you maintain a reference to the tail.
- O(1) time complexity to remove the first element. Arrays require O(n) time (to shift items around).
- Can expand without specifying its size ahead of time.
Cons
- Slow lookup. O(k) time to go from the head of the list to the kth element for access. Arrays have O(1) constant time for access.
Implement Singly Linked List in Python
To create a linked list, you’ll typically create a class for the Node, and a separate class for the list operations.
A basic implementation of the Node class:
class Node:
def __init__(self, data):
self.data= data
self.next = None
You can create a linked list by instantiating multiple nodes, then pointing them to each other by setting their next attribute.
For example, a linked list of a -> b -> c -> null could be implemented as follows:
a = Node(1)
b = Node(2)
c = Node(3)
a.next = b
b.next = c
print(a.value) # 1
print(a.next.data) # 2
A more complete implementation of a singly linked list class can be found below. This includes the following operations:
- prepend: Add a new node at the start of the linked list, which becomes the new head.
- append: Add a new node at the end of the linked list, which becomes the new tail.
- traverse: Loop through the linked list and print the data stored at each node.
- get_head: Retrieve a reference to the current head node.
- is_empty: Check if the linked list contains any nodes.
- clear: Clears a linked list by setting its head to
None. - reverse: Reverse the linked list in place, given a reference to its head node.
class Node:
def __init__(self, data):
"""
Attributes:
data: The data to be contained within the node.
next: Pointer reference to the next node in the list.
"""
self.data = data
self.next = None
class SinglyLinkedList:
def __init__(self):
"""
Attributes:
head: Pointer to the head of the linked list.
"""
self.head = None
def prepend(self, val):
"""Insert a new node at the head of the linked list.
Args:
val: The value being added to the linked list.
"""
newNode = Node(val)
if self.head is None:
self.head = newNode
else:
newNode.next = self.head
self.head = newNode
def append(self, val):
"""Insert a new node at the tail of the linked list.
Args:
val: The value being added to the linked list.
"""
if self.head is None:
self.head = Node(val)
else:
node = self.head
while node.next:
node = node.next
node.next = Node(val)
def traverse(self):
"""
Traverse the linked list and print its data.
"""
node = self.head
while node:
print(node.data)
node = node.next
def get_head(self):
"""Get the head of the linked list.
Returns:
A reference to the current head node.
"""
return self.head
def is_empty(self):
"""Checks if the linked list contains nodes.
Returns:
True if nodes in list, otherwise False.
"""
if(self.head is None):
return True
else:
return False
def clear(self):
"""
Clears a linked list, removing all nodes.
"""
self.head = None
def reverse(self):
"""
Reverses a linked list in place.
"""
previous = None
current = self.head
while current:
nextnode = current.next
current.next = previous
previous = current
current = nextnode
self.head = previous
Delete Node from Singly Linked List
To delete a node from a singly linked list can be more complex than simple operations such as search and append.
Deleting from the head is simple enough. You simply repoint the head reference to the next node.
But what if we’re asked to delete a given node? How do we repoint the node that points to it, when we can only move forwards using node.next?
Case 1: Given only the node to delete
def delete_node(node):
# Get the input node's next node, the one we want to skip to
next_node = node.next
if next_node:
# Set the input node's value and pointer to be the next node's value and pointer.
node.value = next_node.value
node.next = next_node.next
else:
raise Exception("Can't delete the last node with this technique!")
Trade-offs:
- Doesn’t work for deleting the last node in the list.
Side Effects:
- References to the input node now effectively reference its next node
- Pointers to the input node’s original next node are now pointing to an unconnected node
Big O Complexity:
O(1) time and O(1) space.
Case 2: Given the node and the full list
Given node ‘n’, find the previous node ‘prev’ and set ‘prev.next’ equal to ‘n.next’.
If the list is doubly linked, also update ‘n.next’ to set ‘n.next.prev’ equal to ‘n.prev’.
Important: Check for the null pointer, and update the head or tail pointer as necessary.
Case 3: Given the index of a node
Add ‘length’ attribute to the SinglyLinkedList class __init__, to make checks easier. Iterate through the linked list until you find an index that matches the input, then perform the same steps that you would to solve Case 2.
Doubly Linked Lists
Each node stores three references:
- Pointer to the previous node (or None, if node is the head)
- Its data
- Pointer to the next node (or None, if node is the tail)
Implement Doubly Linked List in Python
class Node:
def __init__(self, data):
self.data = data
self.next = None
self.prev = None
a = Node(1)
b = Node(2)
c = Node(3)
a.next = b
b.prev = a
b.next = c
c.prev = b
print(b.value) # 2
print(b.prev.data) # 1
print(b.next.data) # 3
Singly vs. Doubly Linked Lists
Singly Linked List
- Require less memory (don’t have to store pointer to previous element) and simpler to implement
- Downside is they cannot be traversed in reverse
- Good when fast insertion and deletion is goal, and memory should be preserved
Doubly Linked List
- Better when searching
- Downside is requires more memory and more complex
- Good when less limitation on memory
Solving Linked List Problems
The ‘Runner’ Technique
Iterate through the linked list with two pointers, simultaneously, with one ahead of the other.
The ‘fast’ pointer can be ahead by a fixed amount, or hop multiple nodes for each one node the ‘slow’ pointer increments through.
e.g. Have one pointer p1 (fast) move two elements for every one move that p2 (slow) makes. When p1 hits the end of the linked list, p2 is at the midpoint.
Analogy:
Two runners. If runner1 starts on a straight track ahead of runner2, they should never meet. If he starts ahead of runner2 but is on a loop, they will eventually meet.
Sample Linked List Problems
Problem 1:
Write a function to reverse a Linked List in place. The function will take in the head of the list as input and return the new head of the list.
We can reverse the list by changing the next pointer of each node. Each node’s next pointer should point to the previous node.
In one pass from head to tail of our input list, we will point each node’s next pointer to the previous element.
Make sure to copy current.next_node into next_node before setting current.next_node to previous.
Solution 1:
def reverse(head):
"""
Reverses a linked list in place.
"""
previous = None
current = head
while current:
nextnode = current.next
current.next = previous
previous = current
current = nextnode
self.head = previous
Problem 2:
Given a singly-linked list, check if it contains a cycle.
Solution 2a: Hash map technique
Big O Complexity: O(n) time. O(n) space.
def contains_cycle(node):
dict1 = {}
while node:
if node not in dict1:
dict1[node] = 1
else:
return True
node = node.next
return False
Solution 2b: Runner technique
Big O Complexity: O(n) time. O(1) space.
def contains_cycle2(node):
slow = node
fast = node
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False