-
-
Notifications
You must be signed in to change notification settings - Fork 50k
feat: add Jarvis March (Gift Wrapping) convex hull algorithm #14225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AliAlimohammadi
wants to merge
16
commits into
TheAlgorithms:master
Choose a base branch
from
AliAlimohammadi:add-jarvis-march-algorithm
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
f347c7f
Add Jarvis March (Gift Wrapping) convex hull algorithm
AliAlimohammadi 7028fa7
Use descriptive parameter names per algorithms-keeper review
AliAlimohammadi 5460dca
Update jarvis_march.py
AliAlimohammadi 938e20b
Update jarvis_march.py
AliAlimohammadi c2c2e75
fix: add pytest marker
AliAlimohammadi a376a44
Update jarvis_march.py
AliAlimohammadi 7b89619
Merge branch 'TheAlgorithms:master' into add-jarvis-march-algorithm
AliAlimohammadi 985973d
feat: removed doctests and created a separate test file for CI to pass
AliAlimohammadi c0a5d2c
Update jarvis_march_unit.py
AliAlimohammadi 78f1920
Update jarvis_march.py
AliAlimohammadi 56126ab
Update jarvis_march_unit.py
AliAlimohammadi 0ead700
feat: added test folder with tests to pass CI checks
AliAlimohammadi 69b68a5
fix: duplicate points handled
AliAlimohammadi 9861d8a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] abed1d0
fix: fixed ruff errors
AliAlimohammadi ba711a2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| """ | ||
| Jarvis March (Gift Wrapping) algorithm for finding the convex hull of a set of points. | ||
|
|
||
| The convex hull is the smallest convex polygon that contains all the points. | ||
|
|
||
| Time Complexity: O(n*h) where n is the number of points and h is the number of | ||
| hull points. | ||
| Space Complexity: O(h) where h is the number of hull points. | ||
|
|
||
| USAGE: | ||
| -> Import this file into your project. | ||
| -> Use the jarvis_march() function to find the convex hull of a set of points. | ||
| -> Parameters: | ||
| -> points: A list of Point objects representing 2D coordinates | ||
|
|
||
| REFERENCES: | ||
| -> Wikipedia reference: https://en.wikipedia.org/wiki/Gift_wrapping_algorithm | ||
| -> GeeksforGeeks: | ||
| https://www.geeksforgeeks.org/convex-hull-set-1-jarviss-algorithm-or-wrapping/ | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
|
|
||
| class Point: | ||
| """Represents a 2D point with x and y coordinates.""" | ||
|
|
||
| def __init__(self, x_coordinate: float, y_coordinate: float) -> None: | ||
| self.x = x_coordinate | ||
| self.y = y_coordinate | ||
|
|
||
| def __eq__(self, other: object) -> bool: | ||
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if not isinstance(other, Point): | ||
| return NotImplemented | ||
| return self.x == other.x and self.y == other.y | ||
|
|
||
| def __repr__(self) -> str: | ||
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return f"Point({self.x}, {self.y})" | ||
|
|
||
| def __hash__(self) -> int: | ||
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return hash((self.x, self.y)) | ||
|
|
||
|
|
||
| def _cross_product(origin: Point, point_a: Point, point_b: Point) -> float: | ||
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """ | ||
| Calculate the cross product of vectors OA and OB. | ||
|
|
||
| Returns: | ||
| > 0: Counter-clockwise turn (left turn) | ||
| = 0: Collinear | ||
| < 0: Clockwise turn (right turn) | ||
| """ | ||
| return (point_a.x - origin.x) * (point_b.y - origin.y) - (point_a.y - origin.y) * ( | ||
| point_b.x - origin.x | ||
| ) | ||
|
|
||
|
|
||
| def _is_point_on_segment(p1: Point, p2: Point, point: Point) -> bool: | ||
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """Check if a point lies on the line segment between p1 and p2.""" | ||
| # Check if point is collinear with segment endpoints | ||
| cross = (point.y - p1.y) * (p2.x - p1.x) - (point.x - p1.x) * (p2.y - p1.y) | ||
|
|
||
| if abs(cross) > 1e-9: | ||
| return False | ||
|
|
||
| # Check if point is within the bounding box of the segment | ||
| return min(p1.x, p2.x) <= point.x <= max(p1.x, p2.x) and min( | ||
| p1.y, p2.y | ||
| ) <= point.y <= max(p1.y, p2.y) | ||
|
|
||
|
|
||
| def _find_leftmost_point(points: list[Point]) -> int: | ||
| """Find index of leftmost point (and bottom-most in case of tie).""" | ||
| left_idx = 0 | ||
| for i in range(1, len(points)): | ||
| if points[i].x < points[left_idx].x or ( | ||
| points[i].x == points[left_idx].x and points[i].y < points[left_idx].y | ||
| ): | ||
| left_idx = i | ||
| return left_idx | ||
|
|
||
|
|
||
| def _find_next_hull_point(points: list[Point], current_idx: int) -> int: | ||
| """Find the next point on the convex hull.""" | ||
| next_idx = (current_idx + 1) % len(points) | ||
| # Ensure next_idx is not the same as current_idx | ||
| while next_idx == current_idx: | ||
| next_idx = (next_idx + 1) % len(points) | ||
|
|
||
| for i in range(len(points)): | ||
| if i == current_idx: | ||
| continue | ||
| cross = _cross_product(points[current_idx], points[i], points[next_idx]) | ||
| if cross > 0: | ||
| next_idx = i | ||
|
|
||
| return next_idx | ||
|
|
||
|
|
||
| def _is_valid_polygon(hull: list[Point]) -> bool: | ||
| """Check if hull forms a valid polygon (has at least one non-collinear turn).""" | ||
| for i in range(len(hull)): | ||
| p1 = hull[i] | ||
| p2 = hull[(i + 1) % len(hull)] | ||
| p3 = hull[(i + 2) % len(hull)] | ||
| if abs(_cross_product(p1, p2, p3)) > 1e-9: | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| def _add_point_to_hull(hull: list[Point], point: Point) -> None: | ||
| """Add a point to hull, removing collinear intermediate points.""" | ||
| last = len(hull) - 1 | ||
| if len(hull) > 1 and _is_point_on_segment(hull[last - 1], hull[last], point): | ||
| hull[last] = Point(point.x, point.y) | ||
| else: | ||
| hull.append(Point(point.x, point.y)) | ||
|
|
||
|
|
||
| def jarvis_march(points: list[Point]) -> list[Point]: | ||
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
AliAlimohammadi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """ | ||
| Find the convex hull of a set of points using the Jarvis March algorithm. | ||
|
|
||
| The algorithm starts with the leftmost point and wraps around the set of | ||
| points, selecting the most counter-clockwise point at each step. | ||
|
|
||
| Args: | ||
| points: List of Point objects representing 2D coordinates | ||
|
|
||
| Returns: | ||
| List of Points that form the convex hull in counter-clockwise order. | ||
| Returns empty list if there are fewer than 3 non-collinear points. | ||
| """ | ||
| if len(points) <= 2: | ||
| return [] | ||
|
|
||
| # Remove duplicate points to avoid infinite loops | ||
| unique_points = list(set(points)) | ||
|
|
||
| if len(unique_points) <= 2: | ||
| return [] | ||
|
|
||
| convex_hull: list[Point] = [] | ||
|
|
||
| # Find the leftmost point | ||
| left_point_idx = _find_leftmost_point(unique_points) | ||
| convex_hull.append( | ||
| Point(unique_points[left_point_idx].x, unique_points[left_point_idx].y) | ||
| ) | ||
|
|
||
| current_idx = left_point_idx | ||
| while True: | ||
| # Find the next counter-clockwise point | ||
| next_idx = _find_next_hull_point(unique_points, current_idx) | ||
|
|
||
| if next_idx == left_point_idx: | ||
| break | ||
|
|
||
| if next_idx == current_idx: | ||
| break | ||
|
|
||
| current_idx = next_idx | ||
| _add_point_to_hull(convex_hull, unique_points[current_idx]) | ||
|
|
||
| # Check for degenerate cases | ||
| if len(convex_hull) <= 2: | ||
| return [] | ||
|
|
||
| # Check if last point is collinear with first and second-to-last | ||
| last = len(convex_hull) - 1 | ||
| if _is_point_on_segment(convex_hull[last - 1], convex_hull[last], convex_hull[0]): | ||
| convex_hull.pop() | ||
| if len(convex_hull) == 2: | ||
| return [] | ||
|
|
||
| # Verify the hull forms a valid polygon | ||
| if not _is_valid_polygon(convex_hull): | ||
| return [] | ||
|
|
||
| return convex_hull | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| # Example usage | ||
| points = [Point(0, 0), Point(1, 1), Point(0, 1), Point(1, 0), Point(0.5, 0.5)] | ||
| hull = jarvis_march(points) | ||
| print(f"Convex hull: {hull}") | ||
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| """ | ||
| Unit tests for Jarvis March (Gift Wrapping) algorithm. | ||
| """ | ||
|
|
||
| from geometry.jarvis_march import Point, jarvis_march | ||
|
|
||
|
|
||
| class TestPoint: | ||
| """Tests for the Point class.""" | ||
|
|
||
| def test_point_creation(self) -> None: | ||
| """Test Point initialization.""" | ||
| p = Point(1.0, 2.0) | ||
| assert p.x == 1.0 | ||
| assert p.y == 2.0 | ||
|
|
||
| def test_point_equality(self) -> None: | ||
| """Test Point equality comparison.""" | ||
| p1 = Point(1.0, 2.0) | ||
| p2 = Point(1.0, 2.0) | ||
| p3 = Point(2.0, 1.0) | ||
| assert p1 == p2 | ||
| assert p1 != p3 | ||
|
|
||
| def test_point_repr(self) -> None: | ||
| """Test Point string representation.""" | ||
| p = Point(1.5, 2.5) | ||
| assert repr(p) == "Point(1.5, 2.5)" | ||
|
|
||
| def test_point_hash(self) -> None: | ||
| """Test Point hashing.""" | ||
| p1 = Point(1.0, 2.0) | ||
| p2 = Point(1.0, 2.0) | ||
| assert hash(p1) == hash(p2) | ||
|
|
||
|
|
||
| class TestJarvisMarch: | ||
| """Tests for the jarvis_march function.""" | ||
|
|
||
| def test_triangle(self) -> None: | ||
| """Test convex hull of a triangle.""" | ||
| p1, p2, p3 = Point(1, 1), Point(2, 1), Point(1.5, 2) | ||
| hull = jarvis_march([p1, p2, p3]) | ||
| assert len(hull) == 3 | ||
| assert all(p in hull for p in [p1, p2, p3]) | ||
|
|
||
| def test_collinear_points(self) -> None: | ||
| """Test that collinear points return empty hull.""" | ||
| points = [Point(i, 0) for i in range(5)] | ||
| hull = jarvis_march(points) | ||
| assert hull == [] | ||
|
|
||
| def test_rectangle_with_interior_point(self) -> None: | ||
| """Test rectangle with interior point - interior point excluded.""" | ||
| p1, p2 = Point(1, 1), Point(2, 1) | ||
| p3, p4 = Point(2, 2), Point(1, 2) | ||
| p5 = Point(1.5, 1.5) | ||
| hull = jarvis_march([p1, p2, p3, p4, p5]) | ||
| assert len(hull) == 4 | ||
| assert p5 not in hull | ||
|
|
||
| def test_star_shape(self) -> None: | ||
| """Test star shape - only tips are in hull.""" | ||
| tips = [ | ||
| Point(-5, 6), | ||
| Point(-11, 0), | ||
| Point(-9, -8), | ||
| Point(4, 4), | ||
| Point(6, -7), | ||
| ] | ||
| interior = [Point(-7, -2), Point(-2, -4), Point(0, 1)] | ||
| hull = jarvis_march(tips + interior) | ||
| assert len(hull) == 5 | ||
| assert all(p in hull for p in tips) | ||
| assert not any(p in hull for p in interior) | ||
|
|
||
| def test_empty_list(self) -> None: | ||
| """Test empty list returns empty hull.""" | ||
| assert jarvis_march([]) == [] | ||
|
|
||
| def test_single_point(self) -> None: | ||
| """Test single point returns empty hull.""" | ||
| assert jarvis_march([Point(0, 0)]) == [] | ||
|
|
||
| def test_two_points(self) -> None: | ||
| """Test two points return empty hull.""" | ||
| assert jarvis_march([Point(0, 0), Point(1, 1)]) == [] | ||
|
|
||
| def test_square(self) -> None: | ||
| """Test convex hull of a square.""" | ||
| p1, p2 = Point(0, 0), Point(1, 0) | ||
| p3, p4 = Point(1, 1), Point(0, 1) | ||
| hull = jarvis_march([p1, p2, p3, p4]) | ||
| assert len(hull) == 4 | ||
| assert all(p in hull for p in [p1, p2, p3, p4]) | ||
|
|
||
| def test_duplicate_points(self) -> None: | ||
| """Test handling of duplicate points.""" | ||
| p1, p2, p3 = Point(0, 0), Point(1, 0), Point(0, 1) | ||
| points = [p1, p2, p3, p1, p2] # Include duplicates | ||
| hull = jarvis_march(points) | ||
| assert len(hull) == 3 | ||
|
|
||
| def test_pentagon(self) -> None: | ||
| """Test convex hull of a pentagon.""" | ||
| points = [ | ||
| Point(0, 1), | ||
| Point(1, 2), | ||
| Point(2, 1), | ||
| Point(1.5, 0), | ||
| Point(0.5, 0), | ||
| ] | ||
| hull = jarvis_march(points) | ||
| assert len(hull) == 5 | ||
| assert all(p in hull for p in points) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.