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; } + }