← All Posts
DSA Series · Trees · Segment Trees · Part 4

Lazy Propagation

This is the hardest and most important concept in segment trees. If you've understood building, querying, and point updates, you have all the prerequisites. Lazy propagation is the technique that gives us O(log n) range updates, without it, updating an entire range would cost O(n log n).

This post explains lazy propagation from first principles, with extreme detail. We'll build up the idea step by step, trace every operation, and make sure you can implement it confidently.

The Problem: Range Updates Are Slow

Suppose we want to "add 5 to every element from index 1 to index 6" in an 8-element array. With point updates, we'd call update() for each index from 1 to 6, that's 6 separate O(log n) updates = O(n log n) total.

The same problem arises for operations like "set all elements in [l, r] to value v." If the range is large, point-by-point is far too slow.

The core question: In a range query, we don't visit every leaf, we stop at nodes whose range is fully inside the query. Can we do the same for range updates? Mark an entire node as "updated" without touching its children?

Yes. That's exactly what lazy propagation does.

The Idea: Deferred Work

The Key Insight

When we do a range update "add delta to all elements in [l, r]," we traverse the tree just like a range query. When we find a node whose range is fully inside [l, r]:

The children's values are now stale. But we don't care, until someone actually needs to look at them.

The Lazy Array

We maintain a second array, lazy[], the same size as tree[]. lazy[node] stores the pending update that needs to be pushed down to node's children but hasn't been yet.

The Push-Down Operation

When we need to access a node's children (for a query or update that doesn't fully cover this node's range), we first push down the lazy tag:

void pushDown(int node, int start, int end) {
    if (lazy[node] != 0) {
        // Leaf has no children — nothing to push down to
        if (start == end) { lazy[node] = 0; return; }

        int mid = (start + end) / 2;
        int leftSize  = mid - start + 1;
        int rightSize = end - mid;

        // Update left child's value and lazy tag
        tree[2*node]   += lazy[node] * leftSize;
        lazy[2*node]   += lazy[node];

        // Update right child's value and lazy tag
        tree[2*node+1] += lazy[node] * rightSize;
        lazy[2*node+1] += lazy[node];

        // Clear this node's lazy (it's been pushed down)
        lazy[node] = 0;
    }
}

After push-down, the node's children have correct values (for queries) and carry their own lazy tags (for further push-downs if needed).

Think of it like this: The lazy tag is a "promise" or an "IOU." When we mark a node as lazy, we're saying "I've accounted for this update in your value, but I haven't told your children yet." When someone needs to look at the children, we settle the IOU by pushing it down one level. This way, we only do work when it's actually needed.

Range Update with Lazy Propagation

The Algorithm

