🧭

偏角ソートのテンプレート(Python 実装)

に公開

はじめに

偏角ソートを Python で実装した記事が見当たらなかったため、テンプレートを用意しました。
このテンプレートは原点から見た [0, 2 \pi) 範囲(反時計回り)でのソートに対応しています。また、浮動小数点演算を避け、整数演算のみで実装しました。

実装にあたっては ABC 442 E - Laser Takahashi 解説 を参考にさせていただきました。詳しい実装方針についてもこちらで解説されています。
もしコードや説明に誤りがあればコメントにてご指摘ください。

偏角ソートの定義

この記事でいう偏角ソートとは、次の定義に従ったソートアルゴリズムを指します。

  • 原点 (0, 0) を始点として (1, 0) を通る半直線を基準とし、基準から反時計回りに測った角度を偏角と呼ぶ
    • 本来偏角は複素数平面上の点に対して定義される語だが、本記事では通例にならってこの語を用いる
  • 偏角が小さいほうから順に並べる
  • 偏角が等しい場合、原点との距離が小さいほうから順に並べる
  • 原点の偏角は未定義だが、実装上ソート対象に含まれる場合は最小の要素として扱われる
    • 原点は予めソート対象から取り除くことを推奨する

テンプレート

def half(p):
    """点 p が上半平面 (y > 0 または +x 軸上) に属する場合は 0 を, さもなくば 1 を返す"""
    x, y = p
    if y > 0 or (y == 0 and x >= 0):
        return 0
    else:
        return 1


def cross(a, b):
    """原点から見たベクトル a, b の外積 (符号付き面積) の値を返す"""
    ax, ay = a
    bx, by = b
    return ax * by - ay * bx


def norm2(p):
    """点 p の原点からの距離の二乗を返す"""
    x, y = p
    return x * x + y * y


def angle_cmp(a, b):
    """点 a, b の偏角と距離に基づく比較関数"""
    ha = half(a)
    hb = half(b)
    if ha < hb:
        return -1
    elif ha > hb:
        return 1

    c = cross(a, b)
    if c > 0:
        return -1
    elif c < 0:
        return 1

    da = norm2(a)
    db = norm2(b)
    if da < db:
        return -1
    elif da > db:
        return 1

    return 0


def is_same_dir(a, b):
    """点 a, b が原点でなく, かつ原点を始点とする同一半直線上にある場合は True を, さもなくば False を返す"""
    if (a[0] == 0 and a[1] == 0) or (b[0] == 0 and b[1] == 0):
        return False
    return half(a) == half(b) and cross(a, b) == 0

使い方

functools ライブラリの cmp_to_key 関数を用いて比較関数(本テンプレートの angle_cmp)を評価関数に変換し、それを sort メソッドや sorted 関数の key 属性に渡してソートします。

if __name__ == "__main__":
    import random
    from functools import cmp_to_key

    points = [
        (0, 0),  # 動作確認のため原点を含めているが, 実用上は原点を含めないことを推奨
        (1, 0),
        (1, 1),
        (2, 2),
        (0, 1),
        (-1, 1),
        (-1, 0),
        (-1, -1),
        (0, -1),
        (1, -1),
    ]

    # points をシャッフルする
    random.shuffle(points)
    print(points)
    # => [(2, 2), (1, -1), (1, 1), (1, 0), (-1, 1), (0, 0), (0, 1), (-1, -1), (-1, 0), (0, -1)]

    # points に対して偏角ソートを行った結果を得る (sorted 関数)
    sorted_points = sorted(points, key=cmp_to_key(angle_cmp))
    print(sorted_points)
    # => [(0, 0), (1, 0), (1, 1), (2, 2), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]

    # points に対して inplace に偏角ソートを行う (sort メソッド)
    points.sort(key=cmp_to_key(angle_cmp))
    print(points)
    # => [(0, 0), (1, 0), (1, 1), (2, 2), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]

    # 原点から見た角度が等しいか否か (同一半直線上にあるかどうか) を判定する
    print(is_same_dir((1, 2), (2, 4)))  # => True
    print(is_same_dir((1, 2), (-1, -2)))  # => False

    # is_same_dir の引数に (0, 0) が含まれる場合は常に False を返す
    print(is_same_dir((1, 0), (0, 0)))  # => False
    print(is_same_dir((0, 0), (1, 0)))  # => False

偏角ソートを用いる問題例

Discussion