Skip to content
academia.sh

Lesson 07 / 23

Two Pointers

Scanning from both ends of a sorted array; the 25 wrong answers that appear once the precondition breaks and the step cost of establishing that precondition.

Contents

The previous topic showed that an algorithm’s correctness can be stated probabilistically: expected performance is a distribution, a single run is one sample. This topic opens with a different source of uncertainty, and this source is not probabilistic but structural. Choosing a problem-solving pattern is not buying a speedup — it is accepting a precondition.

As long as the precondition holds, the pattern is both correct and cheap. When it does not, the pattern does not stop, does not warn, does not slow down — it gives a wrong answer, and the wrongness cannot be read from the output. That is why all eight lessons of this topic’s eight build the same frame: an oracle (brute force, always correct, always expensive), a pattern, and the number of inputs where the two diverge. The first pattern is two pointers; its precondition is one sentence: the array must be sorted.

Problem, Oracle, and Pattern

The problem is this: does a pair of distinct positions summing to the target exist in an array. The oracle tries every pair; for an array of n values there are n(n1)/2n(n-1)/2 pairs, and when it must, the oracle looks at all of them. The pattern places two pointers at the array’s two ends, moves the left one right if the sum is below target, moves the right one left if it is above.

PP1. The metric is steps, not time. The counter counts every comparison as one step, and real time is measured nowhere. PP2. The corpus comes from a deterministic generator; seed 20260218. The same seed gives the same 40 arrays. PP3. Each array carries 12 values, ranging from −9 to 20. PP4. The oracle is brute force and is counted as always correct. The pattern’s correctness is claimed only by comparison against the oracle. PP5. The corpus satisfying the precondition is the sorted form of the same 40 arrays; it comes from no other generator. The only difference between the two corpora is order.

SEED, LENGTH, CORPUS_SIZE = 20260218, 12, 40


def generator(seed):
    d = seed

    def next_value(n):
        nonlocal d
        d = (d * 1103515245 + 12345) % 2147483648
        return d % n
    return next_value


def corpus(seed=SEED, n=CORPUS_SIZE, length=LENGTH):
    r = generator(seed)
    return [{"no": i + 1, "array": [r(30) - 9 for _ in range(length)]}
            for i in range(n)]


class Counter:
    def __init__(self):
        self.step = 0

    def count(self, n=1):
        self.step += n


def oracle_pairs(array, target, s):
    """Tries every pair. Always correct, always expensive."""
    for i in range(len(array)):
        for j in range(i + 1, len(array)):
            s.count()
            if array[i] + array[j] == target:
                return True
    return False


def pattern_two_pointers(array, target, s):
    """PRECONDITION: array must be sorted."""
    left, right = 0, len(array) - 1
    while left < right:
        s.count()
        t = array[left] + array[right]
        if t == target:
            return True
        if t < target:
            left += 1
        else:
            right -= 1
    return False


def measure(items, target):
    diverging, pk, ok = [], 0, 0
    for k in items:
        s1, s2 = Counter(), Counter()
        a = pattern_two_pointers(k["array"], target, s1)
        b = oracle_pairs(k["array"], target, s2)
        pk, ok = pk + s1.step, ok + s2.step
        if a != b:
            diverging.append(k["no"])
    return {"diverging": len(diverging), "first_diverging": diverging[:6],
            "pattern_step": pk, "oracle_step": ok, "ratio": round(ok / pk, 2)}


K = corpus()
S = [dict(k, array=sorted(k["array"])) for k in K]
print("corpus:", len(K), "arrays x", LENGTH, "values | already sorted:",
      sum(1 for k in K if k["array"] == sorted(k["array"])))
for ad, items in (("precondition holds", S), ("precondition broken", K)):
    print(f"  {ad}", measure(items, 11))
