Search Tech Journey

Find topics, journeys and posts

back to blog
algorithmsbeginner 60m read

L04 · Arrays & Strings — In-Place Discipline

The read pointer and write pointer skeleton that solves a surprising share of Easy problems, and the index bookkeeping that makes it correct on the first try.

🧩DSAPhase 0 · Foundations· Session 004 of 130 60 min

🎯 Master the read-pointer / write-pointer skeleton: overwrite an array in place while scanning it once, keeping a clean invariant so the answer is correct on the first attempt.

Series: LeetCode — From Basics to Interview-Ready · Session 4 / 65 · Phase 0 · Foundations

What this pattern IS — writing over yourself as you read

Many array problems ask you to keep some elements and drop others, then report the new length — remove duplicates, move zeros, remove a value. The naive instinct is to build a fresh list and copy the survivors:

def remove_val_naive(nums, val):
    result = [x for x in nums if x != val]   # O(n) extra memory
    return result

That works, but interviewers often add "do it in place, O(1) extra space." You cannot allocate a second array. You also cannot delete elements from the middle cheaply — list.pop(i) shifts everything after it, turning the loop into O(n^2).

The pattern that solves this cleanly: two indices walking the same array. A read pointer scans every element. A write pointer marks where the next kept element goes. When the reader finds a keeper, you copy it to the writer's slot and advance the writer. Everything before the write pointer is the finished, compacted answer.

The analogy
🌍 Real world
Imagine tidying a bookshelf without a second shelf. You scan left to right (the reader). Each book worth keeping, you slide leftward into the next open gap (the writer). Books you discard, you just skip. When you finish scanning, everything left of your "next gap" marker is the tidy shelf.
💻 Code world
The reader visits every index. The writer points at the next slot for a kept element. Copy keepers to nums[write], advance write. After the pass, nums[:write] is the compacted result and write is its length.

Why not just delete in place?

# ANTI-PATTERN: deleting while iterating
i = 0
while i < len(nums):
    if nums[i] == val:
        nums.pop(i)          # O(n) shift EACH time -> O(n^2) total, and index bugs
    else:
        i += 1

pop(i) shifts every later element left by one — O(n) per removal, O(n^2) overall, and mutating length mid-loop is a classic off-by-one trap. The read/write skeleton avoids both: no deletions, no shifts, one clean pass.

From first principles
Start with the question
Why can one array hold both the unprocessed input and the finished output at the same time?
  1. 1
    The write pointer never moves faster than the read pointer.
    forced by · We only advance write when we keep an element the reader already passed.
  2. 2
    So write <= read always; the write index is never ahead of what we have read.
    forced by · Every keeper we place came from a slot the reader already consumed.
  3. 3
    Therefore copying nums[read] into nums[write] never overwrites data we still need.
    forced by · The slot at write was already read (or equals read), so its old value is safe to lose.
  4. 4
    Elements in nums[:write] are exactly the kept ones, in original order.
    forced by · We wrote them in the order the reader found them, skipping only discards.
⇒ Therefore
The invariant write <= read lets the same array store 'answer so far' in [0, write) and 'still to process' in [read, n) with no overlap — enabling O(1) extra space.

The templates — annotated

Variant A — keep elements that pass a test

def compact(nums, keep):
    write = 0                        # next slot for a KEPT element
    for read in range(len(nums)):    # reader visits every index
        if keep(nums[read]):         # is this element a survivor?
            nums[write] = nums[read]  # place it at the writer
            write += 1               # advance the writer only for keepers
    return write                     # length of the compacted prefix nums[:write]

remove_element(nums, val) is keep = lambda x: x != val. move_zeroes keeps non-zeros then fills the tail with zeros. The whole family is one skeleton with a different keep.

Variant B — dedupe a SORTED array (compare to the last kept)

def remove_duplicates(nums):
    if not nums:
        return 0
    write = 1                        # first element is always kept
    for read in range(1, len(nums)):
        if nums[read] != nums[write - 1]:  # different from the last kept element?
            nums[write] = nums[read]        # keep it
            write += 1
    return write

Because the array is sorted, duplicates are adjacent, so comparing to nums[write-1] (the last thing we kept) is enough to skip runs.

Variant C — reverse / swap from both ends (foreshadows L07)

def reverse_in_place(s):
    left, right = 0, len(s) - 1
    while left < right:
        s[left], s[right] = s[right], s[left]  # swap ends, no temp
        left += 1
        right -= 1
