Two Pointers: Beginner
What you will be able to do
The word that gives it away
- ▸"sorted array" or "sorted list" anywhere in the setup, even as an aside
- ▸"find a pair" or "2 elements whose sum equals X", or whose difference does
- ▸"in place" or "O(1) extra space" stated as a constraint on the answer
- ▸"remove duplicates" or "compact" over data that already arrives ordered
- ▸"merge 2 sorted" inputs into 1, from in-memory arrays and lists up to files and streams
- ▸"palindrome", "symmetric", or anything compared from both ends at once
The 4 shapes, and how to tell them apart
| Shape | Where the pointers start | What moves them | Question it answers |
|---|---|---|---|
| Opposite ends converging | index 0 and index n-1 | sum too small moves left up, too large moves right down | pair with a given sum, palindrome check |
| Same direction, read and write | both at index 0 | read every step, write only on a kept element | remove duplicates, move zeroes, compact |
| Fast and slow | both at the head | slow 1 node, fast 2 nodes per step | cycle detection, middle of a linked list |
| Two inputs, one cursor each | index 0 of each list | the smaller head advances | merge 2 sorted lists, sort-merge join |
The sentence to say before any code
- ▸Writing the nested loop first and having no plan for the follow-up
- ▸Reaching for a set when the question said in place
- ▸Returning values when the question asked for indexes
- ▸Starting to type before saying which of the 4 shapes this is
Why 1 comparison retires many pairs
That's the argument the interviewer waits for, and it fits in 1 sentence: because the list is sorted, each comparison retires 1 element together with every pair that element belongs to, so the loop runs at most n times. Watch the 3 comparisons run over the same 6 amounts. The pointer that moves is always the one whose value caused the sum to miss, and the run stops the moment total == target.
1nums = [1, 2, 4, 7, 9, 11]2target = 113left, right = 0, len(nums) - 14while left < right:5 total = nums[left] + nums[right]6 if total == target:7 print("pair at", left, right)8 break9 if total < target:10 left += 111 else:12 right -= 1
After 3 comparisons, 9 of the 15 pairs are retired and the pointers sit on the match at indexes 1 and 4.
The function to write from memory
Each iteration has exactly 3 outcomes: the pair matches and the function returns, the sum falls short and left moves right, or the sum runs long and right moves left. The loop ends when the pointers meet, and by then either a pair came back or every pair was retired. The animation prints pair at 1 4, the indexes rather than the values, because the question almost always wants positions, and a candidate who returns [2, 9] gets corrected and loses a minute.
The invariant, in 1 sentence
An invariant is a fact that holds before and after every iteration, and the one here is: if a matching pair exists, both of its elements sit between left and right inclusive. When the sum falls short, nums[left] paired with the largest value still in range was short, so it can't be half of any answer, and leaving it behind loses nothing. A sum that runs long retires nums[right] by the mirror argument. Once the pointers cross, the range is empty and no pair exists, which is why returning [] there is a proof rather than a guess.
Palindrome, the same walk on a string
The palindrome check is the phone-screen version of the same convergence. Pointers start at both ends of the string, the characters under them are compared, and both step inward on a match. A mismatch ends the function with False, and pointers that meet without a mismatch mean True. The cleaning line matters: "A man, a plan, a canal: Panama" is expected to pass, so lowercase the text and keep only letters and digits before the loop, and say that you are doing it.
- 1 line, and it's correct
- Allocates a second string of n characters
- Shows that you know a slice, which the interviewer assumed
- Leaves the convergence pattern undemonstrated
- 9 lines, and it is correct
- Allocates 2 integers after the cleaning step
- Shows the loop the question was written to test
- Stops at the first mismatch rather than reversing the whole string
The second shape starts both pointers at index 0 and sends them the same way. read visits every element, 1 per iteration, with no exceptions. write marks the next slot of the output and advances only when the element under read deserves to be kept. write never passes read, so every slot it overwrites is one read has already left behind, which is what makes the rewrite safe to do in place. This is the loop behind removing duplicates, dropping a given value and compacting the non-zero entries of a list.
The input holds 8 values in 3 repeated runs. read passes the second 1 because it equals nums[write]. It reaches the first 2, which differs, so write steps to index 1 and receives the 2. The other 2s pass. The 3 and the first 4 each move write forward once more, the last 4 is skipped, and write finishes on index 3.
1nums = [1, 1, 2, 2, 2, 3, 4, 4]2write = 03for read in range(1, len(nums)):4 if nums[read] != nums[write]:5 write += 16 nums[write] = nums[read]7print(nums[:write + 1])
read visits every slot; write advances only on a new value, and the slots past it keep stale leftovers.
The function returns write + 1, which is 4. The first 4 slots read [1, 2, 3, 4], and the slots after that still hold the leftovers [2, 3, 4, 4], which the caller is told to ignore. Say that out loud, because a candidate who claims the whole list is now clean has not watched the run, and the interviewer has.
The animation started with 8 values, so there was nothing to guard. The function needs its first 2 lines all the same: on an empty list, write + 1 reports 1 unique element in a list that holds none. The invariant to narrate is "every element from index 0 through write is unique and in its final place". It holds before the loop because a single element is unique on its own, which is why read starts at 1 rather than 0.
Move zeroes, the swap variant
The swap rather than a plain copy is the detail that keeps the zeroes alive. A copy pushes nums[read] to the front and leaves a stale duplicate behind it, so a second pass would have to write zeroes over the tail. The swap moves the zero sitting at write out to the read position in the same statement, and the list is finished when the loop ends. When read and write are equal the swap exchanges a slot with itself. That does no harm, and saying so tells the interviewer you saw it.
- ▸"This is a read and write pointer rewrite, so it stays in place."
- ▸"Everything from 0 through write is unique; read discovers the next unique value."
- ▸"Empty input returns 0, so I guard that before the loop."
- ▸"I return the count, and the caller reads the first count slots."
- ▸"The tail past write holds stale values, and that is expected."
The wrong turn at this band
Reaching for a set or a dict. list(set(nums)) throws away the order the sorted input arrived in, and list(dict.fromkeys(nums)) keeps it and is correct in 1 line, but either allocates a new container of up to n entries when the question said in place. Write it and the interviewer asks for the in-place version anyway, and now you're writing the loop with 4 minutes less. Mention the 1-liner as the production shortcut after the pointer version is on the screen, in the way the slice follows the palindrome loop.
The third shape sends 2 pointers from the same start at different speeds through a structure you can only walk forward: slow moves 1 node per step, fast moves 2. If the structure loops back on itself, the faster pointer comes around and catches the slower one from behind. If the structure ends, fast reaches None first. The question arrives as "does this linked list contain a cycle?" or "return the middle node", and what gets graded is whether the loop is correct, whether its stopping condition can crash, and whether you can say why the pointers must meet.
The line that crashes
The failure that costs the round is the guard. fast = fast.next.next takes 2 hops, which means fast has to exist before the line runs and so does fast.next. A loop written while fast: passes every cyclic test and fails on an acyclic list with an odd number of nodes. There fast lands on the last node, where fast.next is None, so the 2-hop line raises AttributeError: 'NoneType' object has no attribute 'next'. The interviewer tests it with 1 node or 3 nodes and no cycle. Even lengths pass by luck, because fast steps from the second-to-last node straight onto None and the loop exits. The is comparison rather than == is the second detail: 2 different nodes can carry the same value, and identity is what cycle detection asks about.
The animation stands in for the linked list with an array of next indexes, nxt[i] being the node that node i points to, with -1 marking the end. Node 5 points back to node 2, so nodes 2 through 5 form a loop of 4. Watch the gap between the 2 pointers close by 1 each step once both are inside the loop.
1nxt = [1, 2, 3, 4, 5, 2] # node i points to nxt[i]; node 5 loops back to 22slow = fast = 03steps = 04while fast != -1 and nxt[fast] != -1:5 slow = nxt[slow]6 fast = nxt[nxt[fast]]7 steps += 18 if slow == fast:9 print("met at node", slow, "after", steps, "steps")10 break
slow gains 1 node a step and fast gains 2; inside the 4-node loop the gap closes by 1 each step until both land on node 4.
Why they must meet
Once both pointers are inside the loop, the distance from fast around to slow shrinks by exactly 1 each step, because fast gains 2 and slow gives back 1. A gap that starts at no more than the loop length and shrinks by 1 per step reaches 0 within 1 lap, which is why the meeting is a certainty, and why the whole run is O(n) with 2 references of extra space. The run above meets at node 4 after 4 steps. Say "the gap closes by 1 each step, so they meet within 1 lap of the cycle", and the interviewer hears a proof instead of a hope.
- Correct in 4 lines, which is why most candidates write it first
- O(n) extra memory, 1 entry per node visited
- Needs the node to be hashable, which a plain object is
- Answers the question and fails the follow-up about space
- 7 lines, correct, with a guard that must be right
- O(1) extra memory, 2 references
- Works on any forward-only structure, including one you cannot store
- Answers the question and the follow-up in the same loop
Middle node, the same walk with a different stop
The guard and the 2 moves are unchanged, and the function returns slow when fast runs out. The follow-up is which middle you return on an even length: with 6 nodes slow stops on the 4th, and if the question wants the 3rd the guard becomes fast.next is not None and fast.next.next is not None, which stops fast 1 step earlier. State which middle you return before being asked, because the interviewer has a 6-node test ready and the answer depends on a line you have already written.
Reconciling 2 sorted ledgers
Walk it on bank_ids = [101, 102, 104, 107] and ledger_ids = [101, 103, 104, 107, 109]. The decision at every step reads the 2 heads and moves the smaller, which is the retire-the-element-that-cannot-match argument from the pair search, applied across 2 inputs: a bank id smaller than the ledger head can never match anything later in the ledger, so it's reported and left behind.
| Step | Bank head | Ledger head | Action |
|---|---|---|---|
| 1 | 101 | 101 | match, both cursors advance |
| 2 | 102 | 103 | 102 missing from ledger, i advances |
| 3 | 104 | 103 | 103 missing from bank, j advances |
| 4 | 104 | 104 | match, both cursors advance |
| 5 | 107 | 107 | match, both cursors advance |
| 6 | exhausted | 109 | tail extend reports 109 missing from bank |
The result is ([102], [103, 109]) after 5 comparisons, and no id was compared against more than 2 others. The same loop with the equality branch emitting a joined row instead of advancing in silence is an inner join; with the smaller-head branches emitting rows it is a full outer join. Say which join your branches implement, because that sentence is the bridge the interviewer is waiting to cross.
What the SQL engine does with it
A sort-merge join: each side sorted once on the join key, then 1 streaming pass with a cursor on each input.
- Build a dict from the smaller side, probe with the larger
- O(n + m) time with no sort required
- Memory holds the whole smaller side
- Default in Spark and Postgres when one side fits
- 1 cursor on each sorted input, smaller head advances
- O(n log n) to sort, then 1 streaming pass
- Once both inputs are sorted, memory holds the current row from each side plus a buffer for runs of equal keys
- Chosen when neither side fits or both arrive sorted
CDC compaction: keep the last change per key
A change data capture log holds every update to a row, and the serving table wants the latest state per key. Sorted by (key, ts), that is the read and write loop with the comparison on the key, and the write cursor keeping the last row of each run rather than the first. Dedup on a sorted event stream is the same loop keeping the first, with the comparison recs[read]['event_id'] != recs[write]['event_id']. In SQL, keep-first dedup is ROW_NUMBER() OVER (PARTITION BY key ORDER BY ts) = 1, and keep-last compaction needs ORDER BY ts DESC in the same window. The sorted requirement is identical for both: without it, a repeat of an earlier key arrives after a different key, and the check against the last kept record misses it.
- Say "the input is sorted, so" before the first line of code
- Name the shape: converging, read and write, fast and slow, or 1 cursor each
- State the invariant and which pointer is safe to discard on each branch
- Return indexes or a count when asked, and say what the tail holds
- Give the complexity with the real input size, in pairs checked
- Write the nested loop first and wait to be asked for better
- Reach for a set or dict when the question said in place
- Guard a 2-hop move with a 1-hop check
- Claim O(1) space while a cleaning step allocates a copy
- Run a merge or dedup on input nobody has sorted
> A payments company closes its books nightly. The bank delivers a settlement file sorted by transaction id, the ledger service exports its rows sorted the same way, and a CDC stream of balance updates arrives sorted by account then timestamp with each change delivered at least once. The reconciliation job has to report ids present on only 1 side, collapse the change stream to the latest balance per account without allocating a second copy, and confirm that the chain of downstream jobs it triggers never loops back on itself before it fires.
Recognize the sorted-input giveaway, pick the right pointer shape, and say why each move is safe before you code it.
- Category
- Python
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 4 hands-on challenges
Topics covered: Spotting the Pattern in 10 Seconds, Opposite-End Convergence, Read and Write Pointers, Fast and Slow Pointers, Two Pointers in Data Engineering
Lesson Sections
- Spotting the Pattern in 10 Seconds (concepts: Two-Pointer)
The word that gives it away The question names the data and never the pattern. The setup says "sorted" in its first or second sentence, usually as an aside, then asks you to find a pair or count matches, or to merge inputs or rewrite one in place. The order is the gift, and the test is whether you noticed it being handed over. A nested loop is a correct answer to every one of these questions, which is why candidates who write one feel fine until the follow-up "can you do better?" arrives with 20
- Opposite-End Convergence (concepts: Two-Pointer, Conditional Loops)
Why 1 comparison retires many pairs Take the 6 sorted amounts [1, 2, 4, 7, 9, 11] with a target of 11. Those values form 15 distinct pairs, and a nested loop visits all 15. Start a pointer at each end. The pair 1 and 11 sums to 12, over the target. Every other partner for 11 is at least as large as 1, so no pair holding 11 can reach the target: 1 comparison has retired 5 pairs. The right pointer steps to 9. Now 1 and 9 sum to 10, under the target. Every remaining partner for 1 is at most 9, so n
- Read and Write Pointers (concepts: Two-Pointer, List Modification, Iteration)
Move zeroes, the swap variant The wrong turn at this band
- Fast and Slow Pointers (concepts: Two-Pointer, Conditional Loops)
The line that crashes Why they must meet Middle node, the same walk with a different stop
- Two Pointers in Data Engineering (concepts: Two-Pointer, List Modification)
The 4th shape, 2 inputs with 1 cursor each, is the one you run in production without calling it two pointers. With 2 sorted inputs you make 1 pass and advance whichever head is smaller. That is the merge step of merge sort, the sort-merge join that Postgres and Spark run (as do most warehouse engines), the reconciliation of a bank file against a ledger, and the compaction of a change log. An interviewer on a data platform team asks this version, because it tells them whether you understand what