← all notes

Two Pointers in Go

I took the Two Pointers from Byte Byte Go and translated into Go

This article is based on https://bytebytego.com/courses/coding-patterns/two-pointers/introduction-to-two-pointers, so all credits are for Byte Byte Go, I just wanted to translate this to Golang syntax.

Two Pointers in Go

A pattern that turns O(n²) scans into O(n) walks — and the invariant that makes it legal.


Intuition

As the name implies, a two-pointer pattern refers to an algorithm that uses two pointers. But what is a pointer here? Not Go's *T — in this context a pointer is just a variable that represents an index or position within a data structure, like a slice or a linked list.

Most algorithms use a single pointer to reach or track a single element:

i
                    ↓
      [ ... 14   5   5   20 ... ]

Introducing a second pointer opens a new world of possibilities. Most importantly, we can now make comparisons. With pointers at two different positions, we can compare the elements at those positions and decide what to do next:

i   j
                ↓   ↓
      [ ... 14  5   5   20 ... ]      compare(nums[i], nums[j])
                                      ──────→ make a decision

In many cases those comparisons are made with two nested loops, which takes O(n²) time, where n is the length of the data structure. Here i and j are two pointers used to compare every pair of elements:

go

for i := 0; i < n; i++ {    for j := i + 1; j < n; j++ {        compare(nums[i], nums[j])    }}

Often this approach does not take advantage of predictable dynamics that might exist in the data structure. An example of a structure with predictable dynamics is a sorted slice: when we move a pointer in a sorted slice, we can predict whether the value we are moving toward is greater or smaller. Moving a pointer right in an ascending slice guarantees we move to a value greater than or equal to the current one:

i  ──→
       ↓
     [ 1   2   3   4 ]
     └──── sorted ────┘
       values only grow to the right

That guarantee is what lets us stop enumerating pairs and start eliminating them.


The idea in one example

Find two values in an ascending slice that sum to a target.

Brute force asks: is this pair the answer? — n²/2 times. Two pointers asks a better question: given this pair, which half of the remaining search space can I throw away?

Start wide, at both ends:

target = 9

      i                   j
      ↓                   ↓
    [ 2   3   4   7  11  15 ]      2 + 15 = 17 > 9  → 15 is too big, j--

      i               j
      ↓               ↓
    [ 2   3   4   7  11  15 ]      2 + 11 = 13 > 9  → j--

      i           j
      ↓           ↓
    [ 2   3   4   7  11  15 ]      2 +  7 =  9      → found

go

// TwoSumSorted returns the indices of the two values in the ascending slice// nums that add up to target, or (-1, -1) if no such pair exists.func TwoSumSorted(nums []int, target int) (int, int) {    i, j := 0, len(nums)-1    for i < j {        sum := nums[i] + nums[j]        switch {        case sum == target:            return i, j        case sum < target:            i++ // nums[i] is too small to pair with anything left        default:            j-- // nums[j] is too big to pair with anything left        }    }    return -1, -1}

Why discarding is safe

This is the part worth internalising, because every opposite-ends problem reuses the same argument.

When nums[i] + nums[j] < target, nums[j] is the largest value still in play. So nums[i] paired with any remaining candidate is at most nums[i] + nums[j], which is already below target. No pair involving i can ever work. Discard i — that is one whole row of the n² table gone in O(1).

The mirror argument holds when the sum overshoots: nums[i] is the smallest value left, so nums[j] can never be part of a solution.

Each step removes a row or a column and moves a pointer one place. The pointers can only travel n positions in total, so the loop runs at most n times. O(n) time, O(1) space.

What it costs in practice

Benchmarked on a sorted slice of 4096 ints, target chosen so neither version can exit early:

Approach ns/op Allocations
Nested loops, O(n²) 3,931,942 0
Two pointers, O(n) 3,930 0

Three orders of magnitude, same zero allocations, nine fewer lines of code.


The three shapes

Almost everything filed under "two pointers" is one of three configurations. Recognising which one a problem wants is most of the work.

A. Opposite ends            B. Fast and slow           C. Same direction
   converging                  different speeds           read / write

   i ──→        ←── j          s ─→  f ──→               w ─→   r ──→
   ↓                ↓          ↓     ↓                   ↓      ↓
 [ . . . . . . . . . ]       [ . . . . . . . ]         [ . . . . . . . ]

 needs order or symmetry     needs a cycle or a        needs to build a
 stop: i >= j                fixed offset              result in place

Variant A — opposite ends

Two pointers start at the extremes and converge. Use it when the data is sorted, or when the problem is symmetric (palindromes), or when moving inward provably cannot improve one side (container with most water).

