← Data structures & algorithms
Techniques Ordered pairs and repeated ranges

Two pointers and prefix sums

Move one edge. Subtract two totals.

The running balance on a bank statement, the cumulative line on a spending chart, and every SQL query that says SUM(amount) OVER (ORDER BY day) keep the same thing: a total so far at every position. Pair it with a sorted list and two indexes walking toward each other, and two old questions get cheap. Let’s watch a bookshop use up a gift card and total a stretch of its price list without adding the same prices twice.

TypeScriptGoOne price shelf, two implementations.

01 / The idea

Sorted prices and running totals both let you skip work.

A customer has a 19-dollar gift card and wants two books that use it up exactly. The shelf is sorted by price: [3, 5, 7, 8, 12, 14, 17]. Checking every pair works, and for seven books it is 21 pairs. The order of the shelf lets you do better: most of those pairs can be ruled out without being added up.

The same shop runs a report that asks for the total of a stretch of that list, again and again: positions 1 to 4, then 2 to 6, then 0 to 3. Adding each stretch from scratch reads the same prices over and over. A list of totals so far, built once, turns every stretch into one subtraction.

Two pointers walk a sorted list from both ends and use the order to throw away pairs that cannot work. Prefix sums store the total before each position so any contiguous range is the difference of two of them. Both use something true of the data, its order or its stability, to skip work.

02 / Name the rule

Keep a bracket of possible pairs, and a zero in front of the totals.

Place left at index 0 and right at the last index. When the sum is too large, move right in; when it is too small, move left in. The invariant: every pair outside the bracket is already known to be wrong. The cheapest and dearest books make 3 + 17 = 20, too much, and no cheaper partner could rescue 17, so 17 is out. Each move removes a whole row or column of the pair table.

while left < right:
	  if values[left] + values[right] == target: found
	  if sum < target: left += 1
	  else: right -= 1

For the prices, the prefix array is [0, 3, 8, 15, 23, 35, 49, 66]. The zero in front means prefix[i] is the total of everything before position i, so positions 1 through 4 are prefix[5] − prefix[1], or 35 − 3 = 32. That extra zero turns every inclusive range into the same subtraction, with no special case when a range starts at 0.

What each rule assumesSorted input, and values that stay put

Two pointers need sorted values for the direction argument. If the values arrive unsorted, sort them first and remember whether the original positions matter.

Prefix sums need values that stay put between building and asking. If one price changes, every total after it changes too. When edits are frequent, a Fenwick tree or segment tree keeps range totals fast while allowing updates, at the cost of more machinery.

03 / Follow one operation

Three comparisons for the gift card, one subtraction for the range.

Before you watch, predict which pair uses up the 19-dollar card and how many sums it takes to find it. The animation follows the two pointers on the shelf, then checks pairs one by one for contrast. Then it builds the prefix array, answers positions 1 to 4 with one subtraction, and lines up the work each approach did. Try it lets you pick your own target and range.

Two pointers and prefix sums

Move one edge or subtract two prefixes.

Modetwo-pointers
Gift card19
Comparisons3

sorted book prices

Use up the 19 gift card

sum = 19
L 3
1 5
2 7
3 8
4 12
5 14
R 17

5 + 14 = 19

0 endpoints checked5 + 14 = 19

Compare 3 + 17 = 20 with 19. Step 1 of 6.

Compare 3 + 17 = 20 with 19. Step 1 of 6.

01/ 05
Find a pair totaling 19

Follow the next safe move.

Compare 3 + 17 = 20 with 19. Step 1 of 6.

Reduced motion: choose a scene to see its completed state.

Read this scene

Compare 3 + 17 = 20 with 19. Step 1 of 6.

Compare 3 + 17 = 20 with 19. Step 1 of 6.

Find a pair totaling 19.

Watch restarts when you return. Step through keeps the selected trace. Try it measures a fresh pair or range query.

04 / Read the shape

Keep the baseline beside the fast version.

Basic form is findPair and rangeTotal: the pointer moves and the prefix construction, each beside its baseline mode, plus prefixSums and rangeFromPrefix for building once and asking many times. In the wild reviews one shelf: a gift card pair and a price range together.

Both modes return the same answer shape, so the faster one is testable: two pointers must find a pair whenever brute force does, with the same total (on repeated prices they can pick different positions), and a prefix query must agree with scanning.

pairPrices and totalRange are the small contracts: find an index pair or return one inclusive range total, with brute-force and scan baselines available for comparison.

