LearnDSA PatternsFast and Slow PointersCase Study

LeetCode 19 · Medium · Two-Pointer Gap · TypeScript

Remove Nth Node From End:
4 Bugs to a Clean Solution

Not a tutorial — a real debugging log. Five attempts, four distinct bugs, each one reproduced with a concrete failing input before it was fixed.

Scroll to follow the journey

The Problem

LeetCode 19 — Remove Nth Node From End of List

Given the head of a singly linked list, remove the nth node from the end of the list and return its head — in a single pass.

Input

[1,2,3,4,5], n=2

Output

[1,2,3,5]

Input

[1], n=1

Output

[]

Input

[1,2], n=1

Output

[1]

The Journey

Attempt by Attempt

Every bug below was reproduced with a real failing input before being fixed — not just described.

1 Dummy Aliases Head Instead of Being a Real Node 🐛 Buggy
function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
  let dummy = head;
  let slow = dummy, fast = dummy;

  for (let i = 0; i < n; i++) fast = fast!.next;

  while (fast!.next) {
    slow = slow!.next;
    fast = fast!.next;
  }

  slow!.next = slow!.next!.next;
  return dummy!.next;
}

`dummy` is just another name for `head` — the same node object, not a fresh predecessor.

A dummy node exists specifically so `slow` always has somewhere valid to sit even when the node being removed is the first one. Aliasing `dummy = head` throws that guarantee away — for a single-node list, advancing `fast` past the (fake) dummy runs off the end of the list entirely.

Proof — input [1], n=1:

dummy = head = Node(1). slow = fast = dummy.
for i=0: fast = fast.next = null.
while(fast.next) → fast is null → fast.next throws.
Expected: [] (empty list). Got: TypeError — Cannot read properties of null (reading 'next').
Fix →

Use a real placeholder node — `new ListNode(0, head)` — so `slow`/`fast` always start one position before the real list, giving every node (including the head) a genuine predecessor.

2 The Gap-Advance Loop Never Actually Advances 🐛 Buggy
function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
  const dummy = new ListNode(0, head);
  let slow = dummy, fast: ListNode | null = dummy;

  while (0 < n) { fast = fast!.next; }

  while (fast!.next) {
    slow = slow!.next;
    fast = fast!.next;
  }

  slow!.next = slow!.next!.next;
  return dummy.next;
}

The loop meant to walk `fast` forward `n` times checks `0 < n` — but nothing inside the loop body ever changes `n`.

`while (0 < n)` reads like it was meant to be a countdown, but the decrement got dropped. `fast` keeps calling `.next` forever, either hanging or eventually crashing once `fast` walks past the end of the list into `null.next`.

Proof — input [1,2,3,4,5], n=2:

Loop body runs unconditionally (n is never touched):
fast → 1 → 2 → 3 → 4 → 5 → null, then null.next → CRASH after 6 iterations.
Condition `0 < n` was never even re-checked meaningfully — n stayed 2 the whole time.
Infinite loop / crash — expected [1,2,3,5].
Fix →

Use a bounded loop that actually consumes `n`: `for (let i = 0; i < n; i++) fast = fast.next;`

3 Removal Buried Inside a Conditional, So It Fires Too Many Times 🐛 Buggy
function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
  const dummy = new ListNode(0, head);
  let slow = dummy, fast: ListNode = dummy;

  for (let i = 0; i < n; i++) fast = fast.next!;

  while (fast.next) {
    slow = slow!.next!;
    fast = fast.next;
    if (fast.next) {
      slow.next = slow.next!.next;
    }
  }

  return dummy.next;
}

The removal line works — but it's nested inside `if (fast.next)` inside the loop, so it runs once per iteration instead of exactly once, after the loop finishes.

This isn't a syntax error, so it silently produces a wrong list instead of crashing. Because the condition is true on almost every pass, the splice (`slow.next = slow.next.next`) fires repeatedly against a `slow` pointer that's still advancing — each firing permanently skips whatever node `slow` was pointing at in that moment, not just the intended target.

Proof — input [1,2,3,4,5], n=2:

gap: fast→2, slow stays at dummy
iter1: slow→1, fast→3. fast.next(4) truthy → splice: 1.next=2.next=3 → dummy→1→3→4→5 (2 wrongly removed!)
iter2: slow→3, fast→4. fast.next(5) truthy → splice: 3.next=4.next=5 → dummy→1→3→5 (4 wrongly removed too!)
iter3: slow→5, fast→5. fast.next=null → no splice. loop ends.
Returns [1,3,5]   (WRONG — expected [1,2,3,5]; two nodes removed instead of one)
Fix →

Move the removal out of the `if` and out of the loop entirely — run it once, unconditionally, right after the `while` finishes.

4 Unconditional Now — But Still Trapped Inside the Loop 🐛 Buggy
function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
  const dummy = new ListNode(0, head);
  let slow = dummy, fast: ListNode = dummy;

  for (let i = 0; i < n; i++) fast = fast.next!;

  while (fast.next) {
    slow = slow!.next!;
    fast = fast.next;
    slow.next = slow.next!.next;
  }

  return dummy.next;
}
// ​​ removal still lives inside the loop — if the loop body never runs (0 iterations), it never fires

The `if` is gone, but the removal is still the last statement inside the `while (fast.next)` loop body — so it only ever runs if the loop body runs at least once.

For most inputs the loop runs multiple times, masking the defect. But when the target to remove is close enough that the loop body never executes even once — e.g. removing the only node in a 1-element list — the removal never fires at all.

