DSA DSA In Depth
0%
build build-labkd-treenearest-neighbor

Build Lab: 2D/3D k-d Tree with Bounding-Box Pruning for K-Nearest Neighbor & Range Search

7 min read Module 6.4: Computational Geometry, Number Theory, Randomized & Approximation Algorithms Updated Oct 23, 2026
📄 Cheat Sheet
KD-Tree Nearest Neighbor Data Structure & Pruning Search
Stable Sort KD-Tree Nearest Neighbor Data Structure & Pruning Search Watch on YouTube ↗

Walks through constructing a static pointer-free k-d Tree using std::nth_element and implementing branch-and-bound hypersphere pruning for K-Nearest Neighbor search.

0:00 / --:--

1. Architecture & Tight Bounding-Box Pruning Invariant

Invariant: Implicit Array Layout & Point-to-AABB Squared Distance Pruning

Instead of allocating heap nodes with left/right pointers, we reorder the input array pts[l..r] in-place around mid = (l + r) / 2 using std::nth_element along axis depth % D. Furthermore, each subtree precomputes its exact tight **Axis-Aligned Bounding Box (AABB)** $[\text{lo}_0.. \text{hi}_0] \times \dots \times [\text{lo}_{D-1}.. \text{hi}_{D-1}]$. For a query point $Q$, the minimum squared Euclidean distance from $Q$ to any point inside a subtree's AABB is:

$$d_{\min}^2(Q, \text{AABB}) = \sum_{a=0}^{D-1} \Big( \max(0LL,\; \text{lo}[a] - Q[a]) + \max(0LL,\; Q[a] - \text{hi}[a]) \Big)^2$$

During $K$-NN search with a max-heap of capacity $K$, if heap.size() == K and $d_{\min}^2(Q, \text{AABB}) \ge \text{heap.top().dist_sq}$, the entire subtree cannot improve the $K$-th nearest neighbor and is pruned immediately!

2. K-NN Branch-and-Bound Execution Flow

