On this page

Range Queries and Segment Trees

Prerequisites: arrays, recursion and O-notation. Integer Sorting is used once, in the Recap.

Eight hours of sales

A shop records its sales hour by hour. Today's eight hours are

6 0 3 1 9 2 2 3 7 4 5 5 1 6 8 7 query(1, 7) = 27
The sales A used throughout this lesson. The bracket covers hours 1 to 6: in code, the half-open range [1, 7).

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 a[0..n−1] under

  • update(i, v): set a[i]=v;
  • query(l, r): return a[l]⊕a[l+1]⊕…⊕a[r−1].

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 r−l elements, so Θ(n) in the worst case. A day of n queries costs Θ(n2) when the ranges are long.

Prefix sums. Store P[k]=a[0]+…+a[k−1]. Then any range sum is one subtraction, P[r]−P[l], in O(1). But a correction to a[i] changes P[i+1],…,P[n]: every later prefix. That is Θ(n) 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 min(a[0..6])=1 and min(a[0..0])=6 say nothing about the minimum over hours 1 to 6. (A sparse table, which stores the minimum of every block of length 2j, answers min in O(1) by two overlapping blocks. That trick relies on min(x,x)=x, so it can't do sums in O(1) (a disjoint variant can), and no sparse table supports updates.)

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 O(1). For string concatenation it really costs their length, so there the bound counts nodes, not characters.

The idea: every node knows its block

Pad n up to a power of two s (here s=n=8). Build a complete binary tree over the s positions and store it in an array t the way a heap is stored: the root is t[1], the children of node i are 2i and 2i+1, and the leaves are t[s] .. t[2s - 1], leaf s+j holding a[j]. Each node covers a block of consecutive positions: the leaves below it.

Invariant

Every node stores the aggregate of its block: t[i]=t[2i]⊕t[2i+1], so a node of height h holds a[k2h]⊕…⊕a[(k+1)2h−1] for its block k.

The code below fills every node straight from that definition. This is a stand-in, and it costs O(nlogn); built bottom-up, from t[s−1] down to t[1], each node is one addition of its two children and the whole tree costs s−1 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))
[0,1) 6 [0,2) 9 [1,2) 3 [0,4) 20 [2,3) 9 [2,4) 11 [3,4) 2 [0,8) 41 [4,5) 7 [4,6) 12 [5,6) 5 [4,8) 21 [6,7) 1 [6,8) 9 [7,8) 8
The sum tree over A: each node shows its block and its sum. query(1, 7) reads the four green nodes: 3 + 11 + 12 + 1 = 27.

Answering a query

A range [l,r) 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 [l,r) 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 l+s and r+s 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 [1,7). 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 [l,r). 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 l in its interior only if it is the block at that level that holds position l, and the same for r. 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 l and its right child ends after r. So at most two nodes are taken per level. There are log2s levels below the root, and the root is taken only when the range is everything, so a query takes at most 2log2s nodes.

The level just below the root does a little better. Two taken nodes there would be the whole range, and the root would be taken instead. One taken node there means the range reaches an end of the array, so only the other boundary takes nodes below it. So for s≥4 no query takes more than 2log2s−2 nodes. On eight leaves that is 4, and query(1, 7) took exactly 4: one leaf from each end, then one node of two leaves from each end. No range on eight leaves takes more.

Updates. Changing a[i] breaks the invariant only at nodes whose block holds i: the leaf and its log2s ancestors. Rewrite the leaf, then recompute each ancestor from its two children, from the bottom up. That is exactly log2s+1 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
[0,1) 6 [0,2) 9 [1,2) 3 [0,4) 28 [2,3) 9 [2,4) 19 [3,4) 10 [0,8) 49 [4,5) 7 [4,6) 12 [5,6) 5 [4,8) 21 [6,7) 1 [6,8) 9 [7,8) 8
After update(3, 10): the leaf and its three ancestors (yellow) are rewritten, bottom-up, to 10, 19, 28, 49. No other node's block holds position 3.

Complexity

Theorem (segment tree)

For any associative operation with an identity, the tree over n positions uses 2s<4n words and is built with s−1 combinations. Each update writes exactly log2s+1 nodes and each query reads at most 2log2s nodes, in the worst case, where s<2n.

Compare that with the two obvious answers. Scanning is O(1) per update and Θ(n) per query. Prefix sums are Θ(n) per update and O(1) per query. The tree is O(logn) for both. For 105 updates and 105 queries on 105 positions, either obvious answer is about 1010 steps in the worst case, while the tree is a few million.