corpus: 40 arrays x 12 values | already sorted: 0
  precondition holds {'diverging': 0, 'first_diverging': [], 'pattern_step': 154, 'oracle_step': 972, 'ratio': 6.31}
  precondition broken {'diverging': 25, 'first_diverging': [1, 2, 3, 6, 8, 9], 'pattern_step': 372, 'oracle_step': 866, 'ratio': 2.33}

Three numbers sit side by side. When the precondition holds, the pattern gives the same answer as the oracle on 40 of 40 inputs and spends 154 steps; the oracle spends 972, a ratio of 6.31. When the precondition breaks, diverging inputs come to 25 and the ratio drops to 2.33.

The most important part of the second row is not the diverging-input count, it is that the two arrive together. Wrongness is not cheap either: the pattern’s step count climbs from 154 to 372, because in an unsorted array the pointers meet at the ends before finding the right pair and the pattern cannot exit early. As the speedup falls from 6.31 to 2.33 times, correctness goes with it.

What Happens on a Diverging Input

The divergence has a single cause. While applying the rule “if the sum is small, grow the left value,” the pattern assumes that the left value grows moving rightward. In an unsorted array this assumption is false; once a pointer moves in the wrong direction, the skipped positions are never revisited.

def generator(seed):
    d = seed

    def next_value(n):
        nonlocal d
        d = (d * 1103515245 + 12345) % 2147483648
        return d % n
    return next_value


def corpus(seed):
    r = generator(seed)
    return [[r(30) - 9 for _ in range(12)] for _ in range(40)]


def oracle_pairs(array, target):
    for i in range(len(array)):
        for j in range(i + 1, len(array)):
            if array[i] + array[j] == target:
                return True, (array[i], array[j])
    return False, None


def pattern_two_pointers(array, target):
    left, right = 0, len(array) - 1
    while left < right:
        t = array[left] + array[right]
        if t == target:
            return True
        if t < target:
            left += 1
        else:
            right -= 1
    return False


first = corpus(20260218)[0]
print("input 1 :", first)
print("  pattern:", pattern_two_pointers(first, 11))
print("  oracle :", oracle_pairs(first, 11))
print("  sorted :", sorted(first), "-> pattern", pattern_two_pointers(sorted(first), 11))
print()
print("seed       target  precondition   diverging/40")
for seed in (20260218, 20260219):
    for target in (11, 25):
        K = corpus(seed)
        for ad, items in (("holds     ", [sorted(d) for d in K]), ("broken    ", K)):
            diverging = sum(1 for d in items
                            if pattern_two_pointers(d, target) != oracle_pairs(d, target)[0])
            print(f"{seed}  {target:5d}  {ad}  {diverging:8d}"
                  f"    ratio {diverging / 40:.4f}")
input 1 : [-8, -5, 2, -1, 2, 5, 4, 1, 16, 17, 6, -1]
  pattern: False
  oracle : (True, (-5, 16))
  sorted : [-8, -5, -1, -1, 1, 2, 2, 4, 5, 6, 16, 17] -> pattern True

seed       target  precondition   diverging/40
20260218     11  holds              0    ratio 0.0000
20260218     11  broken            25    ratio 0.6250
20260218     25  holds              0    ratio 0.0000
20260218     25  broken            22    ratio 0.5500
20260219     11  holds              0    ratio 0.0000
20260219     11  broken            24    ratio 0.6000
20260219     25  holds              0    ratio 0.0000
20260219     25  broken            16    ratio 0.4000

On the first input, the oracle finds the pair (-5, 16); the pattern returns False. Once the same array is sorted, the pattern also returns True. The input did not change, only its order did — what changes the pattern’s answer is not the data’s content but whether the precondition holds.

PP6. The diverging-input count is out of 40. One divergence in 40 inputs is 0.0250; a 1-input gap counts as unmeasured, 3 and above is meaningful. PP7. The second corpus comes from seed 20260219 and is used only to test whether the ratio holds its order of magnitude.

