Emmanuel Akinfulubi

Lesson 08 of 2418 min read

Linked Lists

Nodes joined by references rather than sitting side by side in memory. Simple to describe, easy to get wrong under pressure, and a reliable interview favourite.

By the end: Reverse, merge and detect cycles in a linked list without losing your place.

An array keeps its elements side by side in memory. A linked list does not: each node holds a value and a reference to the next node, and they can sit anywhere. To reach the fifth node you must walk through the first four.

That trade is the whole story. Arrays give you instant indexing and expensive insertion at the front. Linked lists give you O(1) insertion and removal at a position you already hold, and no indexing at all.

A node is an object with a next reference

class ListNode {
  constructor(val, next = null) {
    this.val = val;
    this.next = next;
  }
}

// Built by hand, so the shape is obvious: 1 → 2 → 3 → null
const third = new ListNode(3);
const second = new ListNode(2, third);
const head = new ListNode(1, second);

let node = head;
while (node !== null) {
  console.log('value:', node.val, '| has next:', node.next !== null);
  node = node.next;
}

Output


      

Helpers for the rest of this lesson

Building and printing lists, so examples stay readable

class ListNode {
  constructor(val, next = null) { this.val = val; this.next = next; }
}

function toList(values) {
  let head = null;
  for (let i = values.length - 1; i >= 0; i--) head = new ListNode(values[i], head);
  return head;
}

function toArray(head) {
  const out = [];
  for (let n = head; n !== null; n = n.next) out.push(n.val);
  return out;
}

const list = toList([1, 2, 3, 4, 5]);
console.log('built:', toArray(list));
console.log('length:', toArray(list).length);
console.log('empty list:', toArray(toList([])));

Output


      

The cost of everything

The dummy head, which removes most of the bugs

Operations on the head are special: there is no previous node to relink. The fix is to allocate a throwaway node in front of the real head, do everything uniformly, and return dummy.next at the end.

Deleting nodes, with and without a dummy head

class ListNode { constructor(val, next = null) { this.val = val; this.next = next; } }
const toList = (a) => a.reduceRight((next, val) => new ListNode(val, next), null);
const toArray = (h) => { const o = []; for (let n = h; n; n = n.next) o.push(n.val); return o; };

// Without a dummy: the head needs its own branch, which is easy to get wrong.
function removeAllAwkward(head, target) {
  while (head !== null && head.val === target) head = head.next;   // special case
  let node = head;
  while (node !== null && node.next !== null) {
    if (node.next.val === target) node.next = node.next.next;
    else node = node.next;
  }
  return head;
}

// With a dummy: one uniform loop, no special case.
function removeAll(head, target) {
  const dummy = new ListNode(0, head);
  let node = dummy;
  while (node.next !== null) {
    if (node.next.val === target) node.next = node.next.next;   // skip it
    else node = node.next;
  }
  return dummy.next;
}

console.log('awkward:', toArray(removeAllAwkward(toList([7, 7, 1, 2, 7, 3]), 7)));
console.log('dummy:  ', toArray(removeAll(toList([7, 7, 1, 2, 7, 3]), 7)));
console.log('all removed:', toArray(removeAll(toList([7, 7, 7]), 7)));
console.log('empty:  ', toArray(removeAll(toList([]), 7)));

Output


      

Reversing a list

The single most asked linked-list question. Three references, one loop: keep hold of what came before, flip the current node's pointer backwards, then step forward.

Reverse a list iteratively, with the state shown each step

class ListNode { constructor(val, next = null) { this.val = val; this.next = next; } }
const toList = (a) => a.reduceRight((next, val) => new ListNode(val, next), null);
const toArray = (h) => { const o = []; for (let n = h; n; n = n.next) o.push(n.val); return o; };

function reverseList(head) {
  let prev = null;
  let node = head;
  while (node !== null) {
    const next = node.next;   // 1. remember the rest BEFORE we overwrite it
    node.next = prev;         // 2. flip this node's arrow backwards
    prev = node;              // 3. prev moves up
    node = next;              // 4. and so do we
  }
  return prev;                // node is null; prev is the new head
}

// Trace it so the four lines stop being magic.
function reverseTraced(head) {
  let prev = null, node = head, step = 0;
  while (node !== null) {
    console.log(`step ${step++}: at ${node.val} | reversed so far: [${toArray(prev)}] | remaining: [${toArray(node)}]`);
    const next = node.next;
    node.next = prev;
    prev = node;
    node = next;
  }
  return prev;
}

console.log('result:', toArray(reverseList(toList([1, 2, 3, 4, 5]))));
console.log('single:', toArray(reverseList(toList([1]))));
console.log('empty: ', toArray(reverseList(toList([]))));
console.log('--- traced ---');
reverseTraced(toList([1, 2, 3]));

Output


      

The recursive version, for completeness