To add delta to all elements in [l, r]:

  1. No overlap (node's range is entirely outside [l, r]): Do nothing. Return.
  2. Total overlap (node's range is entirely inside [l, r]): Update the node's value directly. Store the lazy tag. Don't recurse.
  3. Partial overlap: First, push down any existing lazy tag (settle existing IOUs). Then recurse into both children. After recursion, recalculate this node from its children.

The Complete Code

void rangeUpdate(int node, int start, int end,
                 int l, int r, int delta) {
    // Case 1: No overlap
    if (r < start || end < l) return;

    // Case 2: Total overlap
    if (l <= start && end <= r) {
        tree[node] += delta * (end - start + 1);
        lazy[node] += delta;
        return;
    }

    // Case 3: Partial overlap — push down first!
    pushDown(node, start, end);

    int mid = (start + end) / 2;
    rangeUpdate(2 * node,     start, mid,     l, r, delta);
    rangeUpdate(2 * node + 1, mid + 1, end,   l, r, delta);

    // Recalculate from children
    tree[node] = tree[2 * node] + tree[2 * node + 1];
}

Why Push Down on Partial Overlap?

When we recurse into children, their values need to be correct. If this node has a lazy tag, the children's values are stale. Push-down fixes them before we touch them. Without this step, queries through partially-overlapping nodes would return wrong results.

Why delta × (end - start + 1)?

This is the most important line in lazy propagation, and it's specific to range-sum. Let's understand why:

A node stores the sum of all elements in its range. If we add delta to every element in a range of size k = end - start + 1, the sum increases by delta × k:

// Before: sum = a₁ + a₂ + ... + aₖ
// After:  sum = (a₁+d) + (a₂+d) + ... + (aₖ+d)
//             = (a₁ + a₂ + ... + aₖ) + k × d
//             = old_sum + delta × range_size

This is why we can update a node's value without visiting its children — we know exactly how much the sum changes from the delta and the range size alone.

This doesn't work for every operation! The formula delta × range_size is specific to sum + range-add. For other combinations, the "apply" logic changes. See Lazy Propagation for Other Operations below.

Querying with Lazy Propagation

Queries also need to push down lazy tags before recursing. The logic is almost identical to the standard query, with one addition:

int queryLazy(int node, int start, int end, int l, int r) {
    // No overlap
    if (r < start || end < l) return 0;

    // Total overlap — value is already correct (lazy accounted for)
    if (l <= start && end <= r) return tree[node];

    // Partial overlap — push down before recursing
    pushDown(node, start, end);

    int mid = (start + end) / 2;
    return queryLazy(2 * node, start, mid, l, r)
         + queryLazy(2 * node + 1, mid + 1, end, l, r);
}

On total overlap, we can return tree[node] directly, the lazy tag has already been factored into this node's value. We only push down when we need to go deeper.

Walkthrough

Step through a complete example: start with arr = [1, 3, 5, 7], apply two range updates, then a query. Watch how lazy tags (purple) defer work and push-down (orange) happens only when children are actually needed.

▶ Lazy Propagation Animation

Watch range updates defer work with lazy tags (purple), and push-down (orange) happen only when children are needed.

Operations
Tree [] / Lazy []
Trace

When Exactly to Push Down

The rule is simple: push down before accessing children. This happens in two places:

  1. During a query with partial overlap, before recursing into children.
  2. During a range update with partial overlap, before recursing into children.

You do NOT push down when:

This selective push-down is what makes lazy propagation efficient. We only do work when a query or update actually needs to see the children's values.

Complexity Analysis

Range Update: O(log n)

Same argument as range queries. At each tree level, at most 2 nodes have partial overlap. For these, we push down (O(1) work) and recurse. Total-overlap nodes stop immediately. No-overlap nodes are skipped. Total: O(log n).

Range Query: O(log n)

Same as before, with push-down added at partial-overlap nodes. Push-down is O(1). Total: O(log n).

Space: O(n)

The lazy[] array is the same size as tree[], O(4n). Total space: O(n).

OperationWithout LazyWith Lazy
BuildO(n)O(n)
Point QueryO(log n)O(log n)
Range QueryO(log n)O(log n)
Point UpdateO(log n)O(log n)
Range UpdateO(n log n)O(log n)
SpaceO(4n)O(8n)

Lazy Propagation for Other Operations

The delta × range_size formula only works for sum + range-add. Different query/update combinations require different apply logic. The key question is always: can I compute the new node value from the old value, the lazy delta, and the range size — without visiting children?

This is the single most important thing to internalize: the apply function changes with every (query type, update type) pair. Blindly using delta × range_size everywhere is a common mistake. Let's go through each combination.

Range Add + Min Query

If we add delta to every element in a range, the minimum also increases by exactly delta (no multiplication by range size!):

// Apply: just add delta (not delta × size)
void apply(int node, int s, int e, long long val) {
    tree[node] += val;   // min shifts by delta
    lazy[node] += val;   // compose: lazy tags add
}

// Merge: take the min of children
tree[node] = min(tree[2*node], tree[2*node+1]);
Why no × range_size? For sum, adding d to k elements adds d×k to the total. For min, adding d to every element shifts the minimum by exactly d, regardless of how many elements there are. min(a₁+d, a₂+d, ..., aₖ+d) = min(a₁, a₂, ..., aₖ) + d.

Range Add + Max Query

Identical reasoning to min. Adding delta to every element shifts the maximum by exactly delta:

// Apply: just add delta (not delta × size)
void apply(int node, int s, int e, long long val) {
    tree[node] += val;   // max shifts by delta
    lazy[node] += val;   // compose: lazy tags add
}

// Merge: take the max of children
tree[node] = max(tree[2*node], tree[2*node+1]);
Same logic as min: max(a₁+d, a₂+d, ..., aₖ+d) = max(a₁, a₂, ..., aₖ) + d. The maximum shifts by exactly delta, regardless of range size. No × range_size needed.

Range Set + Min Query

Set all elements in a range to v. If all elements equal v, the min is just v:

void apply(int node, int s, int e, long long val) {
    tree[node] = val;       // min of all-same elements = val
    lazy[node] = val;
    hasLazy[node] = true;   // flag needed since val could be 0
}

tree[node] = min(tree[2*node], tree[2*node+1]);

Range Set + Max Query

Same idea: if all elements are set to v, the max is v:

void apply(int node, int s, int e, long long val) {
    tree[node] = val;       // max of all-same elements = val
    lazy[node] = val;
    hasLazy[node] = true;   // flag needed since val could be 0
}

tree[node] = max(tree[2*node], tree[2*node+1]);

Range Add + GCD Query

This one is tricky — naive lazy propagation does NOT work. Adding delta to every element doesn't give you a simple formula for the new GCD:

// gcd(a₁+d, a₂+d, ..., aₖ+d) = ???
// There's no formula using just gcd(a₁,...,aₖ) and d.
// Example: gcd(6, 10) = 2, but gcd(6+3, 10+3) = gcd(9, 13) = 1

So you cannot do range-add with GCD queries using standard lazy propagation. You'd need to either:

Range Set + GCD Query

This does work! If all elements are set to v, the GCD is simply v:

void apply(int node, int s, int e, long long val) {
    tree[node] = val;       // gcd of all-same elements = val
    lazy[node] = val;
    hasLazy[node] = true;
}

tree[node] = __gcd(tree[2*node], tree[2*node+1]);

Why the Formula Changes: An Intuitive Summary

The apply formula depends on how the aggregate (sum, min, max, gcd) responds to a uniform shift of all elements:

OperationWhat happens when you add d to every element?Apply formula
SumEach of k elements gets +d, so total increases by d×ktree[node] += d × (e-s+1)
MinEvery element shifts by +d, so minimum shifts by +dtree[node] += d
MaxEvery element shifts by +d, so maximum shifts by +dtree[node] += d
GCDgcd(a+d, b+d) has no relation to gcd(a,b)❌ No formula exists

For range-set (assign all elements to v), the story is simpler: sum becomes v × k, and min/max/gcd all become v.

The General Pattern

For lazy propagation to work, you need three things:

RequirementWhat it meansSum+AddMin+AddMax+AddGCD+Add
Quick applyCompute new node value from old value + lazy + range sizeval + d×kval + dval + d✘ no formula
Composable tagsTwo lazy tags can be combined into oned₁+d₂d₁+d₂d₁+d₂
Associative mergeParent = merge(left, right) still holds after apply✔ sum✔ min✔ max

Range Set (assign) + Sum Query

Instead of "add delta to range," sometimes you want "set all elements in range to value v." The lazy tag stores the value to assign. Push-down overwrites (not adds to) children's values:

// Range assign: set all elements in [l,r] to val
void rangeSet(int node, int s, int e, int l, int r, int val) {
    if (r < s || e < l) return;
    if (l <= s && e <= r) {
        tree[node] = val * (e - s + 1);
        lazy[node] = val;
        hasLazy[node] = true;  // need a flag since val could be 0
        return;
    }
    pushDown(node, s, e);
    int mid = (s + e) / 2;
    rangeSet(2*node, s, mid, l, r, val);
    rangeSet(2*node+1, mid+1, e, l, r, val);
    tree[node] = tree[2*node] + tree[2*node+1];
}

Note: for range-set, the push-down overwrites the child's value and lazy, not adds. And you need a hasLazy flag because the assigned value might be 0.

Handling Both Add and Set

Some problems require both operations. The interaction is tricky: if a node has a pending "set" and then gets an "add," the add modifies the set value. If it has a pending "add" and gets a "set," the add is discarded. This requires careful ordering in push-down. It's an advanced topic, for most problems, you only need one type.

Complete Implementation

class LazySegTree {
    int n;
    vector<long long> tree, lazy;

    void build(const vector<int>& arr, int node, int s, int e) {
        lazy[node] = 0;
        if (s == e) { tree[node] = arr[s]; return; }
        int mid = (s + e) / 2;
        build(arr, 2*node, s, mid);
        build(arr, 2*node+1, mid+1, e);
        tree[node] = tree[2*node] + tree[2*node+1];
    }

    void pushDown(int node, int s, int e) {
        if (lazy[node] != 0) {
            int mid = (s + e) / 2;
            apply(2*node,   s, mid,     lazy[node]);
            apply(2*node+1, mid+1, e,   lazy[node]);
            lazy[node] = 0;
        }
    }

    void apply(int node, int s, int e, long long val) {
        tree[node] += val * (e - s + 1);
        lazy[node] += val;
    }

    void update(int node, int s, int e, int l, int r, long long val) {
        if (r < s || e < l) return;
        if (l <= s && e <= r) { apply(node, s, e, val); return; }
        pushDown(node, s, e);
        int mid = (s + e) / 2;
        update(2*node, s, mid, l, r, val);
        update(2*node+1, mid+1, e, l, r, val);
        tree[node] = tree[2*node] + tree[2*node+1];
    }

    long long query(int node, int s, int e, int l, int r) {
        if (r < s || e < l) return 0;
        if (l <= s && e <= r) return tree[node];
        pushDown(node, s, e);
        int mid = (s + e) / 2;
        return query(2*node, s, mid, l, r)
             + query(2*node+1, mid+1, e, l, r);
    }

public:
    LazySegTree(const vector<int>& arr)
        : n(arr.size()), tree(4*n, 0), lazy(4*n, 0) {
        build(arr, 1, 0, n-1);
    }

    // Add val to all elements in [l, r]
    void update(int l, int r, long long val) {
        update(1, 0, n-1, l, r, val);
    }

    // Query sum of elements in [l, r]
    long long query(int l, int r) {
        return query(1, 0, n-1, l, r);
    }
};

Common Bugs

Mental Model

Think of lazy propagation as a top-down promise system:

This "just-in-time" resolution is why lazy propagation is efficient: we only do work that's actually needed for the current operation.

Summary