feat: add Graham Scan convex hull algorithm (#14251)

* feat: add Graham Scan convex hull algorithm

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: address pre-commit issues

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* chore: Algorithm-Keeper's comments addressed

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: John Law <johnlaw.po@gmail.com>
Co-authored-by: Christian Clauss <cclauss@me.com>
This commit is contained in:
Ali Alimohammadi
2026-03-07 14:35:52 -08:00
committed by GitHub
parent e333ddb852
commit f527d43d6d
2 changed files with 512 additions and 0 deletions

246
geometry/graham_scan.py Normal file
View File

@@ -0,0 +1,246 @@
"""
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_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:
"""
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, 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) -> int:
"""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)
if dist_b < dist_a:
return -1
elif dist_b > dist_a:
return 1
else:
return 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})")

View File

@@ -0,0 +1,266 @@
"""
Tests for the Graham scan convex hull algorithm.
"""
from geometry.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}