For the longest time, I only knew how to use the lazy segtree for Range Add / Range Sum queries. It would take me painstakingly long to solve problems which had lazy propagation in a slightly different form. I then discovered the Atcoder Lazy Segment Tree template (C++ / Python), which would allow you to solve any Lazy Propagation problem by just defining 6 mathematical parameters: S, op, mapping, composition, e and id. This blog is an attempt to explain the math behind Lazy Propagation in an intuitive manner using Socratic dialogue. I would recommend that you guys ponder over the questions before you read the answers.
Prerequisites: You know how a standard segment tree works. You know how to build it, how to perform point updates, and how to query a range. If you don't, close this tab, learn that, and come back.
Section 1: The Art of Procrastination
Question: A standard segment tree processes a point update in $$$O(\log N)$$$. If a query requires adding a value $$$v$$$ to every element in the range [0, 50000], why can't we simply call the point update function in a loop?
AnswerDoing so results in $$$O(N \log N)$$$ time complexity per query, which mathematically guarantees a Time Limit Exceeded (TLE) verdict. To achieve $$$O(\log N)$$$ range updates, we must completely defer updating the leaf nodes.
Question: To achieve $$$O(\log N)$$$ updates, we must stop tree traversal early. How do we defer these updates mathematically without losing the data?
AnswerDuring the tree traversal, if we encounter a node that is perfectly enveloped by the update query range, we stop. We update that specific node's value, and then we store the operation in a parallel array: the Lazy Tree.
This tag is a mathematical guarantee: the node itself reflects the update, and it promises to eventually propagate the update to its subtree.
Question: If a parent node defers updating its children, what is the exact trigger that forces it to finally push the updates down? Define the golden rule of lazy propagation and the exact steps of the push_down function.
AnswerThe golden rule of lazy propagation is absolute: You only push a tag down when forced to traverse into a node's children. If a new query partially overlaps a node, we must split our traversal to visit its children. Before making that recursive call, we must execute push_down:
- The parent applies its pending tag to its immediate left and right children.
- The parent clears its own tag.
The parent is now clean, the immediate children hold accurate values, and the procrastination is deferred exactly one layer deeper.
Section 2: Formalizing the Mathematics
Question: If a node defers an update and does not visit its children, how does it compute its own correct sum for future queries? If a node represents a segment of $$$L$$$ elements and a Range Add $$$v$$$ operation arrives, what exact state variables must the node store?
AnswerIt computes it mathematically. If a range update adds $$$v$$$ to every element, each of the $$$L$$$ elements increases by $$$v$$$. The total sum of that segment increases by $$$v \times L$$$.
This dictates a fundamental truth: a single integer sum is insufficient. The node's state must formally be defined as a composite structure $$$S = (\text{sum}, \text{length})$$$.
Question: Formally define the mapping(f, S) function. For a Range Add / Range Sum tree, if a tag $$$v$$$ arrives at a node with state $$$S$$$, what is the exact algebraically updated state?
AnswerThe mapping(f, S) function takes an operation $$$f$$$ and a state $$$S$$$, and returns the strictly updated state in $$$O(1)$$$.
For Range Add / Range Sum, it evaluates to: mapping(v, S) $$$\to (S.\text{sum} + v \times S.\text{length}, S.\text{length})$$$
Question: Queuing multiple pending operations on a single node destroys the $$$O(1)$$$ push-down complexity. If a node holding tag $$$g$$$ is hit by a new tag $$$f$$$, define the composition(f, g) function for a Range Add tree to compress them instantly.
AnswerFor range addition, stacking tags is trivial. The operations are just additions, so they compress by adding them together:
composition(f, g) $$$\to f + g$$$
When push_down is eventually called, the parent passes this single compressed tag down to the children.
Question: That handles the top-down updates. How do we compute a parent's state from its children during the bottom-up phase of the traversal? Define the op(left, right) function for a Range Sum tree.
AnswerThis is the horizontal merge function. When merging two adjacent segments, the lengths add together, and the sums add together.
op(left, right) $$$\to (\text{left.sum} + \text{right.sum}, \text{left.length} + \text{right.length})$$$
Question: To implement this cleanly, segment trees usually pad out-of-bounds queries or empty nodes with mathematical identities. Define the identity state e() and the identity operation id() for our Range Add / Range Sum tree.
AnswerFor the state $$$S$$$, we need a value that does not alter the result of op. This is e(). e() $$$\to (0, 0)$$$
For the operation $$$F$$$, we need a lazy tag that does not alter the state when passed into mapping. This is id(). id() $$$\to 0$$$
With S, op, mapping, composition, e, and id strictly defined, the internal engine of the lazy segment tree is entirely complete. It requires no further hardcoding.
Section 3: Letting Algebra Dictate the Architecture
Once the mechanics of S, op, mapping, composition, e, and id are strictly defined, the tree traversal logic is permanently closed. You never modify it again. Every lazy segment tree problem from this point forward is strictly a mathematical exercise in defining these six objects.
We will prove this by escalating the mathematical complexity.
The Problem: You must support two completely different range updates on the same tree: "Add $$$v$$$ to all elements in range" and "Multiply all elements in range by $$$v$$$".
Attempting to track the timeline of different operations via separate variables (e.g., add_val, mult_val) and boolean flags leads to catastrophic edge cases during push_down. Instead of tracking timelines, we must generalize the operation itself.
Question: How can you represent both "Add $$$v$$$" and "Multiply by $$$v$$$" as a single, unified mathematical operation?
AnswerBoth addition and multiplication are specific instances of an affine transformation: $$$f(x) = a \cdot x + b$$$.
- Range Add $$$v$$$ becomes $$$f(x) = 1 \cdot x + v$$$.
- Range Multiply $$$v$$$ becomes $$$f(x) = v \cdot x + 0$$$.
Your operation tag $$$F$$$ is no longer a single integer. It is strictly defined as the vector $$$F = (a, b)$$$.
Question: If the state is $$$S = (\text{sum}, \text{length})$$$ and the arriving tag is $$$F = (a, b)$$$, what is the exact algebraic formula for mapping(F, S)?
AnswerThe transformation $$$a \cdot x + b$$$ is applied to every individual element in the segment. The original sum is scaled by $$$a$$$, and the constant $$$b$$$ is added to every single element in the segment (meaning $$$b$$$ is added $$$\text{length}$$$ times).
mapping(F, S) strictly returns: ( (F.a * S.sum) + (F.b * S.length), S.length )
Question: If a node holds a pending tag $$$g(x) = a_2 x + b_2$$$, and a new tag $$$f(x) = a_1 x + b_1$$$ arrives, how do we compress them? Define composition(f, g) and the identity id().
AnswerComposition is the strict mathematical evaluation of $$$f(g(x))$$$.
$$$f(g(x)) = a_1(a_2 x + b_2) + b_1 = (a_1 a_2)x + (a_1 b_2 + b_1)$$$
composition(F, G) simply returns the new vector: (F.a * G.a, F.a * G.b + F.b)
The identity operation id() must leave $$$x$$$ entirely unchanged. For $$$a \cdot x + b = x$$$, $$$a$$$ must be $$$1$$$ and $$$b$$$ must be $$$0$$$.
id() returns (1, 0).
The Problem: The operation is a simple Range Add $$$v$$$, but the query asks for the Sum of Squares of all elements in a range.
You cannot simply store a sq_sum variable and expect it to update correctly. The query dictates the state. We must let the binomial expansion dictate the exact variables our struct requires.
Question: If a single element $$$x$$$ is updated by adding $$$v$$$, the new square is $$$(x + v)^2$$$. Expand this mathematically across an entire segment of length $$$L$$$. Based on the resulting terms, what exact variables must your state $$$S$$$ store to compute the new sum of squares in $$$O(1)$$$ time?
AnswerFirst, expand the binomial for a single element: $$$(x + v)^2 = x^2 + 2xv + v^2$$$
Now, distribute the summation across an entire segment: $$$\sum (x + v)^2 = \sum x^2 + 2v \sum x + v^2 \sum 1$$$
Analyze the terms required to compute this new value:
- $$$\sum x^2$$$ requires the old sum of squares.
- $$$\sum x$$$ requires the old regular sum.
- $$$\sum 1$$$ evaluates to the length of the segment.
You cannot compute the new sum of squares without the regular sum, and you cannot compute the regular sum without the length. Therefore, your state must explicitly store all three variables: $$$S = (\text{sq_sum}, \text{sum}, \text{length})$$$.
Question: Given the state $$$S = (\text{sq_sum}, \text{sum}, \text{length})$$$, define the exact mapping(v, S) function when a tag $$$v$$$ arrives.
AnswerThe mapping function executes the expanded algebra simultaneously for all required state variables:
S_new.sq_sum = S.sq_sum + 2 * v * S.sum + (v * v) * S.length S_new.sum = S.sum + v * S.length S_new.length = S.length
Because the operation is a standard Range Add, composition(f, g) remains $$$f + g$$$, and id() remains $$$0$$$. op(left, right) simply adds the respective sq_sum, sum, and length fields together.
The Problem: You are given a binary array consisting only of $$$0s$$$ and $$$1s$$$. You must support two operations:
- Range Flip: Flip all bits in a range ($$$0 \to 1$$$, $$$1 \to 0$$$).
- Range Inversions: Count the number of inversions in a range. An inversion is a pair of indices $$$(i, j)$$$ such that $$$i \lt j$$$ and $$$A[i] \gt A[j]$$$ (meaning $$$A[i] = 1$$$ and $$$A[j] = 0$$$).
Question: To support calculating inversions across ranges, what exact variables must the state $$$S$$$ store, and how is the horizontal merge op(left, right) defined?
AnswerConsider the fundamental property of a segment tree: every element in the left child comes strictly before every element in the right child.
When merging two segments, the total inversions in the combined segment consist of:
- The internal inversions completely within the
left segment. - The internal inversions completely within the
right segment. - The cross-inversions formed by elements across the boundary.
Because every 1 in the left segment appears before every 0 in the right segment, each 1 on the left forms exactly one inversion with each 0 on the right. The number of cross-inversions is strictly left.ones * right.zeros.
To compute this in $$$O(1)$$$, the state must store the counts of both states:
$$$S = (\text{ones}, \text{zeros}, \text{inversions})$$$
The horizontal merge op(left, right) returns:
ones = left.ones + right.ones zeros = left.zeros + right.zeros inversions = left.inversions + right.inversions + (left.ones * right.zeros)
Question: Our lazy tag $$$F$$$ is a boolean flag where true means "flip the segment". How do we define mapping(F, S) to instantly calculate the new number of inversions when a flip arrives?
AnswerWhen the segment is flipped, the $$$0s$$$ and $$$1s$$$ simply swap values.
S_new.ones = S.zeros S_new.zeros = S.ones
For the inversions, consider the total number of mixed-bit pairs in the segment. The maximum possible number of pairs between differing bits is exactly $$$S.\text{ones} \times S.\text{zeros}$$$.
Every mixed pair is currently either a $$$(1, 0)$$$ inversion or a $$$(0, 1)$$$ non-inversion. A flip perfectly reverses their relationship. The inversions become non-inversions, and the non-inversions are upgraded into inversions.
Therefore, the mapping function computes the new inversions as the complement of the old inversions:
S_new.inversions = (S.ones * S.zeros) - S.inversions
Question: If a node holds a pending flip tag $$$g$$$, and a new flip tag $$$f$$$ arrives, how do we define composition(f, g) and the identity id()?
AnswerTwo consecutive flips mathematically cancel each other out. The timeline of operations evaluates to basic parity.
This is perfectly captured by the boolean XOR operation (^). composition(f, g) strictly returns f ^ g.
The identity operation id() that leaves the state untouched is trivially false.