flowchart TD Query["KNN_Search(Subtree [l, r], Query Q)"] --> Prune{"heap.size() == K AND
box_dist_sq(Q, AABB) >= heap.top() ?"} Prune -->|"Yes"| Skip["Prune Entire Subtree O(1)"] Prune -->|"No"| EvalMid["Evaluate d2 = dist_sq(Q, pts[mid])
Push to Max-Heap (Pop if size > K)"] EvalMid --> Order["Compare Q[axis] vs pts[mid][axis]"] Order -->|"Q[axis] < pts[mid][axis]"| LeftFirst["Search Left Child First,
Then Right Child (if not pruned)"] Order -->|"Q[axis] >= pts[mid][axis]"| RightFirst["Search Right Child First,
Then Left Child (if not pruned)"]

3. Why Tight AABB Pruning Beats Single-Axis Hyperplane Pruning

Full $D$-Dimensional Box Distance vs Single-Axis $\delta^2$

Standard textbook k-d trees only check the 1D perpendicular distance $\delta^2 = (Q[a] - \text{pivot}[a])^2$ to the current splitting plane. However, a deep subtree may be displaced along multiple axes $(x, y, z)$ simultaneously! Precomputing each node's tight AABB lets us sum squared clamping distances across all $D$ dimensions $\sum_{a=0}^{D-1} \delta_a^2$, pruning dead diagonal octants $2\times$–$4\times$ earlier while keeping all nodes in a flat contiguous std::vector.

4. Complexity Matrix

Andrew Monotone Chain Convex Hull & Sweep-Line Events
O(N log N) · CCW Cross Product

Trace 2D cross-product orientation tests cross(O, A, B) = (Ax-Ox)(By-Oy) - (Ay-Oy)(Bx-Ox) to prune clockwise interior vertices and detect sweep-line segment intersections.

Step 1 / 8
Custom Input:
Timeline

Invariant & State

cross(P0, P1, P2) < 0 (Clockwise / Right Turn): P1 lies strictly inside the lower hull and is popped from the stack.
P0 P1 (Pop!) P2 P3 P4 P5
Inspected Triple
P0 → P1 → P2
Cross Product
cross < 0 (Right Turn)
Stack Action
Pop P1, Push P2
[Step 1] Right turn detected at P1; popped P1 so lower hull edge connects P0 → P2 directly.
Operation ($D$ Dimensions) Average / Typical Time Worst-Case Time Space Overhead
Tree Construction (std::nth_element) O(D * N log N) O(D * N log N) O(D * N) flat array
1-Nearest Neighbor ($1$-NN) O(log N) O(N) O(log N) stack
$K$-Nearest Neighbors ($K$-NN) O(K log N) O(N log K) O(K) max-heap
Orthogonal AABB Range Search O(N^{1 - 1/D} + M) O(N^{1 - 1/D} + M) O(log N) stack

5. Complete C++20 Build Lab Implementation

#include <array>
#include <vector>
#include <cstdint>
#include <queue>
#include <algorithm>
#include <limits>

template <std::size_t D = 3>
class StaticKDTree {
public:
    using Point = std::array<int64_t, D>;

    struct IndexedPoint {
        Point coords;
        int id;
    };

    struct Neighbor {
        int64_t dist_sq;
        int id;
        // Max-heap comparator: largest dist_sq sits at top() for O(1) threshold check
        bool operator<(const Neighbor& o) const {
            return dist_sq != o.dist_sq ? dist_sq < o.dist_sq : id < o.id;
        }
    };

private:
    struct NodeBox {
        Point lo;
        Point hi;
    };

    std::vector<IndexedPoint> pts_;
    std::vector<NodeBox> boxes_;

    static int64_t point_dist_sq(const Point& a, const Point& b) {
        int64_t sum = 0;
        for (std::size_t i = 0; i < D; ++i) {
            int64_t d = a[i] - b[i];
            sum += d * d;
        }
        return sum;
    }

    static int64_t box_min_dist_sq(const Point& q, const NodeBox& box) {
        int64_t sum = 0;
        for (std::size_t i = 0; i < D; ++i) {
            int64_t d = 0;
            if (q[i] < box.lo[i]) d = box.lo[i] - q[i];
            else if (q[i] > box.hi[i]) d = q[i] - box.hi[i];
            sum += d * d;
        }
        return sum;
    }

    static bool box_disjoint(const NodeBox& a, const NodeBox& b) {
        for (std::size_t i = 0; i < D; ++i) {
            if (a.hi[i] < b.lo[i] || a.lo[i] > b.hi[i]) return true;
        }
        return false;
    }

    static bool point_in_box(const Point& p, const NodeBox& b) {
        for (std::size_t i = 0; i < D; ++i) {
            if (p[i] < b.lo[i] || p[i] > b.hi[i]) return false;
        }
        return true;
    }

    void build(int l, int r, int depth) {
        if (l > r) return;
        int mid = l + (r - l) / 2;
        std::size_t axis = static_cast<std::size_t>(depth) % D;

        std::nth_element(pts_.begin() + l, pts_.begin() + mid, pts_.begin() + r + 1,
                         [axis](const IndexedPoint& a, const IndexedPoint& b) {
                             return a.coords[axis] < b.coords[axis];
                         });

        boxes_[mid] = {pts_[mid].coords, pts_[mid].coords};
        for (int i = l; i <= r; ++i) {
            for (std::size_t a = 0; a < D; ++a) {
                boxes_[mid].lo[a] = std::min(boxes_[mid].lo[a], pts_[i].coords[a]);
                boxes_[mid].hi[a] = std::max(boxes_[mid].hi[a], pts_[i].coords[a]);
            }
        }

        build(l, mid - 1, depth + 1);
        build(mid + 1, r, depth + 1);
    }

    void knn_rec(int l, int r, int depth, const Point& q, std::size_t k,
                 std::priority_queue<Neighbor>& pq) const {
        if (l > r) return;
        int mid = l + (r - l) / 2;

        if (pq.size() == k && box_min_dist_sq(q, boxes_[mid]) >= pq.top().dist_sq) {
            return; // Tight AABB branch-and-bound prune!
        }

        int64_t d2 = point_dist_sq(q, pts_[mid].coords);
        Neighbor cand{d2, pts_[mid].id};
        if (pq.size() < k) {
            pq.push(cand);
        } else if (cand < pq.top()) {
            pq.pop();
            pq.push(cand);
        }

        std::size_t axis = static_cast<std::size_t>(depth) % D;
        bool go_left_first = q[axis] < pts_[mid].coords[axis];

        if (go_left_first) {
            knn_rec(l, mid - 1, depth + 1, q, k, pq);
            knn_rec(mid + 1, r, depth + 1, q, k, pq);
        } else {
            knn_rec(mid + 1, r, depth + 1, q, k, pq);
            knn_rec(l, mid - 1, depth + 1, q, k, pq);
        }
    }

    void range_rec(int l, int r, const NodeBox& query_box, std::vector<int>& out) const {
        if (l > r) return;
        int mid = l + (r - l) / 2;
        if (box_disjoint(boxes_[mid], query_box)) return;

        if (point_in_box(pts_[mid].coords, query_box)) {
            out.push_back(pts_[mid].id);
        }
        range_rec(l, mid - 1, query_box, out);
        range_rec(mid + 1, r, query_box, out);
    }

public:
    explicit StaticKDTree(std::vector<IndexedPoint> pts)
        : pts_(std::move(pts)), boxes_(pts_.size()) {
        if (!pts_.empty()) {
            build(0, static_cast<int>(pts_.size()) - 1, 0);
        }
    }

    std::vector<Neighbor> query_knn(const Point& q, std::size_t k) const {
        std::priority_queue<Neighbor> pq;
        if (!pts_.empty() && k > 0) {
            knn_rec(0, static_cast<int>(pts_.size()) - 1, 0, q, k, pq);
        }
        std::vector<Neighbor> res;
        res.reserve(pq.size());
        while (!pq.empty()) {
            res.push_back(pq.top());
            pq.pop();
        }
        std::reverse(res.begin(), res.end()); // Sorted closest to farthest
        return res;
    }

    std::vector<int> query_range(const Point& lo, const Point& hi) const {
        std::vector<int> out;
        if (!pts_.empty()) {
            range_rec(0, static_cast<int>(pts_.size()) - 1, NodeBox{lo, hi}, out);
        }
        return out;
    }
};

Production Pitfall — Using a Min-Heap Instead of a Max-Heap for $K$-Nearest Neighbors

During $K$-NN search, you want to track the $K$ smallest distances seen so far and quickly evict the largest among those $K$ candidates whenever a closer point is discovered. Therefore, the bounded priority queue of size $K$ must be a Max-Heap (std::priority_queue<Neighbor>), where pq.top().dist_sq is the current $K$-th nearest distance (the pruning radius)! Using a Min-Heap puts the 1st nearest neighbor at top(), making it impossible to prune against the $K$-th neighbor in $O(1)$ time.

Check yourself Question 1 of 4
Concept Check Core Invariant Check recall

When implementing $K$-Nearest Neighbor ($K$-NN) search in a k-d Tree using a bounded priority queue of capacity $K$, why must the priority queue be a **Max-Heap** (ordered by largest distance at `top()`) rather than a Min-Heap?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

During recursive k-d Tree Nearest Neighbor search at node `mid` (splitting along axis `a`), why is it critical for performance to always recurse into the child on the **same side** of `pts[mid].coords[a]` as the query point `q[a]` **before** recursing into the opposite child?

Key Takeaways

A pointer-free static k-d Tree partitions pts[l..r] in-place around mid = (l + r) / 2 using std::nth_element in O(D * N log N) time and zero heap allocations per node.
Storing the tight D-dimensional AABB at each subtree node allows pruning against the full Euclidean point-to-box squared distance rather than just a single splitting axis.
Always recurse into the child on the same side of the splitting plane as the query point first, tightening the pruning radius before testing the far child.
K-NN search maintains a bounded Max-Heap of capacity K so the K-th smallest distance sits at pq.top() as the active branch-and-bound pruning radius.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue