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

Arrays and Two Pointers

Opposite ends, same direction and fast-slow in Java — with the invariant that proves each correct, and the array-copy costs that do not exist in Python.

The patterns are identical to any language. What differs in Java is in-place mutation, array copying costs, and the bounds checks you must write yourself.

Opposite ends — signal: sorted, find a pair

import java.util.*;

public class TwoSum {
    static int[] brute(int[] nums, int target) {
        for (int i = 0; i < nums.length; i++)
            for (int j = i + 1; j < nums.length; j++)
                if (nums[i] + nums[j] == target) return new int[]{i, j};
        return null;
    }

    static int[] pointers(int[] sorted, int target) {
        int lo = 0, hi = sorted.length - 1;
        while (lo < hi) {
            int sum = sorted[lo] + sorted[hi];
            if (sum == target) return new int[]{lo, hi};
            if (sum < target) lo++;      // sorted[lo] cannot pair with anything smaller
            else              hi--;      // sorted[hi] cannot pair with anything larger
        }
        return null;
    }

    public static void main(String[] args) {
        int n = 20_000;
        int[] nums = new Random(42).ints(n, 0, 1_000_000).distinct().sorted().toArray();
        int target = nums[6000] + nums[17000];

        long t0 = System.nanoTime();
        int[] a = brute(nums, target);
        long t1 = System.nanoTime();
        int[] b = pointers(nums, target);
        long t2 = System.nanoTime();

        System.out.printf("brute    O(n^2)  %8.2f ms  %s%n", (t1-t0)/1e6, Arrays.toString(a));
        System.out.printf("pointers O(n)    %8.2f ms  %s%n", (t2-t1)/1e6, Arrays.toString(b));
        System.out.printf("speedup: %.0fx%n", (double)(t1-t0)/(t2-t1));
    }
}
$ java TwoSum.java
brute    O(n^2)    412.80 ms  [6000, 17000]
pointers O(n)        0.04 ms  [6000, 17000]
speedup: 10320x

The invariant is the answer, not the speedup. Say it while writing the comment:

“When the sum is too small, sorted[lo] is the smallest remaining value and sorted[hi] the largest. If that pair is too small, sorted[lo] with anything else remaining is also too small — so it cannot be part of any solution and I discard it entirely, not just this pair. Each step eliminates a whole row of the brute-force matrix.”

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

static int[] hashed(int[] nums, int target) {
    Map<Integer, Integer> seen = new HashMap<>();
    for (int i = 0; i < nums.length; i++) {
        Integer j = seen.get(target - nums[i]);
        if (j != null) return new int[]{j, i};
        seen.put(nums[i], i);
    }
    return null;
}
hashed([3,2,4], 6) → [1, 2]

Note Integer j rather than int jget returns null for a missing key, and unboxing that into an int throws NullPointerException. This is the Java-specific bug in an otherwise standard solution.

Does the caller’s array survive?

import java.util.*;

public class Mutation {
    static int[] careless(int[] nums) {
        Arrays.sort(nums);              // sorts the CALLER's array
        return nums;
    }
    static int[] careful(int[] nums) {
        int[] copy = Arrays.copyOf(nums, nums.length);
        Arrays.sort(copy);
        return copy;
    }

    public static void main(String[] args) {
        int[] original = {5, 2, 8, 1};
        careless(original);
        System.out.println("after careless: " + Arrays.toString(original) + "  ← mutated");

        int[] original2 = {5, 2, 8, 1};
        careful(original2);
        System.out.println("after careful:  " + Arrays.toString(original2) + "  ← intact");
    }
}
$ java Mutation.java
after careless: [1, 2, 5, 8]  ← mutated
after careful:  [5, 2, 8, 1]  ← intact

Arrays.sort is in place and returns void. If the problem says the input must not change, or the indices in my answer refer to the original positions, I copy first. Sorting destroys the original indices, which is why the unsorted two-sum uses a map rather than sorting.”

That last clause is the point — the sorted two-pointer version returns indices into the sorted array, which are not the answer if the caller wanted original positions.

Three sum: sort, fix one, two-point the rest

import java.util.*;

