Introduction
A linked list is a linear data structure where each element (node) contains data and a reference (or pointer) to the next node in the sequence. Unlike arrays, linked lists don't require contiguous memory allocation, making insertions and deletions more efficient (O(1) when you have a reference to the node) at the cost of random access (O(n) instead of O(1)).
The most basic linked list is a singly linked list, where each node points only to the next node:
ListNode Definition
Intuitively, you want to imagine a linked list as a chain where each link (node) connects to the next link. The first node is called the "head" of the list, and the last node's next pointer is typically null, marking the end of the list.
Head → [3] → [7] → [2] → [5] → NoneThe beauty and challenge of linked list problems come from manipulating these pointers to efficiently traverse, modify, or analyze the list.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = nextKey Techniques
Key Techniques for Solving Linked List Problems:
- Dummy Node Pattern: Creating a dummy/sentinel node at the start helps avoid edge cases
- Fast Slow (leading/lagging) Template: When you have one pointer that either starts ahead, or moves faster than another one that is lagging. This allows you to do things such as find the kth last node. Just create two pointers that differ in space by k, and when the leading node reaches the end, the lagging node is kth from the end.
- Iteration vs. Recursion: Many linked list problems can be solved either way
- Drawing Out the List: Visualizing the problem on paper often reveals the solution
Common Mistakes:
- Not handling edge cases (empty list, single node)
- Creating cycles accidentally
- Losing the head reference
- Off-by-one errors when counting positions
Basic Traversal
Let's walk through a simple example of traversing a linked list:
def traverse_linked_list(head):
current = head
while current:
print(current.val) # Process the current node
current = current.next # Move to the next nodeThe Dummy Node Template
The dummy node pattern is particularly useful for operations that might modify the head of the list:
def dummy_node_pattern(head):
# Create a dummy node that points to the head
dummy = ListNode(0)
dummy.next = head
# Now we can operate on the list without worrying about edge cases
# where the head might change
current = dummy
# Your operations here...
# Return the new head (which might have changed)
return dummy.nextThis pattern is especially helpful when:
1. You need to insert/delete nodes at the beginning of the list
2. The head of the list might change
3. You need to return a new list
Basic Operations
Fundamental linked list operations including reversal and node removal.
01Reverse Linked ListEasy
Given the head of a singly linked list, reverse the list, and return the new head.
Example:
- Input: 1→2→3→4→5→null
- Output: 5→4→3→2→1→null
This is the quintessential linked list problem. We need to reverse the direction of all pointers in the list.
# Iterative solution
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
prev = None
while head:
head.next, prev, head = prev, head, head.next
return prev
# Recursive solution
def reverseListRecursive(self, head: ListNode) -> ListNode:
# Base case: empty list or list with only one node
if not head or not head.next:
return head
# Recursively reverse the rest of the list after head
new_head = self.reverseListRecursive(head.next)
# The node after head now points back to head
head.next.next = head
# Head becomes the new tail, so it points to None
head.next = None
# Return the new head of the reversed list
return new_headO(n)O(1) iterative, O(n) recursiveTo understand this, let's visualize the reversal process:
Initial state:
None ← prev | current → [1] → [2] → [3] → NoneFirst iteration:
None ← [1] ← prev | current → [2] → [3] → NoneSecond iteration:
None ← [1] ← [2] ← prev | current → [3] → NoneFinal state:
None ← [1] ← [2] ← [3] ← prev | current → NoneFor the recursive case: just draw it out.
1 -> 2 -> 3
We have 1 and 3 -> 2.
We want to set 2 -> 1. However we only have access to the 1 and 3 right now. Iterating from one end of 3 to 2 will take O(n) time, which makes the recursion O(n^2).
But know that 1.next is STILL 2…
So we can access 2 through head.next, and we want to set head.next.next = head.
Now, we just need to get rid of 1 -> 2, by setting head.next = None. so now we have 3 -> 2 -> 1 -> None!
02Remove Nth Node From End of ListMedium
Given the head of a linked list, remove the nth node from the end of the list and return its head.
Example:
- Input: 1→2→3→4→5→null, n = 2
- Output: 1→2→3→5→null
This problem introduces the two-pointer technique:
def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
# Iterative solution using the two-pointer technique
# Dummy node to handle edge cases
dummy = ListNode(0)
dummy.next = head
# Two pointers: fast and slow
fast = dummy
slow = dummy
# Move fast pointer n+1 steps ahead
for _ in range(n + 1):
fast = fast.next
# Move both pointers until fast reaches the end
while fast:
slow = slow.next
fast = fast.next
# Remove the nth node from the end
slow.next = slow.next.next
return dummy.next
# Recursive solution
def removeNthFromEndRecursive(self, head: ListNode, n: int) -> ListNode:
# We'll use a helper function that returns the position from the end
def remove_helper(node, n):
# Base case: we've reached the end of the list
if not node:
return 0
# Recursively process the rest of the list
position = remove_helper(node.next, n) + 1
# If this is the (n+1)th node from the end, remove the nth node
if position == n + 1:
node.next = node.next.next
return position
# Create a dummy node to handle edge cases
dummy = ListNode(0)
dummy.next = head
# Start the recursive process
remove_helper(dummy, n)
return dummy.nextO(n)O(1) iterative, O(n) recursiveThe key insight is that by having the fast pointer n+1 steps ahead of the slow pointer, when the fast pointer reaches the end, the slow pointer will be at the node just before the one we want to remove. (instead of fast, slow, leading and lagging are better names because they travel at the same speed, they just start at different places) The dummy node helps handle the edge case of removing the first node. Try some examples.
03Middle of the Linked ListEasy
Given a non-empty, singly linked list, return a middle node of the linked list. If there are two middle nodes, return the second middle node.
Example:
- Input: [1,2,3,4,5]
- Output: [3,4,5] (The middle node is 3)
This is a classic application of the fast and slow pointer technique:
def middleNode(self, head: ListNode) -> ListNode:
# Iterative solution using fast and slow pointers
# Initialize slow and fast pointers
slow = head
fast = head
# Move slow one step and fast two steps at a time
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# When fast reaches the end, slow is at the middle
return slow
# Recursive solution using a two-pass approach
def middleNodeRecursive(self, head: ListNode) -> ListNode:
# First pass: count the number of nodes
def count_nodes(node):
if not node:
return 0
return 1 + count_nodes(node.next)
# Second pass: find the middle node
def find_middle(node, current, target):
if current == target:
return node
return find_middle(node.next, current + 1, target)
# Count the nodes and calculate the middle position
length = count_nodes(head)
middle_pos = length // 2 # Integer division gives the second middle for even lengths
# Find the middle node
return find_middle(head, 0, middle_pos)O(n)O(1) iterative, O(n) recursiveWhen fast pointer moves at twice the speed of slow, by the time fast reaches the end, slow will be at the middle. For even-length lists, this gives us the second of the two middle nodes, as required.
04Delete Node in a Linked ListMedium
Write a function to delete a node in a singly-linked list. You will not be given access to the head of the list, instead you will be given direct access to the node to be deleted.
Example:
- Input: head = [4,5,1,9], node = 5
- Output: [4,1,9]
This problem has an interesting twist:
def deleteNode(self, node):
# Since we don't have access to the previous node,
# we can't delete the node directly.
# Instead, we'll copy the next node's value to
# the current node and delete the next node
# Copy the next node's value
node.val = node.next.val
# Delete the next node
del_node = node.next
node.next = node.next.next
del del_nodeO(1)O(1)This solution is clever because it bypasses the need to access the previous node. Instead, we copy the value from the next node and delete that node instead.
Ex: 1 -> 2 -> 3 -> 4
We have access to 2.
We do not have access to 1, so what we can do is:
1 -> 3 -> 3 -> 4
1 -> 3 -> 4
Note, for proper node management we should delete the original 3 node.
Arithmetic Operations
Problems involving arithmetic operations on numbers represented as linked lists.
01Add Two NumbersMedium
You are given two non-empty linked lists representing two non-negative integers. The digits are stored in reverse order, and each node contains a single digit. Add the two numbers and return the sum as a linked list.
Example:
- Input: l1 = [2,4,3], l2 = [5,6,4]
- Output: [7,0,8]
- Explanation: 342 + 465 = 807
def addTwoNumbers(self, l1, l2):
dummy = curr = ListNode(0)
carry = 0
while l1 or l2 or carry:
carry, val = divmod(
(l1.val if l1 else 0) + (l2.val if l2 else 0) + carry, 10)
curr.next = ListNode(val)
curr = curr.next
if l1: l1 = l1.next
if l2: l2 = l2.next
return dummy.next
# Recursive solution
def addTwoNumbersRecursive(self, l1: ListNode, l2: ListNode, carry=0) -> ListNode:
# Base case: if both lists are empty and no carry
if not l1 and not l2 and not carry:
return None
# Get values (or 0 if the list has ended)
x = l1.val if l1 else 0
y = l2.val if l2 else 0
# Calculate sum and new carry
total = x + y + carry
carry = total // 10
digit = total % 10
# Create a new node with the digit value
result = ListNode(digit)
# Recursively process the next digits
result.next = self.addTwoNumbersRecursive(
l1.next if l1 else None,
l2.next if l2 else None,
carry
)
return resultO(max(n, m))O(max(n, m))Note that dummy = curr = ListNode(0) is equivalent to two lines:
curr = ListNode(0)
dummy = currNOT
curr = ListNode(0)
dummy = ListNode(0)This is basically just simulating addition of two integers as learned in primary school. We can use divmod to get the quotient and remainder.
02Add Two Numbers IIMedium
You are given two non-empty linked lists representing two non-negative integers. The most significant digit comes first and each of their nodes contains a single digit. Add the two numbers and return the sum as a linked list.
You may assume the two numbers do not contain any leading zero, except the number 0 itself.
def reverse(self, curr):
prev = None
while curr:
curr.next, prev, curr = prev, curr, curr.next
return prev
def addTwoNumbers(self, l1: Optional[ListNode], l2: Optional[ListNode]) -> Optional[ListNode]:
return self.reverse(self.addTwoNumbersI(self.reverse(l1), self.reverse(l2)))
# there are stored left to right...
# can reduce to Add Two Numbers I, by reversing both.
# and then reverse the answerO(max(n, m))O(max(n, m))This is a reminder: try to make connections with similar problems you've solved in the past. Reduction is a powerful problem solving technique, which involves massaging the current problem X in order to provide the correct inputs to a known problem solution Y, and then massaging the output of Y to solve X.
Merging Lists
Problems involving merging sorted linked lists.
01Merge Two Sorted ListsEasy
Merge two sorted linked lists and return it as a sorted list.
Example:
- List1: 1→2→4→null
- List2: 1→3→4→null
- Output: 1→1→2→3→4→4→null
This is a great place to apply our dummy node pattern:
def mergeTwoLists(self, l1: ListNode, l2: ListNode) -> ListNode:
# Iterative solution using dummy node pattern
# Create a dummy node
dummy = ListNode(0)
current = dummy
# Compare nodes from both lists and link the smaller one
while l1 and l2:
if l1.val <= l2.val:
current.next = l1
l1 = l1.next
else:
current.next = l2
l2 = l2.next
current = current.next
# Link the remaining nodes (if any)
current.next = l1 if l1 else l2
# Return the merged list (excluding the dummy node)
return dummy.next
# Recursive solution
def mergeTwoListsRecursive(self, l1: ListNode, l2: ListNode) -> ListNode:
# Base cases
if not l1:
return l2
if not l2:
return l1
# Recursive case: determine which node should come first
if l1.val <= l2.val:
# l1 comes first, so l1.next should be merged with l2
l1.next = self.mergeTwoListsRecursive(l1.next, l2)
return l1
else:
# l2 comes first, so l2.next should be merged with l1
l2.next = self.mergeTwoListsRecursive(l1, l2.next)
return l2O(n + m)O(1) iterative, O(n + m) recursiveThe dummy node pattern shines here because it lets us avoid checking for edge cases like empty lists. We simply connect nodes in sorted order and return the result starting from dummy.next. This is exactly the same as merging two arrays into 1 sorted list, but just operating on a linked list data structure.
02Merge k Sorted ListsHard
You are given an array of k linked-lists, each linked-list is sorted in ascending order. Merge all the linked-lists into one sorted linked-list and return it.
Example:
- Input: lists = [[1→4→5], [1→3→4], [2→6]]
- Output: [1→1→2→3→4→4→5→6]
This problem can be thought of as an extension of our earlier "Merge Two Sorted Lists" problem. We have several approaches:
# Approach 1: Sequential Merging
def mergeKLists(self, lists: List[ListNode]) -> ListNode:
if not lists:
return None
result = lists[0]
# Sequentially merge each list with the result
for i in range(1, len(lists)):
result = self.mergeTwoLists(result, lists[i])
return result
# Time: O(N*k), Space: O(1)
# Approach 2: Divide and Conquer (More Efficient)
def mergeKLists(self, lists: List[ListNode]) -> ListNode:
if not lists:
return None
# Merge lists using divide and conquer
def merge(lists, start, end):
if start == end:
return lists[start]
if start > end:
return None
mid = start + (end - start) // 2
left = merge(lists, start, mid)
right = merge(lists, mid + 1, end)
return mergeTwoLists(left, right)
return merge(lists, 0, len(lists) - 1)
# Time: O(N*log(k)), Space: O(log(k))
# Approach 3: Using a Priority Queue (Heap)
def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
pq = [(node.val, i, node) for i, node in enumerate(lists) if node]
heapify(pq)
dummy = ListNode(-1)
curr = dummy
while pq:
_, i, top_node = heappop(pq)
curr.next = top_node
curr = top_node
if top_node.next:
heappush(pq, (top_node.next.val, i, top_node.next))
return dummy.next
# Time: O(N*log(k)), Space: O(k)O(N*log(k))O(k) for heap approachApproach 1 is intuitive but not optimal, as we repeatedly scan through the growing result list.
Approach 2 T(k) = 2*T(k//2) + O(n) => O(nlogk). This approach is much more efficient as it pairs up lists and merges them, reducing the number of comparisons. You should read the divide and conquer section first before trying to understand this solution. Once you read that section, this logic is straightforward. If we merge all lists on the left half into one list, and all lists on the right half into one list, we can merge them together using the mergeTwoLists function from the last question. Nice.
Approach 3 efficiently merges all lists by always taking the smallest node available across all lists. The priority queue gives us the smallest element in log(k) time. Note that we don't use i, but we need it in the tuple as the second argument, otherwise heapify will complain since it can't compare list nodes using '<'. (try removing it from the tuple)
The key insight in this problem is the efficiency gain from using either divide and conquer or a priority queue, which reduces the time complexity from O(Nk) to O(Nlog(k)). For large values of k, this makes a significant difference.
List Manipulation
Problems involving rotation, partitioning, and partial reversal of linked lists.
01Rotate ListMedium
Given the head of a linked list, rotate the list to the right by k places.
Example:
- Input: 1→2→3→4→5→null, k = 2
- Output: 4→5→1→2→3→null
This problem requires several key steps: finding the length of the list, connecting the tail to the head to form a cycle, then breaking the cycle at the right place.
def rotateRight(self, head: ListNode, k: int) -> ListNode:
# Edge cases
if not head or not head.next or k == 0:
return head
# Step 1: Find the length of the list and the tail node
current = head
length = 1
while current.next:
current = current.next
length += 1
# The last node (tail) is now in current
tail = current
# Step 2: Calculate the effective rotation
# If k = length, we end up with the original list
k = k % length
if k == 0:
return head
# Step 3: Form a cycle by connecting tail to head
tail.next = head
# Step 4: Find the new tail position
# We need to go to the (length - k)th node
current = head
for _ in range(length - k - 1):
current = current.next
# Step 5: Break the cycle at the right position
new_head = current.next
current.next = None
return new_headO(n)O(1)The key insight is that rotation by k places is equivalent to taking the last k nodes and moving them to the front. By connecting the tail to the head, we form a cycle, then break it at the appropriate position.
This pattern of "connect to form a cycle, then break" is useful in many linked list rotation and reordering problems.
02Partition ListMedium
Given the head of a linked list and a value x, partition it such that all nodes less than x come before nodes greater than or equal to x. You should preserve the original relative order of the nodes in each of the two partitions.
Example:
- Input: 1→4→3→2→5→2→null, x = 3
- Output: 1→2→2→4→3→5→null
For this problem, we can maintain two separate lists (one for smaller values, one for greater/equal values) and then merge them at the end:
def partition(self, head: ListNode, x: int) -> ListNode:
# Create dummy heads for two separate lists
smaller_dummy = ListNode(0)
greater_dummy = ListNode(0)
# Pointers to track the current end of each list
smaller = smaller_dummy
greater = greater_dummy
# Traverse the original list
current = head
while current:
if current.val < x:
# Add to smaller list
smaller.next = current
smaller = smaller.next
else:
# Add to greater/equal list
greater.next = current
greater = greater.next
# Move to next node
current = current.next
# Connect the two lists
greater.next = None # Important: prevent cycles!
smaller.next = greater_dummy.next
return smaller_dummy.nextO(n)O(1)The key insight is to build two separate lists in a single pass through the original list, then connect them. The dummy nodes make it easy to handle edge cases, and setting greater.next = None is crucial to prevent cycles in the final list.
This pattern of "build separate lists, then connect" is useful for many partitioning or reordering problems in linked lists.
03Reverse Linked List IIMedium
Given the head of a linked list and two positions left and right, reverse the nodes of the list from position left to position right, and return the reversed list.
Example:
- Input: 1→2→3→4→5→null, left = 2, right = 4
- Output: 1→4→3→2→5→null
This problem requires careful pointer manipulation to reverse only a portion of the list:
def reverseBetween(self, head: Optional[ListNode], left: int, right: int) -> Optional[ListNode]:
dummy = ListNode(-1, head) # NEED THE DUMMY!!! WHAT IF WE REVERSE THE ENTIRE LIST!!!
prev, curr = dummy, head
for _ in range(left - 1):
curr, prev = curr.next, curr
prev_node_before_reverse = prev # 1
first_node_reversed = curr # 2
for _ in range(right - left + 1):
curr.next, curr, prev = prev, curr.next, curr
first_node_after_reverse = curr # 5
last_node_reversed = prev # 4
prev_node_before_reverse.next = last_node_reversed
first_node_reversed.next = first_node_after_reverse
return dummy.nextO(n)O(1)This "in-place partial reversal" technique is elegant because it avoids creating any new nodes and requires only one pass through the list.
Walk through the first example and this will be clear. Basically we just walk up to before we reverse the list. The 4 important nodes are: the node immediately before reversal, (index left -1) the first node being reversed (index left), the last node being reversed (index right), and the node immediately after reversal. (index right+1) We need to store these, reverse the list, and then re-wire accordingly at the end of the reversal.
prev_node_before_reverse.next = last_node_reversed
first_node_reversed.next = first_node_after_reverseCopy Operations
Problems involving deep copying of linked lists with complex pointer structures.
01Copy List with Random PointerMedium
A linked list of length n is given such that each node contains an additional random pointer, which could point to any node in the list, or null. Construct a deep copy of the list.
Example:
Input:
1 → 2 → 3 → null
↓ ↓ ↓
3 1 2where the second row indicates where the random pointer points
Output: A deep copy with the same structure
"""
# Definition for a Node.
class Node:
def __init__(self, x: int, next: 'Node' = None, random: 'Node' = None):
self.val = int(x)
self.next = next
self.random = random
"""
# KEY IDEA: use map between nodes in the original list and nodes in the
# copied list for fast O(1) setting of random ptrs
# O(n) time, O(n) space
# step 1. copy the new linked list with only values and next ptrs
# step 2. fill in the random pointers for the new linked list
class Solution:
def copyRandomList(self, head: 'Optional[Node]') -> 'Optional[Node]':
if not head: return head
old_to_new_map = {}
og_head = head
prev_new_node = None
curr_old_node = head
while curr_old_node:
new_node = Node(curr_old_node.val)
if prev_new_node:
prev_new_node.next = new_node
prev_new_node = new_node
old_to_new_map[curr_old_node] = new_node
curr_old_node = curr_old_node.next
# fill in random nodes now
curr_old_node = og_head
while curr_old_node:
if curr_old_node.random:
old_to_new_map[curr_old_node].random = old_to_new_map[curr_old_node.random]
curr_old_node = curr_old_node.next
return old_to_new_map[og_head]O(n)O(n)You can't just copy the list naively, because how do you copy the random pointers? You need a map between old nodes and new nodes in order to point the random pointers to the correct new node.
The idea is simple, we maintain an old to new node map. We first iterate over the old nodes, creating new nodes and setting the .next pointers of the new nodes, and updating the map.
Once the map is filled, we can fill in the random nodes. We iterate over the old nodes again, and if there is a random pointer, we set the new nodes random pointer to the new node that corresponds to the old nodes random node.
Note there is another solution that involves interleaving, but it is more complex. This solution is generic to the copy graph problem as well involving BFS so I recommend learning this.
Doubly Linked Lists
While we've focused on singly linked lists, many problems involve doubly linked lists, where each node contains pointers to both the next and previous nodes:
Advantages of Doubly Linked Lists:
- Bidirectional traversal: Can move forward and backward through the list
- Efficient deletion: Can delete a node in O(1) time without needing a separate reference to the previous node
- LRU cache implementation: Forms the basis for least recently used (LRU) cache designs
Common Problems With Doubly Linked Lists:
- LRU Cache (LeetCode 146) A doubly linked list combined with a hash map enables O(1) operations for an LRU cache:
- The list maintains the order of elements by recency
- The hash map enables direct access to nodes
- Flatten Multilevel Doubly Linked List (LeetCode 430) Converting a nested structure to a flattened doubly linked list while preserving connections.
- Design Browser History (LeetCode 1472) Implementing back and forward functionality using a doubly linked list.
The key insight with doubly linked lists is that they trade additional memory (for the extra prev pointer) for operational flexibility. This makes them ideal for problems where bidirectional movement is required or where you need to efficiently remove nodes without traversing from the head.
Definition
Visualizing a doubly linked list:
None ← [3] ⇄ [7] ⇄ [2] ⇄ [5] → None
↑headclass DoublyListNode:
def __init__(self, val=0, next=None, prev=None):
self.val = val
self.next = next
self.prev = prevInsertion
def insert_after(self, existing_node, new_value):
new_node = DoublyListNode(new_value)
# Connect new_node to its neighbors
new_node.next = existing_node.next
new_node.prev = existing_node
# Update neighbors to connect to new_node
if existing_node.next:
existing_node.next.prev = new_node
existing_node.next = new_nodeDeletion
def delete_node(self, node_to_delete):
# Connect the node's neighbors to each other
if node_to_delete.prev:
node_to_delete.prev.next = node_to_delete.next
if node_to_delete.next:
node_to_delete.next.prev = node_to_delete.prev
# If deleting the head, update head reference
if self.head == node_to_delete:
self.head = node_to_delete.nextIterative vs Recursive
When solving linked list problems, it's important to understand the tradeoffs between iterative and recursive solutions:
| Aspect | Iterative | Recursive |
|---|---|---|
| Space Complexity | O(1) for most problems | O(n) due to call stack |
| Readability | More explicit, sometimes verbose | Often more concise and elegant |
| Performance | Generally faster in practice | Call stack overhead may slow things down |
| Edge Cases | Requires explicit handling | Base cases handle many edges |
| Debugging | Easier to trace and debug | Can be trickier to follow mentally |