Skip to content

Commit ac05f12

Browse files
feat: add Jarvis March (Gift Wrapping) convex hull algorithm (#14225)
* Add Jarvis March (Gift Wrapping) convex hull algorithm * Use descriptive parameter names per algorithms-keeper review * Update jarvis_march.py * Update jarvis_march.py * fix: add pytest marker * Update jarvis_march.py * feat: removed doctests and created a separate test file for CI to pass * Update jarvis_march_unit.py * Update jarvis_march.py * Update jarvis_march_unit.py * feat: added test folder with tests to pass CI checks * fix: duplicate points handled * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: fixed ruff errors * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 678dedb commit ac05f12

File tree

4 files changed

+303
-1
lines changed

4 files changed

+303
-1
lines changed

data_structures/hashing/hash_table_with_linked_list.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ def __init__(self, *args, **kwargs):
88
super().__init__(*args, **kwargs)
99

1010
def _set_value(self, key, data):
11-
self.values[key] = deque([]) if self.values[key] is None else self.values[key]
11+
self.values[key] = deque() if self.values[key] is None else self.values[key]
1212
self.values[key].appendleft(data)
1313
self._keys[key] = self.values[key]
1414

geometry/jarvis_march.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""
2+
Jarvis March (Gift Wrapping) algorithm for finding the convex hull of a set of points.
3+
4+
The convex hull is the smallest convex polygon that contains all the points.
5+
6+
Time Complexity: O(n*h) where n is the number of points and h is the number of
7+
hull points.
8+
Space Complexity: O(h) where h is the number of hull points.
9+
10+
USAGE:
11+
-> Import this file into your project.
12+
-> Use the jarvis_march() function to find the convex hull of a set of points.
13+
-> Parameters:
14+
-> points: A list of Point objects representing 2D coordinates
15+
16+
REFERENCES:
17+
-> Wikipedia reference: https://en.wikipedia.org/wiki/Gift_wrapping_algorithm
18+
-> GeeksforGeeks:
19+
https://www.geeksforgeeks.org/convex-hull-set-1-jarviss-algorithm-or-wrapping/
20+
"""
21+
22+
from __future__ import annotations
23+
24+
25+
class Point:
26+
"""Represents a 2D point with x and y coordinates."""
27+
28+
def __init__(self, x_coordinate: float, y_coordinate: float) -> None:
29+
self.x = x_coordinate
30+
self.y = y_coordinate
31+
32+
def __eq__(self, other: object) -> bool:
33+
if not isinstance(other, Point):
34+
return NotImplemented
35+
return self.x == other.x and self.y == other.y
36+
37+
def __repr__(self) -> str:
38+
return f"Point({self.x}, {self.y})"
39+
40+
def __hash__(self) -> int:
41+
return hash((self.x, self.y))
42+
43+
44+
def _cross_product(origin: Point, point_a: Point, point_b: Point) -> float:
45+
"""
46+
Calculate the cross product of vectors OA and OB.
47+
48+
Returns:
49+
> 0: Counter-clockwise turn (left turn)
50+
= 0: Collinear
51+
< 0: Clockwise turn (right turn)
52+
"""
53+
return (point_a.x - origin.x) * (point_b.y - origin.y) - (point_a.y - origin.y) * (
54+
point_b.x - origin.x
55+
)
56+
57+
58+
def _is_point_on_segment(p1: Point, p2: Point, point: Point) -> bool:
59+
"""Check if a point lies on the line segment between p1 and p2."""
60+
# Check if point is collinear with segment endpoints
61+
cross = (point.y - p1.y) * (p2.x - p1.x) - (point.x - p1.x) * (p2.y - p1.y)
62+
63+
if abs(cross) > 1e-9:
64+
return False
65+
66+
# Check if point is within the bounding box of the segment
67+
return min(p1.x, p2.x) <= point.x <= max(p1.x, p2.x) and min(
68+
p1.y, p2.y
69+
) <= point.y <= max(p1.y, p2.y)
70+
71+
72+
def _find_leftmost_point(points: list[Point]) -> int:
73+
"""Find index of leftmost point (and bottom-most in case of tie)."""
74+
left_idx = 0
75+
for i in range(1, len(points)):
76+
if points[i].x < points[left_idx].x or (
77+
points[i].x == points[left_idx].x and points[i].y < points[left_idx].y
78+
):
79+
left_idx = i
80+
return left_idx
81+
82+
83+
def _find_next_hull_point(points: list[Point], current_idx: int) -> int:
84+
"""Find the next point on the convex hull."""
85+
next_idx = (current_idx + 1) % len(points)
86+
# Ensure next_idx is not the same as current_idx
87+
while next_idx == current_idx:
88+
next_idx = (next_idx + 1) % len(points)
89+
90+
for i in range(len(points)):
91+
if i == current_idx:
92+
continue
93+
cross = _cross_product(points[current_idx], points[i], points[next_idx])
94+
if cross > 0:
95+
next_idx = i
96+
97+
return next_idx
98+
99+
100+
def _is_valid_polygon(hull: list[Point]) -> bool:
101+
"""Check if hull forms a valid polygon (has at least one non-collinear turn)."""
102+
for i in range(len(hull)):
103+
p1 = hull[i]
104+
p2 = hull[(i + 1) % len(hull)]
105+
p3 = hull[(i + 2) % len(hull)]
106+
if abs(_cross_product(p1, p2, p3)) > 1e-9:
107+
return True
108+
return False
109+
110+
111+
def _add_point_to_hull(hull: list[Point], point: Point) -> None:
112+
"""Add a point to hull, removing collinear intermediate points."""
113+
last = len(hull) - 1
114+
if len(hull) > 1 and _is_point_on_segment(hull[last - 1], hull[last], point):
115+
hull[last] = Point(point.x, point.y)
116+
else:
117+
hull.append(Point(point.x, point.y))
118+
119+
120+
def jarvis_march(points: list[Point]) -> list[Point]:
121+
"""
122+
Find the convex hull of a set of points using the Jarvis March algorithm.
123+
124+
The algorithm starts with the leftmost point and wraps around the set of
125+
points, selecting the most counter-clockwise point at each step.
126+
127+
Args:
128+
points: List of Point objects representing 2D coordinates
129+
130+
Returns:
131+
List of Points that form the convex hull in counter-clockwise order.
132+
Returns empty list if there are fewer than 3 non-collinear points.
133+
"""
134+
if len(points) <= 2:
135+
return []
136+
137+
# Remove duplicate points to avoid infinite loops
138+
unique_points = list(set(points))
139+
140+
if len(unique_points) <= 2:
141+
return []
142+
143+
convex_hull: list[Point] = []
144+
145+
# Find the leftmost point
146+
left_point_idx = _find_leftmost_point(unique_points)
147+
convex_hull.append(
148+
Point(unique_points[left_point_idx].x, unique_points[left_point_idx].y)
149+
)
150+
151+
current_idx = left_point_idx
152+
while True:
153+
# Find the next counter-clockwise point
154+
next_idx = _find_next_hull_point(unique_points, current_idx)
155+
156+
if next_idx == left_point_idx:
157+
break
158+
159+
if next_idx == current_idx:
160+
break
161+
162+
current_idx = next_idx
163+
_add_point_to_hull(convex_hull, unique_points[current_idx])
164+
165+
# Check for degenerate cases
166+
if len(convex_hull) <= 2:
167+
return []
168+
169+
# Check if last point is collinear with first and second-to-last
170+
last = len(convex_hull) - 1
171+
if _is_point_on_segment(convex_hull[last - 1], convex_hull[last], convex_hull[0]):
172+
convex_hull.pop()
173+
if len(convex_hull) == 2:
174+
return []
175+
176+
# Verify the hull forms a valid polygon
177+
if not _is_valid_polygon(convex_hull):
178+
return []
179+
180+
return convex_hull
181+
182+
183+
if __name__ == "__main__":
184+
# Example usage
185+
points = [Point(0, 0), Point(1, 1), Point(0, 1), Point(1, 0), Point(0.5, 0.5)]
186+
hull = jarvis_march(points)
187+
print(f"Convex hull: {hull}")

geometry/tests/__init__.py

Whitespace-only changes.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Unit tests for Jarvis March (Gift Wrapping) algorithm.
3+
"""
4+
5+
from geometry.jarvis_march import Point, jarvis_march
6+
7+
8+
class TestPoint:
9+
"""Tests for the Point class."""
10+
11+
def test_point_creation(self) -> None:
12+
"""Test Point initialization."""
13+
p = Point(1.0, 2.0)
14+
assert p.x == 1.0
15+
assert p.y == 2.0
16+
17+
def test_point_equality(self) -> None:
18+
"""Test Point equality comparison."""
19+
p1 = Point(1.0, 2.0)
20+
p2 = Point(1.0, 2.0)
21+
p3 = Point(2.0, 1.0)
22+
assert p1 == p2
23+
assert p1 != p3
24+
25+
def test_point_repr(self) -> None:
26+
"""Test Point string representation."""
27+
p = Point(1.5, 2.5)
28+
assert repr(p) == "Point(1.5, 2.5)"
29+
30+
def test_point_hash(self) -> None:
31+
"""Test Point hashing."""
32+
p1 = Point(1.0, 2.0)
33+
p2 = Point(1.0, 2.0)
34+
assert hash(p1) == hash(p2)
35+
36+
37+
class TestJarvisMarch:
38+
"""Tests for the jarvis_march function."""
39+
40+
def test_triangle(self) -> None:
41+
"""Test convex hull of a triangle."""
42+
p1, p2, p3 = Point(1, 1), Point(2, 1), Point(1.5, 2)
43+
hull = jarvis_march([p1, p2, p3])
44+
assert len(hull) == 3
45+
assert all(p in hull for p in [p1, p2, p3])
46+
47+
def test_collinear_points(self) -> None:
48+
"""Test that collinear points return empty hull."""
49+
points = [Point(i, 0) for i in range(5)]
50+
hull = jarvis_march(points)
51+
assert hull == []
52+
53+
def test_rectangle_with_interior_point(self) -> None:
54+
"""Test rectangle with interior point - interior point excluded."""
55+
p1, p2 = Point(1, 1), Point(2, 1)
56+
p3, p4 = Point(2, 2), Point(1, 2)
57+
p5 = Point(1.5, 1.5)
58+
hull = jarvis_march([p1, p2, p3, p4, p5])
59+
assert len(hull) == 4
60+
assert p5 not in hull
61+
62+
def test_star_shape(self) -> None:
63+
"""Test star shape - only tips are in hull."""
64+
tips = [
65+
Point(-5, 6),
66+
Point(-11, 0),
67+
Point(-9, -8),
68+
Point(4, 4),
69+
Point(6, -7),
70+
]
71+
interior = [Point(-7, -2), Point(-2, -4), Point(0, 1)]
72+
hull = jarvis_march(tips + interior)
73+
assert len(hull) == 5
74+
assert all(p in hull for p in tips)
75+
assert not any(p in hull for p in interior)
76+
77+
def test_empty_list(self) -> None:
78+
"""Test empty list returns empty hull."""
79+
assert jarvis_march([]) == []
80+
81+
def test_single_point(self) -> None:
82+
"""Test single point returns empty hull."""
83+
assert jarvis_march([Point(0, 0)]) == []
84+
85+
def test_two_points(self) -> None:
86+
"""Test two points return empty hull."""
87+
assert jarvis_march([Point(0, 0), Point(1, 1)]) == []
88+
89+
def test_square(self) -> None:
90+
"""Test convex hull of a square."""
91+
p1, p2 = Point(0, 0), Point(1, 0)
92+
p3, p4 = Point(1, 1), Point(0, 1)
93+
hull = jarvis_march([p1, p2, p3, p4])
94+
assert len(hull) == 4
95+
assert all(p in hull for p in [p1, p2, p3, p4])
96+
97+
def test_duplicate_points(self) -> None:
98+
"""Test handling of duplicate points."""
99+
p1, p2, p3 = Point(0, 0), Point(1, 0), Point(0, 1)
100+
points = [p1, p2, p3, p1, p2] # Include duplicates
101+
hull = jarvis_march(points)
102+
assert len(hull) == 3
103+
104+
def test_pentagon(self) -> None:
105+
"""Test convex hull of a pentagon."""
106+
points = [
107+
Point(0, 1),
108+
Point(1, 2),
109+
Point(2, 1),
110+
Point(1.5, 0),
111+
Point(0.5, 0),
112+
]
113+
hull = jarvis_march(points)
114+
assert len(hull) == 5
115+
assert all(p in hull for p in points)

0 commit comments

Comments
 (0)