In the second corpus, diverging inputs for target 11 are 24, versus 25 in the first. For target 25 they are 16 and 22. In all four measurements the ratio falls between 0.40 and 0.63 — the same order of magnitude; the result does not depend on the corpus. On all four rows where the precondition holds, diverging inputs are zero, and that too is corpus-independent.

The Cost of Establishing the Precondition

The measurement so far leaves a question open: if the array is not sorted, it can be sorted. Then the pattern can still be used. But sorting itself spends steps, and those steps must be charged to the pattern’s account.

PP8. When measuring the cost of establishing the precondition, sorting is performed with a comparison-based procedure and comparisons are counted as steps. The sorting procedures themselves were measured in the Algorithms course; they are not repeated here, only their step count is counted.

def generator(seed):
    d = seed

    def next_value(n):
        nonlocal d
        d = (d * 1103515245 + 12345) % 2147483648
        return d % n
    return next_value


def corpus(seed=20260218):
    r = generator(seed)
    return [[r(30) - 9 for _ in range(12)] for _ in range(40)]


class Counter:
    def __init__(self):
        self.step = 0

    def count(self):
        self.step += 1


def oracle_pairs(array, target, s):
    for i in range(len(array)):
        for j in range(i + 1, len(array)):
            s.count()
            if array[i] + array[j] == target:
                return True
    return False


def pattern_two_pointers(array, target, s):
    left, right = 0, len(array) - 1
    while left < right:
        s.count()
        t = array[left] + array[right]
        if t == target:
            return True
        if t < target:
            left += 1
        else:
            right -= 1
    return False


def sort_counting(array, s):
    """The cost of establishing the precondition. Comparisons are counted as steps."""
    a = list(array)
    for i in range(1, len(a)):
        j = i
        while j > 0:
            s.count()
            if a[j - 1] <= a[j]:
                break
            a[j - 1], a[j] = a[j], a[j - 1]
            j -= 1
    return a


print("target  correct  pattern only  sorting included  oracle  ratio")
for target in (11, 25):
    only, included, oracle, correct = 0, 0, 0, 0
    for array in corpus():
        sa, sk, sh = Counter(), Counter(), Counter()
        y = pattern_two_pointers(sort_counting(array, sa), target, sk)
        h = oracle_pairs(array, target, sh)
        only += sk.step
        included += sa.step + sk.step
        oracle += sh.step
        correct += (y == h)
    print(f"{target:5d}  {correct:2d}/40  {only:12d}  {included:14d}  {oracle:5d}"
          f"  {oracle / included:5.2f}")
target  correct  pattern only  sorting included  oracle  ratio
   11  40/40           154            1737    866   0.50
   25  40/40           352            1935   1564   0.81

Correctness comes back: 40/40. But the ratio drops below 1. For target 11, total steps including sorting are 1737, the oracle spends 866; the pattern does twice the oracle’s work. For target 25 it is 1935 against 1564, a ratio of 0.81.

This is the topic’s second claim making its first payment: a speedup sometimes does not speed anything up. At this input size, two pointers forced to establish its own precondition is more expensive than brute force. The pattern wins where sorting is done once and queried many times, or where the data already arrives sorted. As the array grows this balance shifts — sorting grows as nlognn \log n, the oracle as n2n^2 — but in this corpus 12 values is small enough to leave the balance in brute force’s favor.

Step Count Says Nothing About Correctness

In the two measurements above, divergence arrived together with a step change: from 154 to 372. That is not a rule, and relying on it is dangerous. A counting form of the same pattern shows this. The problem is now “how many pairs sum below the target”; the pattern uses the fact that once a pair is counted at the right end, every pair in between is also counted.

PP9. In the counting form the pattern cannot exit early; in every run the pointers take exactly n−1 steps. The step count is therefore independent of the input’s content.

def generator(seed):
    d = seed

    def next_value(n):
        nonlocal d
        d = (d * 1103515245 + 12345) % 2147483648
        return d % n
    return next_value


def corpus(seed=20260218):
    r = generator(seed)
    return [[r(30) - 9 for _ in range(12)] for _ in range(40)]


