Cairn
data structures · blocks · O(√n) update / O(√n) range query

back to Array-Backed Trees

Sqrt Decomposition (Block Decomposition)

A segment tree gets range-minimum query and point update both down to O(log n) by building an actual tree over the array. Sqrt decomposition asks for the same two operations and settles for O(√n) instead — worse, but the whole structure is one flat array chopped into blocks of about √n elements each, with no recursion, no odd/even bit-parity walk, and no padding up to the next power of two. Split the n-element array into blocks of size b, precompute each block's own minimum once, and a query answers by scanning at most two partial blocks element-by-element plus reading a run of whole blocks straight out of the precomputed table, O(1) each. Scanning a partial block costs up to b, and there are at most n / b whole blocks to read — those two costs trade off against each other, and they're equal exactly when b = √n, which is where the name comes from.

Try it

The array below has 8 values, indexed 0 through 7, split into blocks of size b = 3 (the standard choice, ⌈√8⌉ = 3): block 0 covers indices 0-2, block 1 covers 3-5, and block 2 — shorter than the rest, since 8 doesn't divide evenly by 3 — covers just 6-7. The row below the values shows each block's precomputed minimum. Pick an index and a new value and press Set to update, or pick a range and press Query Min, then step through to see exactly which elements get scanned one at a time and which whole blocks get read directly from the precomputed row.

values (index 0..7) — the tick mark starts a new block
block minimums (block 0, 1, 2)
Press Set or Query Min, then Step through it.

Why it works

On the default array [5, 2, 8, 1, 9, 3, 7, 4]: block 0 owns indices 0-2 ([5, 2, 8], min 2), block 1 owns indices 3-5 ([1, 9, 3], min 1), and block 2 owns indices 6-7 ([7, 4], min 4) — only two elements, because a block's real size is always min(b, n - blockStart), not a fixed b. Querying min over [1, 5] walks index by index but jumps whenever it can: index 1 doesn't start a whole block that fits inside [1, 5] (block 0 actually starts at index 0), so it's scanned directly (value 2); same for index 2 (value 8, running result min(2, 8) = 2); but index 3 does start block 1, and block 1's own range [3, 5] fits entirely inside the query, so the walk takes block 1's precomputed minimum directly — min(2, 1) = 1 — and jumps straight to index 6, past the whole block in one step instead of three. Index 6 is past the query's own end (5), so the walk stops. Final answer: 1, checked directly against a plain scan of values[1..5] after every query in the demo above.

Updating works the other way: change one element, and only the one block that owns it can possibly have a new minimum, so recompute just that block from scratch. Setting index 3 to 6 changes block 1's underlying values to [6, 9, 3], so block 1's minimum moves from 1 to 3 — but block 0 and block 2 are untouched, both in their stored values and in their precomputed minimums, since index 3 doesn't live in either of them. That's the real shape of a sqrt-decomposition update: not "touch one cell," the way a Fenwick or segment tree leaf update works, but "rescan one whole block of about √n elements" — cheap because a block is small, not because the touched cell is handled in isolation.

Reference implementation

One flat array of values, one smaller array of per-block minimums, and two loops — no tree, no recursion:

class SqrtDecomposition {
  constructor(values, blockSize) {
    this.values = values.slice();
    this.n = values.length;
    this.bs = blockSize;
    this.numBlocks = Math.ceil(this.n / this.bs);
    this.blockMin = new Array(this.numBlocks);
    for (let b = 0; b < this.numBlocks; b++) this.recomputeBlock(b);
  }

  blockRange(b) {                  // half-open [start, end); last block may be shorter than bs
    const start = b * this.bs;
    const end = Math.min(this.n, start + this.bs);
    return [start, end];
  }

  recomputeBlock(b) {
    const [start, end] = this.blockRange(b);
    let m = Infinity;
    for (let i = start; i < end; i++) m = Math.min(m, this.values[i]);
    this.blockMin[b] = m;
  }

  update(i, val) {
    this.values[i] = val;
    this.recomputeBlock(Math.floor(i / this.bs));   // only this one block can have changed
  }

  queryMin(l, r) {                 // inclusive range [l, r]
    let res = Infinity;
    let i = l;
    while (i <= r) {
      const b = Math.floor(i / this.bs);
      const [start, end] = this.blockRange(b);
      if (start === i && end - 1 <= r) {
        res = Math.min(res, this.blockMin[b]);       // whole block fits — O(1) from the table
        i = end;
      } else {
        res = Math.min(res, this.values[i]);          // partial block — scan one element
        i++;
      }
    }
    return res;
  }
}

Verified against a naive O(n) scan over 20,000 randomized trials (random array sizes 1-30, random block sizes, a mix of random updates and random-range queries interleaved) with zero mismatches, plus the specific default-demo numbers above traced by hand first.

Pitfalls

The update cost genuinely depends on the operation, unlike Fenwick or segment tree. Both of those touch a fixed O(log n) nodes on every update no matter what operation they're combining. Sqrt decomposition's update cost is different for sum than for min, and the reason is the same invertibility split Fenwick Tree is built around: for sum, changing one element by some delta changes the owning block's total by that exact same delta — blockSum[b] += (newVal - oldVal), genuinely O(1), no rescan needed. For min (what this page's demo implements), there's no inverse operation that undoes "this element used to be part of the min" — if the old value happened to be the block's minimum, the only way to find the new minimum is to recheck every element in the block, O(b). Reusing this page's own update code for a sum-decomposition and expecting the same O(1) shortcut to just work would silently leave the delta-update optimization on the table, not break correctness — the full block rescan above is always correct, for any operation, min or sum alike, just needlessly slow for the invertible case.

The last block is short — don't assume every block has exactly b elements. With n = 8 and b = 3, block 2 only owns 2 elements. blockRange above clamps end to this.n for exactly this reason; an implementation that instead always adds a fixed b to a block's start would read past the end of the array on the final block whenever b doesn't divide n evenly — which is the common case, not the exception.

Complexity

Time: O(√n) for both update and queryMin when b ≈ √n. A query scans at most two partial blocks (up to 2b elements) and reads at most n / b whole blocks at O(1) each; those two terms, b and n / b, are minimized together exactly when they're equal, at b = √n, giving O(√n) + O(√n) = O(√n) — a smaller or larger block size still works correctly, just trades one term for the other rather than shrinking the total. An update rescans one whole block, O(b) = O(√n). Building from an existing array is O(n), one pass computing each block's minimum. Space: O(n) for the values plus O(n / b) = O(√n) for the block-minimum table — smaller, not larger, than a segment tree's own overhead.

Asymptotically this loses to Segment Tree and Fenwick Tree's O(log n) on both operations — for a million elements, √n is about 1,000 against log₂ n's 20. What it buys instead is a flat array and two straight loops, no tree at all, which makes it a natural base to extend with a per-block operation more elaborate than "recompute the minimum" — a whole class of "batch queries by which block they touch" techniques builds directly on this same block-boundary idea.

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