DSA DSA In Depth
0%
lesson persistent-segment-treewavelet-treekth-order

Dynamic (Sparse) & Persistent Segment Trees: K-th Smallest in Subarray in O(log N)

7 min read Module 3.3: Range Query & Sublinear Decomposition Structures Updated Oct 23, 2026
📄 Cheat Sheet
AlgorithmsThread 5: Persistent Segment Trees & Path-Copying K-th Order Statistics
SecondThread AlgorithmsThread 5: Persistent Segment Trees & Path-Copying K-th Order Statistics Watch on YouTube ↗

Explains how prefix frequency trees share O(N log N) nodes via path copying and how subtracting two roots answers subarray order statistics in O(log N).

0:00 / --:--

1. Dynamic (Sparse) Segment Trees vs Persistent Segment Trees

Both structures abandon the fixed $4N$ array layout in favor of an arena pool of nodes (leftChild, rightChild, sum):

  • Dynamic (Sparse) Segment Tree: Used when the coordinate range $[0, U]$ is up to $10^{18}$ and online queries forbid offline coordinate compression. Unvisited children stay 0 (the null sentinel with sum 0).
  • Persistent Segment Tree (Prefix Histogram / Wavelet Isomorphism): After coordinate-compressing $A[1..N]$ into ranks $[0, M-1]$ ($M \le N$), version $R_i = \text{update}(R_{i-1}, 0, M-1, \text{rank}(A[i]), +1)$ creates $\lceil \log_2 M \rceil + 1$ new nodes. Across all $N$ insertions, total memory is $O(N \log N)$ nodes—never $O(N^2)$!
Tree-Subtraction Invariant for Range Order Statistics: For any value sub-interval $[vl, vr]$ corresponding to corresponding nodes $u_r$ in root $R_r$ and $u_{l-1}$ in root $R_{l-1}$, the count of elements in subarray $A[l..r]$ whose values fall in $[vl, vr]$ is: $$\text{count}(l, r, [vl, vr]) = \text{sum}[u_r] - \text{sum}[u_{l-1}]$$ During kthSmallest(l, r, k) ($1$-indexed $k$), let $c_{\text{left}} = \text{sum}[\text{left}[u_r]] - \text{sum}[\text{left}[u_{l-1}]]$. If $k \le c_{\text{left}}$, the $k$-th smallest element lies in the left value half $[vl, vm]$; otherwise we subtract $k \mathrel{-}= c_{\text{left}}$ and descend into the right value half $[vm + 1, vr]$.
Persistent Segment Tree: Path-Copying Root R[i-1] -> Root R[i] (O(log N) new nodes) R[i-1]: 3 L: 2 R: 1 (shared) R[i]: 4 L': 3 Leaf': +1 Yellow dashed pointer shares untouched subtree R:1; subtracting R[r] - R[l-1] isolates subarray A[l..r]
flowchart TD Q["kthSmallest(u_prev, u_curr, vl, vr, k)"] --> Base{"vl == vr?"} Base -- Yes --> Found["Return sortedValues[vl]"] Base -- No --> Calc["cntLeft = sum[left[u_curr]] - sum[left[u_prev]] vm = (vl + vr) >> 1"] Calc --> Branch{"k <= cntLeft?"} Branch -- Yes --> GoL["kthSmallest(left[u_prev], left[u_curr], vl, vm, k)"] Branch -- No --> GoR["kthSmallest(right[u_prev], right[u_curr], vm+1, vr, k - cntLeft)"]
Hardware & Cache Reality — Pre-Allocated TypedArray Arena Pool: For $N = 10^5$ elements, a Persistent Segment Tree allocates $N(\lceil \log_2 N \rceil + 2) \approx 1.9 \times 10^6$ nodes. Allocating 1.9 million JS objects { left, right, sum } consumes ~120 MB of heap and thrashes the garbage collector. Allocating three flat Int32Array(maxNodes) arrays (lc, rc, cnt) and bumping an integer cursor ptr = 1 consumes just $3 \times 4 \times 1.9 \times 10^6 \approx 22.8\text{ MB}$ with zero GC overhead!
Segment Tree Monoids, Lazy Propagation & Ji Driver Beats
O(log N) Lazy · O(log² N) Beats

Watch how a range update [L, R] halts at maximal canonical nodes with a deferred lazy[v] tag, or switch to Segment Tree Beats to see how tracking (max1, smax, cntMax) turns range chmin(x) into a lazy tag when smax < x < max1.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