class Counter:
    def __init__(self):
        self.step = 0

    def count(self):
        self.step += 1


def oracle_small_pairs(array, target, s):
    """Number of pairs summing below target. Every pair is tried."""
    count = 0
    for i in range(len(array)):
        for j in range(i + 1, len(array)):
            s.count()
            if array[i] + array[j] < target:
                count += 1
    return count


def pattern_small_pairs(array, target, s):
    """PRECONDITION: array must be sorted. Counting a pair at the right end counts the ones between it too."""
    left, right, count = 0, len(array) - 1, 0
    while left < right:
        s.count()
        if array[left] + array[right] < target:
            count += right - left
            left += 1
        else:
            right -= 1
    return count


print("precondition  diverging/40  pattern  oracle   ratio")
for ad, prepare in (("holds     ", sorted), ("broken    ", list)):
    diverging, pk, ok = 0, 0, 0
    for array in corpus():
        d = prepare(array)
        s1, s2 = Counter(), Counter()
        a = pattern_small_pairs(d, 6, s1)
        b = oracle_small_pairs(d, 6, s2)
        pk, ok = pk + s1.step, ok + s2.step
        diverging += (a != b)
    print(f"{ad}  {diverging:8d}  {pk:5d}  {ok:5d}  {ok / pk:5.2f}")
precondition  diverging/40  pattern  oracle   ratio
holds              0    440   2640   6.00
broken            39    440   2640   6.00

The step columns in both rows are identical: pattern 440, oracle 2640, ratio 6.00. The diverging-input column climbs from 0 to 39 — on 39 of 40 inputs the pattern returns a wrong number. The pattern still returns an integer, still six times faster, still produces no warning.

This shows why the topic’s third claim is necessary. Step count is a performance metric and says nothing about correctness. Brute force here is not a “slow alternative,” it is the only tool that makes the 39 wrong answers visible. Without the oracle, these two rows would be indistinguishable.

Three Numbers

Metric Oracle Pattern Diverging input
Precondition holds (target 11) 972 steps 154 steps 0/40
Precondition broken (target 11) 866 steps 372 steps 25/40
Establishing the precondition (target 11) 866 steps 1737 steps 0/40
Counting form, precondition broken 2640 steps 440 steps 39/40

Three rows are three separate decision points. The first row is the pattern’s promise. The second shows what happens when the precondition is not checked: sixty-two percent of the answers are broken and the speedup has eroded by two thirds. The third row is the bill for establishing the precondition by hand.

One might think the pattern can be made safe by adding a check: look at whether the array is sorted, and fall back to the oracle if it is not. That check is n−1 comparisons, that is, 440 steps for 40 arrays. The check rescues correctness but does not rescue the speedup: on unsorted input, the work still falls to the oracle. What needs to be measured is not the check’s cost but what share of inputs satisfy the precondition. In this corpus that number is zero.

Summary

  • Choosing a pattern is accepting a precondition; two pointers’ precondition is that the array be sorted.
  • When the precondition holds, the pattern matches the oracle on 40 of 40 inputs and spends 154 steps instead of 972; the ratio is 6.31.
  • When the precondition breaks, the pattern diverges from the oracle on 25 inputs and the speedup drops to 2.33 times; wrongness is not cheap.
  • In the second corpus, diverging inputs are 24; because the ratio holds its order of magnitude, the result does not depend on the corpus.
  • Establishing the precondition by sorting brings correctness back but raises total steps to 1737, making the pattern more expensive than the oracle.

Next Step

Two pointers squeezed the array from both ends and its precondition was order. The next pattern keeps the pointers moving in the same direction and grows and shrinks the region between them like a window; its gain comes from updating the sum incrementally instead of recomputing it from zero each time it slides. Its precondition is also different and never looks at order at all: no value may be negative. The next lesson will count the 10 inputs where that precondition breaks, using the oracle.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close