Cairn
data structures · trees · O(log n) range update / O(log n) range sum query

back to Array-Backed Trees

Segment Tree with Lazy Propagation

The segment tree already answers arbitrary range queries in O(log n), but its update only ever touches one index at a time. Adding 10 to every element in [2, 5] the way that page's update is written means four separate point updates — four separate root-to-leaf walks, each fixing O(log n) nodes, for O(k log n) total on a k-element range. Lazy propagation gets a whole-range update down to O(log n) too: when a range update's target range exactly covers a node's range, stop right there — record the pending change as a tag on that one node instead of walking into its subtree — and only pay to push that tag down into the children later, if and when some other operation actually needs to look inside. Whole subtrees stay "correct in aggregate, stale underneath" for as long as nothing asks to see underneath.

Try it

Same 8-value array as the segment tree page ([5, 2, 8, 1, 9, 3, 7, 4], sum 39), same node numbering (node 1 is the root, nodes 8-15 are the leaves) — but each node now tracks a running sum of its range instead of a minimum, and carries a small orange lazy badge whenever it's holding a pending tag not yet pushed to its children. Pick a range and a value and press Range Add to add that value to every element in the range, or pick a range and press Range Sum to query it, then step through to see exactly which nodes get touched, which get short-circuited with a tag, and which force a push-down.

current values (index 0..7) — computed from stored sums plus any pending tags above each leaf
segment tree — node 1 is the root, nodes 8-15 are the leaves; badge = pending lazy tag
Press Range Add or Range Sum, then Step through it.

Why it works

A range update's recursive walk classifies every node it visits into one of three cases. Outside the target range: skip immediately, no cost at all. Fully inside the target range: this is the short-circuit — apply the update to the node's own stored sum right here (tree[node] += val × (length of the node's range)), record val as a pending tag on the node (lazy[node] += val), and stop, never recursing into its children. Partially overlapping the target range: if this node has a pending tag of its own, push it down to both children first (give each child tag × (that child's range length) added to its own sum, and hand each child the same tag to accumulate into its own lazy slot), then recurse into both children and recompute this node's sum as their total on the way back up. The default Range Add of +10 over [2, 5] visits the root (partial, recurse), node 2 (partial, recurse), node 5 — range [2,3], fully inside — absorbs the tag directly (29, was 9, lazy[5] = 10) and stops, node 3 (partial, recurse), node 6 — range [4,5], fully inside — absorbs the tag directly (32, was 12, lazy[6] = 10) and stops. Only 7 nodes touched, not the 4 separate root-to-leaf walks a point-update segment tree would need — and the root sum is now 79, exactly 39 + 4 × 10.

A later query answers the same three-way question, but instead of applying a change it accumulates a sum, and it must push down before descending into any node with a pending tag rather than only when told to update — a query has to see accurate values too. The default Range Sum over [1, 5] after that update reaches node 9 (leaf, index 1, value 2), then node 5 — fully inside [1,5] — which returns its own stored sum 29 directly, tag and all, with no push-down needed at all: a fully-covered node's own stored value is already correct, only its children are the ones left stale. Then node 6, also fully inside, returns 32 the same way. Total: 2 + 29 + 32 = 63. Two of the query's three contributing nodes were still carrying an un-pushed tag the whole time, and it never mattered — the tag only has to be pushed down before something looks underneath the node that's holding it, and neither query here needed to.

Reference implementation

This is the recursive, top-down form the segment tree page's own pitfalls note mentioned as the alternative to its iterative array trick — lazy propagation needs explicit node ranges and a real push-down step before descending, which the flat bottom-up array doesn't have a clean place for. Because this demo's 8-element input is already a power of two, the same compact 2n-sized arrays and 2i/2i+1 child indexing work unchanged; a general-size input needs the same padding the segment tree page already covers:

class LazySegmentTreeSum {
  constructor(values) {
    let size = 1;
    while (size < values.length) size *= 2;
    this.n = size;
    this.tree = new Array(2 * size).fill(0);
    this.lazy = new Array(2 * size).fill(0);
    this._build(1, 0, size - 1, values);
  }

