mirror of
https://github.com/TheAlgorithms/Python.git
synced 2026-03-13 09:50:19 +08:00
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:
246
geometry/graham_scan.py
Normal file
246
geometry/graham_scan.py
Normal 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})")
|
||||
266
geometry/tests/test_graham_scan.py
Normal file
266
geometry/tests/test_graham_scan.py
Normal 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}
|
||||
Reference in New Issue
Block a user