Proof — input [1], n=1:

dummy→1. slow = fast = dummy.
for i=0: fast = dummy.next = Node(1).
while(fast.next): fast.next = Node(1).next = null → loop body never runs (0 iterations).
Removal line never executes. Returns dummy.next = Node(1), unchanged.
Returns [1]   (WRONG — expected [], the only node was never removed)
Fix →

Pull the removal all the way out of the loop — after the `while`, unconditional, exactly once — so it fires the same way regardless of how many times (including zero) the loop body ran.

5 Final — Gap + Dummy + Single Unconditional Removal ✅ ✅ Correct
// O(N) time, O(1) space — two-pointer gap + dummy node
function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
  const dummy = new ListNode(0, head);
  let slow: ListNode = dummy, fast: ListNode = dummy;

  for (let i = 0; i < n; i++) fast = fast.next!; // open the n-node gap

  while (fast.next) {                    // walk together until fast is on the last node
    slow = slow.next!;
    fast = fast.next;
  }

  slow.next = slow.next!.next;   // single, unconditional removal
  return dummy.next;                // never head — it may have been the removed node
}

// [1,2,3,4,5], n=2 → [1,2,3,5]
console.log(removeNthFromEnd(list1, 2)); // → [1,2,3,5]
// [1], n=1 → [] (no crash, no skipped removal)
console.log(removeNthFromEnd(list2, 1)); // → []

Advance `fast` a fixed `n` steps ahead of `slow` from the dummy. Walk both forward together until `fast` is on the last real node. `slow` is now exactly one node before the target — splice it out once, unconditionally, after the loop. Return `dummy.next`, never `head` — `head` itself may have just been removed.

Proof — both cases verified:

[1,2,3,4,5], n=2 → [1,2,3,5]  ✅ (node 4 removed, no over-removal)
[1], n=1 → []  ✅ (0-iteration loop still triggers the unconditional removal after it)

Interactive

Gap Visualizer

Watch the n=2 gap slide down [1,2,3,4,5] step-by-step.

Ø 1 2 3 4 5 slow fast

fast starts at dummy and advances n=2 steps: dummy→1→2. slow stays at dummy.

slow fast final result

Lessons Learned

Key Takeaways

🪢

A dummy node is required whenever the target might be the head

Aliasing dummy = head isn't a shortcut — it throws away the entire reason a dummy node exists. slow needs a real predecessor node even when the node being removed is the original head.

🔢

Bound your loops with something that actually changes

while (0 < n) with nothing decrementing n is a condition that's permanently true. A for loop with an explicit counter makes the bound impossible to forget.

🎯

Operations meant to happen once, at the end, don't belong inside a loop's if

Burying slow.next = slow.next.next inside an if inside the while loop meant it fired once per qualifying iteration instead of exactly once — silently corrupting the list instead of crashing.

🧵

Zero-iteration loops are a real edge case — test them

Removing the only node in a 1-element list means the second while loop's body never runs at all. Any logic still living inside that loop, even unconditionally, gets skipped entirely.

Final Result

The Verified Solution

Two-pointer gap + dummy node — a single pass, no second traversal to find the length.

Final — Attempt 5
// O(N) time, O(1) space — two-pointer gap + dummy node
function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
  const dummy = new ListNode(0, head);
  let slow: ListNode = dummy, fast: ListNode = dummy;

  for (let i = 0; i < n; i++) fast = fast.next!; // open the n-node gap

  while (fast.next) {                    // walk together until fast is on the last node
    slow = slow.next!;
    fast = fast.next;
  }

  slow.next = slow.next!.next;   // single, unconditional removal
  return dummy.next;                // never head — it may have been the removed node
}

// [1,2,3,4,5], n=2 → [1,2,3,5]
console.log(removeNthFromEnd(list1, 2)); // → [1,2,3,5]
// [1], n=1 → [] (no crash, no skipped removal)
console.log(removeNthFromEnd(list2, 1)); // → []
Solution Time Space Verified
Two-Pointer Gap + Dummy Node (Attempt 5) Verified ✓ O(N) O(1) Multi-node and single-node (0-iteration) cases, single pass

Reuse This

Template: Two-Pointer Gap + Dummy Node

The generic shape behind this solution — adapt it to any "Kth from the end" problem on a linked list.

Pseudocode
function nStepGapWithDummy(head, n):
    dummy = new Node(placeholder)
    dummy.next = head
    slow = dummy
    fast = dummy

    for i in 0..n-1:
        fast = fast.next          // open a fixed n-node gap

    while fast.next is not null:
        slow = slow.next
        fast = fast.next          // walk together — gap stays constant

    // slow is now exactly one node before the target
    performOperation(slow)        // e.g. slow.next = slow.next.next

    return dummy.next             // never return head — it may have been the target

When to reach for this

Kth-from-the-end problems on a singly linked list, or any operation that might need to modify/remove the head.

The trap this journey hit

Operations meant to happen once, at the end, get buried inside an if inside the loop instead — making them fire an unpredictable number of times.

Test Yourself

Quiz: Check Your Understanding

Question 1 of 12 Score: 0
This problem can be solved with two passes: walk once to count the list's length, then walk again to the (length - n)th node. A single-pass approach instead advances a fast pointer n steps ahead first, then moves both pointers together with a fixed gap. Why is the single-pass gap approach often preferred even though both are O(N)? basic

Keep Learning

Bugs are part of the process 🎉

Four real bugs, four real fixes — that's what getting to a correct, single-pass Two-Pointer Gap solution actually looks like.