Here is a singly linked list: 1→2→3→2→1. You can probably tell at a glance that it reads the same forwards and backwards — a palindrome. A human brain does this effortlessly. You hold the whole sequence in your visual memory, compare the first value against the last, the second against the second-to-last, and work inward until everything matches. Two seconds, done.
But now imagine you are a machine, and the only thing you have is a pointer called head that sits on the first node. Each node stores one value and one pointer: .val and .next. That is it. There is no .prev. There is no .tail. There is no .length. You cannot jump to the end. You cannot index into the middle. You start at the head and walk forward, one node at a time, until .next is null — and then you are done. You cannot go back.
Consider what palindrome checking actually requires. You need to compare node[0].val against node[4].val, then node[1].val against node[3].val, working from both ends inward. With an array, this is trivial — arrays support random access, so arr[0] and arr[n-1] are both O(1) lookups:
function isPalindromeArray(arr: number[]): boolean { let l = 0, r = arr.length - 1 while (l < r) { if (arr[l] !== arr[r]) return false l++; r-- } return true}Two pointers, one from each end, marching toward the center. Clean, obvious, O(n) time and O(1) space. But this only works because arrays let you start at both ends simultaneously.
A singly linked list gives you exactly one direction: forward. You can reach node 0 instantly, but reaching node 4 costs four .next hops. Reaching node 3 costs three hops. And crucially, once you have walked to node 4, you cannot walk back to node 3 — there is no reverse pointer. The data structure is a one-way street.
The brute-force escape hatch is obvious: copy every value into an array, then use the two-pointer technique you already know.
function isPalindrome(head: ListNode): boolean { const values: number[] = [] let node = head while (node) { values.push(node.val); node = node.next } let l = 0, r = values.length - 1 while (l < r) { if (values[l] !== values[r]) return false; l++; r-- } return true}This works correctly. For every node in the list, you push its value into values. The array ends up as [1, 2, 3, 2, 1], and then the two-pointer check runs in O(n) time. Total: O(n) time, O(n) space.
But look at what you have done. You duplicated the entire linked list into a second data structure just to read it backward. For a list with a million nodes, you have allocated a million-element array. The linked list was already storing all those values — you copied them into a parallel structure because you could not traverse the original in reverse.
That O(n) space cost is the wall. The problem feels like it should need nothing more than a few pointers — the values are already there, sitting in the nodes, in order. You just need a way to compare the first half against the second half without copying anything. The question is: can you check a linked-list palindrome with O(1) extra space? No arrays, no stacks, no hash maps. Just the list itself and a handful of pointers.
Before you proceed, sit with that constraint. You have a one-way street and you need to compare values from opposite ends. Try to imagine how you might make it work.
Look at this linked list: 1→2→3→2→1. Is it a palindrome?
When a problem feels impossible under tight constraints, the instinct is to search for one brilliant insight — a single trick that solves everything in one pass. That instinct is almost always wrong for linked list problems. The reason is structural: linked lists restrict your movement so severely that most operations require multiple passes, each doing one transformation that sets up the next.
The better instinct is decomposition. Instead of asking “how do I solve this entire problem at once?”, ask: “can I break this into smaller problems I already know how to solve?” Each smaller problem becomes a subroutine — a self-contained function that takes a list (or a pointer into a list) and returns a transformed result. The full solution is those subroutines called in sequence, each one feeding its output to the next as input.
Let us think through what palindrome checking actually demands, step by step.
Step 1: You need to know where the middle is. A palindrome is symmetric — the first half mirrors the second half. Before you can compare halves, you need to know where one half ends and the other begins. In an array, this is trivial: mid = Math.floor(arr.length / 2). In a linked list, you do not know the length without traversing the entire list first. But you have already learned a technique that finds the midpoint in a single pass: the fast-slow pointer walk. Send a slow pointer at 1x speed and a fast pointer at 2x speed. When fast hits the end, slow is at the middle. That is your first subroutine: findMiddle(head).
Step 2: You need the second half in reverse order. Once you know the midpoint, you have two halves. For 1→2→3→2→1, the first half is 1→2 and the second half is 3→2→1 (the middle node 3 can go in either half — it does not need to be compared against anything since it is the center of symmetry). The problem is that both halves point forward. To compare the first half against the second half, you need them facing the same direction. The first half reads 1, 2 from left to right. The second half reads 3, 2, 1 from left to right — but you need it to read 1, 2, 3 from left to right (i.e., the reverse). You already know how to reverse a linked list: the three-pointer dance with prev, curr, and next that flips every .next pointer. That is your second subroutine: reverseList(mid).
Step 3: You need to compare two lists node by node. After finding the middle and reversing the second half, you have two short linked lists that (if the original is a palindrome) contain the same values in the same order. Comparing them is the simplest possible operation: start two pointers at the heads of both lists, compare .val at each position, advance both pointers, and stop when one runs out. If every pair matched, the original list is a palindrome. That is your third subroutine: compareLists(head, rev).
Here is the crucial realization: each of these subroutines is something you already know. Finding the midpoint is the fast-slow pointer technique from earlier in this module. Reversing a linked list is the iterative reversal technique from the linked list fundamentals module. Comparing two lists is a trivial lockstep walk. None of them, individually, is difficult. You are not learning three new algorithms — you are composing three algorithms you have already mastered.
This is the essence of subroutine composition as a problem-solving strategy. A hard problem decomposes into a pipeline of easy problems:
head → findMiddle → mid → reverseList → rev → compareLists → booleanThe pipeline has a beautiful property: each subroutine is stateless and focused. findMiddle does not know or care that you plan to reverse the second half afterward. reverseList does not know or care that you found the midpoint using fast-slow pointers. compareLists does not know or care how the two lists were produced. Each subroutine does one thing, and the composition connects them.
Below is an empty pipeline with three slots. Your job is to fill each slot by working through the decomposition yourself — discovering what operation belongs at each stage before the labels appear.
To check a palindrome, you need to compare the first half against the second half. Where would you SPLIT this list?
Tap the node where you'd split
You have named the three subroutines. Now it is time to watch them execute on a concrete example — but not passively. Before each stage runs, you will predict its outcome. Predicting forces you to simulate the algorithm in your head, which is fundamentally different from watching it play out after the fact. If your prediction is wrong, the gap between what you expected and what actually happened is where real learning lives.
The pipeline works on the list 1→2→3→2→1. Let us walk through what each stage does, with specific values at every step.
Stage 1: findMiddle(head) on 1→2→3→2→1.
Two pointers start at the head (node 1). Each iteration, slow advances one node and fast advances two nodes.
Step 0: slow=1, fast=1 (both at head)Step 1: slow=2, fast=3 (slow +1, fast +2)Step 2: slow=3, fast=null (slow +1, fast hits end)When fast reaches the end (or goes past it), slow is at node 3 — the midpoint. Everything after slow is the second half: 3→2→1. Notice that slow moved exactly n/2 steps while fast moved n steps. The 2:1 speed ratio guarantees that slow lands on the middle, regardless of list length. This works for odd-length lists (where there is a true center node) and even-length lists (where slow lands at the start of the second half).
Stage 2: reverseList(mid) on 3→2→1.
The reversal algorithm uses three pointers: prev (starts null), curr (starts at the head of the sublist), and next (saved before each flip).
Step 0: prev=null, curr=3, next=2 Flip: 3.next = null (was 2) Advance: prev=3, curr=2, next=1Step 1: prev=3, curr=2, next=1 Flip: 2.next = 3 (was 1) Advance: prev=2, curr=1, next=nullStep 2: prev=2, curr=1, next=null Flip: 1.next = 2 (was null) Advance: prev=1, curr=null → doneThe new head is prev, which points to node 1. The reversed second half is now 1→2→3. Notice what happened: the old tail (1) became the new head, and the old head (3) became the new tail. Every .next pointer in the sublist was flipped.
Stage 3: Compare 1→2 (first half) against 1→2→3 (reversed second half).
Two pointers, p1 at the head of the first half and p2 at the head of the reversed half, walk in lockstep:
Step 1: p1.val=1, p2.val=1 → matchStep 2: p1.val=2, p2.val=2 → matchStep 3: p1.next=null → first half exhausted, stopEvery pair matched. The extra node in the reversed half (node 3, the original middle) is never compared — the loop stops when the shorter half runs out. This is correct: the middle of an odd-length palindrome has no mirror partner.
The result: true — the list 1→2→3→2→1 is a palindrome. Three subroutines, each doing one focused transformation, composed into a pipeline that answers a question none of them could answer alone. Zero extra space: findMiddle used two pointers, reverseList used three pointers, and the comparison used two pointers. No arrays were harmed in the making of this solution.
Notice what is happening at a higher level. No single subroutine “checks a palindrome.” Each one transforms the data structure into a shape that the next subroutine needs. findMiddle produces a split point — it does not know why you need it. reverseList uses that split point to create a reversed half — it does not know you plan to compare anything. compare uses the reversed half to answer the original question. The subroutines are not independent — they form a pipeline, where each output becomes the next input. The data flows in one direction: head → mid → rev → boolean.
This pipeline architecture is what makes subroutine composition so powerful. Each stage is simple enough to verify in isolation (you can unit-test findMiddle without thinking about palindromes), yet composed together they solve a problem that would be difficult to tackle monolithically. And because each subroutine is O(n) time and O(1) space, the composed solution inherits those bounds: O(n) total time, O(1) total space.
Predict each stage before it animates below.
Stage 1: Find the middle. A fast pointer moves 2x speed, slow moves 1x through 1→2→3→2→1.
When fast hits the end, will slow have passed the midpoint, be at the midpoint, or still be in the first half?
Here is an entirely different problem: take the list 1→2→3→4→5 and reorder it to 1→5→2→4→3. The first node stays, then the last, then the second, then the second-to-last, interleaving from both ends toward the center. This is LeetCode 143, “Reorder List.”
At first glance, this has nothing to do with palindrome checking. The goal is different (reorder, not verify), the output shape is different (interleaved, not boolean), and interleaving is not the same as comparing. But watch what happens when you decompose it the same way.
What does the problem actually require? You need to take nodes from both ends of the list and weave them together. The first node from the front (1), then the first from the back (5), then the second from the front (2), then the second from the back (4), and so on. To “take from the back,” you need the second half of the list in reverse order. And to know where the second half starts, you need the midpoint.
Step 1: findMiddle(head) on 1→2→3→4→5.
Identical to before. slow lands on node 3. First half: 1→2→3. Second half: 4→5.
Step 2: reverseList(mid.next) on 4→5.
Identical reversal. The result is 5→4. Now the second half is in the order you need for interleaving from the back.
Step 3: mergeLists(firstHalf, reversedHalf) — the new part.
This is the only subroutine that differs from the palindrome pipeline. Instead of comparing values, you interleave nodes by alternating .next pointers:
function mergeLists(l1: ListNode, l2: ListNode): void { while (l2) { const next1 = l1.next // save l1's original next const next2 = l2.next // save l2's original next l1.next = l2 // l1 points to l2 l2.next = next1 // l2 points to l1's old next l1 = next1 // advance l1 l2 = next2 // advance l2 }}Walking through the concrete values:
Start: l1=1→2→3, l2=5→4Step 1: 1.next = 5, 5.next = 2 → list so far: 1→5→2→3 l1=2, l2=4Step 2: 2.next = 4, 4.next = 3 → list so far: 1→5→2→4→3 l1=3, l2=null → doneResult: 1→5→2→4→3. Exactly the target ordering.
Now step back and compare the two pipelines:
Palindrome: findMiddle → reverseList → compareListsReorder List: findMiddle → reverseList → mergeListsTwo out of three subroutines are identical. The first two stages — finding the midpoint and reversing the second half — are the same operation for the same reason: both problems need to process the second half of the list in reverse order. The only difference is what you do with the two halves once you have them. Palindrome checking compares them; reorder-list interleaves them.
This is the real payoff of thinking in subroutines. Once you have a library of composable linked list operations — findMiddle, reverseList, compareLists, mergeLists — new problems stop looking novel. You decompose them into a pipeline, check which subroutines you already own, and only write the piece that is truly new. The “reorder list” problem is one new subroutine away from a problem you already solved.
The pattern extends beyond these two problems. Any time you see a linked list problem that requires processing “from both ends” or “the second half in reverse order,” the first two pipeline stages are almost certainly findMiddle → reverseList. The only question is what the third stage does with the two halves. Comparing them tests a property (palindrome). Merging them produces a new ordering (reorder-list). You could also zip them with a custom predicate, concatenate them in a different order, or apply some other pairwise operation. The framework is the same — only the final combiner changes.
Build the reorder-list pipeline from scratch below. The subroutine bank includes decoys — operations that exist but do not belong in this particular composition.
The list is 1→2→3→4→5 and you need 1→5→2→4→3. What subroutine goes first?
You have built two pipelines now: one for palindrome checking, one for list reordering. Both decomposed a hard problem into three subroutine calls. But there is a gap between “I understand the pipeline visually” and “I can read and write the code that implements it.” This screen bridges that gap.
The palindrome checker fits in remarkably few lines. That is not an accident — it is a direct consequence of the decomposition. When each subroutine is a clean, named function, the top-level code reads like a recipe:
function isPalindrome(head: ListNode): boolean { const mid = findMiddle(head) // Stage 1 const rev = reverseList(mid) // Stage 2 let p1 = head, p2 = rev // Stage 3 setup while (p2) { // Stage 3 loop if (p1.val !== p2.val) return false p1 = p1.next! p2 = p2.next! } return true // All pairs matched}Let us trace the variable flow through each line, using the concrete example 1→2→3→2→1.
Line 2: const mid = findMiddle(head)
Input: head points to node 1, the start of the full list 1→2→3→2→1. The fast-slow pointer walk runs internally. Output: mid points to node 3, the midpoint. At this moment, the list is still intact — findMiddle did not modify any .next pointers. It just returned a pointer to the middle node.
Line 3: const rev = reverseList(mid)
Input: mid points to node 3, the head of the sublist 3→2→1. The reversal algorithm flips every .next pointer in this sublist. Output: rev points to node 1 (the old tail, now the new head). The sublist is now 1→2→3. Important: this modifies the original list in place. The first half 1→2 still has 2.next = 3, but 3.next now points to null (or back toward 2 depending on the exact split). The list has been surgically altered.
Line 4: let p1 = head, p2 = rev
Two pointers for the comparison walk. p1 starts at node 1 (head of first half). p2 starts at node 1 (head of reversed second half). They will walk in lockstep.
Lines 5-8: The comparison loop
Each iteration compares p1.val against p2.val. If they differ, the function returns false immediately — not a palindrome. If they match, both pointers advance. The loop condition is while (p2) — it runs until the reversed half is exhausted. Why p2 and not p1? Because the reversed half may be shorter (for odd-length lists, the middle node ends up in the reversed half, making it one node longer, but the first half pointer runs out at the same time or earlier). In practice, either p1 or p2 works as the loop guard for a correctly split list, but checking p2 is conventional.
Line 9: return true
If the loop completes without finding a mismatch, every compared pair matched. The list is a palindrome.
Notice how the code maps 1-to-1 with the pipeline diagram. There is no clever bit manipulation, no index arithmetic, no nested loops. A reader who understands the three subroutines can read isPalindrome and immediately see the pipeline: find, reverse, compare. The decomposition bought you not just correctness, but legibility.
One subtlety worth noting: this implementation mutates the input list. After reverseList(mid), the original list's structure is altered — the second half's pointers have been flipped. In a production codebase, you would typically restore the list after the check by reversing the second half again. That is a single additional line (reverseList(rev) after the comparison), but it is omitted here for clarity.
Explore each section of the code below. For every line, trace it back to the pipeline stage it implements.
function isPalindrome(head: ListNode): boolean { const mid = findMiddle(head) const rev = reverseList(mid) let p1 = head, p2 = rev while (p2) { if (p1.val !== p2.val) return false p1 = p1.next! p2 = p2.next! } return true}0/4 sections explored
Let us go back to the beginning. The naive palindrome checker — the one that copies every value into an array — works correctly. Here it is again:
function isPalindrome(head: ListNode): boolean { const values: number[] = [] let node = head while (node) { values.push(node.val); node = node.next } let l = 0, r = values.length - 1 while (l < r) { if (values[l] !== values[r]) return false; l++; r-- } return true}For the list 1→2→3→2→1, the values array becomes [1, 2, 3, 2, 1] — five elements, one per node. That is O(n) extra space. Every single value in the linked list has been duplicated into a second data structure. The array exists purely because linked lists cannot be traversed backward, so you needed a random-access copy to run the two-pointer comparison.
Now you know a better approach. This screen asks you to perform the transformation — refactoring the naive code into the composed solution, one step at a time, watching the space complexity drop at each stage.
Ratchet Step 1: Replace the full scan with findMiddle.
The naive code traverses the entire list to populate values. But if you use findMiddle, you can start the array population at the midpoint instead of the head. The code becomes:
function isPalindrome(head: ListNode): boolean { const mid = findMiddle(head) // NEW: start at middle const values: number[] = [] let node = mid while (node) { values.push(node.val); node = node.next } let l = 0, r = values.length - 1 while (l < r) { if (values[l] !== values[r]) return false; l++; r-- } return true}For 1→2→3→2→1, mid is node 3, so values becomes [3, 2, 1] — three elements instead of five. Space drops from O(n) to O(n/2). That is technically still O(n) in big-O terms, but the constant factor halved. The array is smaller, yet it still exists. You still need it because you still cannot traverse the second half backward.
Ratchet Step 2: Eliminate the array entirely.
Here is the breakthrough. Instead of copying the second half into an array so you can walk it backward, you reverse the second half in place. After reversal, the second half faces the same direction as the first half, and you can compare them with a simple pointer walk. No array needed at all.
function isPalindrome(head: ListNode): boolean { const mid = findMiddle(head) const rev = reverseList(mid) // NEW: reverse in-place let p1 = head, p2 = rev while (p2) { if (p1.val !== p2.val) return false p1 = p1.next!; p2 = p2.next! } return true}The values array is completely gone. reverseList(mid) modified the linked list's own pointers — the data that was already in the nodes is now accessible in the order you need. No copying, no duplication, no auxiliary data structure. Space drops from O(n/2) to O(1).
Look at the transformation in summary:
| Version | Extra space | What it stores |
|---|---|---|
| Naive | O(n) | Array of all n values |
After findMiddle | O(n/2) | Array of second-half values |
After reverseList | O(1) | Nothing — just pointers |
Each refactoring step replaced a data-copying operation with a pointer-manipulation operation. The array existed because you could not traverse the list backward. reverseList gave you backward traversal by modifying the list itself — no copying required. This is a general principle in linked list optimization: when you need data in a different order, consider rearranging the pointers instead of copying the values.
Watch the space meter as you work through the refactoring below. Each step is a ratchet — the code gets strictly better and never regresses. By the end, you will have transformed a seven-line function with an auxiliary array into a five-line function with nothing but pointers. Same correctness, fundamentally less waste.
function isPalindrome(head: ListNode): boolean {
const values: number[] = []
let node = head
while (node) { values.push(node.val); node = node.next }
let l = 0, r = values.length - 1
while (l < r) { if (values[l] !== values[r]) return false; l++; r-- }
return true
}This approach copies all node values into an array, then checks both ends. What's the space complexity?
You have learned that findMiddle, reverseList, and a final-stage operation compose into solutions for palindrome checking and list reordering. But subroutine composition is not a fixed recipe — it is a framework. The specific subroutines you combine, and the order you combine them in, depend on what the problem actually needs.
The framework has a shape:
input → [locate a split point] → [transform one or both halves] → [combine/compare] → outputFor palindrome checking, the three slots filled as: findMiddle ⟶ reverseList ⟶ compareLists. For reorder-list, they filled as: findMiddle ⟶ reverseList ⟶ mergeLists. But those are just two instantiations of the framework. Other problems might fill the slots differently.
Sometimes the pipeline uses all three operations you have practiced. Sometimes it uses two of them and introduces a new one. And sometimes, a subroutine you expect to need turns out to be unnecessary — the problem's structure makes it redundant. This last case is the trickiest, because your pattern-matching brain will want to apply the full palindrome pipeline to every linked list problem. Composition means choosing the right subroutines, not applying all of them reflexively.
Consider these three scenarios:
Scenario 1: Reorder List. You have already solved this. The pipeline is findMiddle → reverseList → mergeLists. This is a direct transfer — the same first two stages as palindrome, with a different final operation.
Scenario 2: Check if the second half of a linked list is sorted in ascending order. Your instinct might be to reverse the second half so you can walk it... but wait. “Sorted in ascending order” means each node's value is less than or equal to the next node's value. You can check that by walking forward through the second half and comparing each node to its successor. No reversal needed! The pipeline is just findMiddle → walk forward comparing consecutive values. Reversal would actually make this harder — it would flip the order, and then you would be checking descending order on the reversed list, which is the same thing with extra steps.
Scenario 3: Merge sort on a linked list (finding the split point). Merge sort recursively splits a list in half, sorts each half, and merges them. The splitting step needs to find the midpoint. That is findMiddle again — the same subroutine reused in yet another context. The point is that findMiddle is not a “palindrome technique.” It is a general-purpose linked list operation that shows up whenever you need to locate the center of a list without knowing its length.
The meta-lesson is this: fast-slow pointers are a building block, not a standalone trick. By themselves, they find midpoints and detect cycles. But composed with other operations — reversal, comparison, merging, interleaving — they become a component in solutions to problems that seem unrelated on the surface.
The three scenarios below test whether you have internalized the framework or just memorized the palindrome pipeline. Each one describes a linked list problem and asks you to choose the correct composition. One is a direct transfer from what you have already built. One changes the final stage. And one removes a stage entirely — the correct answer does not include reversal, even though your instinct will be to include it.
If you can decompose novel problems into the right subroutines, choose only the ones that are needed, and assemble them in the correct order, you own the composition pattern.
To reorder a list L0⟶L1⟶...⟶Ln into L0⟶Ln⟶L1⟶Ln-1⟶..., what pipeline of subroutines do you need?