When the operation has an inverse, as sums do, a lighter structure exists: the Fenwick (binary indexed) tree keeps about n words instead of 2s and has the same O(logn) bounds with shorter loops. It answers a range as the difference of two prefixes, so it can't do range minima.

Can we do better than logn? Not by much. Pătraşcu and Demaine proved (2004) that for dynamic range sums of word-sized integers in the cell-probe model, the model that charges only memory accesses, no data structure makes both operations fast: the slower of update and query needs Ω(logn) memory accesses, amortized. This lesson states that bound without proof.

Any associative operation

Nothing in the proof used addition itself. It used two facts:

  • Associativity, (x⊕y)⊕z=x⊕(y⊕z), because the tree regroups the range into blocks;
  • an identity e, with e⊕x=x=x⊕e, 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 2×2 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 [5,3,9] 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 x⊖y=x−y?

The tree regroups, so the answer depends on its shape. On [6,3,9,2] the left-to-right fold is ((6−3)−9)−2=−8, while the tree computes (6−3)−(9−2)=−4. 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 nlogn 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 i, how many j<i have a[j]>a[i]? The obvious loop compares all (n2) pairs.

Turn it around. Keep a sum tree indexed by value, where position v counts how many elements of value v 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: O(nlogn) node operations. If the values are large, replace each by its rank among the distinct values first, so the tree has at most n 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 4,2,7,1,5,3,0,6. When the element 1 arrives (rank 0), six values have been seen, and the query over ranks [1,8) reads their count, 6, from three nodes instead of comparing with six elements one by one. The totals are 15 inversions for A and (655362)=2147450880 pair checks for the brute force at n=65,536.

Updating a whole range

What if a correction adds v to every hour in [l,r)? Doing it one position at a time costs (r−l)logn. Lazy propagation keeps O(logn). The update takes the same at most 2log2s nodes a query would take. At each one it adjusts the stored sum (by v times the block's length) then recomputes their ancestors on the way back, and leaves a pending tag: "add v 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 O(logn) 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 k" walks down from the root, going left whenever the left child's sum is still at least what remains of k. That is one root-to-leaf path, O(logn) reads.

Measure the claim

The proof says a query reads at most 2log2n nodes and an update writes exactly log2n+1. 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 nlog2n, 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)]
n worst query, nodes read bound 2log2n update writes inversions: operations per nlog2n
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. That needs a taken node at both ends of every level but the top one, and an exhaustive search over every range finds it only at query(1, s - 1):

def worst_ranges(s):
    zeros = [0] * (2 * s)
    reads = {(l, r): len(query(zeros, s, l, r)[1])
             for l in range(s + 1) for r in range(l, s + 1)}
    most = max(reads.values())
    return most, [lr for lr, k in reads.items() if k == most]

assert worst_ranges(4)[0] == 2
for s in (8, 16, 32, 64):
    assert worst_ranges(s) == (2 * int(math.log2(s)) - 2, [(1, s - 1)])

At n=1,024 that worst range reads 18 nodes, three more than the worst of the 2,000 random ones. The inversion column is flat, as O(nlogn) 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 l through r 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 O(nlogn).
  • Invariant: every node stores the aggregate of its block, t[i]=t[2i]⊕t[2i+1].
  • Complexity achieved: O(logn) node operations per update and per query in the worst case, for any monoid (each ⊕ counted as one step), against Θ(n) in the worst case for one of the two operations with scanning or with prefix sums; O(n) 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 105 integers below 109, how can the ranks be computed in O(n) word operations? (Integer Sorting answers it.)

Check yourself

  1. 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 A and explain why node 4 is not taken.
  2. Replace sum by subtraction, which is not associative. What exactly breaks, and on which input can you show it?
  3. 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.

Your turn

Now try it yourself

Write the code, press Run, and scrub through the frames your program draws. The checks tell you when it works.

Loading the lab…