class ListNode { constructor(val, next = null) { this.val = val; this.next = next; } }
const toList = (a) => a.reduceRight((next, val) => new ListNode(val, next), null);
const toArray = (h) => { const o = []; for (let n = h; n; n = n.next) o.push(n.val); return o; };

function reverseRecursive(head) {
  if (head === null || head.next === null) return head;   // base: 0 or 1 node
  const newHead = reverseRecursive(head.next);            // reverse the rest
  head.next.next = head;                                  // the node after me points back at me
  head.next = null;                                       // and I become the tail
  return newHead;
}

console.log(toArray(reverseRecursive(toList([1, 2, 3, 4]))));
// O(n) time but O(n) SPACE, because of the call stack. The iterative
// version is O(1) space, which is why interviewers prefer it.

Output


      

The fast and slow pointer

Move one pointer one step at a time and another two steps at a time. Two facts fall out of this, and both are interview staples.

Fact one: when fast reaches the end, slow is at the middle.

Fact two: if there is a cycle, fast will eventually lap slow and they will meet. If there is no cycle, fast reaches null.

Finding the middle in one pass

class ListNode { constructor(val, next = null) { this.val = val; this.next = next; } }
const toList = (a) => a.reduceRight((next, val) => new ListNode(val, next), null);

function middleNode(head) {
  let slow = head, fast = head;
  while (fast !== null && fast.next !== null) {
    slow = slow.next;
    fast = fast.next.next;
  }
  return slow;
}

console.log('[1,2,3,4,5] middle:', middleNode(toList([1, 2, 3, 4, 5])).val);      // 3
console.log('[1,2,3,4] middle:  ', middleNode(toList([1, 2, 3, 4])).val);         // 3, the second of the two
console.log('[1] middle:        ', middleNode(toList([1])).val);                   // 1

Output


      

Cycle detection, and finding where the cycle starts

class ListNode { constructor(val, next = null) { this.val = val; this.next = next; } }

function hasCycle(head) {
  let slow = head, fast = head;
  while (fast !== null && fast.next !== null) {
    slow = slow.next;
    fast = fast.next.next;
    if (slow === fast) return true;    // compare NODES, not values
  }
  return false;
}

// Where does the cycle begin? After they meet, reset one pointer to the head
// and advance both one step at a time; they meet at the entry point.
function cycleStart(head) {
  let slow = head, fast = head;
  while (fast !== null && fast.next !== null) {
    slow = slow.next;
    fast = fast.next.next;
    if (slow === fast) {
      let a = head;
      while (a !== slow) { a = a.next; slow = slow.next; }
      return a;
    }
  }
  return null;
}

// Build 1 → 2 → 3 → 4 → back to 2
const n1 = new ListNode(1), n2 = new ListNode(2), n3 = new ListNode(3), n4 = new ListNode(4);
n1.next = n2; n2.next = n3; n3.next = n4; n4.next = n2;

console.log('cyclic list has a cycle:', hasCycle(n1));
console.log('cycle starts at value:', cycleStart(n1).val);

const clean = new ListNode(1, new ListNode(2, new ListNode(3)));
console.log('clean list has a cycle:', hasCycle(clean), '| start:', cycleStart(clean));

Output


      

Nth node from the end, in one pass

class ListNode { constructor(val, next = null) { this.val = val; this.next = next; } }
const toList = (a) => a.reduceRight((next, val) => new ListNode(val, next), null);
const toArray = (h) => { const o = []; for (let n = h; n; n = n.next) o.push(n.val); return o; };

function removeNthFromEnd(head, n) {
  const dummy = new ListNode(0, head);
  let lead = dummy, trail = dummy;

  for (let i = 0; i < n; i++) lead = lead.next;   // open a gap of n
  while (lead.next !== null) {                     // slide both to the end
    lead = lead.next;
    trail = trail.next;
  }
  trail.next = trail.next.next;                    // trail is just before the target
  return dummy.next;
}

console.log(toArray(removeNthFromEnd(toList([1, 2, 3, 4, 5]), 2)));   // [1,2,3,5]
console.log(toArray(removeNthFromEnd(toList([1]), 1)));               // []
console.log(toArray(removeNthFromEnd(toList([1, 2]), 1)));            // [1]
console.log(toArray(removeNthFromEnd(toList([1, 2]), 2)));            // [2]

Output


      

Merging and reordering

Merge two sorted lists

class ListNode { constructor(val, next = null) { this.val = val; this.next = next; } }
const toList = (a) => a.reduceRight((next, val) => new ListNode(val, next), null);
const toArray = (h) => { const o = []; for (let n = h; n; n = n.next) o.push(n.val); return o; };

function mergeTwoLists(a, b) {
  const dummy = new ListNode(0);
  let tail = dummy;

  while (a !== null && b !== null) {
    if (a.val <= b.val) { tail.next = a; a = a.next; }
    else { tail.next = b; b = b.next; }
    tail = tail.next;
  }
  tail.next = a !== null ? a : b;   // attach whatever is left, no loop needed
  return dummy.next;
}

console.log(toArray(mergeTwoLists(toList([1, 3, 5]), toList([2, 4, 6]))));
console.log(toArray(mergeTwoLists(toList([]), toList([0]))));
console.log(toArray(mergeTwoLists(toList([1, 1]), toList([1]))));

Output


      

Reorder a list: split, reverse, weave

class ListNode { constructor(val, next = null) { this.val = val; this.next = next; } }
const toList = (a) => a.reduceRight((next, val) => new ListNode(val, next), null);
const toArray = (h) => { const o = []; for (let n = h; n; n = n.next) o.push(n.val); return o; };

// L0 → Ln → L1 → Ln-1 → ... Three well-known steps, each already covered above.
function reorderList(head) {
  if (head === null || head.next === null) return head;

  // 1. Find the FIRST middle and cut the list in two.
  let slow = head, fast = head;
  while (fast.next !== null && fast.next.next !== null) { slow = slow.next; fast = fast.next.next; }
  let second = slow.next;
  slow.next = null;

  // 2. Reverse the second half.
  let prev = null;
  while (second !== null) { const nx = second.next; second.next = prev; prev = second; second = nx; }

  // 3. Weave the two halves together.
  let a = head, b = prev;
  while (b !== null) {
    const an = a.next, bn = b.next;
    a.next = b;
    if (an === null) break;
    b.next = an;
    a = an; b = bn;
  }
  return head;
}

console.log(toArray(reorderList(toList([1, 2, 3, 4]))));        // [1,4,2,3]
console.log(toArray(reorderList(toList([1, 2, 3, 4, 5]))));     // [1,5,2,4,3]
console.log(toArray(reorderList(toList([1, 2]))));              // [1,2]

Output


      

Notice that this hard-sounding problem is three easy problems in sequence. That decomposition is the skill: when a linked-list question looks complicated, ask whether it is find-the-middle, reverse, and merge stitched together.

Doubly linked lists

Each node also points backwards. That makes deletion O(1) when you hold the node itself, with no need to find its predecessor. This is what an LRU cache is built from, paired with a hash map.

A doubly linked list with sentinel nodes at both ends

class DNode {
  constructor(val) { this.val = val; this.prev = null; this.next = null; }
}

class DList {
  #head = new DNode(null);   // sentinels: never removed, so no null checks
  #tail = new DNode(null);
  #size = 0;

  constructor() { this.#head.next = this.#tail; this.#tail.prev = this.#head; }

  get size() { return this.#size; }

  /* O(1): insert just after the head sentinel. */
  addFirst(val) {
    const node = new DNode(val);
    node.next = this.#head.next;
    node.prev = this.#head;
    this.#head.next.prev = node;
    this.#head.next = node;
    this.#size++;
    return node;
  }

  /* O(1): unlink a node you already hold. No search needed. */
  remove(node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
    this.#size--;
    return node.val;
  }

  removeLast() {
    if (this.#size === 0) return null;
    return this.remove(this.#tail.prev);
  }

  toArray() {
    const out = [];
    for (let n = this.#head.next; n !== this.#tail; n = n.next) out.push(n.val);
    return out;
  }
}

const d = new DList();
d.addFirst('c'); d.addFirst('b');
const nodeA = d.addFirst('a');
console.log('list:', d.toArray(), '| size:', d.size);

d.remove(nodeA);                       // O(1), we held the node
console.log('after removing a:', d.toArray());
console.log('removeLast returned:', d.removeLast(), '→', d.toArray());

Output


      

Your turn: four list problems

  1. isPalindromeList(head) — is the list a palindrome? Aim for O(n) time and O(1) space: find the middle, reverse the second half, compare.
  2. addTwoNumbers(a, b) — two lists hold digits in reverse order ([2,4,3] is 342). Return their sum as a list. Watch the final carry.
  3. oddEvenList(head) — regroup so all odd positions come before all even positions, preserving relative order. [1,2,3,4,5] gives [1,3,5,2,4].
  4. intersectionNode(a, b) — the node where two lists merge, or null. Neat trick: walk both, and when one ends switch it to the other's head; they meet at the intersection after at most two passes.

Editable. Fill them in, then run.

Output


      

Recap

  • A node holds a value and a reference; reaching the ith node costs O(n), but relinking a node you hold costs O(1).
  • Save node.next before you overwrite it, or the rest of the list is lost.
  • A dummy head removes the special case whenever the first node might change.
  • Fast and slow pointers find the middle in one pass and detect a cycle; resetting one to the head after they meet finds where the cycle starts.
  • Hard list problems are usually find-the-middle, reverse and merge composed together.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.