TypeScriptReading
books.ts
// Two pointers are safe here because the values are sorted: moving the left pointer only raises
// the sum, and moving the right pointer only lowers it. The source records comparisons for the UI.
export function findPair(
	values: readonly number[],
	target: number,
	mode: PairMode = 'two-pointers',
	trace = false
): PairEvaluation {
	const checked = validateValues(values, mode === 'two-pointers');
	validateTarget(target);
	const steps: PairStep[] = [];
	let comparisons = 0;
	let pair: [number, number] | null = null;

	if (mode === 'brute-force') {
		for (let left = 0; left < checked.length - 1; left++) {
			for (let right = left + 1; right < checked.length; right++) {
				const sum = checked[left] + checked[right];
				comparisons++;
				if (trace)
					steps.push({
						kind: 'compare',
						left,
						right,
						leftValue: checked[left],
						rightValue: checked[right],
						sum,
						target
					});
				if (sum === target) {
					pair = [left, right];
					if (trace) steps.push({ kind: 'found', left, right, sum });
					return { kind: 'pair', mode, values: checked, target, pair, comparisons, steps };
				}
			}
		}
		if (trace) steps.push({ kind: 'miss' });
		return { kind: 'pair', mode, values: checked, target, pair, comparisons, steps };
	}

	let left = 0;
	let right = checked.length - 1;
	while (left < right) {
		const sum = checked[left] + checked[right];
		comparisons++;
		if (trace)
			steps.push({
				kind: 'compare',
				left,
				right,
				leftValue: checked[left],
				rightValue: checked[right],
				sum,
				target
			});
		if (sum === target) {
			pair = [left, right];
			if (trace) steps.push({ kind: 'found', left, right, sum });
			break;
		}
		if (sum < target) {
			const from = left;
			left++;
			if (trace) steps.push({ kind: 'move', pointer: 'left', from, to: left });
		} else {
			const from = right;
			right--;
			if (trace) steps.push({ kind: 'move', pointer: 'right', from, to: right });
		}
	}
	if (!pair && trace) steps.push({ kind: 'miss' });
	return { kind: 'pair', mode, values: checked, target, pair, comparisons, steps };
}

// Prefix sums spend O(n) once so every later inclusive range [start, end] is one subtraction.
export function rangeTotal(
	values: readonly number[],
	start: number,
	end: number,
	mode: RangeMode = 'prefix-sum',
	trace = false
): RangeEvaluation {
	const checked = validateValues(values, false);
	validateRange(checked.length, start, end);
	const steps: RangeStep[] = [];
	if (mode === 'scan') {
		let total = 0;
		for (let index = start; index <= end; index++) {
			total += checked[index];
			if (trace) steps.push({ kind: 'scan', index, value: checked[index], running: total });
		}
		return {
			kind: 'range',
			mode,
			values: checked,
			start,
			end,
			total,
			work: end - start + 1,
			prefix: [],
			steps
		};
	}

	const prefix = [0];
	for (let index = 0; index < checked.length; index++) {
		const total = prefix[index] + checked[index];
		prefix.push(total);
		if (trace) steps.push({ kind: 'prefix', index, value: checked[index], total });
	}
	const total = prefix[end + 1] - prefix[start];
	if (trace)
		steps.push({
			kind: 'range',
			start,
			end,
			fromPrefix: prefix[start],
			toPrefix: prefix[end + 1],
			total
		});
	// The preparation pass is part of this call's work: one addition per value, then one
	// subtraction. It pays for itself only when the same prefix array answers many ranges.
	return {
		kind: 'range',
		mode,
		values: checked,
		start,
		end,
		total,
		work: checked.length + 1,
		prefix,
		steps
	};
}

// Build once, then answer any number of inclusive ranges with rangeFromPrefix.
export function prefixSums(values: readonly number[]): number[] {
	const prefix = [0];
	for (const value of validateValues(values, false)) prefix.push(prefix[prefix.length - 1] + value);
	return prefix;
}

