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:
- 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.
- 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.