In this lab

  1. Build the tree, then keep it true through corrections

    A second shop sold B = [4, 7, 1, 8, 5, 2, 9, 3] units in its eight hours, and the till will send three corrections: hour 2 was 6, hour 5 was 10, hour 0 was 0.

    Write build(values), which returns the sum tree as a list t in the heap layout from the lesson: the root is t[1], the children of node i are 2i and 2i + 1, and position j is the leaf t[size + j], where size is the smallest power of two that is at least len(values). Padding leaves hold 0, the identity for sums. The starter already places the leaves; fill every internal node from its two children, in an order that computes children before parents.

    Then write update(t, i, v): set position i to v, repair every node whose block holds i, and return the nodes you wrote, leaf first.

    replay is written for you. It draws a frame after the build and after each correction, labelling each node with its block and its value and colouring the nodes your update wrote. The checks read those labels and assert, in every frame, that every node holds the sum of its block of the current array. Before you press Run: what does the root hold after each correction, and how many nodes does each correction rewrite?

  2. Trace the walk from the leaves, one frame per node taken

    The lesson walked up from the leaves in prose. Now write that walk and watch it, on the second shop's hours B = [4, 7, 1, 8, 5, 2, 9, 3]. The tree is built for you (it is exercise 1's build).

    Write trace_query(t, l, r). Put one marker on leaf l + size and one on r + size, which points one past the range. While the markers haven't met: if the left marker is a right child (an odd node), take it and step it one node right; if the right marker is a right child, step it one node left and take that node; then move both markers to their parents. Call show(t, taken, total, l, r) every time you take a node, so the lab records one frame per node taken, and return the taken nodes as (node, value) pairs in left-to-right order of their blocks.

    The starter takes every leaf of the range one by one. It gets the right sum with too many nodes. Answer the two questions before you run anything: which nodes does query(3, 7) take, and in which order does the walk take them?

  3. One tree for every monoid, strings included

    Now one tree for every monoid. Write SegmentTree(values, op, identity) with update(i, v) and query(l, r) (half-open, returning identity for an empty range). Keep the tree in self.t in the heap layout of exercise 1, with self.size leaves, so that self.t[i] == op(self.t[2i], self.t[2i + 1]) for every internal node; the checks read it. Pad with identity, not 0: a min tree padded with 0 would claim that the minimum of everything is 0.

    Use the walk from exercise 2 for query, and remember that op may not commute. The demo builds a concatenation tree over the letters of "rangequery", where the order of the pieces is the whole answer. Keep two accumulators: nodes taken by the left marker join left on its right, nodes taken by the right marker join right on its left, and the answer is op(left, right).

    The starter is correct for every monoid, but it folds all r - l values. A check counts the calls your code makes to op on 1,000 values: at most 2 log₂ size + 1 = 21 per query and log₂ size + 1 = 11 per update. Before you run it: what should query(1, 9) return on "rangequery", and what does a walk that appends on both sides return for it?

  4. Measure: count the nodes, not the seconds

    Time to count. SumTree is the walk from the first three exercises with counters built in: SumTree.reads goes up by one for every node a query takes, and SumTree.writes by one for every node add writes.

    First, write count_greater_before(a): for each position i, how many earlier elements are larger than a[i]. Replace each value by its rank among the distinct values, keep one SumTree over the ranks that counts the values seen so far, and for each element query the ranks above its own before adding 1 at its own. On [4, 7, 1, 8, 5, 2, 9, 3] the answer is [0, 0, 2, 0, 2, 4, 0, 5].

    Second, write profile(sizes). For each n in 1,024, 4,096 and 16,384, and with every random choice drawn from random.Random(606 + n), record three things. The first is the most nodes any of 2,000 random queries [l, r) reads on a tree of n random values. The second is the writes of one add. The third is the reads plus writes of count_greater_before on a shuffled range(n), divided by n log₂ n. The demo draws your numbers.

    Before you run it, what should the update column say at each size? And if the inversion bars come out flat, which constant do you expect them to sit near? The questions ask you to derive it. One check also calls your profile on two sizes it doesn't publish and counts the nodes itself, so profile has to measure, not report numbers you expect.

  5. Mastery challenge: the box office

    A concert hall sells seats 0 .. n − 1 in one long row, and the box office handles a stream of events. ("book", i) sells seat i, which was free. ("cancel", i) frees seat i again. ("count", l, r) asks how many of the seats l .. r − 1 are sold, and ("next_free", s) asks for the lowest-numbered free seat at or after s, or -1 if there is none. The answer to every count and next-free question goes back to a customer.

    Halls have up to 100,000 seats, and on the night of a big show tens of thousands of events arrive while the row is nearly full. Write the class BoxOffice(n, cells). All of its state lives in cells: 4n + 4 integer cells, all 0 at the start. Every read and every write of a cell is counted, and the object must keep nothing else that grows with the hall. run_box_office(n, events) is written for you.

    The starter is correct, and it keeps one cell per seat. But a count over half the hall reads half the hall, and a next-free question in a nearly full row walks along it. A check runs about 45,000 events on 40,000 seats, ending with a block party that books a long stretch of the row, and allows setup at most 10n + 10 cells and every single event at most 6 ⌈log₂ n⌉ + 10 = 106 cells. It stops the starter at the first event that needs more. The checks meter their own copy of cells and look for state kept elsewhere, but they give feedback, not proof: code written to dodge them can pass.

    Answer the four questions, then write it. The demo draws the row after the example's events.