Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Prefix Sums & Range Queries  ›  Capstone Project

Capstone: Quarry's Analytics Engine

50 minRange bumps, totals and floors in one lazy segment tree, with every operation O(log n)
You're building a piece ofQuarry — the analytics engine
This piece — analytics(): Range bumps, sums and minima over the whole log in O(log n) each.
Scenario The ops team's ticket asks for three things at once: wide backfills, range totals, and range floors to spot outages. Quarry's analytics engine answers all three from one lazy segment tree, every operation O(log n) however wide the range.
Your task
Build analytics(values, ops). values is the per-minute log. Each op is ["bump", l, r, d] (add d to every minute in l..r), ["sum", l, r] (total of l..r) or ["min", l, r] (lowest count in l..r); ranges include both ends. Return the answers to the sum and min ops in order, keeping every op O(log n) with one lazy segment tree whose nodes carry a sum, a minimum and a pending add. Example: analytics([4, 9, 2, 7], [["bump", 1, 3, 3], ["min", 2, 3], ["sum", 0, 2]]) → [5, 21].

Capstone: Quarry's Analytics Engine

In short: one lazy segment tree carries a sum and a minimum per node plus a single pending add, because both aggregates can be updated from a bump without looking at the children — the sum rises by d × width and the minimum rises by exactly d. Range bumps, range totals and range floors are all O(log n), the same three-case recursion as every segment tree query, with push and pull as the two small helpers that keep the tag honest.

A node holding sum and min plus a pending tag: before a +3 bump it shows sum 9, min 2, and after it shows sum 15, min 5, tag +3, with no children touched

Five lessons, five tools, each built because the one before it broke on some kind of change:

