Range Queries and Segment Trees
Prerequisites: arrays, recursion and -notation. Module 03 is used once, in the Recap.
Eight hours of sales
A shop records its sales hour by hour. Today's eight hours are
Two kinds of request arrive all day, interleaved. A manager asks for an aggregate over a range of hours: "total sales in hours 1 through 6" (27), or "the slowest hour in that stretch" (1). And the till sends a correction: "hour 3 was really 10, not 2". This is the dynamic range query problem: keep an array under
update(i, v): set ;query(l, r): return .
Ranges are half-open and 0-indexed in code, so "hours 1 through 6" is query(1, 7). Writing
query(1, 6) returns 26 and silently drops hour 6, which is the commonest bug in this module.
Two obvious answers, each fast at one thing
Scan the range. An update is one write. A query reads elements, so in the worst case. A day of queries costs .
Prefix sums. Store . Then any range sum is one subtraction, , in . But a correction to changes : every later prefix. That is writes per update.
A = [6, 3, 9, 2, 7, 5, 1, 8]
def prefix_sums(a):
P = [0]
for x in a:
P.append(P[-1] + x)
return P
P = prefix_sums(A)
assert P == [0, 6, 9, 18, 20, 27, 32, 33, 41]
assert P[7] - P[1] == 27 == sum(A[1:7])
assert sum(A[1:6]) == 26 # off by one: hour 6 is missing
fixed = A[:3] + [10] + A[4:]
Q = prefix_sums(fixed)
assert [k for k in range(9) if P[k] != Q[k]] == [4, 5, 6, 7, 8]
Prefix sums have a second weakness: they need subtraction. There is no inverse for min, so the prefix minima and say nothing about the minimum over hours 1 to 6. (A sparse table, which stores the minimum of every block of length , answers min in by two overlapping blocks. It relies on , so it can't do sums, and it supports no updates at all.)
The waste is symmetric. Scanning recomputes, on every query, sums that nothing has changed. Prefix sums precompute, on every update, sums that nobody will ask for. We want something in between: each update should touch few stored sums, and each query should need few of them.
The model
We count node operations: reads and writes of stored aggregates, one word each. Combining two values with costs . For string concatenation it really costs their length, so there the bound counts nodes, not characters.
The idea: every node knows its block
Pad up to a power of two (here ). Build a complete binary tree over the
positions and store it in an array t the way a heap is stored: the root is t[1], the children
of node are and , and the leaves are t[s] .. t[2s - 1], leaf holding
. Each node covers a block of consecutive positions: the leaves below it.
Invariant
Every node stores the aggregate of its block: , so a node of height holds for its block .
The code below fills every node straight from that definition. This is a stand-in, and it costs ; built bottom-up, from down to , each node is one addition of its two children and the whole tree costs additions.
def block(node, size):
"""The half-open range of positions below a node."""
lo, hi = node, node + 1
while lo < size:
lo, hi = 2 * lo, 2 * hi
return lo - size, hi - size
def tree_from_definition(a, size, op=lambda x, y: x + y, identity=0):
padded = list(a) + [identity] * (size - len(a))
t = [identity] * (2 * size)
for node in range(1, 2 * size):
lo, hi = block(node, size)
acc = identity
for x in padded[lo:hi]:
acc = op(acc, x)
t[node] = acc
return t
t = tree_from_definition(A, 8)
assert t[1:8] == [41, 20, 21, 9, 11, 12, 9]
assert all(t[i] == t[2 * i] + t[2 * i + 1] for i in range(1, 8))
Answering a query
A range is a union of blocks. The query takes the largest blocks that fit inside the range, and there are few of them. Think of it top-down. Start at the root. A node whose block lies inside is taken whole, and a node whose block misses the range is ignored. A node that straddles a boundary is split into its two children.
def query(t, size, l, r, op=lambda x, y: x + y, identity=0):
"""The aggregate of [l, r), top-down, and the nodes taken."""
taken = []
def visit(node, lo, hi):
if hi <= l or r <= lo: # the block misses the range
return identity
if l <= lo and hi <= r: # inside the range: take it
taken.append((node, t[node]))
return t[node]
mid = (lo + hi) // 2 # straddles a boundary: split
left = visit(2 * node, lo, mid)
return op(left, visit(2 * node + 1, mid, hi))
return visit(1, 0, size), taken
assert query(t, 8, 1, 7) == (27, [(9, 3), (5, 11), (6, 12), (14, 1)])
assert query(t, 8, 0, 5) == (27, [(2, 20), (12, 7)])
The same nodes can be found bottom-up, with no recursion, which is how the array layout is usually used. Put two markers on the leaves and and move them up one level at a time. At each level, if the left marker sits on a right child, its parent's block starts to the left of the range, so the parent can't be taken. The marker's own node is taken, and the marker steps one node right. Symmetrically, if the right marker (which points one past the range) sits on a right child, the node just before it is taken. Then both markers move to their parents. The walk stops when the markers meet.
Predict: leaf 1 is in the range and its parent, node 4, covers [0, 2). Why isn't node 4 taken?
Because its block includes position 0, which is outside . In the bottom-up walk the left marker starts on leaf 9, a right child, so leaf 9 is taken alone and the marker steps right, to node 10. Node 4 is never visited.
Why it is correct, and why it is fast
Correctness. The taken blocks are disjoint and together they are exactly . Top-down, every position of the range lies under the root, and at each node it goes to exactly one child. It stops at the first node whose block lies wholly inside the range, which is taken. A position outside the range never gets inside a taken block, because only blocks inside the range are taken. By the invariant each taken node holds the aggregate of its block, so adding them in left-to-right order gives the aggregate of the range. The ordering matters, as the next section shows.
At most two taken nodes per level. Call a node split if it straddles a boundary of the range. A block contains the boundary in its interior only if it is the block at that level that holds position , and the same for . So each level has at most two split nodes. Every taken node's parent is a split node, since a parent inside the range would have been taken instead. And a split node that holds only one boundary has at most one child inside the range. One split node holding both boundaries has no child inside: its left child starts before and its right child ends after . So at most two nodes are taken per level. There are levels below the root, and the root is taken only when the range is everything, so a query takes at most nodes.
On eight leaves that is 6, and query(1, 7) took 4: one leaf from each end, then one node of
two leaves from each end.
Updates. Changing breaks the invariant only at nodes whose block holds : the leaf and its ancestors. Rewrite the leaf, then recompute each ancestor from its two children, from the bottom up. That is exactly writes, and afterwards every node again holds its block's sum.
fixed = A[:3] + [10] + A[4:] # hour 3 was 10, not 2
t2 = tree_from_definition(fixed, 8) # stand-in: recompute all
changed = [i for i in range(1, 16) if t[i] != t2[i]]
assert changed == [1, 2, 5, 11] # leaf 11 and its ancestors
assert [t2[i] for i in (11, 5, 2, 1)] == [10, 19, 28, 49]
assert query(t2, 8, 1, 7)[0] == 35
Complexity
Theorem (segment tree)
For any associative operation with an identity, the tree over positions uses words and is built with combinations. Each update writes exactly nodes and each query reads at most nodes, in the worst case, where .
Compare that with the two obvious answers. Scanning is per update and per query. Prefix sums are per update and per query. The tree is for both. For updates and queries on positions, either obvious answer is about steps, while the tree is a few million.
Can we do better than ? Not by much. Pătraşcu and Demaine proved (2004) that for dynamic range sums in the cell-probe model, the model that charges only memory accesses, any data structure needs operations per update or query, amortized. This lesson states that bound without proof.
Any associative operation
Nothing in the proof used addition itself. It used two facts:
- Associativity, , because the tree regroups the range into blocks;
- an identity , with , for padding and for empty ranges.
A set with such an operation is a monoid. Sum with 0, min with , max with ,
gcd with 0, string concatenation with "" and matrix product with the identity matrix
are all monoids, and the same tree serves every one of them.
INF = float("inf")
mins = tree_from_definition(A, 8, min, INF)
assert mins[1:8] == [1, 2, 1, 3, 2, 5, 1]
assert query(mins, 8, 1, 7, min, INF)[0] == 1
cat = tree_from_definition(list("segment"), 8, lambda x, y: x + y, "")
assert query(cat, 8, 1, 6, lambda x, y: x + y, "")[0] == "egmen"
assert query(cat, 8, 0, 7, lambda x, y: x + y, "")[0] == "segment"
The identity matters for padding. Pad a min tree over with 0 and its root claims the minimum is 0. The padding must be the identity, for min, or the invariant is false at every node above a padded leaf.
Order matters when does not commute. Concatenation is associative but
"ab" + "c" is not "c" + "ab". The top-down query above keeps the order on its own, because it always combines a left child's
result with its right sibling's, in that order.
The bottom-up walk takes nodes from both ends at once, so it needs two accumulators: nodes from the
left marker are appended to the right end of left, nodes from the right marker are prepended to
right, and the answer is left ⊕ right. A walk that appends both returns "tensegm" for all of
"segment". Sums and minima hide that bug completely, so test it with a monoid that does not
commute.
Predict: what if is not associative, say ?
The tree regroups, so the answer depends on its shape. On the left-to-right fold is , while the tree computes . Associativity is what makes "the aggregate of a block" a single well-defined value. Commutativity is not needed.
Speeding up an algorithm
Range queries also turn quadratic loops into ones. A common shape is "for each element, aggregate over the earlier elements whose values fall in some range". Counting inversions is the standard example. For each , how many have ? The obvious loop compares all pairs.
Turn it around. Keep a sum tree indexed by value, where position counts how many elements of value have been seen so far. Walk the array. For each element, first query the positions above its value, which counts the earlier larger elements, then add 1 at its value. That is one query and one update per element: . If the values are large, replace each by its rank among the distinct values first, so the tree has at most leaves.
def greater_before(a):
"""The definition, by brute force (a stand-in)."""
return [sum(1 for j in range(i) if a[j] > a[i])
for i in range(len(a))]
assert greater_before(A) == [0, 1, 0, 3, 1, 3, 6, 1]
assert sum(greater_before(A)) == 15
assert [sorted(A).index(x) for x in A] == [4, 2, 7, 1, 5, 3, 0, 6]
assert len(query([0] * 16, 8, 1, 8)[1]) == 3
On A the ranks are . When the element 1 arrives (rank 0), six values have been seen, and the query over ranks reads their count, 6, from three nodes instead of comparing with six elements one by one. The totals are 15 inversions for and pair checks for the brute force at .
Updating a whole range
What if a correction adds to every hour in ? Doing it one position at a time costs . Lazy propagation keeps . The update takes the same at most nodes a query would take. At each one it adjusts the stored sum (by times the block's length) then recomputes their ancestors on the way back, and leaves a pending tag: "add to everything below me". The tag is pushed down to the two children only when a later operation has to descend through that node. Every operation still touches nodes. This lesson only sketches the idea and doesn't prove it.
A related trick uses the aggregates to search rather than to sum. With non-negative values, "the first position at which the running total reaches " walks down from the root, going left whenever the left child's sum is still at least what remains of . That is one root-to-leaf path, reads.
Measure the claim
The proof says a query reads at most nodes and an update writes exactly
. Below, the worst over 2,000 random queries (from random.Random(n)) is compared with
the bound, and inversion counting is costed in node reads plus writes per , on a random
permutation.
import math, random
measured = []
for n in (1024, 8192, 65536):
r = random.Random(n)
a = [r.randint(0, 99) for _ in range(n)]
tree = [0] * (2 * n)
tree[n:] = a
for i in range(n - 1, 0, -1):
tree[i] = tree[2 * i] + tree[2 * i + 1]
worst = 0
for _ in range(2000):
l = r.randrange(n + 1)
taken = query(tree, n, l, r.randrange(l, n + 1))[1]
worst = max(worst, len(taken))
r.randrange(n)
perm = list(range(n))
r.shuffle(perm)
counts, ops = [0] * (2 * n), 0
for x in perm:
# reads: the nodes that count the earlier, larger values
ops += len(query(counts, n, x + 1, n)[1])
j = x + n
while j: # writes: leaf to root
counts[j] += 1
ops += 1
j //= 2
lg = int(math.log2(n))
measured.append((n, worst, 2 * lg, round(ops / (n * lg), 2)))
assert measured == [(1024, 15, 20, 1.6), (8192, 19, 26, 1.58),
(65536, 23, 32, 1.56)]
| worst query, nodes read | bound | update writes | inversions: operations per | |
|---|---|---|---|---|
| 1,024 | 15 | 20 | 11 | 1.60 |
| 8,192 | 19 | 26 | 14 | 1.58 |
| 65,536 | 23 | 32 | 17 | 1.56 |
Random ranges rarely hit the worst case, which needs both boundaries to take a node at every level. The inversion column is flat, as predicts, and on these inputs it sits near 1.5. These are measurements on these inputs, not a theorem about the constant.
A problem that looks different
A game engine plays back a list of 50,000 moves. Each move is a rotation followed by a shift, so it is a small matrix. Designers keep editing single moves and keep asking where an object ends up if only moves through are applied. Nothing here is a sum, and the order of the moves certainly matters. Is there still a tree for it? The lab's last problem is a different one.
Practise
The lab has five parts. You build a tree and replay corrections on a new array, and the checks confirm that every node holds its block's sum in every frame. You trace the bottom-up walk node by node. You write one tree for any monoid, and a string check catches it if it ever combines out of order. You count the nodes your own inversion counter touches at three sizes. Finally you solve a problem that doesn't say what it is.
Recap
- You can now: say when prefix sums or a sparse table are enough and why neither survives updates; build a segment tree for any monoid; prove that a query takes at most two nodes per level and that an update rewrites exactly one path; and turn an "each element against the earlier ones" loop into .
- Invariant: every node stores the aggregate of its block, .
- Complexity achieved: node operations per update and per query in the worst case, for any monoid, against for one of the two operations with scanning or with prefix sums; build and space.
- Failure mode: combining the right-hand nodes in the wrong order for an operation that does not commute, padding with a value that is not the identity, or reading an inclusive range as half-open.
- In real software: the Linux kernel's interval trees (
include/linux/interval_tree_generic.h) are red-black trees in which every node also stores the largest endpoint in its subtree (ITSUBTREE), kept up to date along the path on each change, and a search skips any subtree whose stored maximum rules it out. It is the same "node = aggregate of its subtree" invariant, on a balanced search tree instead of a fixed array. - Retrieval: counting inversions needs values replaced by their ranks. For integers below , how can the ranks be computed in word operations? (Module 03 answers it.)
Check yourself
- Why does a query take at most two nodes per level, and why do the taken blocks make up exactly
the range? Use
query(1, 7)on and explain why node 4 is not taken. - Replace sum by subtraction, which is not associative. What exactly breaks, and on which input can you show it?
- A dashboard asks 1,000 range sums for every correction. Would you still use the tree, or prefix sums? At what ratio of queries to updates does the choice change?
Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.