""" This is a pure Python implementation of the Graham scan algorithm Source: https://en.wikipedia.org/wiki/Graham_scan For doctests run following command: python3 -m doctest -v graham_scan.py """ from __future__ import annotations from collections import deque from enum import Enum from math import atan2, degrees from sys import maxsize # traversal from the lowest and the most left point in anti-clockwise direction # if direction gets right, the previous point is not the convex hull. class Direction(Enum): left = 1 straight = 2 right = 3 def __repr__(self): return f"{self.__class__.__name__}.{self.name}" def angle_comparer(point: tuple[int, int], minx: int, miny: int) -> float: """Return the angle toward to point from (minx, miny) :param point: The target point minx: The starting point's x miny: The starting point's y :return: the angle Examples: >>> angle_comparer((1,1), 0, 0) 45.0 >>> angle_comparer((100,1), 10, 10) -5.710593137499642 >>> angle_comparer((5,5), 2, 3) 33.690067525979785 """ # sort the points accorgind to the angle from the lowest and the most left point x, y = point return degrees(atan2(y - miny, x - minx)) def check_direction( starting: tuple[int, int], via: tuple[int, int], target: tuple[int, int] ) -> Direction: """Return the direction toward to the line from via to target from starting :param starting: The starting point via: The via point target: The target point :return: the Direction Examples: >>> check_direction((1,1), (2,2), (3,3)) Direction.straight >>> check_direction((60,1), (-50,199), (30,2)) Direction.left >>> check_direction((0,0), (5,5), (10,0)) Direction.right """ x0, y0 = starting x1, y1 = via x2, y2 = target via_angle = degrees(atan2(y1 - y0, x1 - x0)) via_angle %= 360 target_angle = degrees(atan2(y2 - y0, x2 - x0)) target_angle %= 360 # t- # \ \ # \ v # \| # s # via_angle is always lower than target_angle, if direction is left. # If they are same, it means they are on a same line of convex hull. if target_angle > via_angle: return Direction.left elif target_angle == via_angle: return Direction.straight else: return Direction.right def graham_scan(points: list[tuple[int, int]]) -> list[tuple[int, int]]: """Pure implementation of graham scan algorithm in Python :param points: The unique points on coordinates. :return: The points on convex hell. Examples: >>> graham_scan([(9, 6), (3, 1), (0, 0), (5, 5), (5, 2), (7, 0), (3, 3), (1, 4)]) [(0, 0), (7, 0), (9, 6), (5, 5), (1, 4)] >>> graham_scan([(0, 0), (1, 0), (1, 1), (0, 1)]) [(0, 0), (1, 0), (1, 1), (0, 1)] >>> graham_scan([(0, 0), (1, 1), (2, 2), (3, 3), (-1, 2)]) [(0, 0), (1, 1), (2, 2), (3, 3), (-1, 2)] >>> graham_scan([(-100, 20), (99, 3), (1, 10000001), (5133186, -25), (-66, -4)]) [(5133186, -25), (1, 10000001), (-100, 20), (-66, -4)] """ if len(points) <= 2: # There is no convex hull raise ValueError("graham_scan: argument must contain more than 3 points.") if len(points) == 3: return points # find the lowest and the most left point minidx = 0 miny, minx = maxsize, maxsize for i, point in enumerate(points): x = point[0] y = point[1] if y < miny: miny = y minx = x minidx = i if y == miny and x < minx: minx = x minidx = i # remove the lowest and the most left point from points for preparing for sort points.pop(minidx) sorted_points = sorted(points, key=lambda point: angle_comparer(point, minx, miny)) # This insert actually costs complexity, # and you should instead add (minx, miny) into stack later. # I'm using insert just for easy understanding. sorted_points.insert(0, (minx, miny)) stack: deque[tuple[int, int]] = deque() stack.append(sorted_points[0]) stack.append(sorted_points[1]) stack.append(sorted_points[2]) # The first 3 points lines are towards the left because we sort them by their angle # from minx, miny. current_direction = Direction.left for i in range(3, len(sorted_points)): while True: starting = stack[-2] via = stack[-1] target = sorted_points[i] next_direction = check_direction(starting, via, target) if next_direction == Direction.left: current_direction = Direction.left break if next_direction == Direction.straight: if current_direction == Direction.left: # We keep current_direction as left. # Because if the straight line keeps as straight, # we want to know if this straight line is towards left. break elif current_direction == Direction.right: # If the straight line is towards right, # every previous points on that straight line is not convex hull. stack.pop() if next_direction == Direction.right: stack.pop() stack.append(sorted_points[i]) return list(stack)