export function rangeFromPrefix(prefix: readonly number[], start: number, end: number): number {
	validateRange(prefix.length - 1, start, end);
	return prefix[end + 1] - prefix[start];
}
GoAlongside
books.go
// Two pointers use sortedness as their invariant: the left move can only raise a sum and the
// right move can only lower it. The brute-force version is retained as a correctness baseline.
func FindPair(values []int, target int, mode PairMode, trace bool) (PairEvaluation, error) {
	checked, err := validateValues(values, mode == TwoPointers)
	if err != nil {
		return PairEvaluation{}, err
	}
	if err := validateTarget(target); err != nil {
		return PairEvaluation{}, err
	}
	result := PairEvaluation{Mode: mode, Values: checked, Target: target}
	compare := func(left, right int) bool {
		sum := checked[left] + checked[right]
		result.Comparisons++
		if trace {
			result.Steps = append(result.Steps, PairStep{Kind: "compare", Left: left, Right: right, LeftValue: checked[left], RightValue: checked[right], Sum: sum, Target: target})
		}
		if sum == target {
			result.Pair = [2]int{left, right}
			result.HasPair = true
			if trace {
				result.Steps = append(result.Steps, PairStep{Kind: "found", Left: left, Right: right, Sum: sum})
			}
			return true
		}
		return false
	}

	if mode == BruteForce {
		for left := 0; left < len(checked)-1; left++ {
			for right := left + 1; right < len(checked); right++ {
				if compare(left, right) {
					return result, nil
				}
			}
		}
		if trace {
			result.Steps = append(result.Steps, PairStep{Kind: "miss"})
		}
		return result, nil
	}

	left, right := 0, len(checked)-1
	for left < right {
		if compare(left, right) {
			return result, nil
		}
		sum := checked[left] + checked[right]
		if sum < target {
			from := left
			left++
			if trace {
				result.Steps = append(result.Steps, PairStep{Kind: "move", Pointer: "left", From: from, To: left})
			}
		} else {
			from := right
			right--
			if trace {
				result.Steps = append(result.Steps, PairStep{Kind: "move", Pointer: "right", From: from, To: right})
			}
		}
	}
	if trace {
		result.Steps = append(result.Steps, PairStep{Kind: "miss"})
	}
	return result, nil
}

// Prefix sums store the total before each position, turning every later inclusive range into one
// subtraction. Scanning remains available as the simple baseline for the same query.
func RangeTotal(values []int, start, end int, mode RangeMode, trace bool) (RangeEvaluation, error) {
	checked, err := validateValues(values, false)
	if err != nil {
		return RangeEvaluation{}, err
	}
	if err := validateRange(len(checked), start, end); err != nil {
		return RangeEvaluation{}, err
	}
	result := RangeEvaluation{Mode: mode, Values: checked, Start: start, End: end}
	if mode == Scan {
		for index := start; index <= end; index++ {
			result.Total += checked[index]
			result.Work++
			if trace {
				result.Steps = append(result.Steps, RangeStep{Kind: "scan", Index: index, Value: checked[index], Running: result.Total})
			}
		}
		return result, nil
	}

	result.Prefix = []int{0}
	for index, value := range checked {
		total := result.Prefix[index] + value
		result.Prefix = append(result.Prefix, total)
		if trace {
			result.Steps = append(result.Steps, RangeStep{Kind: "prefix", Index: index, Value: value, Total: total})
		}
	}
	result.Total = result.Prefix[end+1] - result.Prefix[start]
	// The preparation pass is part of this call's work: one addition per value, then one
	// subtraction. It pays for itself only when the same prefix array answers many ranges.
	result.Work = len(checked) + 1
	if trace {
		result.Steps = append(result.Steps, RangeStep{Kind: "range", Start: start, End: end, FromPrefix: result.Prefix[start], ToPrefix: result.Prefix[end+1], Total: result.Total})
	}
	return result, nil
}

// PrefixSums builds once; RangeFromPrefix then answers any number of inclusive ranges.
func PrefixSums(values []int) ([]int, error) {
	checked, err := validateValues(values, false)
	if err != nil {
		return nil, err
	}
	prefix := []int{0}
	for _, value := range checked {
		prefix = append(prefix, prefix[len(prefix)-1]+value)
	}
	return prefix, nil
}

func RangeFromPrefix(prefix []int, start, end int) (int, error) {
	if err := validateRange(len(prefix)-1, start, end); err != nil {
		return 0, err
	}
	return prefix[end+1] - prefix[start], nil
}
Reading the TypeScriptImmutable inputs and explicit modes

findPair checks that the values are sorted only in the two-pointer mode. rangeTotal keeps the scan baseline beside the prefix implementation, so the work counters describe the strategy that ran, preparation included.

Reading the GoThe same invariant with explicit structs

Go returns structs holding the answer, the work count, and an optional trace. Validation happens at the boundary, so the loops stay about pointer moves and running totals.

What would I normally use in application code?A loop, or the database’s window function

Neither language ships either technique; each is a few lines. When the numbers live in a database, SUM(amount) OVER (ORDER BY day) computes the running totals for you, and a range is two rows of that result.

05 / Try a decision

Choose the preparation only when the questions repay it.

A preparation pass costs a read of every price. Decide when that pays for itself before the feedback tells you.

A report now asks for hundreds of contiguous totals from the same unchanging price list. What should you reach for?

06 / Follow the cost

Count the preparation once, then each question.

Here is every operation at a glance, with n prices and q range questions. The rest of this section is about when preparation wins.