Loop skeleton:

go

i, j := 0, len(xs)-1for i < j {    // decide which side to move based on xs[i] vs xs[j]}

Reverse in place

The simplest possible instance — no comparison at all, just symmetric swapping.

go

func Reverse[T any](xs []T) {    for i, j := 0, len(xs)-1; i < j; i, j = i+1, j-1 {        xs[i], xs[j] = xs[j], xs[i]    }}

The for i, j := 0, len(xs)-1; i < j; i, j = i+1, j-1 header is the idiomatic Go spelling of this pattern. Learn it as one unit; you will type it constantly.

Valid palindrome

Here the pointers do not advance in lockstep — each side skips characters it does not care about.

go

// IsPalindrome reports whether s reads the same forwards and backwards,// considering only letters and digits and ignoring case.func IsPalindrome(s string) bool {    r := []rune(s)    i, j := 0, len(r)-1    for i < j {        for i < j && !isAlnum(r[i]) {            i++        }        for i < j && !isAlnum(r[j]) {            j--        }        if toLower(r[i]) != toLower(r[j]) {            return false        }        i++        j--    }    return true}

Note the i < j guard inside both skip loops. Without it, a string of pure punctuation walks a pointer off the end. This is the single most common crash in this pattern.

The []rune(s) conversion is deliberate — see the Go-specific notes near the end for why indexing the string directly would be wrong.

Container with most water

No sorting here, which makes the discard argument more interesting. Area is min(height[i], height[j]) * (j - i). Moving either pointer inward always shrinks the width, so the only way to win is to increase the height — and the height is capped by the shorter wall. Moving the taller wall inward can never help. So: always move the shorter one.

go

func MaxArea(height []int) int {    best := 0    i, j := 0, len(height)-1    for i < j {        if area := min(height[i], height[j]) * (j - i); area > best {            best = area        }        // Move the shorter wall: it is the binding constraint.        if height[i] < height[j] {            i++        } else {            j--        }    }    return best}

min and max are builtins as of Go 1.21 — no math.Min float round-trip, no hand-rolled helper.

3Sum — pinning a pointer

Three-value problems reduce to two-value problems by fixing one index and running the two-pointer scan on the rest. Sort first, and the duplicate-skipping falls out naturally.

go

// ThreeSum returns every unique triplet in nums that sums to zero.// nums must be sorted ascending.func ThreeSum(nums []int) [][3]int {    var out [][3]int    for a := 0; a < len(nums)-2; a++ {        if a > 0 && nums[a] == nums[a-1] {            continue // skip duplicate anchors        }        i, j := a+1, len(nums)-1        for i < j {            sum := nums[a] + nums[i] + nums[j]            switch {            case sum < 0:                i++            case sum > 0:                j--            default:                out = append(out, [3]int{nums[a], nums[i], nums[j]})                i++                j--                for i < j && nums[i] == nums[i-1] {                    i++                }                for i < j && nums[j] == nums[j+1] {                    j--                }            }        }    }    return out}

O(n²) total — an outer loop over n anchors, each running an O(n) scan — down from the O(n³) of three nested loops. The sort costs O(n log n) and disappears into the n² term.


Variant B — fast and slow

Both pointers move in the same direction, but at different speeds or with a fixed gap between them. This is the shape for linked lists, where you cannot index and cannot walk backwards.

The insight: two pointers moving at different rates encode a relationship you would otherwise need a map or a second pass to discover.

go

type Node struct {    Val  int    Next *Node}

Cycle detection (Floyd)

Slow moves one, fast moves two. In an acyclic list fast falls off the end. In a cyclic one, fast laps slow and they collide — the gap between them closes by exactly one node per step, so a collision is guaranteed, not merely likely.

1 → 2 → 3 → 4
          ↑   ↓
          6 ← 5

  step: s=1 f=1  →  s=2 f=3  →  s=3 f=5  →  s=4 f=3  →  s=5 f=5  ✓

go

func HasCycle(head *Node) bool {    slow, fast := head, head    for fast != nil && fast.Next != nil {        slow = slow.Next        fast = fast.Next.Next        if slow == fast {            return true        }    }    return false}

O(1) space — the reason this beats the obvious map[*Node]bool.

The loop condition fast != nil && fast.Next != nil is load-bearing in both halves. Go's && short-circuits left to right, so the first check protects the second, and both together protect fast.Next.Next.

Where the cycle begins

The same walk, extended. Once slow and fast collide, reset a pointer to the head and advance both one step at a time: they meet at the cycle entrance. (The proof is a short bit of modular arithmetic on the distances; the code is four lines.)

go

func CycleStart(head *Node) *Node {    slow, fast := head, head    for fast != nil && fast.Next != nil {        slow, fast = slow.Next, fast.Next.Next        if slow == fast {            p := head            for p != slow {                p, slow = p.Next, slow.Next            }            return p        }    }    return nil}

Middle of a list, in one pass

When fast reaches the end, slow is at the halfway mark. No length count, no second traversal.

go

func Middle(head *Node) *Node {    slow, fast := head, head    for fast != nil && fast.Next != nil {        slow, fast = slow.Next, fast.Next.Next    }    return slow}

N-th node from the end

Here the two pointers move at the same speed but with a fixed gap of n. When fast hits the tail, slow is exactly n nodes short of it. The dummy head removes the "what if we delete the first node" special case.

n = 2

  dummy → 1 → 2 → 3 → 4 → 5 → nil
          s         f              gap of 2, advance together
                    s         f    fast at tail → slow.Next is the node to drop

go

func RemoveNthFromEnd(head *Node, n int) *Node {    dummy := &Node{Next: head}    slow, fast := dummy, dummy    for i := 0; i < n; i++ {        if fast.Next == nil {            return head // n is larger than the list        }        fast = fast.Next    }    for fast.Next != nil {        slow, fast = slow.Next, fast.Next    }    slow.Next = slow.Next.Next    return dummy.Next}

Variant C — same direction

A read pointer scans the input; a write pointer marks where the next kept element goes. Because the writer never outruns the reader, you can overwrite the input safely as you go — O(1) extra space.

w       r
     ↓       ↓
   [ 1 2 . . 2 3 3 4 ]
     └─kept─┘ └─unscanned─┘

Compact a sorted slice

go

// Dedupe compacts a sorted slice in place and returns the unique prefix.func Dedupe(nums []int) []int {    if len(nums) == 0 {        return nums    }    w := 1    for r := 1; r < len(nums); r++ {        if nums[r] != nums[w-1] {            nums[w] = nums[r]            w++        }    }    return nums[:w]}

Returning nums[:w] is the Go-native finish. The equivalent in most languages is returning an int length and leaving the caller to slice. Note the aliasing consequence: the returned slice shares the caller's backing array, and everything past w is now garbage.

Move zeroes to the back

Same skeleton, swapping instead of copying, which preserves the relative order of non-zero elements.

go

func MoveZeroes(nums []int) {    w := 0    for r := range nums {        if nums[r] != 0 {            nums[w], nums[r] = nums[r], nums[w]            w++        }    }}

Merge two sorted slices

The two pointers live in different slices. This is the merge step of merge sort, and the reason merge sort is O(n log n) rather than worse.

go

func Merge(a, b []int) []int {    out := make([]int, 0, len(a)+len(b))    i, j := 0, 0    for i < len(a) && j < len(b) {        if a[i] <= b[j] {            out = append(out, a[i])            i++        } else {            out = append(out, b[j])            j++        }    }    out = append(out, a[i:]...)    out = append(out, b[j:]...)    return out}

Two details worth copying: make([]int, 0, len(a)+len(b)) preallocates so append never regrows, and the trailing append(out, a[i:]...) drains whichever slice has leftovers without a second loop — one of them is always empty.

Using <= rather than < keeps the merge stable: equal elements retain their original relative order. That matters the moment you sort structs by one field.

Sliding window — the variable-gap cousin

A window is two same-direction pointers where the gap grows and shrinks according to a constraint. Same family, different bookkeeping.

go

// LongestUnique returns the length of the longest substring of s with no// repeated byte.func LongestUnique(s string) int {    lastSeen := make(map[byte]int)    best, left := 0, 0    for right := 0; right < len(s); right++ {        if p, ok := lastSeen[s[right]]; ok && p >= left {            left = p + 1        }        lastSeen[s[right]] = right        if n := right - left + 1; n > best {            best = n        }    }    return best}

The p >= left check is the subtle part: a character seen before the current window's left edge is not a repeat inside the window, so left must never move backwards.


Go-specific notes

Indices are your pointers. Go has real pointers but no pointer arithmetic — &nums[i] + 1 does not compile, and reaching for unsafe to get it is the wrong instinct. Index into the slice. The compiler hoists bounds checks out of these loops well; write the clear version first and read go build -gcflags=-d=ssa/check_bce/debug=1 only if a profile says to.

Strings index bytes, not characters. s[i] has type byte. A byte-wise palindrome check on "señor" compares half of a two-byte rune against a letter and returns nonsense. Two safe options:

  • []rune(s) — one O(n) allocation, then index freely. Clearest, and what the code above uses.
  • utf8.DecodeRuneInString / utf8.DecodeLastRuneInString — zero allocation, walks in from both ends, more code.

Stay on bytes only when the input is guaranteed ASCII and you have measured that it matters.

go

// ReverseString reverses s by code point. Reversing s[i] bytes would corrupt// any multi-byte rune.func ReverseString(s string) string {    r := []rune(s)    Reverse(r)    return string(r)}

(Even this is not a full answer for text with combining marks or emoji sequences — reversing by code point splits a family emoji into its components. For real text processing reach for golang.org/x/text.)

Slices alias. Every in-place function here mutates the caller's backing array. Dedupe(nums) returns a new header but writes through the same memory, and a slice sharing that array elsewhere in the program sees the change. Say so in the doc comment, or take a copy.

Generics fit this pattern well. Reverse[T any] works because reversal never inspects the values. The moment you need ordering, constrain to cmp.Ordered and use cmp.Compare.

min / max are builtins since Go 1.21, for any ordered type. Delete your minInt helper.

Benchmark with b.Loop() (Go 1.24+) rather than for i := 0; i < b.N; i++. It keeps the compiler from optimising away a pure function whose result you discard — exactly the failure mode when benchmarking something like TwoSumSorted.


When to reach for it

Signal in the problem Likely variant
Input is sorted, or sorting is allowed A — opposite ends
"Find a pair / triplet summing to X" A, with an outer loop for triplets
Palindromes, symmetry, reversal A
Maximise something between two positions A, move the binding constraint
Linked list, cycle, "without extra space" B — fast and slow
"N-th from the end", middle, one pass B, fixed gap
Remove / filter / compact in place C — read and write
Merge sorted inputs C, one pointer per input
"Longest / shortest subarray such that…" C, sliding window

Two questions decide it:

  1. Is there predictable structure? Sorted order, symmetry, a monotone constraint. Without one, two pointers has nothing to discard and you are back to brute force or a hash map.
  2. Can moving a pointer eliminate candidates rather than just visiting one? If yes, the pattern applies. If each move only visits, you have written a nested loop with extra steps.

Where it does not apply: unsorted pair-sum where sorting is too expensive or destroys required indices — use a map[int]int for O(n) time and O(n) space. Two pointers buys O(1) space; it does not beat hashing on time.


Pitfalls

i <= j instead of i < j****. In a converging loop this lets an element pair with itself. TwoSumSorted([]int{4}, 8) would return (0, 0).

A branch that advances nothing. Every path through a two-pointer loop must move at least one pointer. A switch with a default that only recomputes is an infinite loop. When the body is complex, audit each branch for a move.

Unguarded skip loops. The inner for i < j && !isAlnum(...) loops in the palindrome check need that i < j condition or they index past the end on degenerate input.

Duplicate results in k-sum. Both the anchor and the inner pointers need duplicate-skipping. Missing either produces repeated triplets, and it will not show up in a test with distinct inputs — test [0, 0, 0, 0].

Losing nums[w-1] in the compaction loop. Compare the read value against the last written value, not against nums[r-1]. They diverge as soon as the first element is dropped.

Assuming fast/slow implies a cycle. fast != nil && fast.Next != nil must be checked every iteration, in that order.


Practice, in rough order of difficulty

Problem Variant Why it's worth doing
Reverse a slice / string A The skeleton, no decisions
Valid palindrome A Asymmetric advancement
Two Sum II (sorted input) A The discard argument
Remove duplicates from sorted array C Read/write split
Move zeroes C Swap vs. copy
Merge two sorted arrays C Pointers in different inputs
Linked list cycle B O(1) space beats a map
Middle of the linked list B Speed ratio as arithmetic
Remove N-th node from end B Fixed gap, dummy head
Container with most water A Discard without sorted order
3Sum A Pinning plus duplicate handling
Trapping rain water A Two running maxima alongside the pointers
Longest substring without repeating characters C Window with a lookup table

Takeaway

Two pointers is not a trick for shaving constants. It is a search-space argument: each comparison must let you discard a whole class of candidates, not just reject one. Find the property that licenses the discard — sorted order, symmetry, a monotone constraint, a speed ratio — and the O(n) implementation is four lines of obvious code. Fail to find it, and no amount of pointer juggling will help.

When you next write a nested loop, ask what the inner loop is proving. If the answer is "that nothing better exists in this direction", you already have the two-pointer solution; you just have not moved the second pointer yet.


All code in this article compiles under Go 1.24 and is covered by a passing table-driven test suite, including empty, single-element, all-duplicate, and Unicode inputs.