From 202f1d7e301010f37d61a8110f6b1f653a975dec Mon Sep 17 00:00:00 2001 From: progys Date: Sun, 16 Aug 2026 00:05:28 +0300 Subject: [PATCH] Add in-memory spatial grid index for point queries Point queries now narrow the candidate set via a uniform-grid index over shape bounding boxes (broad phase) instead of scanning every stored shape, then refine with inShape in parallel. Shapes spanning more than 64 cells are kept in an overflow list to bound index memory. Adds BoundingBox value type and getBounds() to the sealed Shape hierarchy; Store gains queryContaining(). --- README.md | 4 +- .../interview/quiz/commands/PointCommand.java | 2 +- .../interview/quiz/model/BoundingBox.java | 12 +++ .../progys/interview/quiz/model/Circle.java | 6 ++ .../progys/interview/quiz/model/Donut.java | 5 ++ .../progys/interview/quiz/model/Shape.java | 2 + .../progys/interview/quiz/model/Triangle.java | 9 ++ .../quiz/persistence/ObjectStore.java | 11 +++ .../quiz/persistence/ShapeIndex.java | 81 +++++++++++++++++ .../interview/quiz/persistence/Store.java | 3 + .../quiz/commands/PointCommandTest.java | 6 +- .../quiz/persistence/ObjectStoreTest.java | 10 +++ .../quiz/persistence/ShapeIndexTest.java | 88 +++++++++++++++++++ 13 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/progys/interview/quiz/model/BoundingBox.java create mode 100644 src/main/java/com/progys/interview/quiz/persistence/ShapeIndex.java create mode 100644 src/test/java/com/progys/interview/quiz/persistence/ShapeIndexTest.java diff --git a/README.md b/README.md index f2ed1c7..7d0a1ea 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This project was written as a solution to a Java developer job interview quiz. T - Define shapes interactively or from a file: **circle**, **triangle**, **donut** - Query all shapes that contain a given point, along with each shape's surface area and the total combined area -- Point queries run in **parallel** for good performance with large numbers of shapes +- Point queries are backed by an in-memory **spatial grid index** (bounding-box broad phase) and run in **parallel** for good performance with large numbers of shapes - Shapes are persisted in an **ObjectDB** database - Additional commands: `list`, `clear`, `help`, `exit` - Meaningful error messages for unexpected input; execution continues after an error @@ -134,7 +134,7 @@ Shapes can also be loaded from a file with `-f ` (see `shapesInput.txt - **Java 25** — language features and Java streams - **Maven** — build tool and dependency management -- **Java Streams** — parallel processing for point queries +- **Java Streams** — parallel point query processing over index candidates - **Guice** — dependency injection - **ObjectDB + JPA** — shape persistence - **Args4j** — command line argument parsing diff --git a/src/main/java/com/progys/interview/quiz/commands/PointCommand.java b/src/main/java/com/progys/interview/quiz/commands/PointCommand.java index 613c66d..b9f70e5 100644 --- a/src/main/java/com/progys/interview/quiz/commands/PointCommand.java +++ b/src/main/java/com/progys/interview/quiz/commands/PointCommand.java @@ -27,7 +27,7 @@ public class PointCommand extends AbstractCommand { @Override public void process() { - List containing = persistence.getAll().parallelStream() + List containing = persistence.queryContaining(point).parallelStream() .filter(stored -> stored.shape().inShape(point)) .toList(); printShapes(containing); diff --git a/src/main/java/com/progys/interview/quiz/model/BoundingBox.java b/src/main/java/com/progys/interview/quiz/model/BoundingBox.java new file mode 100644 index 0000000..7574b1d --- /dev/null +++ b/src/main/java/com/progys/interview/quiz/model/BoundingBox.java @@ -0,0 +1,12 @@ +package com.progys.interview.quiz.model; + +/** + * Axis-aligned bounding box of a shape. + * + * @author progys + */ +public record BoundingBox(double minX, double maxX, double minY, double maxY) { + public boolean contains(Point point) { + return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY; + } +} diff --git a/src/main/java/com/progys/interview/quiz/model/Circle.java b/src/main/java/com/progys/interview/quiz/model/Circle.java index 21772a5..c278997 100644 --- a/src/main/java/com/progys/interview/quiz/model/Circle.java +++ b/src/main/java/com/progys/interview/quiz/model/Circle.java @@ -40,6 +40,12 @@ public boolean inShape(Point point) { return pow(point.x - center.x, 2) + pow(point.y - center.y, 2) < pow(radius, 2); } + @Override + public BoundingBox getBounds() { + return new BoundingBox(center.x - radius, center.x + radius, center.y - radius, + center.y + radius); + } + @Override public String toString() { return String.format("circle with centre at (%s, %s) and radius %s", center.x, center.y, diff --git a/src/main/java/com/progys/interview/quiz/model/Donut.java b/src/main/java/com/progys/interview/quiz/model/Donut.java index df45aad..b1cb471 100644 --- a/src/main/java/com/progys/interview/quiz/model/Donut.java +++ b/src/main/java/com/progys/interview/quiz/model/Donut.java @@ -52,6 +52,11 @@ public boolean inShape(Point point) { return outerCircle.inShape(point) && !innerCircle.inShape(point); } + @Override + public BoundingBox getBounds() { + return outerCircle.getBounds(); + } + @Override public String toString() { return String.format( diff --git a/src/main/java/com/progys/interview/quiz/model/Shape.java b/src/main/java/com/progys/interview/quiz/model/Shape.java index c631e2f..48aee9a 100644 --- a/src/main/java/com/progys/interview/quiz/model/Shape.java +++ b/src/main/java/com/progys/interview/quiz/model/Shape.java @@ -9,4 +9,6 @@ public sealed interface Shape permits Circle, Triangle, Donut { double getArea(); boolean inShape(Point point); + + BoundingBox getBounds(); } diff --git a/src/main/java/com/progys/interview/quiz/model/Triangle.java b/src/main/java/com/progys/interview/quiz/model/Triangle.java index 7110c1b..7075531 100644 --- a/src/main/java/com/progys/interview/quiz/model/Triangle.java +++ b/src/main/java/com/progys/interview/quiz/model/Triangle.java @@ -3,6 +3,8 @@ import com.google.common.base.Preconditions; import static java.lang.StrictMath.abs; +import static java.lang.StrictMath.max; +import static java.lang.StrictMath.min; /** * Defines a triangle. @@ -51,6 +53,13 @@ public boolean inShape(Point point) { return s > 0 && t > 0 && (s + t) < 1; } + @Override + public BoundingBox getBounds() { + return new BoundingBox( + min(min(v0.x, v1.x), v2.x), max(max(v0.x, v1.x), v2.x), + min(min(v0.y, v1.y), v2.y), max(max(v0.y, v1.y), v2.y)); + } + @Override public String toString() { return String.format("triangle at v0=(%s, %s), v1=(%s, %s), v2=(%s, %s)", v0.x, v0.y, v1.x, diff --git a/src/main/java/com/progys/interview/quiz/persistence/ObjectStore.java b/src/main/java/com/progys/interview/quiz/persistence/ObjectStore.java index 73061fe..ed30cde 100644 --- a/src/main/java/com/progys/interview/quiz/persistence/ObjectStore.java +++ b/src/main/java/com/progys/interview/quiz/persistence/ObjectStore.java @@ -2,6 +2,7 @@ import com.google.inject.Inject; import com.google.inject.Singleton; +import com.progys.interview.quiz.model.Point; import com.progys.interview.quiz.model.Shape; import javax.persistence.EntityManager; @@ -29,12 +30,15 @@ public class ObjectStore implements Store { private final EntityManagerFactory entityManagerFactory; private final EntityManager manager; private final List shapes; + private final ShapeIndex index; @Inject ObjectStore(EntityManagerFactory entityManagerFactory) { this.entityManagerFactory = entityManagerFactory; this.manager = entityManagerFactory.createEntityManager(); this.shapes = new ArrayList<>(loadShapesFromDatabase()); + this.index = new ShapeIndex(); + shapes.forEach(index::put); } @Override @@ -48,6 +52,7 @@ public StoredShape put(Shape shape) { transaction.commit(); StoredShape stored = new StoredShape(entity.getId(), shape); shapes.add(stored); + index.put(stored); return stored; } catch (RuntimeException e) { if (transaction.isActive()) { @@ -66,6 +71,7 @@ public void clear() { manager.createQuery("delete from ShapeEntity").executeUpdate(); transaction.commit(); shapes.clear(); + index.clear(); } catch (RuntimeException e) { if (transaction.isActive()) { transaction.rollback(); @@ -80,6 +86,11 @@ public Collection getAll() { return Collections.unmodifiableList(shapes); } + @Override + public Collection queryContaining(Point point) { + return index.query(point); + } + private List loadShapesFromDatabase() { TypedQuery query = manager.createQuery( "SELECT e FROM " + ShapeEntity.class.getName() + " e", ShapeEntity.class); diff --git a/src/main/java/com/progys/interview/quiz/persistence/ShapeIndex.java b/src/main/java/com/progys/interview/quiz/persistence/ShapeIndex.java new file mode 100644 index 0000000..361d544 --- /dev/null +++ b/src/main/java/com/progys/interview/quiz/persistence/ShapeIndex.java @@ -0,0 +1,81 @@ +package com.progys.interview.quiz.persistence; + +import com.progys.interview.quiz.model.BoundingBox; +import com.progys.interview.quiz.model.Point; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * In-memory uniform-grid index over shape bounding boxes. A point query only examines shapes + * whose bounding box overlaps the grid cell containing the query point, instead of scanning + * every stored shape. Shapes whose bounding box spans more than {@link #MAX_OVERLAPPED_CELLS} + * cells are kept in an overflow list checked by every query, which bounds the index memory + * footprint for very large shapes. + * + * @author progys + */ +public final class ShapeIndex { + private static final double DEFAULT_CELL_SIZE = 10.0; + private static final int MAX_OVERLAPPED_CELLS = 64; + + private final double cellSize; + private final Map> cells = new HashMap<>(); + private final List largeShapes = new ArrayList<>(); + + public ShapeIndex() { + this(DEFAULT_CELL_SIZE); + } + + public ShapeIndex(double cellSize) { + this.cellSize = cellSize; + } + + public void put(StoredShape stored) { + BoundingBox bounds = stored.shape().getBounds(); + long minX = cell(bounds.minX()); + long maxX = cell(bounds.maxX()); + long minY = cell(bounds.minY()); + long maxY = cell(bounds.maxY()); + + long width = maxX - minX + 1; + long height = maxY - minY + 1; + if (width > MAX_OVERLAPPED_CELLS || height > MAX_OVERLAPPED_CELLS + || width * height > MAX_OVERLAPPED_CELLS) { + largeShapes.add(stored); + return; + } + for (long x = minX; x <= maxX; x++) { + for (long y = minY; y <= maxY; y++) { + cells.computeIfAbsent(new Cell(x, y), key -> new ArrayList<>()).add(stored); + } + } + } + + public Collection query(Point point) { + List candidates = + cells.getOrDefault(new Cell(cell(point.x), cell(point.y)), List.of()); + if (largeShapes.isEmpty()) { + return candidates; + } + List result = new ArrayList<>(candidates.size() + largeShapes.size()); + result.addAll(candidates); + result.addAll(largeShapes); + return result; + } + + public void clear() { + cells.clear(); + largeShapes.clear(); + } + + private long cell(double coordinate) { + return (long) Math.floor(coordinate / cellSize); + } + + private record Cell(long x, long y) { + } +} diff --git a/src/main/java/com/progys/interview/quiz/persistence/Store.java b/src/main/java/com/progys/interview/quiz/persistence/Store.java index 337e606..42dc68e 100644 --- a/src/main/java/com/progys/interview/quiz/persistence/Store.java +++ b/src/main/java/com/progys/interview/quiz/persistence/Store.java @@ -1,5 +1,6 @@ package com.progys.interview.quiz.persistence; +import com.progys.interview.quiz.model.Point; import com.progys.interview.quiz.model.Shape; import java.util.Collection; @@ -14,5 +15,7 @@ public interface Store { Collection getAll(); + Collection queryContaining(Point point); + void close(); } diff --git a/src/test/java/com/progys/interview/quiz/commands/PointCommandTest.java b/src/test/java/com/progys/interview/quiz/commands/PointCommandTest.java index 9f176bc..935cc89 100644 --- a/src/test/java/com/progys/interview/quiz/commands/PointCommandTest.java +++ b/src/test/java/com/progys/interview/quiz/commands/PointCommandTest.java @@ -12,6 +12,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; class PointCommandTest { @@ -22,7 +23,8 @@ void printsContainingShapesInEncounterOrder() { StoredShape insideSmall = new StoredShape(1L, new Circle(new Point(0, 0), 1)); StoredShape outside = new StoredShape(2L, new Circle(new Point(100, 100), 1)); StoredShape insideBig = new StoredShape(3L, new Circle(new Point(0, 0), 2)); - when(mockStore.getAll()).thenReturn(List.of(insideSmall, outside, insideBig)); + when(mockStore.queryContaining(any(Point.class))).thenReturn( + List.of(insideSmall, outside, insideBig)); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PointCommand pointCommand = @@ -41,7 +43,7 @@ void printsContainingShapesInEncounterOrder() { void printsNoShapesFoundWhenNothingContainsPoint() { Store mockStore = Mockito.mock(Store.class); StoredShape outside = new StoredShape(1L, new Circle(new Point(100, 100), 1)); - when(mockStore.getAll()).thenReturn(List.of(outside)); + when(mockStore.queryContaining(any(Point.class))).thenReturn(List.of(outside)); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PointCommand pointCommand = diff --git a/src/test/java/com/progys/interview/quiz/persistence/ObjectStoreTest.java b/src/test/java/com/progys/interview/quiz/persistence/ObjectStoreTest.java index e853723..027146e 100644 --- a/src/test/java/com/progys/interview/quiz/persistence/ObjectStoreTest.java +++ b/src/test/java/com/progys/interview/quiz/persistence/ObjectStoreTest.java @@ -74,6 +74,16 @@ void clearEmptiesStore() { assertThat(store.getAll()).isEmpty(); } + @Test + void queryContainingReturnsOnlyShapesNearThePoint() { + StoredShape circle = store.put(new Circle(new Point(0, 0), 1)); + store.put(new Triangle(new Point(100, 100), new Point(101, 100), new Point(100, 101))); + + Collection candidates = store.queryContaining(new Point(0.5, 0.5)); + + assertThat(candidates).containsExactly(circle); + } + @Test void loadsPersistedShapesFromDatabaseOnStartup() { store.put(new Triangle(new Point(0, 0), new Point(1, 0), new Point(0, 1))); diff --git a/src/test/java/com/progys/interview/quiz/persistence/ShapeIndexTest.java b/src/test/java/com/progys/interview/quiz/persistence/ShapeIndexTest.java new file mode 100644 index 0000000..e77fc85 --- /dev/null +++ b/src/test/java/com/progys/interview/quiz/persistence/ShapeIndexTest.java @@ -0,0 +1,88 @@ +package com.progys.interview.quiz.persistence; + +import com.progys.interview.quiz.model.Circle; +import com.progys.interview.quiz.model.Donut; +import com.progys.interview.quiz.model.Point; +import com.progys.interview.quiz.model.Triangle; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ShapeIndexTest { + private static final StoredShape SMALL_CIRCLE = + new StoredShape(1L, new Circle(new Point(0, 0), 1)); + private static final StoredShape BIG_CIRCLE = + new StoredShape(2L, new Circle(new Point(1000, 1000), 500)); + + @Test + void findsShapeFromQueryPointInsideItsBoundingBox() { + ShapeIndex index = new ShapeIndex(); + index.put(SMALL_CIRCLE); + + assertThat(index.query(new Point(0.5, 0.5))).containsExactly(SMALL_CIRCLE); + } + + @Test + void findsShapeSpanningMultipleCellsFromAnyOverlappedCell() { + ShapeIndex index = new ShapeIndex(1.0); + StoredShape circle = new StoredShape(3L, new Circle(new Point(0, 0), 2)); + index.put(circle); + + assertThat(index.query(new Point(-1.5, -1.5))).containsExactly(circle); + assertThat(index.query(new Point(1.5, 1.5))).containsExactly(circle); + } + + @Test + void keepsLargeShapesInOverflowListFoundFromAnyPoint() { + ShapeIndex index = new ShapeIndex(1.0); + index.put(BIG_CIRCLE); + + assertThat(index.query(new Point(900, 900))).containsExactly(BIG_CIRCLE); + assertThat(index.query(new Point(1100, 1100))).containsExactly(BIG_CIRCLE); + } + + @Test + void combinesCellCandidatesAndLargeShapes() { + ShapeIndex index = new ShapeIndex(1.0); + index.put(SMALL_CIRCLE); + index.put(BIG_CIRCLE); + + assertThat(index.query(new Point(0.5, 0.5))).containsExactly(SMALL_CIRCLE, BIG_CIRCLE); + } + + @Test + void returnsEmptyWhenNoShapeNearby() { + ShapeIndex index = new ShapeIndex(); + index.put(SMALL_CIRCLE); + + assertThat(index.query(new Point(100, 100))).isEmpty(); + } + + @Test + void queryResultIsDeterministic() { + ShapeIndex index = new ShapeIndex(1.0); + index.put(SMALL_CIRCLE); + index.put(new StoredShape(4L, new Donut(1, 2, new Point(0, 0)))); + index.put(new StoredShape(5L, new Triangle(new Point(-1, -1), new Point(1, -1), + new Point(-1, 1)))); + + List first = List.copyOf(index.query(new Point(0, 0))); + List second = List.copyOf(index.query(new Point(0, 0))); + + assertThat(first).containsExactlyElementsOf(second); + } + + @Test + void clearRemovesAllShapes() { + ShapeIndex index = new ShapeIndex(1.0); + index.put(SMALL_CIRCLE); + index.put(BIG_CIRCLE); + + index.clear(); + + assertThat(index.query(new Point(0.5, 0.5))).isEmpty(); + assertThat(index.query(new Point(1000, 1000))).isEmpty(); + } +}