Mental modelReader scouts, writer builds
Two fingers on one array. The reader finger sweeps left to right touching everything. The writer finger sits at the boundary of the finished part and only steps forward when the reader hands it a keeper. Left of the writer = done and compacted; from the reader onward = untouched.
  • write starts at 0 (or 1 when the first element is always kept).
  • Advance write ONLY when you keep an element.
  • Copy nums[read] -> nums[write], never delete/pop.
  • The answer length is the final write value; the answer is nums[:write].
🔔 Fires when you see
Prompt says 'in place', 'O(1) extra space', 'return the new length', or 'modify the array' for keep/drop/compact tasks.

Memory technique — "the writer only earns a step"

The one rule people forget is when to advance the write pointer. Peg it as a payday: the writer only earns a step when it is handed a keeper. The reader works every tick (it is in the for), but the writer is paid on results. If you keep this image, you will never advance write inside the wrong branch — the single most common bug in this pattern.

Reinforce it with the shape of the code: the skeleton is always for read: if keep: nums[write]=nums[read]; write += 1. Picture those two lines as a locked block that always move together. If you see write += 1 outside a keep branch, the bell rings.

Common misconception
✗ What most people think
To remove elements in place I should delete them from the list as I go.
✓ What is actually true
You never delete. You overwrite: copy the elements you keep toward the front with a write pointer, and ignore the rest. The caller uses only the first `write` elements.
Why the myth is so sticky
Deleting from a Python list shifts all later elements (O(n) each, O(n^2) total) and changes the length mid-loop, causing skipped elements and index errors. Overwriting is one clean O(n) pass with no shifting.
Prove it to yourself
If your loop calls .pop() or del on the array you are scanning, you are on the wrong path. The correct solution only assigns nums[write] = nums[read] and increments write.

What interviewers actually ask

In-place compaction is the warm-up round — the interviewer is checking your index discipline before the real question. These are the classics:

  • Remove Duplicates from Sorted Array (26) · Microsoft, Facebook/Meta (Easy) — probes: compare-to-last-kept write pointer, return new length. Follow-up: allow each value at most twice (80) → compare to nums[write-2].
  • Remove Element (27) · Amazon (Easy) — probes: keep-if-not-equal write pointer. Follow-up: "order does not matter — can you do fewer writes?" (swap from the end).
  • Move Zeroes (283) · Amazon, Meta, Bloomberg (Easy) — probes: keep non-zeros with the writer, then fill the tail. Follow-up: "minimise total operations."
  • Merge Sorted Array (88) · Microsoft, Amazon (Easy) — probes: write from the BACK to avoid overwriting unmerged values. Follow-up: "why back-to-front?"
  • Reverse String (344) · Amazon, Microsoft (Easy) — probes: two-ends swap, O(1) space. Follow-up: "reverse words (151)."
  • Sort Colors / Dutch National Flag (75) · Amazon, Microsoft, Meta (Medium) — probes: three pointers (low/mid/high) partitioning in one pass. Follow-up: "one pass, O(1) space — prove it terminates."

Worked example 1 — Move Zeroes

Problem in plain words: Move every 0 in nums to the end while keeping the order of the non-zero elements. Do it in place.

Brute + cost. Build a new list of the non-zeros, pad with zeros, copy back — O(n) time but O(n) extra space, and the interviewer wanted O(1).

Insight. Keep the non-zeros packed at the front with a write pointer (Variant A). Whatever slots remain after the last keeper must be zeros.

def move_zeroes(nums):
    write = 0                            # next slot for a non-zero
    for read in range(len(nums)):
        if nums[read] != 0:              # keeper?
            nums[write] = nums[read]     # pack it forward
            write += 1                   # writer earns a step
    for i in range(write, len(nums)):    # remaining slots -> zeros
        nums[i] = 0
    return nums
# Time O(n), space O(1).

Complexity. O(n) time, O(1) extra space. Two linear passes (pack, then fill) is still O(n).

Follow-up (fewer writes). Swap instead of overwrite, so you never touch a slot that is already correct:

def move_zeroes_swap(nums):
    write = 0
    for read in range(len(nums)):
        if nums[read] != 0:
            nums[write], nums[read] = nums[read], nums[write]  # swap keeper into place
            write += 1
    return nums
Try itAdapt the write-pointer skeleton.
def keep_above(nums, threshold):
    write = 0
    for read in range(len(nums)):
        if nums[read] > threshold:       # your keep-test here
            nums[write] = nums[read]
            write += 1
    return write
 
print(keep_above([1, 5, 2, 8, 3], 3))    # expect 2  (5 and 8 kept)

Now change it to keep elements greater than the threshold and even. Only the if changes — the skeleton is fixed.

💡 Hint · Keep only elements STRICTLY greater than a threshold, in place; return the new length.

Worked example 2 — Remove Duplicates from Sorted Array

Problem in plain words: Given a sorted array, remove duplicates in place so each value appears once; return the new length.

Brute + cost. Convert to a set and back — loses O(1) space and the sorted order guarantee, and set iteration order is not the ask.

Insight. Sorted → duplicates are adjacent → compare each element to the last kept one (Variant B).

def remove_duplicates(nums):
    if not nums:
        return 0
    write = 1                            # nums[0] is always kept
    for read in range(1, len(nums)):
        if nums[read] != nums[write - 1]:  # new value, not a repeat of last kept
            nums[write] = nums[read]
            write += 1
    return write
# nums[:write] holds the unique values in order.  Time O(n), space O(1).

Complexity. O(n) time, O(1) space, single pass.

Follow-up (at most twice, problem 80). Allow each value to appear up to two times by comparing to the element two slots back:

def remove_duplicates_ii(nums):
    write = 0
    for x in nums:
        if write < 2 or x != nums[write - 2]:  # fewer than 2 kept, or x differs from 2-back
            nums[write] = x
            write += 1
    return write

That write - 2 generalises to "at most k copies" with write - k.

Worked example 3 — Sort Colors (Dutch National Flag)

Problem in plain words: Sort an array of 0s, 1s, and 2s in place in one pass.

Brute + cost. Count each color and overwrite (two passes), or sort O(n log n). The interviewer wants one pass, O(1) space.

Insight. Three pointers: low (boundary of the 0s), high (boundary of the 2s), mid scanning. Swap 0s down to low, 2s up to high, leave 1s in the middle.

def sort_colors(nums):
    low, mid, high = 0, 0, len(nums) - 1
    while mid <= high:
        if nums[mid] == 0:
            nums[low], nums[mid] = nums[mid], nums[low]  # send 0 to the low region
            low += 1
            mid += 1
        elif nums[mid] == 1:
            mid += 1                                      # 1 is already in the middle
        else:  # nums[mid] == 2
            nums[mid], nums[high] = nums[high], nums[mid] # send 2 to the high region
            high -= 1                                     # do NOT advance mid: recheck swapped-in value
    return nums
# Time O(n), space O(1), single pass.

Why mid does not advance after a 2-swap: the value swapped in from high is unexamined, so you must re-inspect it. Missing this is the classic bug.

Complexity. O(n) time, O(1) space, one pass.

Follow-up. "k colors instead of 3" → counting sort in O(n + k), since the three-pointer trick is specific to three partitions.

The tradeoff
In-place write-pointer vs building a new array.
In-place write pointer
+ you gain O(1) extra space; exactly what 'in place' interview constraints demand.
− you pay Mutates the caller's array; slightly more index care.
pick when When O(1) space is required, or the input may be reused/modified by design.
Build a fresh list (comprehension)
+ you gain Simplest to write and read; no mutation surprises.
− you pay O(n) extra memory; disallowed by 'in place' constraints.
pick when When memory is ample and clarity beats space, or you must not mutate the input.
Delete in place with pop/del
+ you gain Feels direct.
− you pay O(n^2) from shifting; index bugs from changing length mid-loop.
pick when Never — this is the trap the write pointer exists to avoid.
What a senior engineer actually does
Default to the write pointer for any keep/drop/compact task under an in-place constraint; only build a new list when mutation is forbidden and memory is free. Never pop in a loop.

Where this shows up in production

The read/write two-pointer compaction is the same idea behind an in-place filter over a fixed buffer — for example, removing tombstoned entries from a memory buffer without allocating a second buffer, or compacting a slab of records after deletes. Garbage collectors and log compactors use exactly this "slide the survivors down, then truncate" move to reclaim space without a second array. The interview problem is a scaled-down version of a real memory-management technique, which is why it is a durable warm-up.

You can now…
  • Recognise 'in place / O(1) space / return new length' as the write-pointer signal.
  • Write the read/write skeleton and advance the writer only on keepers.
  • Dedupe a sorted array by comparing to the last kept element.
  • Generalise to 'at most k copies' with the write - k comparison.
  • Explain the write <= read invariant that makes overwriting safe.
  • Partition in one pass with three pointers (Dutch National Flag).
  • Avoid the pop-in-a-loop O(n^2) anti-pattern.
Recall check · click to reveal
★ = stretch question

Practice queue

Grind easy→hard; force yourself to satisfy the in-place / O(1) space constraint.