lesson handles breaks on
prefix row any range sum in O(1) any edit: O(n)
√n blocks edits and queries in O(√n) a busy dashboard
Fenwick tree point edits and sums in O(log n) min, which can't be subtracted
segment tree any combinable aggregate, with range edits via tags pages on disk
B-tree sorted keys on disk, a few reads per lookup (it's the disk layer)

Now Quarry's analytics engine needs all of the in-memory part at once. The operations team's list, word for word from the ticket:

  1. Backfill: "add d to every minute from l to r", for clock skew, late batches and corrections. These arrive in bulk and can be wide.
  2. Totals: "how many events between minute l and minute r?"
  3. Floors: "what was the lowest count between l and r?", which is how the on-call team spots an outage: a stretch where traffic fell to nothing.

Range updates plus min rules out everything except the lazy segment tree. The design question left is what each node has to carry.

One node, two answers, one tag

Each node stores the sum and the minimum of its range, plus one pending add. The key question for any lazy tree is: when everything under a node rises by d, can the node update its own answers without looking down?

  • the sum rises by d × width: every minute adds d;
  • the minimum rises by exactly d: adding the same amount to every value keeps the same value smallest.

Both are yes, so one tag serves both aggregates. That test is the one to apply whenever you design a lazy tree. An update that replaces every value by its remainder mod 10 fails it, for example: the new sum depends on each value separately, so no node can update its answer without looking all the way down.

What does this print?
values = [4, 9, 2, 7]
d = 3
bumped = [v + d for v in values]
print(sum(bumped) - sum(values), min(bumped) - min(values))

Here's the whole engine on four minutes. Each badge reads s for sum and m for minimum, and a + is a pending tag:

watch it move · tree

One tag updates both answers. The bump stops at nodes it covers, and the later query pushes the tag only where it has to go deeper.

The log is 4, 9, 2, 7. Each node holds its range's sum and minimum. Binary tree, 7 nodes, height 3.

1 / 9

The code, in four helpers

Every lazy tree comes down to four small functions. Once these are right, bump and each query are the three-case recursion you already know.

python

Get apply wrong and the tree still runs. It just gives wrong answers for exactly the ranges that crossed a tag, which is the worst kind of bug to find in production:

broken — fix it

Bumping all four minutes of the log 4, 9, 2, 7 by 3 gives 7, 12, 5, 10: sum 34, floor 5. This reports a floor of 14, which isn't even one of the values.

Step through a push and watch the tag move down one level and disappear:

step through it
1s = [0, 31, 16, 15, 4, 12, 2, 7]
2m = [0, 4, 4, 5, 4, 12, 2, 7]
3tag = [0, 0, 0, 3, 0, 0, 0, 0]
4node, lo, hi = 3, 2, 3
5mid = (lo + hi) // 2
6for child, width in ((2 * node, mid - lo + 1), (2 * node + 1, hi - mid)):
7 s[child] += tag[node] * width
8 m[child] += tag[node]
9 tag[child] += tag[node]
10tag[node] = 0
11print(s[6], s[7], tag[3])

(Here node 3 owns minutes 2..3, and its children 6 and 7 are those minutes.)

What does this print?
def visits(lo, hi, l, r):
    if r < lo or hi < l or (l <= lo and hi <= r):
        return 1
    mid = (lo + hi) // 2
    return 1 + visits(lo, mid, l, r) + visits(mid + 1, hi, l, r)

print(visits(0, 7, 0, 7), visits(0, 7, 1, 6), visits(0, 1023, 1, 1022))

Measuring it on real traffic

Here's the engine with every node as an object, running a small burst of the ops team's traffic. After each op the tree is redrawn with the nodes that op touched highlighted, and the caption counts them. Change the ops or the log, and watch the counts stay near the height of the tree however wide the ranges get:

your code, drawn · tree

Your analytics engine on a burst of traffic. The highlighted nodes are the ones each operation visited.

❓ Cross-question — "Where does the B-tree fit in?" Underneath. This engine holds the minute counts in memory for the live dashboard. The raw events they were counted from sit on disk in a B-tree keyed by timestamp. A backfill job range-scans the B-tree for the late events, counts them per minute, and sends the engine one bump per affected stretch. Each structure does the job its cost model suits.

Quick practice

What does this print?
values = [6, 1, 9, 3, 2]
d = 4
bumped = [v + d for v in values]
print(sum(bumped) - sum(values), min(bumped) - min(values))
broken — fix it

Children with sums 10 and 7 and minimums 5 and 2 should recombine to sum 17, min 2. This prints 17 5 — the floor comes back wrong because max was used instead of min.


🎯 Your turn

Build analytics(values, ops), Quarry's analytics engine. values is the per-minute log. Each op is one of:

  • ["bump", l, r, d]: add d to every minute in l..r;
  • ["sum", l, r]: the total of minutes l..r;
  • ["min", l, r]: the lowest count in minutes l..r.

Ranges include both ends. Return the answers to the sum and min ops, in order. Every op must be O(log n): one lazy segment tree whose nodes carry a sum, a minimum and a pending add.

  • analytics([4, 9, 2, 7], [["bump", 1, 3, 3], ["min", 2, 3], ["sum", 0, 2]]) → [5, 21]

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green means Quarry's analytics engine is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upCount the visits

Write node_visits(n, l, r) returning how many nodes a recursive segment-tree query over minutes 0..n-1 visits for the range l..r. Every call counts as one visit. A call stops if its node is disjoint from l..r or wholly inside it. Otherwise it splits at mid = (lo + hi) // 2 and calls both children. node_visits(8, 1, 6) → 11.

segment-treecomplexity
DrillFloors under backfill

Write add_min(values, ops). Each op is ["add", l, r, d] or ["min", l, r]. Use a lazy segment tree of minima (a covered node's minimum rises by d, and it keeps a pending tag). Return the min answers. add_min([4, 9, 2, 7], [["add", 0, 2, 5], ["min", 0, 3], ["min", 0, 1]]) → [7, 9].

lazy-propagationrange-min
BuildWhere was the outage?

Write lowest_minute(values, ops). Each op is ["bump", l, r, d] or ["where", l, r], which answers the index of the smallest value in l..r, the earliest one on a tie. Nodes store (min, index) pairs, and a bump raises the min but not the index. Return the where answers. lowest_minute([4, 9, 2, 7], [["where", 0, 3], ["bump", 2, 2, 10], ["where", 0, 3]]) → [2, 0].

lazy-propagationsegment-tree
BossIngest slots with a capacity

Interview-style. Quarry's ingest workers are scheduled in minute slots. At most capacity jobs may run in any single minute. Jobs request time ranges one at a time, as [l, r] (minutes l..r, both included). A request is accepted if every minute in its range currently has fewer than capacity jobs, and then it occupies all of them. Otherwise it's rejected and changes nothing. Write book_slots(capacity, minutes, requests) returning a list of True/False, one per request.

Constraints: 1 <= minutes <= 10^5, up to 10^5 requests, ranges can span the whole day. Aim for O(log n) per request. The tests are small.

Example: book_slots(2, 10, [[1, 4], [3, 6], [4, 8], [0, 2]]) → [True, True, False, True]. The third request fails because minute 4 already has two jobs.

lazy-propagationrange-queries
CapstoneHow many different sources?

Interview-style. Each minute of Quarry's log records which upstream source sent the most traffic. Write distinct_counts(sources, queries) returning, for each query [l, r], how many different sources appear in minutes l..r. All queries are known up front, so you may answer them in any order as long as you return them in the original order.

Constraints: 1 <= len(sources) <= 10^5, up to 10^5 queries, sources are strings. Counting a set per query is O(n) each. Aim for O((n + q) log n) overall. The tests are small.

Example: distinct_counts(["eu", "us", "eu", "ap", "us"], [[0, 2], [1, 4], [2, 2]]) → [2, 3, 1].

fenwickrange-queries
ExplainDefending the engine's design

In a design review, someone asks why Quarry's analytics engine is a lazy segment tree carrying a sum, a minimum and one pending add, and not something simpler. Using the ops team's three requirements (range backfills, range totals, range floors), explain why each simpler structure from this section fails, and why one tag is enough for both aggregates.

analytics.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
analytics(values, ops) → listAnswers to the sum and min ops, with bumps applied in between.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.