  _build(node, l, r, values) {
    if (l === r) { this.tree[node] = values[l] || 0; return; }
    const mid = (l + r) >> 1;
    this._build(2 * node, l, mid, values);
    this._build(2 * node + 1, mid + 1, r, values);
    this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
  }

  _pushDown(node, l, r) {
    if (this.lazy[node] === 0) return;
    const mid = (l + r) >> 1;
    const lc = 2 * node, rc = 2 * node + 1;
    this.tree[lc] += this.lazy[node] * (mid - l + 1);
    this.lazy[lc] += this.lazy[node];
    this.tree[rc] += this.lazy[node] * (r - mid);
    this.lazy[rc] += this.lazy[node];
    this.lazy[node] = 0;
  }

  rangeAdd(node, l, r, ql, qr, val) {
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) {
      this.tree[node] += val * (r - l + 1);
      this.lazy[node] += val;
      return;
    }
    this._pushDown(node, l, r);
    const mid = (l + r) >> 1;
    this.rangeAdd(2 * node, l, mid, ql, qr, val);
    this.rangeAdd(2 * node + 1, mid + 1, r, ql, qr, val);
    this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
  }

  rangeSum(node, l, r, ql, qr) {
    if (qr < l || r < ql) return 0;
    if (ql <= l && r <= qr) return this.tree[node];
    this._pushDown(node, l, r);
    const mid = (l + r) >> 1;
    return this.rangeSum(2 * node, l, mid, ql, qr)
         + this.rangeSum(2 * node + 1, mid + 1, r, ql, qr);
  }
}

Pitfalls

Skip the push-down and a query into a tagged node silently reads stale data. After the default Range Add (+10 over [2, 5]), nodes 5 and 6 are each holding a pending tag that was never pushed to their leaves — index 3 (leaf node 11, under node 5) and index 4 (leaf node 12, under node 6) are still physically storing their old values, 1 and 9, even though the tree's aggregate sums are already correct. Query Range Sum [3, 4]: this range only partially overlaps both node 5 and node 6, so a correct query must push both tags down first before it can descend — giving tree[11] = 11, tree[12] = 19, and a correct total of 30 (checked: 1+10=11, 9+10=19, matches the naive recomputation from scratch). Dropping the push-down step before recursing into a partially-overlapped node — an easy edit to get away with for a while, since most queries land on already-fully-covered or already-fully-pushed nodes without ever exposing it — makes this same query read the stale leaves directly: 1 + 9 = 10, three times too small and eleven off from the second query defaults above, no crash, no error, just silently wrong by exactly the un-applied tag's contribution.

Forgetting to scale a pending tag by how many elements it covers. When a node fully absorbs a range update, the update has to land on every element the node represents, not just once — tree[node] += val × (r - l + 1), not tree[node] += val. Nodes 5 and 6 above each cover 2 elements, so the correct absorption adds 10 × 2 = 20 to each, for a correct root sum of 79 (39 + 4 × 10). Add val alone instead of val × length and each of those two nodes under-adds by exactly 10 — a root sum of 59, twenty short, growing worse the larger the fully-covered nodes a given update happens to land on. This is specific to sum (and any other size-sensitive aggregate, like average or count) — a min-tracking node's pending tag needs no length factor at all, since adding a constant to every element in a range shifts that range's minimum by the exact same constant regardless of how many elements are in it. The same lazy-propagation mechanism, applied to a different combining operation, needs a different rule for what "apply the tag" even means.

Complexity

Time: O(log n) for both rangeAdd and rangeSum — the same argument as the plain segment tree's point update and range query: a range decomposes into at most O(log n) fully-covered nodes, reached via at most O(log n) partially-covered nodes on the way down, and each visited node does O(1) work (including the push-down check) rather than recursing further once it's fully covered or fully outside. Building from an existing array is O(n), same as the plain segment tree. Space: O(n), but doubled over the plain segment tree — a lazy array the same size as tree, tracking one pending value per node instead of zero.

This site's guide, Choosing a Range Query Structure, compares this entry against the other five Array-Backed Trees structures that answer the same kind of question side by side.