From 210c14e6fa2641c6585feb49c103b7c6cdd1f6e5 Mon Sep 17 00:00:00 2001 From: Ali Alimohammadi Date: Thu, 5 Feb 2026 11:29:03 -0800 Subject: [PATCH 1/5] feat: add Graham Scan convex hull algorithm --- .../hashing/hash_table_with_linked_list.py | 2 +- geometry/graham_scan.py | 234 +++++++++++++++ geometry/tests/__init__.py | 0 geometry/tests/test_graham_scan.py | 266 ++++++++++++++++++ 4 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 geometry/graham_scan.py create mode 100644 geometry/tests/__init__.py create mode 100644 geometry/tests/test_graham_scan.py diff --git a/data_structures/hashing/hash_table_with_linked_list.py b/data_structures/hashing/hash_table_with_linked_list.py index f404c5251246..c8dffa30b8e8 100644 --- a/data_structures/hashing/hash_table_with_linked_list.py +++ b/data_structures/hashing/hash_table_with_linked_list.py @@ -8,7 +8,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def _set_value(self, key, data): - self.values[key] = deque([]) if self.values[key] is None else self.values[key] + self.values[key] = deque() if self.values[key] is None else self.values[key] self.values[key].appendleft(data) self._keys[key] = self.values[key] diff --git a/geometry/graham_scan.py b/geometry/graham_scan.py new file mode 100644 index 000000000000..92bcec95dbc9 --- /dev/null +++ b/geometry/graham_scan.py @@ -0,0 +1,234 @@ +""" +Graham Scan algorithm for finding the convex hull of a set of points. + +The Graham scan is a method of computing the convex hull of a finite set of points +in the plane with time complexity O(n log n). It is named after Ronald Graham, who +published the original algorithm in 1972. + +The algorithm finds all vertices of the convex hull ordered along its boundary. +It uses a stack to efficiently identify and remove points that would create +non-convex angles. + +References: +- https://en.wikipedia.org/wiki/Graham_scan +- Graham, R.L. (1972). "An Efficient Algorithm for Determining the Convex Hull of a + Finite Planar Set" +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TypeVar + +T = TypeVar("T", bound="Point") + + +@dataclass +class Point: + """ + A point in 2D space. + + >>> Point(0, 0) + Point(x=0.0, y=0.0) + >>> Point(1.5, 2.5) + Point(x=1.5, y=2.5) + """ + + x: float + y: float + + def __init__(self, x: float, y: float) -> None: + self.x = float(x) + self.y = float(y) + + def __eq__(self, other: object) -> bool: + """ + Check if two points are equal. + + >>> Point(1, 2) == Point(1, 2) + True + >>> Point(1, 2) == Point(2, 1) + False + """ + if not isinstance(other, Point): + return NotImplemented + return self.x == other.x and self.y == other.y + + def __lt__(self, other: Point) -> bool: + """ + Compare two points for sorting (bottom-most, then left-most). + + >>> Point(1, 2) < Point(1, 3) + True + >>> Point(1, 2) < Point(2, 2) + True + >>> Point(2, 2) < Point(1, 2) + False + """ + if self.y == other.y: + return self.x < other.x + return self.y < other.y + + def euclidean_distance(self, other: Point) -> float: + """ + Calculate Euclidean distance between two points. + + >>> Point(0, 0).euclidean_distance(Point(3, 4)) + 5.0 + >>> Point(1, 1).euclidean_distance(Point(4, 5)) + 5.0 + """ + return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5 + + def consecutive_orientation(self, point_a: Point, point_b: Point) -> float: + """ + Calculate the cross product of vectors (self -> point_a) and + (point_a -> point_b). + + Returns: + - Positive value: counter-clockwise turn + - Negative value: clockwise turn + - Zero: collinear points + + >>> Point(0, 0).consecutive_orientation(Point(1, 0), Point(1, 1)) + 1.0 + >>> Point(0, 0).consecutive_orientation(Point(1, 0), Point(1, -1)) + -1.0 + >>> Point(0, 0).consecutive_orientation(Point(1, 0), Point(2, 0)) + 0.0 + """ + return (point_a.x - self.x) * (point_b.y - point_a.y) - ( + point_a.y - self.y + ) * (point_b.x - point_a.x) + + +def graham_scan(points: Sequence[Point]) -> list[Point]: + """ + Find the convex hull of a set of points using the Graham scan algorithm. + + The algorithm works as follows: + 1. Find the bottom-most point (or left-most in case of tie) + 2. Sort all other points by polar angle with respect to the bottom-most point + 3. Process points in order, maintaining a stack of hull candidates + 4. Remove points that would create a clockwise turn + + Args: + points: A sequence of Point objects + + Returns: + A list of Point objects representing the convex hull in counter-clockwise order. + Returns an empty list if there are fewer than 3 distinct points or if all + points are collinear. + + Time Complexity: O(n log n) due to sorting + Space Complexity: O(n) for the output hull + + >>> graham_scan([]) + [] + >>> graham_scan([Point(0, 0)]) + [] + >>> graham_scan([Point(0, 0), Point(1, 1)]) + [] + >>> hull = graham_scan([Point(0, 0), Point(1, 0), Point(0.5, 1)]) + >>> len(hull) + 3 + >>> Point(0, 0) in hull and Point(1, 0) in hull and Point(0.5, 1) in hull + True + """ + if len(points) <= 2: + return [] + + # Find the bottom-most point (left-most in case of tie) + min_point = min(points) + + # Remove the min_point from the list + points_list = [p for p in points if p != min_point] + if not points_list: + # Edge case where all points are the same + return [] + + def polar_angle_key(point: Point) -> tuple[float, float]: + """ + Key function for sorting points by polar angle relative to min_point. + + Points are sorted counter-clockwise. When two points have the same angle, + the farther point comes first (we'll remove duplicates later). + """ + # We use a dummy third point (min_point itself) to calculate relative angles + # Instead, we'll compute the angle between points + dx = point.x - min_point.x + dy = point.y - min_point.y + + # Use atan2 for angle, but we can also use cross product for comparison + # For sorting, we compare orientations between consecutive points + distance = min_point.euclidean_distance(point) + return (dx, dy, -distance) # Negative distance to sort farther points first + + # Sort by polar angle using a comparison based on cross product + def compare_points(point_a: Point, point_b: Point) -> float: + """Compare two points by polar angle relative to min_point.""" + orientation = min_point.consecutive_orientation(point_a, point_b) + if orientation < 0.0: + return 1 # point_a comes after point_b (clockwise) + elif orientation > 0.0: + return -1 # point_a comes before point_b (counter-clockwise) + else: + # Collinear: farther point should come first + dist_a = min_point.euclidean_distance(point_a) + dist_b = min_point.euclidean_distance(point_b) + return -1 if dist_b < dist_a else (1 if dist_b > dist_a else 0) + + from functools import cmp_to_key + + points_list.sort(key=cmp_to_key(compare_points)) + + # Build the convex hull + convex_hull: list[Point] = [min_point, points_list[0]] + + for point in points_list[1:]: + # Skip consecutive points with the same angle (collinear with min_point) + if min_point.consecutive_orientation(point, convex_hull[-1]) == 0.0: + continue + + # Remove points that create a clockwise turn (or are collinear) + while len(convex_hull) >= 2: + orientation = convex_hull[-2].consecutive_orientation( + convex_hull[-1], point + ) + if orientation <= 0.0: + convex_hull.pop() + else: + break + + convex_hull.append(point) + + # Need at least 3 points for a valid convex hull + if len(convex_hull) <= 2: + return [] + + return convex_hull + + +if __name__ == "__main__": + import doctest + + doctest.testmod() + + # Example usage + points = [ + Point(0, 0), + Point(1, 0), + Point(2, 0), + Point(2, 1), + Point(2, 2), + Point(1, 2), + Point(0, 2), + Point(0, 1), + Point(1, 1), # Interior point + ] + + hull = graham_scan(points) + print("Convex hull vertices:") + for point in hull: + print(f" ({point.x}, {point.y})") diff --git a/geometry/tests/__init__.py b/geometry/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/geometry/tests/test_graham_scan.py b/geometry/tests/test_graham_scan.py new file mode 100644 index 000000000000..886f7e2640e1 --- /dev/null +++ b/geometry/tests/test_graham_scan.py @@ -0,0 +1,266 @@ +""" +Tests for the Graham scan convex hull algorithm. +""" + +from graham_scan import Point, graham_scan + + +def test_empty_points() -> None: + """Test with no points.""" + assert graham_scan([]) == [] + + +def test_single_point() -> None: + """Test with a single point.""" + assert graham_scan([Point(0, 0)]) == [] + + +def test_two_points() -> None: + """Test with two points.""" + assert graham_scan([Point(0, 0), Point(1, 1)]) == [] + + +def test_duplicate_points() -> None: + """Test with all duplicate points.""" + p = Point(0, 0) + points = [p, Point(0, 0), Point(0, 0), Point(0, 0), Point(0, 0)] + assert graham_scan(points) == [] + + +def test_collinear_points() -> None: + """Test with all points on the same line.""" + points = [ + Point(1, 0), + Point(2, 0), + Point(3, 0), + Point(4, 0), + Point(5, 0), + ] + assert graham_scan(points) == [] + + +def test_triangle() -> None: + """Test with a triangle (3 points).""" + p1 = Point(1, 1) + p2 = Point(2, 1) + p3 = Point(1.5, 2) + points = [p1, p2, p3] + hull = graham_scan(points) + + assert len(hull) == 3 + assert p1 in hull + assert p2 in hull + assert p3 in hull + + +def test_rectangle() -> None: + """Test with a rectangle (4 points).""" + p1 = Point(1, 1) + p2 = Point(2, 1) + p3 = Point(2, 2) + p4 = Point(1, 2) + points = [p1, p2, p3, p4] + hull = graham_scan(points) + + assert len(hull) == 4 + assert all(p in hull for p in points) + + +def test_triangle_with_interior_points() -> None: + """Test triangle with points inside.""" + p1 = Point(1, 1) + p2 = Point(2, 1) + p3 = Point(1.5, 2) + p4 = Point(1.5, 1.5) # Interior + p5 = Point(1.2, 1.3) # Interior + p6 = Point(1.8, 1.2) # Interior + p7 = Point(1.5, 1.9) # Interior + + hull_points = [p1, p2, p3] + interior_points = [p4, p5, p6, p7] + all_points = hull_points + interior_points + + hull = graham_scan(all_points) + + # All hull points should be in the result + for p in hull_points: + assert p in hull + + # No interior points should be in the result + for p in interior_points: + assert p not in hull + + +def test_rectangle_with_interior_points() -> None: + """Test rectangle with points inside.""" + p1 = Point(1, 1) + p2 = Point(2, 1) + p3 = Point(2, 2) + p4 = Point(1, 2) + p5 = Point(1.5, 1.5) # Interior + p6 = Point(1.2, 1.3) # Interior + p7 = Point(1.8, 1.2) # Interior + p8 = Point(1.9, 1.7) # Interior + p9 = Point(1.4, 1.9) # Interior + + hull_points = [p1, p2, p3, p4] + interior_points = [p5, p6, p7, p8, p9] + all_points = hull_points + interior_points + + hull = graham_scan(all_points) + + # All hull points should be in the result + for p in hull_points: + assert p in hull + + # No interior points should be in the result + for p in interior_points: + assert p not in hull + + +def test_star_shape() -> None: + """Test with a star shape where only tips are on the convex hull.""" + # Tips of the star (on convex hull) + p1 = Point(-5, 6) + p2 = Point(-11, 0) + p3 = Point(-9, -8) + p4 = Point(4, 4) + p5 = Point(6, -7) + + # Interior points (not on convex hull) + p6 = Point(-7, -2) + p7 = Point(-2, -4) + p8 = Point(0, 1) + p9 = Point(1, 0) + p10 = Point(-6, 1) + + hull_points = [p1, p2, p3, p4, p5] + interior_points = [p6, p7, p8, p9, p10] + all_points = hull_points + interior_points + + hull = graham_scan(all_points) + + # All hull points should be in the result + for p in hull_points: + assert p in hull + + # No interior points should be in the result + for p in interior_points: + assert p not in hull + + +def test_rectangle_with_collinear_points() -> None: + """Test rectangle with points on the edges (collinear with vertices).""" + p1 = Point(1, 1) + p2 = Point(2, 1) + p3 = Point(2, 2) + p4 = Point(1, 2) + p5 = Point(1.5, 1) # On edge p1-p2 + p6 = Point(1, 1.5) # On edge p1-p4 + p7 = Point(2, 1.5) # On edge p2-p3 + p8 = Point(1.5, 2) # On edge p3-p4 + + hull_points = [p1, p2, p3, p4] + edge_points = [p5, p6, p7, p8] + all_points = hull_points + edge_points + + hull = graham_scan(all_points) + + # All corner points should be in the result + for p in hull_points: + assert p in hull + + # Edge points should not be in the result (only corners) + for p in edge_points: + assert p not in hull + + +def test_point_equality() -> None: + """Test Point equality.""" + p1 = Point(1, 2) + p2 = Point(1, 2) + p3 = Point(2, 1) + + assert p1 == p2 + assert p1 != p3 + + +def test_point_comparison() -> None: + """Test Point comparison for sorting.""" + p1 = Point(1, 2) + p2 = Point(1, 3) + p3 = Point(2, 2) + + assert p1 < p2 # Lower y value + assert p1 < p3 # Same y, lower x + assert not p2 < p1 + + +def test_euclidean_distance() -> None: + """Test Euclidean distance calculation.""" + p1 = Point(0, 0) + p2 = Point(3, 4) + + assert p1.euclidean_distance(p2) == 5.0 + + +def test_consecutive_orientation() -> None: + """Test orientation calculation.""" + p1 = Point(0, 0) + p2 = Point(1, 0) + p3_ccw = Point(1, 1) # Counter-clockwise + p3_cw = Point(1, -1) # Clockwise + p3_collinear = Point(2, 0) # Collinear + + assert p1.consecutive_orientation(p2, p3_ccw) > 0 # Counter-clockwise + assert p1.consecutive_orientation(p2, p3_cw) < 0 # Clockwise + assert p1.consecutive_orientation(p2, p3_collinear) == 0 # Collinear + + +def test_large_hull() -> None: + """Test with a larger set of points.""" + # Create a circle of points + import math + + points = [] + for i in range(20): + angle = 2 * math.pi * i / 20 + x = math.cos(angle) + y = math.sin(angle) + points.append(Point(x, y)) + + # Add some interior points + points.append(Point(0, 0)) + points.append(Point(0.5, 0.5)) + points.append(Point(-0.3, 0.2)) + + hull = graham_scan(points) + + # The hull should contain the circle points but not the interior points + assert len(hull) >= 3 + assert Point(0, 0) not in hull + assert Point(0.5, 0.5) not in hull + assert Point(-0.3, 0.2) not in hull + + +def test_random_order() -> None: + """Test that point order doesn't affect the result.""" + p1 = Point(0, 0) + p2 = Point(4, 0) + p3 = Point(4, 3) + p4 = Point(0, 3) + p5 = Point(2, 1.5) # Interior + + # Try different orderings + order1 = [p1, p2, p3, p4, p5] + order2 = [p5, p4, p3, p2, p1] + order3 = [p3, p5, p1, p4, p2] + + hull1 = graham_scan(order1) + hull2 = graham_scan(order2) + hull3 = graham_scan(order3) + + # All should have the same points (though possibly in different order) + assert len(hull1) == len(hull2) == len(hull3) == 4 + assert {(p.x, p.y) for p in hull1} == {(p.x, p.y) for p in hull2} + assert {(p.x, p.y) for p in hull2} == {(p.x, p.y) for p in hull3} From bef1cc7e4bd138f6a91b379bb2fe744b2c31aac6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:33:44 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- geometry/graham_scan.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/geometry/graham_scan.py b/geometry/graham_scan.py index 92bcec95dbc9..21c17d20fcc7 100644 --- a/geometry/graham_scan.py +++ b/geometry/graham_scan.py @@ -98,9 +98,9 @@ def consecutive_orientation(self, point_a: Point, point_b: Point) -> float: >>> Point(0, 0).consecutive_orientation(Point(1, 0), Point(2, 0)) 0.0 """ - return (point_a.x - self.x) * (point_b.y - point_a.y) - ( - point_a.y - self.y - ) * (point_b.x - point_a.x) + return (point_a.x - self.x) * (point_b.y - point_a.y) - (point_a.y - self.y) * ( + point_b.x - point_a.x + ) def graham_scan(points: Sequence[Point]) -> list[Point]: From ec6badf1c71fe6c413ed322bab4d09c2a437f52e Mon Sep 17 00:00:00 2001 From: Ali Alimohammadi Date: Thu, 5 Feb 2026 16:53:36 -0800 Subject: [PATCH 3/5] fix: address pre-commit issues --- geometry/graham_scan.py | 17 +++++++++++------ geometry/tests/test_graham_scan.py | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/geometry/graham_scan.py b/geometry/graham_scan.py index 21c17d20fcc7..9109bd706635 100644 --- a/geometry/graham_scan.py +++ b/geometry/graham_scan.py @@ -98,9 +98,9 @@ def consecutive_orientation(self, point_a: Point, point_b: Point) -> float: >>> Point(0, 0).consecutive_orientation(Point(1, 0), Point(2, 0)) 0.0 """ - return (point_a.x - self.x) * (point_b.y - point_a.y) - (point_a.y - self.y) * ( - point_b.x - point_a.x - ) + return (point_a.x - self.x) * (point_b.y - point_a.y) - ( + point_a.y - self.y + ) * (point_b.x - point_a.x) def graham_scan(points: Sequence[Point]) -> list[Point]: @@ -148,7 +148,7 @@ def graham_scan(points: Sequence[Point]) -> list[Point]: # Edge case where all points are the same return [] - def polar_angle_key(point: Point) -> tuple[float, float]: + def polar_angle_key(point: Point) -> tuple[float, float, float]: """ Key function for sorting points by polar angle relative to min_point. @@ -166,7 +166,7 @@ def polar_angle_key(point: Point) -> tuple[float, float]: return (dx, dy, -distance) # Negative distance to sort farther points first # Sort by polar angle using a comparison based on cross product - def compare_points(point_a: Point, point_b: Point) -> float: + def compare_points(point_a: Point, point_b: Point) -> int: """Compare two points by polar angle relative to min_point.""" orientation = min_point.consecutive_orientation(point_a, point_b) if orientation < 0.0: @@ -177,7 +177,12 @@ def compare_points(point_a: Point, point_b: Point) -> float: # Collinear: farther point should come first dist_a = min_point.euclidean_distance(point_a) dist_b = min_point.euclidean_distance(point_b) - return -1 if dist_b < dist_a else (1 if dist_b > dist_a else 0) + if dist_b < dist_a: + return -1 + elif dist_b > dist_a: + return 1 + else: + return 0 from functools import cmp_to_key diff --git a/geometry/tests/test_graham_scan.py b/geometry/tests/test_graham_scan.py index 886f7e2640e1..d9a573289ce9 100644 --- a/geometry/tests/test_graham_scan.py +++ b/geometry/tests/test_graham_scan.py @@ -2,7 +2,7 @@ Tests for the Graham scan convex hull algorithm. """ -from graham_scan import Point, graham_scan +from geometry.graham_scan import Point, graham_scan def test_empty_points() -> None: From 624a83bb27d97ee2eea378ce1ce8272e223b7fae Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:54:06 +0000 Subject: [PATCH 4/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- geometry/graham_scan.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/geometry/graham_scan.py b/geometry/graham_scan.py index 9109bd706635..b53c73567e9f 100644 --- a/geometry/graham_scan.py +++ b/geometry/graham_scan.py @@ -98,9 +98,9 @@ def consecutive_orientation(self, point_a: Point, point_b: Point) -> float: >>> Point(0, 0).consecutive_orientation(Point(1, 0), Point(2, 0)) 0.0 """ - return (point_a.x - self.x) * (point_b.y - point_a.y) - ( - point_a.y - self.y - ) * (point_b.x - point_a.x) + return (point_a.x - self.x) * (point_b.y - point_a.y) - (point_a.y - self.y) * ( + point_b.x - point_a.x + ) def graham_scan(points: Sequence[Point]) -> list[Point]: From ea059bd30a3c7251058d8b1456d0bbd7b9281265 Mon Sep 17 00:00:00 2001 From: Ali Alimohammadi Date: Thu, 5 Feb 2026 16:57:31 -0800 Subject: [PATCH 5/5] chore: Algorithm-Keeper's comments addressed --- geometry/graham_scan.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/geometry/graham_scan.py b/geometry/graham_scan.py index b53c73567e9f..a48391dfbc5d 100644 --- a/geometry/graham_scan.py +++ b/geometry/graham_scan.py @@ -38,9 +38,16 @@ class Point: x: float y: float - def __init__(self, x: float, y: float) -> None: - self.x = float(x) - self.y = float(y) + def __init__(self, x_coordinate: float, y_coordinate: float) -> None: + """ + Initialize a 2D point. + + Args: + x_coordinate: The x-coordinate (horizontal position) of the point + y_coordinate: The y-coordinate (vertical position) of the point + """ + self.x = float(x_coordinate) + self.y = float(y_coordinate) def __eq__(self, other: object) -> bool: """