cswork / libraryOpen practice workspace

Algorithm tutorials

Binary Lifting (Jump Pointers)

Precompute where you land after 1, 2, 4, 8, ... steps so that any k-step jump takes O(log k): k-th ancestor queries, lowest common ancestor, huge jump counts on functional graphs, and the sparse table built on the same doubling idea.

Binary lifting is an extension topic beyond most interviews; it pays off in hard OAs and top-tier rounds that ask for many "k-th ancestor" or "lowest common ancestor" queries, or for following a pointer an astronomically large number of times.

The idea fits in one sentence: instead of walking k steps one at a time, precompute where you end up after 1, 2, 4, 8, ... steps, then combine the powers of two that make up k. Any jump of up to 2^L steps then costs O(L) table lookups.

Intuition

Write k in binary. To move 13 = 8 + 4 + 1 steps, jump 1, then 4, then 8 (the order does not matter because every jump is "follow the same pointer some number of times"). So you need, for every node x and every level j, the node up[j][x] reached after 2^j steps. The table fills itself by doubling:

up[0][x] = the next node (the parent, or next[x])
up[j][x] = up[j - 1][ up[j - 1][x] ]     2^j steps = 2^(j-1) steps, twice

The same doubling works for any quantity that can be combined along the way: the sum of weights along a jump (sum[j][x] = sum[j - 1][x] + sum[j - 1][up[j - 1][x]]), the minimum edge on a path, and so on. It is also the idea behind the sparse table, where level j stores the minimum of each block of length 2^j, and any range is covered by two overlapping blocks.

Lowest common ancestor. First lift the deeper node until both nodes are at the same depth (a k-th ancestor query). If they now coincide, that is the answer. Otherwise, try jumps from the largest level down, and jump both nodes whenever their ancestors at that level differ; this keeps them just below their lowest common ancestor, whose parent is then the answer.

When to use it in interviews / OAs

  • "Find the k-th ancestor of a node" with many queries on a static tree.
  • "Lowest common ancestor" with many queries, distances between tree nodes (depth[a] + depth[b] - 2 * depth[lca]), or path aggregates (maximum edge weight on a path).
  • "Follow next[] m times" where m is up to 10^9 or 10^18, possibly accumulating a sum: functional graphs, a pointer that jumps around a circle, simulations with a deterministic next state.
  • Static range minimum or maximum queries in O(1) after O(n log n) preprocessing (sparse table).

Template

k-th ancestor

class KthAncestor:
    def __init__(self, parent: list[int]):  # parent[root] == -1
        n = len(parent)
        self.levels = max(1, n.bit_length())  # 2^levels > n - 1 >= any depth
        self.up = [parent[:]]  # up[j][v] = 2^j-th ancestor of v, or -1
        for _ in range(1, self.levels):
            prev = self.up[-1]
            self.up.append([prev[prev[v]] if prev[v] != -1 else -1 for v in range(n)])

    def query(self, v: int, k: int) -> int:
        j = 0
        while k and v != -1:
            if k & 1:
                v = self.up[j][v] if j < self.levels else -1
            k >>= 1
            j += 1
        return v

Lowest common ancestor

class LCA:
    def __init__(self, parent: list[int]):
        n = len(parent)
        self.depth = [-1] * n
        for v in range(n):  # fill depths iteratively by walking up to a known node
            path, u = [], v
            while u != -1 and self.depth[u] == -1:
                path.append(u)
                u = parent[u]
            d = self.depth[u] if u != -1 else -1
            for w in reversed(path):
                d += 1
                self.depth[w] = d
        self.kth = KthAncestor(parent)

    def query(self, a: int, b: int) -> int:
        up = self.kth.up
        if self.depth[a] < self.depth[b]:
            a, b = b, a
        a = self.kth.query(a, self.depth[a] - self.depth[b])  # lift a to b's depth
        if a == b:
            return a
        for j in range(len(up) - 1, -1, -1):
            if up[j][a] != up[j][b]:  # still below the LCA: jump both
                a, b = up[j][a], up[j][b]
        return up[0][a]

Huge jump counts with an aggregated sum

Follow nxt m times from start and add the weight of every node you jump from. Each level stores a half-open sum (the weights of the 2^j nodes you leave), so consecutive jumps never double count:

def jump_sum(nxt: list[int], weight: list[int], start: int, m: int) -> tuple[int, int]:
    """Return (final node, sum of the weights of the m nodes jumped from)."""
    n = len(nxt)
    to, acc = nxt[:], weight[:]  # level 0: one step
    node, total = start, 0
    while m:
        if m & 1:
            total += acc[node]
            node = to[node]
        m >>= 1
        if m:  # build the next level: two jumps of the current size
            acc = [acc[x] + acc[to[x]] for x in range(n)]
            to = [to[to[x]] for x in range(n)]
    return node, total

This version builds levels on the fly for a single query in O(n log m). With many queries, store every level (O(n log m) memory) and answer each in O(log m).

Sparse table for range minimum

class SparseTableMin:
    def __init__(self, a: list[int]):
        self.t = [a[:]]  # t[j][i] = min of a[i : i + 2^j]
        j = 1
        while (1 << j) <= len(a):
            prev, half = self.t[-1], 1 << (j - 1)
            self.t.append([min(prev[i], prev[i + half]) for i in range(len(a) - (1 << j) + 1)])
            j += 1

    def query(self, l: int, r: int) -> int:
        """Minimum of a[l..r], inclusive, in O(1)."""
        j = (r - l + 1).bit_length() - 1  # largest block that fits
        return min(self.t[j][l], self.t[j][r - (1 << j) + 1])  # two blocks may overlap

Overlap is harmless for min, max and gcd (idempotent operations) but not for sums; use prefix sums for those.

Complexity

  • Building the jump table: O(n log n) time and memory (O(n log m) for jumps of up to m steps).
  • k-th ancestor: O(log k) per query. LCA: O(log n) per query.
  • jump_sum: O(n log m) for one query as written.
  • Sparse table: O(n log n) to build, O(1) per query; no updates.

Common pitfalls

  • Too few levels. Use enough levels to cover the largest jump: about log2(n) for ancestors, about 60 for m up to 10^18.
  • Missing sentinel. Jumping past the root must stay at -1 (or at the root, if that is how the problem defines it); never index with -1 in Python, which silently wraps around to the last element.
  • Double counting in sums. Store half-open aggregates (the nodes you leave, not the node you arrive at).
  • Updating the table in place while computing the next level: compute the new level from the previous one only.
  • Recursion for depths. Trees can be paths of 10^5 nodes; compute depths iteratively (BFS order or the walk-up method above).
  • Sparse table for sums. Overlapping blocks only work for idempotent operations.

Interview variations

  • Kth ancestor of a tree node: the class above.
  • Lowest common ancestor with many queries, distance between two nodes in a tree.
  • Maximum or minimum edge on a tree path: store the path aggregate next to each jump pointer.
  • Jumping on a cycle or functional graph a huge number of times: binary lifting, or detect the cycle and use modular arithmetic.
  • Static range minimum queries: sparse table.
  • Beyond typical interviews: LCA by Euler tour plus sparse table (O(1) per query) and heavy-light decomposition for path updates.