GUIDE · Lists
Linked list patterns
pointer surgery; O(1) insert/delete given a node, O(n) access.
At a glance
- WHAT
- pointer surgery; O(1) insert/delete given a node, O(n) access.
- TELL
- "ListNode", "in place", "O(1) extra space", "cycle", "middle", "nth from end", "reorder".
- SAY
- dummy / sentinel headfast & slow pointers (Floyd's tortoise and hare)prev/cur/nexttwo-pointer gapsplicedoubly linked listhead/tail sentinels
- BIG-O
- O(n) time, O(1) space for almost everything here (recursion costs O(n) stack).
The idea#
Picture a treasure hunt made of paper slips. Each slip holds a prize and the name of the next hiding spot. You can tape a new slip between two others, and the rest of the hunt stays put. To read the fifth prize, you still start at the first slip and walk.
You do pointer surgery: insert or delete is O(1) once you are holding the node, reaching that node is O(n), and almost every routine on the sheet is O(n) time and O(1) extra space, with the recursive reverse spending O(n) on the call stack.
A node is a small object: a value, and a next that is another node or null. The list you can find is the first node, the head. There is no index. Drop the head and the later slips are still tied together, and you have no way back to them.
Splicing means you change one or two next fields. A reverse walks with three fingers: previous, current, and next. Save next, point current back at previous, step forward. A dummy head is a sentinel node that is not part of the answer. It gives you a stable hook when the real head might be the node you cut.
Two pointers can share one walk. Fast takes two steps, slow takes one, and when fast falls off the end, slow is on the middle. Give them a fixed gap and slow stops just before the node you want to cut. Floyd's tortoise and hare uses those two speeds to meet inside a cycle, then one pointer goes back to the head and they walk in step until they meet at the entrance.
Why it matters#
An array is the right box until the job is "I am already holding this item, pull it out" or "open a hole right here." Opening a hole in an array shifts every later item, which is O(n). A list rewires two pointers and the other nodes stay where they are. The bill arrives when someone asks for the item at position 400. You walk 400 links. Random access is the trade.
Listen for "in place," "O(1) extra space," "cycle," "middle," "nth from the end," and "reorder." Those are pointer-walk problems. The tools are a dummy head, fast and slow, a gap of n, and a splice. If you copy the values into an array, you solved a different problem and you spent the memory the question asked you to keep.
You already use the shape. A playlist that drops the current song and stitches its neighbors is a list. An LRU cache, the box that forgets whatever you have not touched lately, is a doubly linked list plus a map. The map finds the node in O(1). The two links pull it out and park it at the front in O(1).
A small example#
function middleValue(head) { let slow = head; let fast = head; while (fast && fast.next) { slow = slow.next; fast = fast.next.next; } return slow ? slow.val : null;} const list = { val: 1, next: { val: 2, next: { val: 3, next: null } } };const mid = middleValue(list);mid is 2. Fast fell off after slow had taken one step. Same walk, two speeds, no second list.
What the code below is doing#
The sheet opens with ListNode, plus fromArray and toArray, so later checks can build a list and read it back.
Section 1 reverses with prev, cur, and a saved next, and returns prev. O(n) time, O(1) space. Section 2 reverses the rest first, then sets head.next.next = head and clears head.next. Same time, O(n) stack.
Section 3 finds the middle. On an even length, slow lands on the second of the two middle nodes. A tighter loop, stopping when fast cannot take two more steps, lands on the first middle. Section 4 detects a cycle and returns the entrance node. No cycle returns null.
Section 5 merges two sorted lists. A dummy head, always attach the smaller node, then append whichever list remains. O(n + m) time, O(1) space. Section 6 removes the nth from the end: dummy, two pointers with a gap of n + 1, and when fast hits null, slow.next is the node to splice out. That gap is what lets you delete the head. Section 11 walks a sorted list and skips next while the values match.
Section 7 reorders into first, last, second, second-to-last: first middle, reverse the second half, interleave. Section 8 checks a palindrome the same way, then compares halves. Section 9 adds two numbers stored in reverse, carrying while either list or the carry remains. Section 10 finds a shared node by jumping each pointer to the other head when it falls off, so both walk length a + b. Section 13 reverses in groups of k, leaves a short tail alone, and hooks each group's new head to the previous tail.
Section 12 is the doubly linked sketch and the LRU. Sentinels sit at both ends. remove and addFront are O(1) given the node. A map from key to node is how get and put find it. Over capacity, the node just in front of the tail sentinel is the one you evict.
When you talk about it, say this: a list is pointer surgery. O(1) to splice a node you are holding, O(n) to find it, and O(1) extra space when a few fingers on the links do the work.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict'); class ListNode { constructor(val = 0, next = null) { this.val = val; this.next = next; }}const fromArray = (arr) => arr.reduceRight((next, val) => new ListNode(val, next), null);const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.val); return out; };assert.deepEqual(toArray(fromArray([1, 2, 3])), [1, 2, 3]);assert.deepEqual(toArray(fromArray([])), []);Reverse (iterative)#
prev/cur; flip next pointer; return prev
function reverse(head) { let prev = null, cur = head; while (cur) { const nxt = cur.next; cur.next = prev; prev = cur; cur = nxt; } return prev;}Verified by 3 asserts
assert.deepEqual(toArray(reverse(fromArray([1, 2, 3, 4]))), [4, 3, 2, 1]);assert.deepEqual(toArray(reverse(fromArray([1]))), [1]);assert.equal(reverse(null), null);Reverse (recursive)#
reverse rest, then head.next.next = head
function reverseRec(head) { if (!head || !head.next) return head; const newHead = reverseRec(head.next); head.next.next = head; head.next = null; return newHead;}Verified by 2 asserts
assert.deepEqual(toArray(reverseRec(fromArray([1, 2, 3]))), [3, 2, 1]);assert.equal(reverseRec(null), null);Find middle (fast/slow)#
fast moves 2, slow moves 1; slow = middle (second of two for even)
function middle(head) { let slow = head, fast = head; while (fast && fast.next) { slow = slow.next; fast = fast.next.next; } return slow;}Verified by 3 asserts
assert.equal(middle(fromArray([1, 2, 3, 4, 5])).val, 3);assert.equal(middle(fromArray([1, 2, 3, 4])).val, 3); // for first-middle use: while (fast.next && fast.next.next)assert.equal(middle(null), null);Detect cycle + find cycle start (Floyd)#
meet inside cycle; reset one to head; walk at same speed
function detectCycle(head) { let slow = head, fast = head; while (fast && fast.next) { slow = slow.next; fast = fast.next.next; if (slow === fast) { slow = head; while (slow !== fast) { slow = slow.next; fast = fast.next; } return slow; // cycle entrance } } return null;}const cyc = fromArray([3, 2, 0, -4]);cyc.next.next.next.next = cyc.next; // tail -> node(2)Verified by 3 asserts
assert.equal(detectCycle(cyc).val, 2);assert.equal(detectCycle(fromArray([1, 2])), null);assert.equal(detectCycle(null), null);Merge two sorted lists#
dummy head; attach smaller; append remainder
function mergeTwo(a, b) { const dummy = new ListNode(); let t = dummy; while (a && b) { if (a.val <= b.val) { t.next = a; a = a.next; } else { t.next = b; b = b.next; } t = t.next; } t.next = a ?? b; return dummy.next;}Verified by 3 asserts
assert.deepEqual(toArray(mergeTwo(fromArray([1, 2, 4]), fromArray([1, 3, 4]))), [1, 1, 2, 3, 4, 4]);assert.deepEqual(toArray(mergeTwo(null, fromArray([0]))), [0]);assert.deepEqual(toArray(mergeTwo(null, null)), []);Remove nth from end#
two pointers with gap n+1 from a dummy; when fast hits null, slow.next is the target
function removeNthFromEnd(head, n) { const dummy = new ListNode(0, head); let fast = dummy, slow = dummy; for (let i = 0; i <= n; i++) fast = fast.next; while (fast) { fast = fast.next; slow = slow.next; } slow.next = slow.next.next; return dummy.next;}Verified by 3 asserts
assert.deepEqual(toArray(removeNthFromEnd(fromArray([1, 2, 3, 4, 5]), 2)), [1, 2, 3, 5]);assert.deepEqual(toArray(removeNthFromEnd(fromArray([1]), 1)), []);assert.deepEqual(toArray(removeNthFromEnd(fromArray([1, 2]), 2)), [2]); // removing headReorder list (L0 Ln L1 Ln-1 ...)#
middle, reverse second half, interleave
function reorderList(head) { if (!head || !head.next) return head; let slow = head, fast = head; while (fast.next && fast.next.next) { slow = slow.next; fast = fast.next.next; } let second = reverse(slow.next); slow.next = null; let first = head; while (second) { const n1 = first.next, n2 = second.next; first.next = second; second.next = n1; first = n1; second = n2; } return head;}Verified by 3 asserts
assert.deepEqual(toArray(reorderList(fromArray([1, 2, 3, 4, 5]))), [1, 5, 2, 4, 3]);assert.deepEqual(toArray(reorderList(fromArray([1, 2, 3, 4]))), [1, 4, 2, 3]);assert.deepEqual(toArray(reorderList(fromArray([1]))), [1]);Palindrome linked list#
middle, reverse second half, compare
function isPalindrome(head) { let slow = head, fast = head; while (fast && fast.next) { slow = slow.next; fast = fast.next.next; } let second = reverse(slow), first = head; while (second) { if (first.val !== second.val) return false; first = first.next; second = second.next; } return true;}Verified by 3 asserts
assert.equal(isPalindrome(fromArray([1, 2, 2, 1])), true);assert.equal(isPalindrome(fromArray([1, 2])), false);assert.equal(isPalindrome(fromArray([7])), true);Add two numbers (digits reversed)#
carry; loop while either list or carry
function addTwoNumbers(a, b) { const dummy = new ListNode(); let t = dummy, carry = 0; while (a || b || carry) { const sum = (a?.val ?? 0) + (b?.val ?? 0) + carry; carry = (sum / 10) | 0; t = t.next = new ListNode(sum % 10); a = a?.next ?? null; b = b?.next ?? null; } return dummy.next;}Verified by 3 asserts
assert.deepEqual(toArray(addTwoNumbers(fromArray([2, 4, 3]), fromArray([5, 6, 4]))), [7, 0, 8]); // 342 + 465 = 807assert.deepEqual(toArray(addTwoNumbers(fromArray([9, 9]), fromArray([1]))), [0, 0, 1]); // carry outassert.deepEqual(toArray(addTwoNumbers(fromArray([0]), fromArray([0]))), [0]);Intersection of two lists#
two pointers; on null, switch to the other head; meet after a+b steps
function getIntersection(a, b) { let p = a, q = b; while (p !== q) { p = p ? p.next : b; q = q ? q.next : a; } return p;}const shared = fromArray([8, 4, 5]);const la = new ListNode(4, new ListNode(1, shared)), lb = new ListNode(5, new ListNode(6, new ListNode(1, shared)));Verified by 2 asserts
assert.equal(getIntersection(la, lb), shared);assert.equal(getIntersection(fromArray([1]), fromArray([2])), null);Remove duplicates from sorted list#
skip while cur.val === cur.next.val
function deleteDuplicates(head) { for (let cur = head; cur && cur.next;) { if (cur.val === cur.next.val) cur.next = cur.next.next; else cur = cur.next; } return head;}Verified by 3 asserts
assert.deepEqual(toArray(deleteDuplicates(fromArray([1, 1, 2, 3, 3]))), [1, 2, 3]);assert.deepEqual(toArray(deleteDuplicates(fromArray([1, 1, 1]))), [1]);assert.deepEqual(toArray(deleteDuplicates(null)), []);Doubly linked list sketch + LRU structure#
head/tail sentinels; O(1) remove(node) and addFront(node); Map key -> node
class DNode { constructor(key, val) { this.key = key; this.val = val; this.prev = null; this.next = null; } }class LRU { #cap; #map = new Map(); #head = new DNode(); #tail = new DNode(); // sentinels constructor(cap) { this.#cap = cap; this.#head.next = this.#tail; this.#tail.prev = this.#head; } #remove(n) { n.prev.next = n.next; n.next.prev = n.prev; } #addFront(n) { n.next = this.#head.next; n.prev = this.#head; this.#head.next.prev = n; this.#head.next = n; } get(k) { const n = this.#map.get(k); if (!n) return -1; this.#remove(n); this.#addFront(n); return n.val; } put(k, v) { if (this.#map.has(k)) { this.#remove(this.#map.get(k)); } const n = new DNode(k, v); this.#map.set(k, n); this.#addFront(n); if (this.#map.size > this.#cap) { const lru = this.#tail.prev; this.#remove(lru); this.#map.delete(lru.key); } }}const lru = new LRU(2);lru.put(1, 1); lru.put(2, 2); lru.get(1); lru.put(3, 3);Verified by 1 assert
assert.deepEqual([lru.get(2), lru.get(1), lru.get(3)], [-1, 1, 3]);Reverse in k-groups#
check k nodes exist; reverse that segment; link prev-group tail to new head
function reverseKGroup(head, k) { const dummy = new ListNode(0, head); let groupPrev = dummy; for (;;) { let kth = groupPrev; for (let i = 0; i < k && kth; i++) kth = kth.next; if (!kth) return dummy.next; // fewer than k left: leave as is const groupNext = kth.next; let prev = groupNext, cur = groupPrev.next; // reverse [groupPrev.next .. kth], tail points to groupNext while (cur !== groupNext) { const nxt = cur.next; cur.next = prev; prev = cur; cur = nxt; } const oldHead = groupPrev.next; groupPrev.next = kth; groupPrev = oldHead; }}Verified by 3 asserts
assert.deepEqual(toArray(reverseKGroup(fromArray([1, 2, 3, 4, 5]), 2)), [2, 1, 4, 3, 5]);assert.deepEqual(toArray(reverseKGroup(fromArray([1, 2, 3, 4, 5]), 3)), [3, 2, 1, 4, 5]);assert.deepEqual(toArray(reverseKGroup(fromArray([1]), 1)), [1]);See also