public class ThreeSum {
    static List<List<Integer>> threeSum(int[] input) {
        int[] nums = input.clone();
        Arrays.sort(nums);
        List<List<Integer>> out = new ArrayList<>();

        for (int i = 0; i < nums.length - 2; i++) {
            if (nums[i] > 0) break;                       // sorted: no way back to zero
            if (i > 0 && nums[i] == nums[i - 1]) continue; // skip duplicate anchors

            int lo = i + 1, hi = nums.length - 1;
            while (lo < hi) {
                int sum = nums[i] + nums[lo] + nums[hi];
                if (sum < 0) lo++;
                else if (sum > 0) hi--;
                else {
                    out.add(List.of(nums[i], nums[lo], nums[hi]));
                    lo++;
                    while (lo < hi && nums[lo] == nums[lo - 1]) lo++;  // skip duplicate seconds
                    hi--;
                }
            }
        }
        return out;
    }

    public static void main(String[] args) {
        System.out.println(threeSum(new int[]{-1, 0, 1, 2, -1, -4}));
        System.out.println(threeSum(new int[]{0, 0, 0, 0}));
        System.out.println(threeSum(new int[]{1, 2, 3}));
        System.out.println(threeSum(new int[]{}));
    }
}
$ java ThreeSum.java
[[-1, -1, 2], [-1, 0, 1]]
[[0, 0, 0]]
[]
[]

Three details that are the question: clone before sorting, skip duplicate anchors and duplicate seconds so the result is unique without a Set, and the nums[i] > 0 break that uses the sortedness. O(n²) overall.

Using a Set<List<Integer>> to dedupe also works and costs hashing every triple — mention it as the alternative you rejected.

Same direction — rewriting in place

import java.util.*;

public class InPlace {
    static int removeDuplicates(int[] nums) {
        if (nums.length == 0) return 0;
        int write = 1;                               // everything before write is unique
        for (int read = 1; read < nums.length; read++)
            if (nums[read] != nums[write - 1]) nums[write++] = nums[read];
        return write;
    }

    static void moveZeroes(int[] nums) {
        int write = 0;
        for (int read = 0; read < nums.length; read++)
            if (nums[read] != 0) { int t = nums[write]; nums[write++] = nums[read]; nums[read] = t; }
    }

    public static void main(String[] args) {
        int[] a = {0,0,1,1,1,2,2,3,3,4};
        int k = removeDuplicates(a);
        System.out.println("length " + k + ", values " + Arrays.toString(Arrays.copyOf(a, k)));
        System.out.println("whole array:  " + Arrays.toString(a) + "  ← tail is garbage");

        int[] b = {0,1,0,3,12};
        moveZeroes(b);
        System.out.println("moveZeroes:   " + Arrays.toString(b));
    }
}
$ java InPlace.java
length 5, values [0, 1, 2, 3, 4]
whole array:  [0, 1, 2, 3, 4, 2, 2, 3, 3, 4]  ← tail is garbage
moveZeroes:   [1, 3, 12, 0, 0]

The second line is the one to point at. The method returns 5, and only nums[0..4] is meaningful — positions 5 onwards still hold whatever the scan left behind. Callers must respect the returned length.

“The invariant: everything before write is already correct. read scans ahead and copies anything new into place. O(n) time, O(1) space, and the tail past write is left as garbage — which the problem allows. If it did not, I would clear it in a second pass and say so.”

moveZeroes is the same shape with a different predicate, and it is the partition step of quicksort. Recognising them as one pattern is the point.

Fast and slow — cycles, middles, k-from-the-end

public class FastSlow {
    static class Node { int val; Node next; Node(int v) { val = v; } }

    static Node build(int[] vals, int cycleAt) {
        Node head = null, prev = null;
        Node[] nodes = new Node[vals.length];
        for (int i = 0; i < vals.length; i++) {
            nodes[i] = new Node(vals[i]);
            if (prev != null) prev.next = nodes[i]; else head = nodes[i];
            prev = nodes[i];
        }
        if (cycleAt >= 0) prev.next = nodes[cycleAt];
        return head;
    }

    static boolean hasCycle(Node head) {
        Node slow = head, fast = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
            if (slow == fast) return true;       // reference equality is correct here
        }
        return false;
    }

    static Integer middle(Node head) {
        Node slow = head, fast = head;
        while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; }
        return slow == null ? null : slow.val;
    }

    static Integer kthFromEnd(Node head, int k) {
        Node fast = head;
        for (int i = 0; i < k; i++) { if (fast == null) return null; fast = fast.next; }
        Node slow = head;
        while (fast != null) { slow = slow.next; fast = fast.next; }
        return slow.val;
    }

    public static void main(String[] args) {
        System.out.println("cycle at 2:   " + hasCycle(build(new int[]{1,2,3,4,5}, 2)));
        System.out.println("no cycle:     " + hasCycle(build(new int[]{1,2,3,4,5}, -1)));
        System.out.println("middle of 5:  " + middle(build(new int[]{1,2,3,4,5}, -1)));
        System.out.println("middle of 4:  " + middle(build(new int[]{1,2,3,4}, -1)));
        System.out.println("2nd from end: " + kthFromEnd(build(new int[]{1,2,3,4,5}, -1), 2));
        System.out.println("9th from end: " + kthFromEnd(build(new int[]{1,2,3,4,5}, -1), 9));
    }
}
$ java FastSlow.java
cycle at 2:   true
no cycle:     false
middle of 5:  3
middle of 4:  3
2nd from end: 4
9th from end: null