Sorted pair search and static range totals
OperationTimeExtra spaceWhat it assumes
Brute-force pair searchO(n²)O(1)Try every distinct pair until one adds up to the target.
Two pointers on sorted valuesO(n)O(1)Each pointer only moves inward; the input must be sorted or sorted as a separate step.
Build prefix sumsO(n)O(n)One running total for each position plus an initial zero.
One range from a built prefix arrayO(1)O(1)An inclusive range [start, end] is prefix[end + 1] − prefix[start].
Scan one rangeO(end − start + 1)O(1)Simple and often right when there are only a few queries or the ranges are short.

Two pointers are linear because each pointer moves at most n times in total. In the animation, three sums found 5 + 14; checking pairs in order took ten.

Prefix sums shine only across many questions. One range from positions 1 to 4 costs a scan four additions, and costs the prefix mode seven additions to prepare plus one subtraction: the lab shows 4 against 8. A thousand such questions cost the scan up to seven thousand additions and the built prefix array seven additions and a thousand subtractions. The useful comparison is O(n) + q · O(1) against q · O(r) for ranges of length r.

07 / Give it a real job

Build the totals where the numbers stop changing.

In a real shop, the sales report reads a day’s prices once, builds the running totals, and answers every “how much between these two positions” from them. The same totals serve every question until the price list changes, and then they are rebuilt. The gift card check runs on a sorted copy of the shelf and reports prices, not positions, because sorting moves them.

What the examples leave out is a decision too. Prices are whole dollars, so totals stay exact; real money needs cents or a decimal type. A price list that changes all day needs an update structure instead of a rebuild. And two pointers find one pair; listing every pair that fits needs a different loop.

Build UIs?See the running totals your charts and lists already keep, and the day you keep them yourself.

Where it already is in your components

A cumulative line on a spending chart is a prefix sum: each point is the total so far. The moment someone brushes a date range and asks how much was spent in it, that line answers with two lookups and a subtraction instead of a loop over the days.

Virtualized lists keep one too. TanStack Virtual measures each row and stores its start as the end of the row before it, a running total of heights, then finds the first row on screen by searching those starts with the scroll offset.

When you have to own it

Now the list is a chat transcript with messages of different heights, and your own component renders only what is on screen. Keep the top of every message as the heights before it added up. A scroll then needs two searches over those tops, one for the first message in view and one for the last, instead of adding up thousands of heights. When an image loads and a message grows, every top after it moves, so rebuild the totals from the new list, or reach for a Fenwick tree if heights change constantly.

The running totals behind a cumulative spend chart. Built once per data change; the spend in any brushed range is two lookups and a subtraction.

ReactAlready in your code
SpendBetween.tsx
import { useMemo } from 'react';

type Day = { date: string; spentCents: number };

export function SpendBetween({ days, from, to }: { days: Day[]; from: number; to: number }) {
	// The running totals behind a cumulative spend chart, with a zero in front:
	// totals[i] is everything spent before day i.
	const totals = useMemo(() => {
		const running = [0];
		for (const day of days) running.push(running[running.length - 1] + day.spentCents);
		return running;
	}, [days]);

	// Spent from day `from` through day `to`, inclusive: two lookups and one subtraction,
	// however wide the range someone brushes on the chart.
	const spent = totals[to + 1] - totals[from];

	return (
		<p>
			{(spent / 100).toFixed(2)} spent from {days[from].date} to {days[to].date}
		</p>
	);
}

08 / Make the call

Ask what the input promises.

Reach for two pointers when the values are sorted and a move in one direction only raises the sum while the other only lowers it: pairs that meet a target, or a partition around a value. Reach for prefix sums when the sequence stays put and many questions ask for contiguous totals.

Look elsewhere when the question changes. A handful of short ranges: scan them. Values that change often: a Fenwick or segment tree. One contiguous range that moves with the data and grows or shrinks under a rule: a sliding window. A pair in unsorted data you would rather not sort: a hash set of the values seen so far.

09 / Take the idea with you

Explain it without saying “two pointers” or “prefix sums.”

“The prices are in order, so I start with the cheapest and the dearest. Too much, and I drop the dearest; too little, and I drop the cheapest. For totals, I write down how much I have added up before each position, once, and any stretch is the later number minus the earlier one.” That describes the mechanism. The names are what you call it in a review.

Before moving on, explain three things without the names: why 17 could be ruled out after one sum, why the prefix array starts with a zero, and why a single range question does not repay the preparation. Then find a loop in your own code that adds up a stretch of numbers every time someone asks, and decide whether the numbers change more often than they are asked about.

Connections to follow nextRelated lessons
  • Sliding window keeps one moving range and its running summary instead of answering separate ranges.
  • Binary search uses the same sortedness to discard half of what is left, and finds a row among prefix sums.
  • Sorting is the setup two pointers depend on, and its merge walks two pointers of its own.