Initial Segment Tree over A = [1, 2, 3, 4, 5, 6, 7, 8]. All lazy[v] = 0.
🌳 Live 2D Hierarchical Tree Topology — Segment Tree Monoids, Lazy Propagation & Ji Driver Beats Visited/Active Pivot/Root Resolved
Root [0..7… [0] Left [0..3… [1] Right [4..7… [2] [0..1… [3] [2..3… [4] [4..5… [5] [6..7… [6]
[0][0..7]:36Root
[1][0..3]:10Left
[2][4..7]:26Right
[3][0..1]:3
[4][2..3]:7
[5][4..5]:11
[6][6..7]:15
Canonical Nodes Hit
≤ 2 per Tree Level
Total Nodes Visited
O(log N)
Lazy Tag Rule
tree[v] += len(v) · δ
[Step 1] Built segment tree over [0..7]. Executing rangeAdd(L=2, R=5, delta=+10).

2. Production Implementation: Persistent Segment Tree for $O(\log N)$ Range $k$-th Smallest

/**
 * Persistent Segment Tree (Chairman Tree) using a Flat TypedArray Arena Pool
 * Answers: k-th smallest element (1-indexed k) in subarray A[l..r] (0-indexed) in O(log N) time.
 */
class PersistentKthSegmentTree {
  constructor(arr) {
    this.n = arr.length;
    // 1. Coordinate compress values in A
    this.sortedVals = Array.from(new Set(arr)).sort((a, b) => a - b);
    this.m = this.sortedVals.length;

    const maxNodes = (this.n + 1) * (Math.ceil(Math.log2(Math.max(2, this.m))) + 3);
    this.lc = new Int32Array(maxNodes);
    this.rc = new Int32Array(maxNodes);
    this.cnt = new Int32Array(maxNodes);
    this.ptr = 1; // Node 0 is the implicit zero sentinel (lc[0]=0, rc[0]=0, cnt[0]=0)

    this.roots = new Int32Array(this.n + 1);
    this.roots[0] = 0;

    for (let i = 0; i < this.n; i++) {
      const rank = this._lowerBound(this.sortedVals, arr[i]);
      this.roots[i + 1] = this._insert(this.roots[i], 0, this.m - 1, rank);
    }
  }

  _lowerBound(sorted, x) {
    let lo = 0, hi = sorted.length - 1;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (sorted[mid] < x) lo = mid + 1;
      else hi = mid;
    }
    return lo;
  }

  _insert(prevNode, vl, vr, targetRank) {
    const curr = this.ptr++;
    this.lc[curr] = this.lc[prevNode];
    this.rc[curr] = this.rc[prevNode];
    this.cnt[curr] = this.cnt[prevNode] + 1;

    if (vl === vr) return curr;
    const vm = (vl + vr) >> 1;
    if (targetRank <= vm) {
      this.lc[curr] = this._insert(this.lc[prevNode], vl, vm, targetRank);
    } else {
      this.rc[curr] = this._insert(this.rc[prevNode], vm + 1, vr, targetRank);
    }
    return curr;
  }

  /**
   * Returns the k-th smallest value (1 <= k <= r - l + 1) in A[l..r] (0-indexed)
   */
  kthSmallest(l, r, k) {
    let uPrev = this.roots[l];
    let uCurr = this.roots[r + 1];
    let vl = 0, vr = this.m - 1;

    while (vl < vr) {
      const vm = (vl + vr) >> 1;
      const leftCount = this.cnt[this.lc[uCurr]] - this.cnt[this.lc[uPrev]];
      if (k <= leftCount) {
        uPrev = this.lc[uPrev];
        uCurr = this.lc[uCurr];
        vr = vm;
      } else {
        k -= leftCount;
        uPrev = this.rc[uPrev];
        uCurr = this.rc[uCurr];
        vl = vm + 1;
      }
    }
    return this.sortedVals[vl];
  }
}

3. Complexity Matrix

Structure Universe / Versions Space Complexity Update Time Range $k$-th / Aggregate Query
Dynamic (Sparse) Segment Tree Universe $[0, U-1]$, $Q$ updates $O(Q \log U)$ nodes $O(\log U)$ $O(\log U)$
Persistent Segment Tree (Static Range $k$-th) $N$ prefix versions $O(N \log N)$ nodes $O(\log N)$ per version $O(\log N)$
Fenwick of Dynamic Segment Trees (Dynamic Range $k$-th) $N$ elements + $Q$ point updates $O((N + Q) \log^2 N)$ nodes $O(\log^2 N)$ $O(\log^2 N)$
Merge Sort Tree (Subarray Binary Search) Static array of size $N$ $O(N \log N)$ words — $O(\log^3 N)$ for $k$-th smallest
Common Pitfall — Using Naive Lazy Propagation Inside a Persistent Segment Tree: In a persistent segment tree, a single child node may be shared by dozens of historical parent roots. If pushDown(v) mutates v.left and v.right in place, it silently corrupts every historical version sharing those children! To combine persistence with range updates, either (1) use Permanent Lazy Tags (accumulate tags along the query path without pushing down) when the operation allows, or (2) allocate new cloned copies of v.left and v.right inside pushDown(v).
Check yourself Question 1 of 4
Concept Check Node Allocation Bound in a Persistent Segment Tree recall

We build $N$ versions of a Persistent Segment Tree over a coordinate-compressed value domain of size $M \le N$ by starting from an empty tree $R_0$ and performing $N$ point insertions $R_i = \text{insert}(R_{i-1}, \text{rank}(A[i]))$.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

What happens if you execute a standard in-place `pushDown(u)` during a query or update on version $R_v$?

Key Takeaways

Dynamic (Sparse) Segment Trees allocate child nodes on demand from a node pool, handling coordinate universes up to U = 10^18 in O(Q log U) space.
Node 0 serves as an implicit null sentinel (lc[0] = 0, rc[0] = 0, cnt[0] = 0), eliminating null-pointer branches during tree walks.
A Persistent Segment Tree over value ranks creates N prefix roots R[1..N] in O(N log N) total time and space via path copying.
Subtracting cnt[u_curr] - cnt[u_prev] on the fly lets us binary-search the frequency histogram of subarray A[l..r] for the k-th smallest element in O(log N) time.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue