Skip to content

Commit d5289b9

Browse files
authored
Fix ConvexHull to return points in counter-clockwise order (#6810)
* Fix ConvexHull to return points in counter-clockwise order - Add sortCounterClockwise method to ensure CCW ordering - Start from bottom-most, left-most point for deterministic results - Fix issue where unordered HashSet broke downstream algorithms - Add comprehensive tests with CCW order verification * test(geometry): Achieve 100% test coverage for ConvexHull
1 parent 4a97258 commit d5289b9

File tree

2 files changed

+197
-9
lines changed

2 files changed

+197
-9
lines changed

src/main/java/com/thealgorithms/geometry/ConvexHull.java

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,24 @@ public static List<Point> convexHullBruteForce(List<Point> points) {
6161
return new ArrayList<>(convexSet);
6262
}
6363

64+
/**
65+
* Computes the convex hull using a recursive divide-and-conquer approach.
66+
* Returns points in counter-clockwise order starting from the bottom-most, left-most point.
67+
*
68+
* @param points the input points
69+
* @return the convex hull points in counter-clockwise order
70+
*/
6471
public static List<Point> convexHullRecursive(List<Point> points) {
72+
if (points.size() < 3) {
73+
List<Point> result = new ArrayList<>(points);
74+
Collections.sort(result);
75+
return result;
76+
}
77+
6578
Collections.sort(points);
6679
Set<Point> convexSet = new HashSet<>();
67-
Point leftMostPoint = points.get(0);
68-
Point rightMostPoint = points.get(points.size() - 1);
80+
Point leftMostPoint = points.getFirst();
81+
Point rightMostPoint = points.getLast();
6982

7083
convexSet.add(leftMostPoint);
7184
convexSet.add(rightMostPoint);
@@ -85,9 +98,8 @@ public static List<Point> convexHullRecursive(List<Point> points) {
8598
constructHull(upperHull, leftMostPoint, rightMostPoint, convexSet);
8699
constructHull(lowerHull, rightMostPoint, leftMostPoint, convexSet);
87100

88-
List<Point> result = new ArrayList<>(convexSet);
89-
Collections.sort(result);
90-
return result;
101+
// Convert to list and sort in counter-clockwise order
102+
return sortCounterClockwise(new ArrayList<>(convexSet));
91103
}
92104

93105
private static void constructHull(Collection<Point> points, Point left, Point right, Set<Point> convexSet) {
@@ -114,4 +126,82 @@ private static void constructHull(Collection<Point> points, Point left, Point ri
114126
}
115127
}
116128
}
129+
130+
/**
131+
* Sorts convex hull points in counter-clockwise order starting from
132+
* the bottom-most, left-most point.
133+
*
134+
* @param hullPoints the unsorted convex hull points
135+
* @return the points sorted in counter-clockwise order
136+
*/
137+
private static List<Point> sortCounterClockwise(List<Point> hullPoints) {
138+
if (hullPoints.size() <= 2) {
139+
Collections.sort(hullPoints);
140+
return hullPoints;
141+
}
142+
143+
// Find the bottom-most, left-most point (pivot)
144+
Point pivot = hullPoints.getFirst();
145+
for (Point p : hullPoints) {
146+
if (p.y() < pivot.y() || (p.y() == pivot.y() && p.x() < pivot.x())) {
147+
pivot = p;
148+
}
149+
}
150+
151+
// Sort other points by polar angle with respect to pivot
152+
final Point finalPivot = pivot;
153+
List<Point> sorted = new ArrayList<>(hullPoints);
154+
sorted.remove(finalPivot);
155+
156+
sorted.sort((p1, p2) -> {
157+
int crossProduct = Point.orientation(finalPivot, p1, p2);
158+
159+
if (crossProduct == 0) {
160+
// Collinear points: sort by distance from pivot (closer first for convex hull)
161+
long dist1 = distanceSquared(finalPivot, p1);
162+
long dist2 = distanceSquared(finalPivot, p2);
163+
return Long.compare(dist1, dist2);
164+
}
165+
166+
// Positive cross product means p2 is counter-clockwise from p1
167+
// We want counter-clockwise order, so if p2 is CCW from p1, p1 should come first
168+
return -crossProduct;
169+
});
170+
171+
// Build result with pivot first, filtering out intermediate collinear points
172+
List<Point> result = new ArrayList<>();
173+
result.add(finalPivot);
174+
175+
if (!sorted.isEmpty()) {
176+
// This loop iterates through the points sorted by angle.
177+
// For points that are collinear with the pivot, we only want the one that is farthest away.
178+
// The sort places closer points first.
179+
for (int i = 0; i < sorted.size() - 1; i++) {
180+
// Check the orientation of the pivot, the current point, and the next point.
181+
int orientation = Point.orientation(finalPivot, sorted.get(i), sorted.get(i + 1));
182+
183+
// If the orientation is not 0, it means the next point (i+1) is at a new angle.
184+
// Therefore, the current point (i) must be the farthest point at its angle. We keep it.
185+
if (orientation != 0) {
186+
result.add(sorted.get(i));
187+
}
188+
// If the orientation is 0, the points are collinear. We discard the current point (i)
189+
// because it is closer to the pivot than the next point (i+1).
190+
}
191+
// Always add the very last point from the sorted list. It is either the only point
192+
// at its angle, or it's the farthest among a set of collinear points.
193+
result.add(sorted.getLast());
194+
}
195+
196+
return result;
197+
}
198+
199+
/**
200+
* Computes the squared distance between two points to avoid floating point operations.
201+
*/
202+
private static long distanceSquared(Point p1, Point p2) {
203+
long dx = (long) p1.x() - p2.x();
204+
long dy = (long) p1.y() - p2.y();
205+
return dx * dx + dy * dy;
206+
}
117207
}
Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.thealgorithms.geometry;
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertTrue;
45

6+
import java.util.ArrayList;
57
import java.util.Arrays;
68
import java.util.List;
79
import org.junit.jupiter.api.Test;
@@ -10,31 +12,127 @@ public class ConvexHullTest {
1012

1113
@Test
1214
void testConvexHullBruteForce() {
15+
// Test 1: Triangle with intermediate point
1316
List<Point> points = Arrays.asList(new Point(0, 0), new Point(1, 0), new Point(10, 1));
1417
List<Point> expected = Arrays.asList(new Point(0, 0), new Point(1, 0), new Point(10, 1));
1518
assertEquals(expected, ConvexHull.convexHullBruteForce(points));
1619

20+
// Test 2: Collinear points
1721
points = Arrays.asList(new Point(0, 0), new Point(1, 0), new Point(10, 0));
1822
expected = Arrays.asList(new Point(0, 0), new Point(10, 0));
1923
assertEquals(expected, ConvexHull.convexHullBruteForce(points));
2024

25+
// Test 3: Complex polygon
2126
points = Arrays.asList(new Point(0, 3), new Point(2, 2), new Point(1, 1), new Point(2, 1), new Point(3, 0), new Point(0, 0), new Point(3, 3), new Point(2, -1), new Point(2, -4), new Point(1, -3));
2227
expected = Arrays.asList(new Point(2, -4), new Point(1, -3), new Point(0, 0), new Point(3, 0), new Point(0, 3), new Point(3, 3));
2328
assertEquals(expected, ConvexHull.convexHullBruteForce(points));
2429
}
2530

2631
@Test
2732
void testConvexHullRecursive() {
33+
// Test 1: Triangle - CCW order starting from bottom-left
34+
// The algorithm includes (1,0) as it's detected as an extreme point
2835
List<Point> points = Arrays.asList(new Point(0, 0), new Point(1, 0), new Point(10, 1));
36+
List<Point> result = ConvexHull.convexHullRecursive(points);
2937
List<Point> expected = Arrays.asList(new Point(0, 0), new Point(1, 0), new Point(10, 1));
30-
assertEquals(expected, ConvexHull.convexHullRecursive(points));
38+
assertEquals(expected, result);
39+
assertTrue(isCounterClockwise(result), "Points should be in counter-clockwise order");
3140

41+
// Test 2: Collinear points
3242
points = Arrays.asList(new Point(0, 0), new Point(1, 0), new Point(10, 0));
43+
result = ConvexHull.convexHullRecursive(points);
3344
expected = Arrays.asList(new Point(0, 0), new Point(10, 0));
34-
assertEquals(expected, ConvexHull.convexHullRecursive(points));
45+
assertEquals(expected, result);
3546

47+
// Test 3: Complex polygon
48+
// Convex hull vertices in CCW order from bottom-most point (2,-4):
49+
// (2,-4) -> (3,0) -> (3,3) -> (0,3) -> (0,0) -> (1,-3) -> back to (2,-4)
3650
points = Arrays.asList(new Point(0, 3), new Point(2, 2), new Point(1, 1), new Point(2, 1), new Point(3, 0), new Point(0, 0), new Point(3, 3), new Point(2, -1), new Point(2, -4), new Point(1, -3));
37-
expected = Arrays.asList(new Point(2, -4), new Point(1, -3), new Point(0, 0), new Point(3, 0), new Point(0, 3), new Point(3, 3));
38-
assertEquals(expected, ConvexHull.convexHullRecursive(points));
51+
result = ConvexHull.convexHullRecursive(points);
52+
expected = Arrays.asList(new Point(2, -4), new Point(3, 0), new Point(3, 3), new Point(0, 3), new Point(0, 0), new Point(1, -3));
53+
assertEquals(expected, result);
54+
assertTrue(isCounterClockwise(result), "Points should be in counter-clockwise order");
55+
}
56+
57+
@Test
58+
void testConvexHullRecursiveAdditionalCases() {
59+
// Test 4: Square (all corners on hull)
60+
List<Point> points = Arrays.asList(new Point(0, 0), new Point(2, 0), new Point(2, 2), new Point(0, 2));
61+
List<Point> result = ConvexHull.convexHullRecursive(points);
62+
List<Point> expected = Arrays.asList(new Point(0, 0), new Point(2, 0), new Point(2, 2), new Point(0, 2));
63+
assertEquals(expected, result);
64+
assertTrue(isCounterClockwise(result), "Square points should be in CCW order");
65+
66+
// Test 5: Pentagon with interior point
67+
points = Arrays.asList(new Point(0, 0), new Point(4, 0), new Point(5, 3), new Point(2, 5), new Point(-1, 3), new Point(2, 2) // (2,2) is interior
68+
);
69+
result = ConvexHull.convexHullRecursive(points);
70+
// CCW from (0,0): (0,0) -> (4,0) -> (5,3) -> (2,5) -> (-1,3)
71+
expected = Arrays.asList(new Point(0, 0), new Point(4, 0), new Point(5, 3), new Point(2, 5), new Point(-1, 3));
72+
assertEquals(expected, result);
73+
assertTrue(isCounterClockwise(result), "Pentagon points should be in CCW order");
74+
75+
// Test 6: Simple triangle (clearly convex)
76+
points = Arrays.asList(new Point(0, 0), new Point(4, 0), new Point(2, 3));
77+
result = ConvexHull.convexHullRecursive(points);
78+
expected = Arrays.asList(new Point(0, 0), new Point(4, 0), new Point(2, 3));
79+
assertEquals(expected, result);
80+
assertTrue(isCounterClockwise(result), "Triangle points should be in CCW order");
81+
}
82+
83+
/**
84+
* Helper method to verify if points are in counter-clockwise order.
85+
* Uses the signed area method: positive area means CCW.
86+
*/
87+
private boolean isCounterClockwise(List<Point> points) {
88+
if (points.size() < 3) {
89+
return true; // Less than 3 points, trivially true
90+
}
91+
92+
long signedArea = 0;
93+
for (int i = 0; i < points.size(); i++) {
94+
Point p1 = points.get(i);
95+
Point p2 = points.get((i + 1) % points.size());
96+
signedArea += (long) p1.x() * p2.y() - (long) p2.x() * p1.y();
97+
}
98+
99+
return signedArea > 0; // Positive signed area means counter-clockwise
100+
}
101+
102+
@Test
103+
void testRecursiveHullForCoverage() {
104+
// 1. Test the base cases of the convexHullRecursive method (covering scenarios with < 3 input points).
105+
106+
// Test Case: 0 points
107+
List<Point> pointsEmpty = new ArrayList<>();
108+
List<Point> resultEmpty = ConvexHull.convexHullRecursive(pointsEmpty);
109+
assertTrue(resultEmpty.isEmpty(), "Should return an empty list for an empty input list");
110+
111+
// Test Case: 1 point
112+
List<Point> pointsOne = List.of(new Point(5, 5));
113+
// Pass a new ArrayList because the original method modifies the input list.
114+
List<Point> resultOne = ConvexHull.convexHullRecursive(new ArrayList<>(pointsOne));
115+
List<Point> expectedOne = List.of(new Point(5, 5));
116+
assertEquals(expectedOne, resultOne, "Should return the single point for a single-point input");
117+
118+
// Test Case: 2 points
119+
List<Point> pointsTwo = Arrays.asList(new Point(10, 1), new Point(0, 0));
120+
List<Point> resultTwo = ConvexHull.convexHullRecursive(new ArrayList<>(pointsTwo));
121+
List<Point> expectedTwo = Arrays.asList(new Point(0, 0), new Point(10, 1)); // Should return the two points, sorted.
122+
assertEquals(expectedTwo, resultTwo, "Should return the two sorted points for a two-point input");
123+
124+
// 2. Test the logic for handling collinear points in the sortCounterClockwise method.
125+
126+
// Construct a scenario where multiple collinear points lie on an edge of the convex hull.
127+
// The expected convex hull vertices are (0,0), (10,0), and (5,5).
128+
// When (0,0) is used as the pivot for polar angle sorting, (5,0) and (10,0) are collinear.
129+
// This will trigger the crossProduct == 0 branch in the sortCounterClockwise method.
130+
List<Point> pointsWithCollinearOnHull = Arrays.asList(new Point(0, 0), new Point(5, 0), new Point(10, 0), new Point(5, 5), new Point(2, 2));
131+
132+
List<Point> resultCollinear = ConvexHull.convexHullRecursive(new ArrayList<>(pointsWithCollinearOnHull));
133+
List<Point> expectedCollinear = Arrays.asList(new Point(0, 0), new Point(10, 0), new Point(5, 5));
134+
135+
assertEquals(expectedCollinear, resultCollinear, "Should correctly handle collinear points on the hull edge");
136+
assertTrue(isCounterClockwise(resultCollinear), "The result of the collinear test should be in counter-clockwise order");
39137
}
40138
}

0 commit comments

Comments
 (0)