DSA Removing the Nth Node from the End
Removing the nth node from the end deletes the node that sits n places before the last node. Two pointers with a fixed gap between them find that node in one walk.
The problem
The problem gives the head of a linked list and a number n. The answer is the same list with one node removed. The removed node is the nth node from the end.
Counting starts at the last node. The last node is the 1st from the end. Its neighbour is the 2nd from the end.
The value of n is always valid here. It sits between 1 and the length of the list. The list holds at least one node.
The cases used in this article
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
head = None
tail = None
for value in values:
node = Node(value)
if head is None:
head = node
tail = node
else:
tail.next = node
tail = node
return head
def render(head):
parts = []
node = head
while node is not None:
parts.append(str(node.value))
node = node.next
parts.append("None")
return " -> ".join(parts)
def values_of(head):
values = []
node = head
while node is not None:
values.append(node.value)
node = node.next
return values
cases = [
([1, 2, 3, 4, 5], 2, [1, 2, 3, 5]),
([1, 2, 3, 4, 5], 5, [2, 3, 4, 5]),
([1, 2, 3, 4, 5], 1, [1, 2, 3, 4]),
([1], 1, []),
]
for values, n, expected in cases:
print("list", values, "remove", n, "from end ->", expected)Read the second row closely. Removing the 5th from the end of a five-node list removes the head. That case breaks naive code most often.
The last row removes the only node. Its answer is the empty list.
Intuition
The first idea converts the problem. The nth node from the end is a fixed position from the start. That position follows from the length.
A five-node list makes it concrete. The 2nd from the end sits at index 3 from the start. The formula is length - n, using zero-based indexes.
From the end to an index from the start
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
head = None
tail = None
for value in values:
node = Node(value)
if head is None:
head = node
tail = node
else:
tail.next = node
tail = node
return head
def render(head):
parts = []
node = head
while node is not None:
parts.append(str(node.value))
node = node.next
parts.append("None")
return " -> ".join(parts)
def values_of(head):
values = []
node = head
while node is not None:
values.append(node.value)
node = node.next
return values
values = [1, 2, 3, 4, 5]
for n in [1, 2, 5]:
index = len(values) - n
print("n:", n, "index from start:", index, "value:", values[index])The conversion is exact. It also needs the length first. Learning the length costs a full walk before any removal work starts.
Removing the head needs special care in that plan. No node stands before the head. Code that unlinks through a previous node has nothing to hold there.
Brute force
The brute force counts the length in a first walk. It then walks to the node before the target. One link change skips the target.
The head case gets its own branch. Removing the head returns the second node as the new head. Every other target unlinks through its previous node.
Count, walk, unlink
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
head = None
tail = None
for value in values:
node = Node(value)
if head is None:
head = node
tail = node
else:
tail.next = node
tail = node
return head
def render(head):
parts = []
node = head
while node is not None:
parts.append(str(node.value))
node = node.next
parts.append("None")
return " -> ".join(parts)
def values_of(head):
values = []
node = head
while node is not None:
values.append(node.value)
node = node.next
return values
def remove_brute(head, n):
length = 0
node = head
while node is not None:
length += 1
node = node.next
steps = length
target_index = length - n
if target_index == 0:
return head.next, steps
node = head
for _ in range(target_index - 1):
node = node.next
steps += 1
node.next = node.next.next
return head, steps
cases = [
([1, 2, 3, 4, 5], 2),
([1, 2, 3, 4, 5], 5),
([1, 2, 3, 4, 5], 1),
([1], 1),
]
for values, n in cases:
new_head, steps = remove_brute(build(values), n)
print(values, "n:", n, "->", values_of(new_head), "node visits:", steps)Every case from the start of the article passes. The head case passes only through its special branch. That branch is a smell.
The visit count shows the other cost. Five nodes cost five counting visits before the walk even starts. The chain is walked twice in the worst case.
The optimal solution: two pointers with a fixed gap
Hold two pointers n links apart, so when the front pointer reaches the last node the rear pointer sits just before the target. That fixed gap is the key idea.
A dummy head stands before the real head. Both pointers start on the dummy. The fast pointer first moves n steps alone, which opens the gap.
Both pointers then move together, one step each. The gap never changes during that joint walk. The slow pointer ends exactly where the unlink must happen.
The gap solution
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
head = None
tail = None
for value in values:
node = Node(value)
if head is None:
head = node
tail = node
else:
tail.next = node
tail = node
return head
def render(head):
parts = []
node = head
while node is not None:
parts.append(str(node.value))
node = node.next
parts.append("None")
return " -> ".join(parts)
def values_of(head):
values = []
node = head
while node is not None:
values.append(node.value)
node = node.next
return values
def remove_nth(head, n):
dummy = Node(0)
dummy.next = head
fast = dummy
slow = dummy
for _ in range(n):
fast = fast.next
while fast.next is not None:
fast = fast.next
slow = slow.next
slow.next = slow.next.next
return dummy.next
cases = [
([1, 2, 3, 4, 5], 2),
([1, 2, 3, 4, 5], 5),
([1, 2, 3, 4, 5], 1),
([1], 1),
]
for values, n in cases:
print(values, "n:", n, "->", values_of(remove_nth(build(values), n)))All four cases pass through one code path. The dummy head absorbs the head-removal case. Removing the head unlinks through the dummy, like any other node.
No length is counted. The chain is walked once. The answer returns as dummy.next, which is the new head.
Walkthrough
Watch the removal of the 2nd node from the end of [1, 2, 3, 4, 5]. The target is 4. The printout shows both pointers after each stage.
Opening the gap, then moving together
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
head = None
tail = None
for value in values:
node = Node(value)
if head is None:
head = node
tail = node
else:
tail.next = node
tail = node
return head
def render(head):
parts = []
node = head
while node is not None:
parts.append(str(node.value))
node = node.next
parts.append("None")
return " -> ".join(parts)
def values_of(head):
values = []
node = head
while node is not None:
values.append(node.value)
node = node.next
return values
head = build([1, 2, 3, 4, 5])
dummy = Node(0)
dummy.next = head
fast = dummy
slow = dummy
n = 2
for _ in range(n):
fast = fast.next
print("gap open: slow at dummy, fast at", fast.value)
while fast.next is not None:
fast = fast.next
slow = slow.next
print("move: slow at", slow.value, "fast at", fast.value)
print("slow sits before target:", slow.next.value)
slow.next = slow.next.next
print("after removal:", values_of(dummy.next))The gap opens with the fast pointer on 2. The slow pointer still sits on the dummy. Two links separate them, counting the steps the fast pointer took.
Three joint moves end with fast on 5, the last node. Slow sits on 3. Its next node is 4, the target, and one link change removes it.
Complexity
The time cost is O(n) for a list of n nodes, where this n means the length. The fast pointer crosses the chain once, and the slow pointer follows inside the same walk.
The space cost is O(1). Two pointers and one dummy node are stored, whatever the length.
Counting fast-pointer moves as the list grows
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
head = None
tail = None
for value in values:
node = Node(value)
if head is None:
head = node
tail = node
else:
tail.next = node
tail = node
return head
def render(head):
parts = []
node = head
while node is not None:
parts.append(str(node.value))
node = node.next
parts.append("None")
return " -> ".join(parts)
def values_of(head):
values = []
node = head
while node is not None:
values.append(node.value)
node = node.next
return values
def fast_moves(head, n):
dummy = Node(0)
dummy.next = head
fast = dummy
slow = dummy
moves = 0
for _ in range(n):
fast = fast.next
moves += 1
while fast.next is not None:
fast = fast.next
slow = slow.next
moves += 1
return moves
for size in [5, 10, 20, 40]:
head = build(list(range(1, size + 1)))
print("nodes", size, "fast moves:", fast_moves(head, 2))The fast pointer moves exactly the length of the list. Doubling the list doubles the moves. One pass means exactly that, counted.
The brute force adds a counting pass on top. Its growth rate matches, but its constant work is close to double on long lists.
Edge cases and pitfalls
Removing the head is the sharpest case. The gap walk handles it without a branch. The slow pointer simply never leaves the dummy.
Removing the tail is the opposite end. The slow pointer stops on the second-to-last node. Its link is set to None through the skipped tail.
Edge cases, run
class Node:
def __init__(self, value):
self.value = value
self.next = None
def build(values):
head = None
tail = None
for value in values:
node = Node(value)
if head is None:
head = node
tail = node
else:
tail.next = node
tail = node
return head
def render(head):
parts = []
node = head
while node is not None:
parts.append(str(node.value))
node = node.next
parts.append("None")
return " -> ".join(parts)
def values_of(head):
values = []
node = head
while node is not None:
values.append(node.value)
node = node.next
return values
def remove_nth(head, n):
dummy = Node(0)
dummy.next = head
fast = dummy
slow = dummy
for _ in range(n):
fast = fast.next
while fast.next is not None:
fast = fast.next
slow = slow.next
slow.next = slow.next.next
return dummy.next
print("remove head:", values_of(remove_nth(build([1, 2, 3]), 3)))
print("remove tail:", values_of(remove_nth(build([1, 2, 3]), 1)))
print("remove only node:", values_of(remove_nth(build([9]), 1)))
print("remove middle:", values_of(remove_nth(build([1, 2, 3, 4]), 2)))An off-by-one gap is the classic pitfall. Opening the gap one step too wide lands slow on the target itself. The unlink then removes the node after the target.
Starting the pointers at the head instead of a dummy reopens the head problem. The head case then needs its own branch again. The dummy exists to delete that branch.
Exercise
To remove the nth node from the end, how far ahead does the fast pointer start?