LeetCode 19 - Remove Nth Node From End of List
This problem asks us to remove the nth node counted from the end of a singly linked list and return the modified list. A singly linked list is a sequence of nodes where each node contains a value and a pointer to the next node.
Difficulty: 🟡 Medium
Topics: Linked List, Two Pointers
Solution
Problem Understanding
This problem asks us to remove the nth node counted from the end of a singly linked list and return the modified list.
A singly linked list is a sequence of nodes where each node contains a value and a pointer to the next node. Unlike arrays, linked lists do not allow direct indexing, so accessing a node at a specific position requires traversing the list step by step.
The input consists of two values:
head, which points to the first node of the linked listn, which tells us which node from the end should be removed
The output should be the head of the updated linked list after removing the target node.
For example, if the list is:
1 -> 2 -> 3 -> 4 -> 5
and n = 2, the second node from the end is 4, so the result becomes:
1 -> 2 -> 3 -> 5
The constraints are relatively small, since the list size is at most 30 nodes. Even an O(n²) solution would technically pass. However, the follow-up explicitly asks whether we can solve it in one pass, which strongly suggests that the intended solution uses a more efficient traversal strategy.
Several edge cases are important here.
If the list contains only one node and that node must be removed, the result should be an empty list.
If the node to remove is the head itself, we must correctly update the head pointer. This is a common source of bugs in linked list problems.
If the node to remove is the last node, we must correctly disconnect the tail.
The problem guarantees that 1 <= n <= sz, so we never need to handle invalid values of n.
Approaches
Brute Force Approach
A straightforward solution is to perform two passes over the linked list.
In the first pass, we count the total number of nodes in the list. Once we know the length, we can determine the position of the node that must be removed from the beginning of the list.
If the list length is L, then the node to remove is at position:
L - n
from the start, using zero-based indexing.
We then perform a second traversal to reach the node immediately before the target node and update its next pointer to skip the target.
This approach is correct because counting the total length allows us to precisely convert a position from the end into a position from the start.
Although this solution is perfectly acceptable for the given constraints, it requires two passes through the list. The follow-up asks for a one-pass solution, which motivates a more optimal technique.
Optimal Approach
The key insight is that we do not actually need to know the total length of the list.
Instead, we can maintain a fixed gap of n nodes between two pointers. When the fast pointer reaches the end of the list, the slow pointer will automatically be positioned right before the node that needs to be removed.
This works because the distance between the pointers remains constant throughout the traversal.
To simplify edge cases, especially when removing the head node, we introduce a dummy node before the actual head. The dummy node points to the original head and allows us to treat head removal exactly the same as any other removal operation.
The algorithm proceeds as follows:
- Move the fast pointer
nsteps ahead - Move both pointers together until the fast pointer reaches the last node
- The slow pointer now sits immediately before the target node
- Remove the target by updating
slow.next
This achieves the follow-up requirement because the list is traversed only once.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n) | O(1) | Two passes, first counts length, second removes node |
| Optimal | O(n) | O(1) | One-pass two-pointer solution with fixed gap |
Algorithm Walkthrough
- Create a dummy node whose
nextpointer references the original head.
This dummy node simplifies removal logic. Without it, removing the head node would require separate handling because the head pointer itself changes.
2. Initialize two pointers, fast and slow, both starting at the dummy node.
The goal is to maintain a gap of exactly n nodes between them.
3. Move the fast pointer forward n times.
After this step, fast is n nodes ahead of slow.
4. Move both pointers forward together until fast.next becomes None.
At this moment:
fastis at the last nodeslowis immediately before the node that must be removed
- Remove the target node by updating:
slow.next = slow.next.next
This bypasses the target node and reconnects the list.
- Return
dummy.next.
This correctly handles all cases, including when the original head was removed.
Why it works
The algorithm maintains an invariant that the fast pointer is always n nodes ahead of the slow pointer. When fast reaches the final node, slow must therefore be positioned immediately before the node that is n positions from the end. Since linked list removal only requires access to the previous node, slow is exactly where we need it to be.
Python Solution
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, val=0, next=None):
# self.val = val
# self.next = next
from typing import Optional
class Solution:
def removeNthFromEnd(
self,
head: Optional[ListNode],
n: int
) -> Optional[ListNode]:
dummy = ListNode(0, head)
fast = dummy
slow = dummy
# Move fast pointer n steps ahead
for _ in range(n):
fast = fast.next
# Move both pointers until fast reaches the end
while fast.next:
fast = fast.next
slow = slow.next
# Remove the target node
slow.next = slow.next.next
return dummy.next
The implementation begins by creating a dummy node that points to the original head. This avoids special-case logic for deleting the first node.
Both fast and slow pointers start at the dummy node. The fast pointer is advanced n steps ahead so that the required gap is established.
The main traversal loop moves both pointers together while preserving the gap. Once fast.next becomes None, the slow pointer is positioned directly before the node to remove.
The removal itself is performed with a single pointer update:
slow.next = slow.next.next
This skips the target node and reconnects the remaining list.
Finally, dummy.next is returned because the original head may have been removed.
Go Solution
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func removeNthFromEnd(head *ListNode, n int) *ListNode {
dummy := &ListNode{
Val: 0,
Next: head,
}
fast := dummy
slow := dummy
// Move fast pointer n steps ahead
for i := 0; i < n; i++ {
fast = fast.Next
}
// Move both pointers together
for fast.Next != nil {
fast = fast.Next
slow = slow.Next
}
// Remove target node
slow.Next = slow.Next.Next
return dummy.Next
}
The Go implementation follows exactly the same logic as the Python solution. The primary difference is explicit pointer syntax.
Go uses nil instead of Python's None, and struct pointers are manipulated directly using *ListNode.
Memory management is handled automatically by Go's garbage collector, so the removed node does not need explicit deallocation.
Worked Examples
Example 1
Input: head = [1,2,3,4,5], n = 2
Initial list:
dummy -> 1 -> 2 -> 3 -> 4 -> 5
After moving fast forward 2 steps:
| Step | fast | slow |
|---|---|---|
| Start | dummy | dummy |
| Move 1 | 1 | dummy |
| Move 2 | 2 | dummy |
Now move both pointers together:
| Iteration | fast | slow |
|---|---|---|
| 1 | 3 | 1 |
| 2 | 4 | 2 |
| 3 | 5 | 3 |
At this point, fast.next is None, so stop.
slow is at node 3, which is immediately before node 4.
Perform removal:
3.next = 5
Final list:
1 -> 2 -> 3 -> 5
Example 2
Input: head = [1], n = 1
Initial list:
dummy -> 1
Move fast one step:
| Step | fast | slow |
|---|---|---|
| Start | dummy | dummy |
| Move 1 | 1 | dummy |
Since fast.next is None, the loop does not execute.
slow remains at the dummy node.
Removal:
dummy.next = None
Final list:
[]
Example 3
Input: head = [1,2], n = 1
Initial list:
dummy -> 1 -> 2
Move fast one step:
| Step | fast | slow |
|---|---|---|
| Start | dummy | dummy |
| Move 1 | 1 | dummy |
Move both pointers:
| Iteration | fast | slow |
|---|---|---|
| 1 | 2 | 1 |
Now fast.next is None.
slow points to node 1, which is immediately before node 2.
Removal:
1.next = None
Final list:
1
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n) | Each node is visited at most once |
| Space | O(1) | Only a few pointer variables are used |
The algorithm performs a single traversal of the linked list. Even though two pointers move through the structure, each pointer only advances forward and never revisits nodes, so the total work remains linear in the number of nodes.
The space complexity is constant because no auxiliary data structures proportional to input size are allocated.
Test Cases
# Helper functions for testing
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def build_list(values):
dummy = ListNode()
current = dummy
for value in values:
current.next = ListNode(value)
current = current.next
return dummy.next
def list_to_array(head):
result = []
while head:
result.append(head.val)
head = head.next
return result
class Solution:
def removeNthFromEnd(self, head, n):
dummy = ListNode(0, head)
fast = dummy
slow = dummy
for _ in range(n):
fast = fast.next
while fast.next:
fast = fast.next
slow = slow.next
slow.next = slow.next.next
return dummy.next
solution = Solution()
# Example 1
head = build_list([1, 2, 3, 4, 5])
result = solution.removeNthFromEnd(head, 2)
assert list_to_array(result) == [1, 2, 3, 5] # remove middle node
# Example 2
head = build_list([1])
result = solution.removeNthFromEnd(head, 1)
assert list_to_array(result) == [] # remove only node
# Example 3
head = build_list([1, 2])
result = solution.removeNthFromEnd(head, 1)
assert list_to_array(result) == [1] # remove tail node
# Remove head from larger list
head = build_list([1, 2, 3])
result = solution.removeNthFromEnd(head, 3)
assert list_to_array(result) == [2, 3] # remove head
# Remove tail from longer list
head = build_list([10, 20, 30, 40])
result = solution.removeNthFromEnd(head, 1)
assert list_to_array(result) == [10, 20, 30] # remove last node
# Remove second node
head = build_list([5, 6, 7, 8])
result = solution.removeNthFromEnd(head, 3)
assert list_to_array(result) == [5, 7, 8] # remove internal node
# Two-node list removing first node
head = build_list([1, 2])
result = solution.removeNthFromEnd(head, 2)
assert list_to_array(result) == [2] # remove head in small list
# Larger sequential list
head = build_list(list(range(1, 11)))
result = solution.removeNthFromEnd(head, 5)
assert list_to_array(result) == [1, 2, 3, 4, 5, 7, 8, 9, 10] # remove middle element
print("All test cases passed!")
| Test | Why |
|---|---|
[1,2,3,4,5], n=2 |
Standard middle-node removal |
[1], n=1 |
Single-node edge case |
[1,2], n=1 |
Tail removal |
[1,2,3], n=3 |
Head removal |
[10,20,30,40], n=1 |
Removing last node in longer list |
[5,6,7,8], n=3 |
Internal node removal |
[1,2], n=2 |
Removing head in minimal multi-node list |
[1..10], n=5 |
Larger traversal validation |
Edge Cases
One important edge case occurs when the head node itself must be removed. This happens when n equals the length of the list. Without a dummy node, the implementation would need special logic to update the head pointer separately. The dummy node eliminates this complication because the node before the head always exists.
Another critical case is a single-node list. For example:
[1], n = 1
After removal, the list becomes empty. A careless implementation might attempt to access next pointers that no longer exist. The current solution handles this safely because the dummy node remains valid even when the real list becomes empty.
Removing the tail node is also a common source of mistakes. In a singly linked list, there is no backward traversal, so we must stop at the node immediately before the tail. The two-pointer gap guarantees that the slow pointer lands exactly at that location, making tail removal straightforward.
A final subtle edge case involves very small lists, especially two-node lists. Pointer movement logic can easily become off by one in these scenarios. Using the invariant that fast stays exactly n nodes ahead of slow ensures the algorithm behaves consistently regardless of list size.