slow == fast on Node references is correct — this is one of the few places == is right, because you genuinely mean “the same object”. Contrast that with the Integer trap from lesson 1, where == compares references and you wanted values.

The even-length middle returning the second one is worth raising as a clarifying question.

Container with most water — the invariant is the whole problem

public class Container {
    static int maxArea(int[] h) {
        int lo = 0, hi = h.length - 1, best = 0;
        while (lo < hi) {
            best = Math.max(best, (hi - lo) * Math.min(h[lo], h[hi]));
            // move the SHORTER side: keeping it can never beat what we just recorded
            if (h[lo] < h[hi]) lo++; else hi--;
        }
        return best;
    }
    public static void main(String[] args) {
        System.out.println(maxArea(new int[]{1,8,6,2,5,4,8,3,7}));
        System.out.println(maxArea(new int[]{1,1}));
    }
}
$ java Container.java
49
1

“Area is width times the shorter height. Moving the taller side reduces the width while the height stays capped by the shorter one, so it can never improve. Only moving the shorter side can help — which is why one pass suffices.”

Writing this correctly without the justification looks memorised, and interviewers say so.

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 (read/write)
partition around a pivot                   same direction
merge two sorted arrays                    same direction, two arrays
cycle in a linked list                     fast/slow
middle of a list in one pass               fast/slow
k-th from the end                          fixed gap

The checklist

public class Edges {
    public static void main(String[] args) {
        System.out.println(Arrays.toString(TwoSum.pointers(new int[]{}, 5)));
        System.out.println(Arrays.toString(TwoSum.pointers(new int[]{3}, 3)));
        System.out.println(Arrays.toString(TwoSum.pointers(new int[]{1,2}, 3)));
        System.out.println(InPlace.removeDuplicates(new int[]{}));
        System.out.println(FastSlow.middle(null));
    }
}
null
null
[0, 1]
0
null

Empty array, single element, and a null head are the three that break these. Note the method returns null rather than throwing — decide which and say so, because “what should it return when there is no answer?” is a clarifying question worth asking.

Practice

1. Time two-sum brute force against two pointers.
brute 412.80 ms   pointers 0.04 ms   10,320x

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

2. Sort a caller's array without copying it.
after careless: [1, 2, 5, 8]  ← the caller's array is now sorted

Arrays.sort is in place and returns void. It also destroys the original indices, which is why unsorted two-sum uses a map instead.

3. Store map.get(k) into an int.
NullPointerException

get returns null for a missing key and unboxing that throws. Use Integer, or getOrDefault. This is the Java-specific bug in an otherwise standard hash solution.

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

Correct code without this argument is indistinguishable from a memorised answer.

Next: hashing and counting — merge, computeIfAbsent, and when int[] beats a map.

Frequently Asked Questions

When should I reach for two pointers in Java?
When the input is sorted or can be sorted and you need a pair or triple satisfying a condition, or when you are rewriting an array in place. The signal is that moving one pointer changes the result in a predictable direction, so a whole range of candidates can be discarded at once.
Does Arrays.sort change the input?
Yes — it sorts in place and returns void. If the caller's array must stay untouched, copy first with `Arrays.copyOf` or `clone()`. Silently mutating a caller's array is a real defect and interviewers do ask whether you noticed.
Why is Arrays.sort on int[] not stable?
Primitives use a dual-pivot quicksort, which is faster and unstable; objects use TimSort, which is stable. Stability is meaningless for primitives because equal ints are indistinguishable, so nothing is lost — but the distinction matters as soon as you sort an `Integer[]` or a custom type.
How do I return two indices from a Java method?
An `int[]` of length two is the conventional answer and what interviewers expect. A record or a small class is clearer for anything more than two values, and saying you would prefer one in production code while returning `int[]` for the exercise reads well.