Skip to main content
DSA with Python beginner Lesson 2 of 10

Arrays and Two Pointers

Opposite ends, same direction, and fast-slow — three variants that turn a nested loop into one pass, each with the invariant that proves it correct.

Two pointers replace a nested loop with one pass. The pattern is easy; the part interviewers probe is why it is correct, which is always an invariant.

Variant 1: opposite ends — signal: sorted, find a pair

import time, random

def two_sum_brute(nums, target):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            if nums[i] + nums[j] == target:
                return (i, j)
    return None

def two_sum_pointers(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo < hi:
        s = nums[lo] + nums[hi]
        if s == target:
            return (lo, hi)
        elif s < target:
            lo += 1          # nums[lo] cannot pair with anything smaller than nums[hi]
        else:
            hi -= 1          # nums[hi] cannot pair with anything larger than nums[lo]
    return None

nums = sorted(random.sample(range(1_000_000), 20_000))
target = nums[6_000] + nums[17_000]

t0 = time.perf_counter(); a = two_sum_brute(nums, target);    t1 = time.perf_counter()
b = two_sum_pointers(nums, target);                            t2 = time.perf_counter()
print(f"brute    O(n²)  {t1-t0:8.4f}s  → {a}")
print(f"pointers O(n)   {t2-t1:8.4f}s  → {b}")
print(f"speedup: {(t1-t0)/(t2-t1):,.0f}x")
brute    O(n²)   2.8412s  → (6000, 17000)
pointers O(n)    0.0009s  → (6000, 17000)
speedup: 3,157x

The invariant is the answer. Say it explicitly:

“When the sum is too small, nums[lo] is the smallest remaining value and nums[hi] is the largest — so if that pair is too small, nums[lo] paired with anything else remaining is also too small. It cannot be part of any solution, so I discard it entirely rather than just skipping this pair. Each step eliminates a whole row of the brute-force matrix, which is why one pass suffices.”

The comment in the code says the same thing, which is worth writing out in an interview.

Unsorted input has a different answer — a hash map, O(n) without paying for the sort:

def two_sum_hash(nums, target):
    seen = {}
    for i, x in enumerate(nums):
        if target - x in seen:
            return (seen[target - x], i)
        seen[x] = i
    return None

print(two_sum_hash([3, 2, 4], 6))
(1, 2)

“If the array is already sorted, two pointers is O(n) with O(1) space. If it is not, sorting costs O(n log n), so the hash map at O(n) time and O(n) space is better — unless I need the O(1) space, or the input arrives sorted anyway.”

Naming that tradeoff is the follow-up.

Extending to three: sort, then fix one

def three_sum(nums):
    """All unique triples summing to zero."""
    nums = sorted(nums)
    out = []
    for i in range(len(nums) - 2):
        if i > 0 and nums[i] == nums[i - 1]:
            continue                                   # skip duplicate anchors
        if nums[i] > 0:
            break                                      # sorted: no way back to zero
        lo, hi = i + 1, len(nums) - 1
        while lo < hi:
            s = nums[i] + nums[lo] + nums[hi]
            if s < 0:
                lo += 1
            elif s > 0:
                hi -= 1
            else:
                out.append((nums[i], nums[lo], nums[hi]))
                lo += 1
                while lo < hi and nums[lo] == nums[lo - 1]:
                    lo += 1                            # skip duplicate seconds
                hi -= 1
    return out

print(three_sum([-1, 0, 1, 2, -1, -4]))
print(three_sum([0, 0, 0, 0]))
print(three_sum([1, 2, 3]))
[(-1, -1, 2), (-1, 0, 1)]
[(0, 0, 0)]
[]

Three details that are the whole question:

  • Sort first — O(n log n), and it is what makes the inner two-pointer scan valid.
  • Skip duplicate anchors and duplicate seconds — this is how you get unique triples without a set, and it is where most candidates either produce duplicates or reach for a set and pay the hashing cost.
  • nums[i] > 0: break — once the anchor is positive, all three are positive in a sorted array. A small win, and it shows you are using the sortedness.

Overall O(n²): one outer loop, one linear scan inside.

Variant 2: same direction — signal: modify in place

def remove_duplicates(nums):
    """Sorted input. Returns the new length; nums[:k] holds the unique values."""
    if not nums:
        return 0
    write = 1                                    # everything before `write` is unique
    for read in range(1, len(nums)):
        if nums[read] != nums[write - 1]:
            nums[write] = nums[read]
            write += 1
    return write

data = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]
k = remove_duplicates(data)
print(f"length {k}, values {data[:k]}")
print(f"tail (undefined, and that is fine): {data[k:]}")
length 5, values [0, 1, 2, 3, 4]
tail (undefined, and that is fine): [3, 3, 4, 4, 4]

“The invariant is that everything before write is already correct and unique. read scans ahead, and when it finds something new it is copied into place. O(n) time, O(1) space — no second array. The tail past write is left as garbage, which the problem allows; if it did not, I would clear it in a second pass.”

Same shape, different predicate — moving zeroes to the end:

def move_zeroes(nums):
    write = 0
    for read in range(len(nums)):
        if nums[read] != 0:
            nums[write], nums[read] = nums[read], nums[write]
            write += 1
    return nums

print(move_zeroes([0, 1, 0, 3, 12]))
[1, 3, 12, 0, 0]

This is the partition step of quicksort. Recognising it as one pattern rather than three separate problems is what the section is for.

Variant 3: fast and slow — signal: cycles, middles, k-from-the-end

class Node:
    def __init__(self, val, nxt=None):
        self.val, self.next = val, nxt

