diff --git a/modules/core/src/main/java/org/locationtech/jts/index/kdtree/KdNode.java b/modules/core/src/main/java/org/locationtech/jts/index/kdtree/KdNode.java
index d260c74adf..8e9b770e63 100644
--- a/modules/core/src/main/java/org/locationtech/jts/index/kdtree/KdNode.java
+++ b/modules/core/src/main/java/org/locationtech/jts/index/kdtree/KdNode.java
@@ -22,40 +22,54 @@
*/
public class KdNode {
- private Coordinate p = null;
- private Object data;
- private KdNode left;
- private KdNode right;
- private int count;
+ private final Coordinate p;
+ private final Object data;
+ private KdNode left;
+ private KdNode right;
+ private int count;
+ private final boolean axisIsX; // whether node splits along X (true) or Y (false)
- /**
- * Creates a new KdNode.
- *
- * @param _x coordinate of point
- * @param _y coordinate of point
- * @param data a data objects to associate with this node
- */
- public KdNode(double _x, double _y, Object data) {
- p = new Coordinate(_x, _y);
- left = null;
- right = null;
- count = 1;
- this.data = data;
- }
+ /**
+ * Creates a new {@code KdNode}.
+ *
+ * @param x x–coordinate of the point represented by this node
+ * @param y y–coordinate of the point represented by this node
+ * @param data arbitrary user data to associate with the node
+ * @param axisIsX {@code true} if this node partitions the space with a vertical
+ * line (i.e. it compares x–coordinates and its children
+ * lie to the “left” and “right” of that line); {@code false} if
+ * it partitions with a horizontal line (it compares
+ * y–coordinates and its children lie “below” and
+ * “above” that line). By convention the root uses an X-axis
+ * split, so the very first node inserted into an empty tree
+ * should be created with {@code axisIsX == true}. Thereafter the
+ * axis alternates naturally as each level of the tree is filled.
+ */
+ public KdNode(double x, double y, Object data, boolean axisIsX) {
+ this(new Coordinate(x, y), data, axisIsX);
+ }
- /**
- * Creates a new KdNode.
- *
- * @param p point location of new node
- * @param data a data objects to associate with this node
- */
- public KdNode(Coordinate p, Object data) {
- this.p = new Coordinate(p);
- left = null;
- right = null;
- count = 1;
- this.data = data;
- }
+ /**
+ * Creates a new KdNode.
+ *
+ * @param p point location of new node
+ * @param data a data objects to associate with this node.
+ * @param axisIsX {@code true} if this node partitions the space with a vertical
+ * line (i.e. it compares x–coordinates and its children
+ * lie to the “left” and “right” of that line); {@code false} if
+ * it partitions with a horizontal line (it compares
+ * y–coordinates and its children lie “below” and
+ * “above” that line). By convention the root uses an X-axis
+ * split, so the very first node inserted into an empty tree
+ * should be created with {@code axisIsX == true}. Thereafter the
+ * axis alternates naturally as each level of the tree is filled.
+ */
+ public KdNode(Coordinate p, Object data, boolean axisIsX) {
+ this.p = new Coordinate(p);
+ this.data = data;
+ this.axisIsX = axisIsX;
+ this.count = 1;
+ }
/**
* Returns the X coordinate of the node
@@ -141,6 +155,14 @@ public int getCount() {
return count;
}
+ /**
+ * {@code true} if this node splits along the X axis, {@code false} if it splits
+ * along the Y axis.
+ */
+ public boolean isAxisX() {
+ return axisIsX;
+ }
+
/**
* Tests whether more than one point with this value have been inserted (up to the tolerance)
*
@@ -149,6 +171,18 @@ public int getCount() {
public boolean isRepeated() {
return count > 1;
}
+
+ @Override
+ public String toString() {
+ return String.format(
+ "KdNode[p=%s, data=%s, count=%d, left=%s, right=%s]",
+ p,
+ data,
+ count,
+ left != null ? left.p : "null",
+ right != null ? right.p : "null"
+ );
+ }
// Sets left node value
void setLeft(KdNode _left) {
diff --git a/modules/core/src/main/java/org/locationtech/jts/index/kdtree/KdTree.java b/modules/core/src/main/java/org/locationtech/jts/index/kdtree/KdTree.java
index ad40b75f25..d40a6d3422 100644
--- a/modules/core/src/main/java/org/locationtech/jts/index/kdtree/KdTree.java
+++ b/modules/core/src/main/java/org/locationtech/jts/index/kdtree/KdTree.java
@@ -15,43 +15,42 @@
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.Deque;
import java.util.Iterator;
import java.util.List;
+import java.util.PriorityQueue;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateList;
import org.locationtech.jts.geom.Envelope;
/**
- * An implementation of a
- * KD-Tree
- * over two dimensions (X and Y).
- * KD-trees provide fast range searching and fast lookup for point data.
- * The tree is built dynamically by inserting points.
- * The tree supports queries by range and for point equality.
- * For querying an internal stack is used instead of recursion to avoid overflow.
+ * A 2D KD-Tree spatial
+ * index for efficient point query and retrieval.
+ *
+ * KD-trees provide fast range searching and fast lookup for point data. The
+ * tree is built dynamically by inserting points. The tree supports queries by
+ * location and range, and for point equality. For querying, an internal stack
+ * is used instead of recursion to avoid overflow.
*
* This implementation supports detecting and snapping points which are closer
- * than a given distance tolerance.
- * If the same point (up to tolerance) is inserted
- * more than once, it is snapped to the existing node.
- * In other words, if a point is inserted which lies
- * within the tolerance of a node already in the index,
- * it is snapped to that node.
- * When an inserted point is snapped to a node then a new node is not created
- * but the count of the existing node is incremented.
- * If more than one node in the tree is within tolerance of an inserted point,
- * the closest and then lowest node is snapped to.
+ * than a given distance tolerance. If the same point (up to tolerance) is
+ * inserted more than once, it is snapped to the existing node. In other words,
+ * if a point is inserted which lies within the tolerance of a node already in
+ * the index, it is snapped to that node. When an inserted point is snapped to a
+ * node then a new node is not created but the count of the existing node is
+ * incremented. If more than one node in the tree is within tolerance of an
+ * inserted point, the closest and then lowest node is snapped to.
*
- * The structure of a KD-Tree depends on the order of insertion of the points.
- * A tree may become unbalanced if the inserted points are coherent
- * (e.g. monotonic in one or both dimensions).
- * A perfectly balanced tree has depth of only log2(N),
- * but an unbalanced tree may be much deeper.
- * This has a serious impact on query efficiency.
- * One solution to this is to randomize the order of points before insertion
- * (e.g. by using Fisher-Yates shuffling).
+ * The structure of a KD-Tree depends on the order of insertion of the points. A
+ * tree may become unbalanced if the inserted points are coherent (e.g.
+ * monotonic in one or both dimensions). A perfectly balanced tree has depth of
+ * only log2(N), but an unbalanced tree may be much deeper. This has a serious
+ * impact on query efficiency. One solution to this is to randomize the order of
+ * points before insertion (e.g. by using Fisher-Yates
+ * shuffling).
*
* @author David Skea
* @author Martin Davis
@@ -65,7 +64,7 @@ public class KdTree {
* a collection of nodes
* @return an array of the coordinates represented by the nodes
*/
- public static Coordinate[] toCoordinates(Collection kdnodes) {
+ public static Coordinate[] toCoordinates(Collection kdnodes) {
return toCoordinates(kdnodes, false);
}
@@ -80,9 +79,9 @@ public static Coordinate[] toCoordinates(Collection kdnodes) {
* be included multiple times
* @return an array of the coordinates represented by the nodes
*/
- public static Coordinate[] toCoordinates(Collection kdnodes, boolean includeRepeated) {
+ public static Coordinate[] toCoordinates(Collection kdnodes, boolean includeRepeated) {
CoordinateList coord = new CoordinateList();
- for (Iterator it = kdnodes.iterator(); it.hasNext();) {
+ for (Iterator it = kdnodes.iterator(); it.hasNext();) {
KdNode node = (KdNode) it.next();
int count = includeRepeated ? node.getCount() : 1;
for (int i = 0; i < count; i++) {
@@ -94,7 +93,8 @@ public static Coordinate[] toCoordinates(Collection kdnodes, boolean includeRepe
private KdNode root = null;
private long numberOfNodes;
- private double tolerance;
+ private final double tolerance;
+ private final double toleranceSq;
/**
* Creates a new instance of a KdTree with a snapping tolerance of 0.0. (I.e.
@@ -114,6 +114,7 @@ public KdTree() {
*/
public KdTree(double tolerance) {
this.tolerance = tolerance;
+ this.toleranceSq = tolerance*tolerance;
}
/**
@@ -160,7 +161,7 @@ public KdNode insert(Coordinate p) {
*/
public KdNode insert(Coordinate p, Object data) {
if (root == null) {
- root = new KdNode(p, data);
+ root = new KdNode(p, data, true);
return root;
}
@@ -179,6 +180,165 @@ public KdNode insert(Coordinate p, Object data) {
return insertExact(p, data);
}
+
+ /**
+ * Finds the nearest node in the tree to the given query point.
+ *
+ * @param query the query point
+ * @return the nearest node, or null if the tree is empty
+ */
+ public KdNode nearestNeighbor(final Coordinate query) {
+ if (root == null)
+ return null;
+
+ KdNode bestNode = null;
+ double bestDistSq = Double.POSITIVE_INFINITY;
+
+ Deque stack = new ArrayDeque<>();
+ stack.push(root);
+
+ while (!stack.isEmpty()) {
+ KdNode node = stack.pop();
+ if (node == null)
+ continue;
+
+ // 1. visit this node
+ double dSq = query.distanceSq(node.getCoordinate());
+ if (dSq < bestDistSq) {
+ bestDistSq = dSq;
+ bestNode = node;
+ if (dSq == 0)
+ break; // perfect hit
+ }
+
+ // 2. decide which child to explore first
+ boolean axisIsX = node.isAxisX();
+ double diff = axisIsX ? query.x - node.getCoordinate().x : query.y - node.getCoordinate().y;
+
+ KdNode nearChild = (diff < 0) ? node.getLeft() : node.getRight();
+ KdNode farChild = (diff < 0) ? node.getRight() : node.getLeft();
+
+ // 3. depth-first: push far side only if it can still win
+ if (farChild != null && diff * diff < bestDistSq) {
+ stack.push(farChild);
+ }
+ if (nearChild != null)
+ stack.push(nearChild);
+ }
+ return bestNode;
+ }
+
+ /**
+ * Finds the nearest N nodes in the tree to the given query point.
+ *
+ * @param query the query point
+ * @param n the number of nearest nodes to find
+ * @return a list of the nearest nodes, sorted by distance (closest first), or
+ * an empty list if the tree is empty.
+ */
+ public List nearestNeighbors(final Coordinate query, final int k) {
+ if (root == null || k <= 0) {
+ return Collections.emptyList();
+ }
+
+ final PriorityQueue heap = new PriorityQueue<>(k);
+ double worstDistSq = Double.POSITIVE_INFINITY; // updated when heap full
+
+ // depth-first search with an explicit stack
+ final Deque stack = new ArrayDeque<>();
+ KdNode node = root; // the subtree we are about to visit
+
+ while (node != null || !stack.isEmpty()) {
+
+ // a) descend
+ if (node != null) {
+
+ // visit the current node
+ double distSq = query.distanceSq(node.getCoordinate());
+
+ if (heap.size() < k) { // not full yet
+ heap.offer(new Neighbor(node, distSq));
+ if (heap.size() == k)
+ worstDistSq = heap.peek().distSq;
+ } else if (distSq < worstDistSq) { // better than worst
+ heap.poll(); // discard worst
+ heap.offer(new Neighbor(node, distSq));
+ worstDistSq = heap.peek().distSq; // new worst
+ }
+
+ // choose near / far child
+ boolean axisIsX = node.isAxisX();
+ double split = axisIsX ? node.getCoordinate().x : node.getCoordinate().y;
+ double diff = axisIsX ? query.x - split : query.y - split;
+
+ KdNode nearChild = (diff < 0) ? node.getLeft() : node.getRight();
+ KdNode farChild = (diff < 0) ? node.getRight() : node.getLeft();
+
+ // push the far branch (if it exists) together with split info
+ if (farChild != null) {
+ stack.push(new NNStackFrame(farChild, axisIsX, split));
+ }
+
+ // tail-recurse into the near branch
+ node = nearChild;
+ }
+
+ // b) backtrack
+ else { // stack not empty
+ NNStackFrame sf = stack.pop();
+
+ double diff = sf.parentSplitAxis ? query.x - sf.parentSplitValue : query.y - sf.parentSplitValue;
+ double diffSq = diff * diff;
+
+ if (heap.size() < k || diffSq < worstDistSq) {
+ node = sf.node; // explore that side
+ } else {
+ node = null; // prune whole subtree
+ }
+ }
+ }
+
+ List result = new ArrayList<>(heap.size());
+ while (!heap.isEmpty())
+ result.add(heap.poll().node); // worst -> best
+ Collections.reverse(result); // best -> worst
+ return result;
+ }
+
+ /**
+ * Internal helper used by nearest-neighbour search.
+ */
+ private static final class Neighbor implements Comparable {
+ final KdNode node;
+ final double distSq; // pre-computed once
+
+ Neighbor(KdNode node, double distSq) {
+ this.node = node;
+ this.distSq = distSq;
+ }
+
+ // “Reverse” ordering -> max-heap (peek == farthest of the N kept so far).
+ @Override
+ public int compareTo(Neighbor o) {
+ return Double.compare(o.distSq, this.distSq);
+ }
+ }
+
+ /**
+ * One entry of the explicit depth-first-search stack used by the query
+ * algorithm.
+ */
+ private static class NNStackFrame {
+ KdNode node;
+ boolean parentSplitAxis;
+ double parentSplitValue;
+
+ NNStackFrame(KdNode node, boolean parentSplitAxis, double parentSplitValue) {
+ this.node = node;
+ this.parentSplitAxis = parentSplitAxis;
+ this.parentSplitValue = parentSplitValue;
+ }
+ }
/**
* Finds the node in the tree which is the best match for a point
@@ -189,10 +349,9 @@ public KdNode insert(Coordinate p, Object data) {
* existing node.
*
* @param p the point being inserted
- * @return the best matching node
- * @return null if no match was found
+ * @return the best matching node. null if no match was found.
*/
- private KdNode findBestMatchNode(Coordinate p) {
+ public KdNode findBestMatchNode(Coordinate p) {
BestMatchVisitor visitor = new BestMatchVisitor(p, tolerance);
query(visitor.queryEnvelope(), visitor);
return visitor.getNode();
@@ -248,137 +407,104 @@ public void visit(KdNode node) {
* @param data the data for the point
* @return the created node
*/
- private KdNode insertExact(Coordinate p, Object data) {
- KdNode currentNode = root;
- KdNode leafNode = root;
- boolean isXLevel = true;
- boolean isLessThan = true;
-
- /**
- * Traverse the tree, first cutting the plane left-right (by X ordinate)
- * then top-bottom (by Y ordinate)
- */
- while (currentNode != null) {
- boolean isInTolerance = p.distance(currentNode.getCoordinate()) <= tolerance;
-
- // check if point is already in tree (up to tolerance) and if so simply
- // return existing node
- if (isInTolerance) {
- currentNode.increment();
- return currentNode;
- }
-
- double splitValue = currentNode.splitValue(isXLevel);
- if (isXLevel) {
- isLessThan = p.x < splitValue;
- } else {
- isLessThan = p.y < splitValue;
- }
- leafNode = currentNode;
- if (isLessThan) {
- //System.out.print("L");
- currentNode = currentNode.getLeft();
- } else {
- //System.out.print("R");
- currentNode = currentNode.getRight();
- }
-
- isXLevel = ! isXLevel;
- }
- //System.out.println("<<");
- // no node found, add new leaf node to tree
- numberOfNodes = numberOfNodes + 1;
- KdNode node = new KdNode(p, data);
- if (isLessThan) {
- leafNode.setLeft(node);
- } else {
- leafNode.setRight(node);
- }
- return node;
- }
-
- /**
- * Performs a range search of the points in the index and visits all nodes found.
- *
- * @param queryEnv the range rectangle to query
- * @param visitor a visitor to visit all nodes found by the search
- */
- public void query(Envelope queryEnv, KdNodeVisitor visitor) {
- //-- Deque is faster than Stack
- Deque queryStack = new ArrayDeque();
- KdNode currentNode = root;
- boolean isXLevel = true;
-
- // search is computed via in-order traversal
- while (true) {
- if ( currentNode != null ) {
- queryStack.push(new QueryStackFrame(currentNode, isXLevel));
-
- boolean searchLeft = currentNode.isRangeOverLeft(isXLevel, queryEnv);
- if ( searchLeft ) {
- currentNode = currentNode.getLeft();
- if ( currentNode != null ) {
- isXLevel = ! isXLevel;
- }
- }
- else {
- currentNode = null;
- }
- }
- else if ( ! queryStack.isEmpty() ) {
- // currentNode is empty, so pop stack
- QueryStackFrame frame = queryStack.pop();
- currentNode = frame.getNode();
- isXLevel = frame.isXLevel();
-
- //-- check if search matches current node
- if ( queryEnv.contains(currentNode.getCoordinate()) ) {
- visitor.visit(currentNode);
- }
-
- boolean searchRight = currentNode.isRangeOverRight(isXLevel, queryEnv);
- if ( searchRight ) {
- currentNode = currentNode.getRight();
- if ( currentNode != null ) {
- isXLevel = ! isXLevel;
- }
- }
- else {
- currentNode = null;
- }
- } else {
- //-- stack is empty and no current node
- return;
- }
- }
- }
+ private KdNode insertExact(Coordinate p, Object data) {
+ // 1. empty tree: create root (splits on X by convention)
+ if (root == null) {
+ numberOfNodes = 1;
+ return root = new KdNode(p, data, true);
+ }
+
+ // 2. walk down until we hit a null child
+ KdNode parent = null;
+ KdNode curr = root;
+ boolean goLeft = true; // will stay tied to ‘parent’ once we exit loop
+
+ while (curr != null) {
+
+ final double distSq = p.distanceSq(curr.getCoordinate());
+ if (distSq <= toleranceSq) { // duplicate (within tol)
+ curr.increment();
+ return curr;
+ }
+
+ parent = curr;
+ if (curr.isAxisX()) { // node splits on X
+ goLeft = p.x < curr.getCoordinate().x;
+ } else { // node splits on Y
+ goLeft = p.y < curr.getCoordinate().y;
+ }
+ curr = goLeft ? curr.getLeft() : curr.getRight();
+ }
+
+ // 3. Insert new leaf (child axis is the opposite one)
+ final boolean childAxisIsX = !parent.isAxisX();
+ KdNode leaf = new KdNode(p, data, childAxisIsX);
+ if (goLeft)
+ parent.setLeft(leaf);
+ else
+ parent.setRight(leaf);
+
+ ++numberOfNodes;
+ return leaf;
+ }
+
+ /**
+ * Performs a range search of the points in the index and visits all nodes
+ * found.
+ *
+ * @param queryEnv the range rectangle to query
+ * @param visitor a visitor to visit all nodes found by the search
+ */
+ public void query(final Envelope queryEnv, final KdNodeVisitor visitor) {
+ if (root == null)
+ return;
+
+ final double minX = queryEnv.getMinX();
+ final double maxX = queryEnv.getMaxX();
+ final double minY = queryEnv.getMinY();
+ final double maxY = queryEnv.getMaxY();
+
+ // dfs with stack
+ final Deque stack = new ArrayDeque<>();
+ stack.push(root);
+
+ while (!stack.isEmpty()) {
+ KdNode node = stack.pop();
+ if (node == null)
+ continue;
+
+ Coordinate pt = node.getCoordinate();
+ double x = pt.x;
+ double y = pt.y;
+
+ if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
+ visitor.visit(node);
+ }
+
+ boolean axisIsX = node.isAxisX();
+
+ if (axisIsX) { // node splits on X
+ if (minX <= x && node.getLeft() != null)
+ stack.push(node.getLeft());
+ if (maxX >= x && node.getRight() != null)
+ stack.push(node.getRight());
+ } else { // node splits on Y
+ if (minY <= y && node.getLeft() != null)
+ stack.push(node.getLeft());
+ if (maxY >= y && node.getRight() != null)
+ stack.push(node.getRight());
+ }
+ }
+ }
- private static class QueryStackFrame {
- private KdNode node;
- private boolean isXLevel = false;
-
- public QueryStackFrame(KdNode node, boolean isXLevel) {
- this.node = node;
- this.isXLevel = isXLevel;
- }
-
- public KdNode getNode() {
- return node;
- }
-
- public boolean isXLevel() {
- return isXLevel;
- }
- }
-
/**
* Performs a range search of the points in the index.
*
* @param queryEnv the range rectangle to query
* @return a list of the KdNodes found
*/
- public List query(Envelope queryEnv) {
- final List result = new ArrayList();
+ public List query(Envelope queryEnv) {
+ final List result = new ArrayList();
query(queryEnv, result);
return result;
}
@@ -391,7 +517,7 @@ public List query(Envelope queryEnv) {
* @param result
* a list to accumulate the result nodes into
*/
- public void query(Envelope queryEnv, final List result) {
+ public void query(Envelope queryEnv, final List result) {
query(queryEnv, new KdNodeVisitor() {
public void visit(KdNode node) {
@@ -426,6 +552,35 @@ public KdNode query(Coordinate queryPt) {
//-- point not found
return null;
}
+
+ /**
+ * Performs an in-order traversal of the tree, collecting and returning all
+ * nodes that have been inserted.
+ *
+ * @return A list containing all nodes in the KdTree. Returns an empty list if
+ * the tree is empty.
+ */
+ public List getNodes() {
+ List nodeList = new ArrayList<>();
+ if (root == null) {
+ return nodeList; // empty list for empty tree
+ }
+
+ Deque stack = new ArrayDeque<>();
+ KdNode currentNode = root;
+
+ while (currentNode != null || !stack.isEmpty()) {
+ if (currentNode != null) {
+ stack.push(currentNode);
+ currentNode = currentNode.getLeft();
+ } else {
+ currentNode = stack.pop();
+ nodeList.add(currentNode);
+ currentNode = currentNode.getRight();
+ }
+ }
+ return nodeList;
+ }
/**
* Computes the depth of the tree.
diff --git a/modules/core/src/test/java/org/locationtech/jts/index/kdtree/KdTreeTest.java b/modules/core/src/test/java/org/locationtech/jts/index/kdtree/KdTreeTest.java
index 79a46b1f53..9d862138a2 100644
--- a/modules/core/src/test/java/org/locationtech/jts/index/kdtree/KdTreeTest.java
+++ b/modules/core/src/test/java/org/locationtech/jts/index/kdtree/KdTreeTest.java
@@ -12,8 +12,13 @@
package org.locationtech.jts.index.kdtree;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
import java.util.List;
+import java.util.Random;
+import java.util.stream.Collectors;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateArrays;
@@ -43,7 +48,7 @@ public void testSinglePoint() {
Envelope queryEnv = new Envelope(0, 10, 0, 10);
- List result = index.query(queryEnv);
+ List result = index.query(queryEnv);
assertTrue(result.size() == 1);
KdNode node = (KdNode) result.get(0);
@@ -103,6 +108,101 @@ public void testSizeDepth() {
assertTrue( depth <= size );
}
+ public void testNearestNeighbor() {
+ int n = 1000;
+ int queries = 500;
+ KdTree tree = new KdTree();
+ Random rand = new Random(1337);
+
+ for (int i = 0; i < n; i++) {
+ double x = rand.nextDouble();
+ double y = rand.nextDouble();
+ tree.insert(new Coordinate(x, y));
+ }
+
+ for (int i = 0; i < queries; i++) {
+ double queryX = rand.nextDouble();
+ double queryY = rand.nextDouble();
+ Coordinate query = new Coordinate(queryX, queryY);
+
+ KdNode nearestNode = tree.nearestNeighbor(query);
+
+ Coordinate bruteForceNearest = bruteForceNearestNeighbor(tree, query);
+
+ assertEquals(nearestNode.getCoordinate(), bruteForceNearest);
+ }
+ }
+
+ public void testNearestNeighbors() {
+ int n = 2500;
+ int numTrials = 50;
+ Random rand = new Random(0);
+
+ for (int trial = 0; trial < numTrials; trial++) {
+ KdTree tree = new KdTree();
+
+ for (int i = 0; i < n; i++) {
+ double x = rand.nextDouble();
+ double y = rand.nextDouble();
+ tree.insert(new Coordinate(x, y));
+ }
+
+ Coordinate query = new Coordinate(rand.nextDouble(), rand.nextDouble());
+ int k = rand.nextInt(n/10);
+
+ List nearestNodes = tree.nearestNeighbors(query, k);
+
+ List bruteForceNearest = bruteForceNearestNeighbors(tree, query, k);
+
+ assertEquals(k, nearestNodes.size());
+ for (int i = 0; i < k; i++) {
+ assertEquals(bruteForceNearest.get(i), nearestNodes.get(i).getCoordinate());
+ }
+ }
+ }
+
+ public void testRangeQuery() {
+ final int n = 2500;
+ final int numTrials = 50;
+ final Random rand = new Random(0);
+
+ for (int trial = 0; trial < numTrials; trial++) {
+ KdTree tree = new KdTree();
+ for (int i = 0; i < n; i++) {
+ tree.insert(new Coordinate(rand.nextDouble(), rand.nextDouble()));
+ }
+
+ double x1 = rand.nextDouble();
+ double x2 = rand.nextDouble();
+ double y1 = rand.nextDouble();
+ double y2 = rand.nextDouble();
+ Envelope env = new Envelope(Math.min(x1, x2), Math.max(x1, x2),
+ Math.min(y1, y2), Math.max(y1, y2));
+
+ List kdResult = new ArrayList<>();
+ tree.query(env, node -> kdResult.add(node.getCoordinate()));
+
+ List bruteResult = bruteForceInEnvelope(tree, env);
+
+ assertEquals(bruteResult.size(), kdResult.size());
+ assertEquals(new HashSet<>(bruteResult), new HashSet<>(kdResult));
+ }
+ }
+
+ public void testCollectNodes() {
+ int n = 1000;
+ KdTree tree = new KdTree();
+ Random rand = new Random(1337);
+
+ for (int i = 0; i < n; i++) {
+ double x = rand.nextDouble();
+ double y = rand.nextDouble();
+ tree.insert(new Coordinate(x, y));
+ }
+
+ assertEquals(n, tree.getNodes().size());
+ }
+
private void testQuery(String wktInput, double tolerance,
Envelope queryEnv, String wktExpected) {
KdTree index = build(wktInput, tolerance);
@@ -155,6 +255,49 @@ private void testQuery(KdTree index,
assertEquals("Point query not found", node.getCoordinate(), p);
}
}
+
+ // Helper method to find the nearest neighbor using brute-force
+ private Coordinate bruteForceNearestNeighbor(KdTree tree, Coordinate query) {
+ List allPoints = getAllPoints(tree);
+ Coordinate nearest = null;
+ double minDistance = Double.POSITIVE_INFINITY;
+
+ for (Coordinate point : allPoints) {
+ double distance = query.distance(point);
+ if (distance < minDistance) {
+ minDistance = distance;
+ nearest = point;
+ }
+ }
+
+ return nearest;
+ }
+
+ private List bruteForceNearestNeighbors(KdTree tree, Coordinate query, int k) {
+ List allPoints = getAllPoints(tree);
+
+ // Sort all points by distance to the query point
+ allPoints.sort(Comparator.comparingDouble(point -> query.distance(point)));
+
+ // Return the first k points (ordered closest first)
+ return allPoints.subList(0, Math.min(k, allPoints.size()));
+ }
+
+ private List bruteForceInEnvelope(KdTree tree, Envelope queryEnv) {
+ List allPoints = getAllPoints(tree);
+
+ List inEnvelope = new ArrayList<>();
+ for (Coordinate p : allPoints) {
+ if (queryEnv.contains(p)) {
+ inEnvelope.add(p);
+ }
+ }
+ return inEnvelope;
+ }
+
+ private List getAllPoints(KdTree tree) {
+ return Arrays.stream(KdTree.toCoordinates(tree.getNodes())).collect(Collectors.toList());
+ }
private KdTree build(String wktInput, double tolerance) {
final KdTree index = new KdTree(tolerance);
diff --git a/modules/core/src/test/java/test/jts/perf/index/KdtreePerfTest.java b/modules/core/src/test/java/test/jts/perf/index/KdtreePerfTest.java
new file mode 100644
index 0000000000..952cfd4c14
--- /dev/null
+++ b/modules/core/src/test/java/test/jts/perf/index/KdtreePerfTest.java
@@ -0,0 +1,75 @@
+package test.jts.perf.index;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Random;
+
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.Envelope;
+import org.locationtech.jts.index.kdtree.KdNode;
+import org.locationtech.jts.index.kdtree.KdTree;
+
+import test.jts.perf.PerformanceTestCase;
+import test.jts.perf.PerformanceTestRunner;
+
+public class KdtreePerfTest extends PerformanceTestCase {
+
+ private List points;
+ private KdTree tree;
+ private Coordinate query;
+ private int k;
+
+ public static void main(String[] args) {
+ PerformanceTestRunner.run(KdtreePerfTest.class);
+ }
+
+ public KdtreePerfTest(String name) {
+ super(name);
+ setRunSize(new int[] { 100_000, 1_000_000, 5_000_000 });
+ setRunIterations(1);
+ }
+
+ @Override
+ public void startRun(int size) throws Exception {
+ Random rnd = new Random(12345);
+ points = new ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ points.add(new Coordinate(rnd.nextDouble(), rnd.nextDouble()));
+ }
+ tree = new KdTree(); // empty tree
+ query = new Coordinate(rnd.nextDouble(), rnd.nextDouble());
+ k = Math.max(1, size / 100);
+
+ for (Coordinate pt : points) {
+ tree.insert(pt);
+ }
+ }
+
+ /**
+ * Do a single k-NN query using the kd-tree. Framework will time this method.
+ */
+ public void runKdTreeNearest() {
+ @SuppressWarnings("unused")
+ List result = tree.nearestNeighbors(query, k);
+ }
+
+ /**
+ * Do a single k-NN query by brute-force. Framework will time this method.
+ */
+ public void runBruteForceNearest() {
+ // make a copy of the points list
+ List copy = new ArrayList<>(points);
+ copy.sort(Comparator.comparingDouble(query::distance));
+ @SuppressWarnings("unused")
+ List nearest = copy.subList(0, Math.min(k, copy.size()));
+ }
+
+ public void runKdTreeEnvelope() {
+ tree.query(new Envelope(0.25, 0.75, 0.25, 0.75));
+ }
+
+ public void runKdTreeEnvelopeAll() {
+ tree.query(new Envelope(0, 1, 0, 1));
+ }
+}
\ No newline at end of file
diff --git a/modules/core/src/test/java/test/jts/perf/index/KdtreeStressTest.java b/modules/core/src/test/java/test/jts/perf/index/KdtreeStressTest.java
index ed1dbf940b..7b6c178f22 100644
--- a/modules/core/src/test/java/test/jts/perf/index/KdtreeStressTest.java
+++ b/modules/core/src/test/java/test/jts/perf/index/KdtreeStressTest.java
@@ -36,6 +36,8 @@ private void run() {
}
System.out.format("Queries complete\n");
}
+
+
/**
* Create an unbalanced tree by loading a
@@ -52,4 +54,5 @@ private KdTree createUnbalancedTree(int numPts) {
}
return index;
}
+
}