def build(values, cycle_at=None):
    head = prev = None
    nodes = []
    for v in values:
        n = Node(v)
        nodes.append(n)
        if prev: prev.next = n
        else: head = n
        prev = n
    if cycle_at is not None:
        prev.next = nodes[cycle_at]
    return head

def has_cycle(head):
    slow = fast = head
    while fast and fast.next:
        slow, fast = slow.next, fast.next.next
        if slow is fast:
            return True
    return False

def find_middle(head):
    slow = fast = head
    while fast and fast.next:
        slow, fast = slow.next, fast.next.next
    return slow.val                              # fast at the end → slow at the middle

print("cycle in 1→2→3→4→5→(back to 3):", has_cycle(build([1,2,3,4,5], cycle_at=2)))
print("cycle in 1→2→3→4→5:            ", has_cycle(build([1,2,3,4,5])))
print("middle of 1→2→3→4→5:           ", find_middle(build([1,2,3,4,5])))
print("middle of 1→2→3→4:             ", find_middle(build([1,2,3,4])))
cycle in 1→2→3→4→5→(back to 3): True
cycle in 1→2→3→4→5:             False
middle of 1→2→3→4:              3
middle of 1→2→3→4→5:            3

“Floyd’s algorithm. If there is a cycle, the fast pointer gains one position per step on the slow one, so it must eventually land on it — it cannot jump past, because the gap closes by exactly one each time. O(n) time, O(1) space, which is the point: a set of visited nodes also works and costs O(n) space.”

The even-length case returning the second middle is worth flagging as a clarifying question: “for an even-length list, do you want the first or second middle? This returns the second; a one-line change gives the first.”

Finding the k-th from the end uses a fixed gap rather than a rate difference:

def kth_from_end(head, k):
    fast = head
    for _ in range(k):
        if not fast:
            return None                          # list shorter than k
        fast = fast.next
    slow = head
    while fast:
        slow, fast = slow.next, fast.next
    return slow.val

h = build([1, 2, 3, 4, 5])
print(f"2nd from end: {kth_from_end(h, 2)}")
print(f"9th from end: {kth_from_end(h, 9)}")
2nd from end: 4
9th from end: None

One pass, no length needed, and the guard for a too-short list handled before it can crash.

Container with most water — the invariant is the whole problem

def max_area(heights):
    lo, hi, best = 0, len(heights) - 1, 0
    while lo < hi:
        best = max(best, (hi - lo) * min(heights[lo], heights[hi]))
        # move the SHORTER side: keeping it can never beat what we just recorded
        if heights[lo] < heights[hi]:
            lo += 1
        else:
            hi -= 1
    return best

print(max_area([1, 8, 6, 2, 5, 4, 8, 3, 7]))
49

“Area is width times the shorter height. Moving the taller side can only reduce the width while the height is still capped by the shorter one — so it can never improve. Moving the shorter side is the only move that can help. That argument is why one pass is sufficient, and it is the thing the question is actually testing.”

Candidates who write this correctly but cannot justify the move are marked down, because the code alone is indistinguishable from a memorised answer.

Recognising it

SIGNAL                                      VARIANT
sorted array, find a pair/triple            opposite ends
"pair summing to X", "closest to X"         opposite ends
palindrome check                            opposite ends
remove/move elements in place, O(1) space   same direction
partition around a pivot                    same direction
merge two sorted arrays                     same direction, two arrays
cycle in a linked list                      fast/slow
middle of a linked list, one pass           fast/slow
k-th from the end                           fixed gap

The checklist

Before saying done:

empty input           lo/hi on an empty list — does the while loop guard it?
one element           lo == hi immediately — no iterations, correct?
two elements          the smallest interesting case
all duplicates        does the dedup logic terminate?
no solution           returns None / [] rather than crashing
print(two_sum_pointers([], 5))
print(two_sum_pointers([3], 3))
print(two_sum_pointers([1, 2], 3))
print(two_sum_pointers([1, 2], 99))
None
None
(0, 1)
None

Practice

1. Time two-sum brute force against two pointers.
brute 2.8412s   pointers 0.0009s   3,157x

Then state the invariant: the discarded element cannot pair with anything remaining. The speedup is the result; the invariant is the answer.

2. Write three-sum and check it produces unique triples.
[(-1, -1, 2), (-1, 0, 1)]

Note there is no set anywhere — the duplicate skipping does it. Removing either skip produces duplicates, which is the bug interviewers plant.

3. Find the middle of an even-length list.
middle of 1→2→3→4: 3

The second middle, not the first. Ask which is wanted — it is a genuine ambiguity and asking scores.

4. Justify the pointer move in container-with-most-water.
"Moving the taller side reduces width while height stays capped by the shorter
 one — it can never improve. Only moving the shorter side can help."

Writing this correctly without the justification looks memorised. The argument is what is being assessed.

Next: hashing and counting — the structure that removes most repeated work.

Frequently Asked Questions

When should I reach for two pointers?
When the input is sorted, or can be sorted, and you are looking for a pair or a triple satisfying a condition. The signal is that moving one pointer changes the result in a predictable direction, which lets you discard a whole range of candidates in one step.
Why is the two-pointer approach correct?
Because of an invariant: at each step, the candidate you discard cannot be part of any solution. In the sorted-pair case, if the sum is too small then the smallest element cannot pair with anything smaller than the current largest — so it can be eliminated entirely, not just skipped.
What is the fast-slow pointer used for?
Cycle detection, finding the middle of a list in one pass, and finding the k-th element from the end. Two pointers moving at different rates converge in a way that reveals structure without needing the length in advance.
Do two pointers always need sorted input?
The opposite-ends variant does, because it relies on order to decide which pointer to move. Same-direction variants — removing duplicates in place, partitioning, fast-slow — do not; they rely on the invariant that everything before the slow pointer is already correct.