Day 159-Q5 — 2026-09-24 — Chan のアルゴリズム(出力依存 $O(n\log h)$ 凸包・Graham と Jarvis を「サイズを当てながら」合体する)

2026-09-24 赤色 Master (Phase 8+) ★★★★★★★★★ 出力サイズ $h$ に計算量を依存させる設計と、「$m=2^{2^t}$ で推定値を平方していく」二重指数の当て方

問題

平面上の相異なる $N$ 点が与えられる。凸包の頂点を、$x$ 座標最小(同点なら $y$ 最小)の点から反時計回りに出力せよ。辺の途中に乗る点(3 点が一直線上にある場合の中央の点)は頂点に含めない。

入力形式

N
x_1 y_1
...
x_N y_N

制約

#条件
1$1\le N\le 2\times10^5$、$|x_i|,|y_i|\le10^9$、すべて整数、点は相異なる

入出力例

入力例1

8
0 0
4 0
4 3
0 3
2 1
1 2
3 2
2 3

出力例1

4
0 0
4 0
4 3
0 3

($(2,3)$ は辺 $(0,3)$–$(4,3)$ の上にあるが頂点ではない)

この問題の背景: T. M. Chan「Optimal output-sensitive convex hull algorithms in two and three dimensions」(Discrete & Computational Geometry 16, 1996、https://doi.org/10.1007/BF02712873 、2026-09-24 参照)。$O(n\log h)$ は Kirkpatrick–Seidel(1986)が先に達成したが、Chan の方法は「既存の $O(n\log n)$ アルゴリズム+Jarvis march+接線の二分探索」だけで書けるほど単純で、しかも $h$ を知らずに $h$ 依存の計算量を出す。本問は $h$ が小さい入力(乱数点:$h=O(\log N)$ や $O(N^{1/3})$)と $h=N$ の入力(円周上の点)の両方を含む。

概念図:群の凸包と接線による Jarvis の 1 歩

群ごとの凸包に「現在点からの接線」を二分探索し、最も時計回り側を次の頂点にする cur(現在の凸包頂点) ○ = 各群の接点(二分探索 O(log m)) 群数 n/m 個を比べて最も時計回り側を採用 m を当てる: m = 2^(2^t) = 4, 16, 256, 65536 … m 歩で閉じなければ失敗 → t+1。各ラウンド O(n·2^t) 等比級数なので合計は最後のラウンド O(n log h) に吸収 退化: cur が自群の頂点 → 単に次頂点。cur が他群の辺の延長線上 → 二分探索の結果を隣へ登って直す(実測 2000 件で一致)

ヒント(クリックで段階的に開示)

ヒント1(方向性)

Graham / Andrew の単調鎖は $O(n\log n)$、Jarvis march(包装法)は $O(nh)$。$h$ が小さいなら Jarvis が速いが、$h$ は事前に分からない。両者を組み合わせて $O(n\log h)$ にするのが目標。Jarvis の 1 歩(「今の頂点から見て最も時計回り側の点」を探す)を $O(n)$ でなく速くできないか。

ヒント2(アプローチ)

点を大きさ $m$ の群 $\lceil n/m\rceil$ 個に分け、各群の凸包を単調鎖で求める(合計 $O(n\log m)$)。Jarvis の 1 歩は「各群の凸包に対する現在点からの接線」を 二分探索 $O(\log m)$ で求めて群数分比べれば $O((n/m)\log m)$。$h$ 歩で $O(h(n/m)\log m)$。$m=h$ なら全体 $O(n\log h)$。$h$ が分からないので $m$ を当てにいく: $m$ 歩以内に閉じなければ失敗として $m$ を増やす。$m=2^{2^t}$($4,16,256,65536,\dots$)で増やすと失敗の総コストが最後の成功に吸収される。

ヒント3(誘導)
t = 1
while True:
    m = min(n, 2 ** (2 ** t))
    hulls = [monotone_chain(pts[i:i+m]) for i in range(0, n, m)]
    hull = [start]; cur = start
    for step in range(m):                     # m 歩以内に閉じなければ諦める
        cand = None
        for h in hulls:
            q = h[tangent_index(h, cur)]      # cur から h への接点(二分探索)
            if cand is None or cross(cur, cand, q) < 0: cand = q   # より時計回り側
        if cand == start: return hull
        hull.append(cand); cur = cand
    t += 1

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys


def cross(o, a, b):
    return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])


def monotone_chain(pts):
    """Andrew の単調鎖。CCW、共線点は除く。O(k log k)"""
    pts = sorted(pts)
    if len(pts) <= 2:
        return pts
    lower = []
    for p in pts:
        while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
            lower.pop()
        lower.append(p)
    upper = []
    for p in reversed(pts):
        while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
            upper.pop()
        upper.append(p)
    return lower[:-1] + upper[:-1]


def better(p, a, b):
    """p から見て a を b より優先するか(Jarvis の次頂点として)。共線なら遠い方。"""
    c = cross(p, b, a)
    if c != 0:
        return c < 0
    return (a[0]-p[0])**2 + (a[1]-p[1])**2 > (b[0]-p[0])**2 + (b[1]-p[1])**2


def tangent_index(hull, p):
    """CCW 凸多角形 hull と外部点 p に対し「全頂点が有向直線 p->hull[i] の左側(または直線上)」
    となる接点 i を O(log n) で返す。Sunday (geomalgorithms.com, tangent_PointPoly) の二分探索。
    p が辺の延長線上にある退化では 1〜2 個ずれるので、隣へ登れる限り登る(退化時のみ数歩)。"""
    n = len(hull)
    if n == 1:
        return 0
    if n == 2:
        return 0 if better(p, hull[0], hull[1]) else 1

    def above(a, b): return cross(p, a, b) > 0      # b は有向直線 p->a の左
    def below(a, b): return cross(p, a, b) < 0

    def between(a, b):                              # p が線分 ab 上(共線前提)
        return (min(a[0], b[0]) <= p[0] <= max(a[0], b[0]) and
                min(a[1], b[1]) <= p[1] <= max(a[1], b[1]))

    def prefer(j, i):                               # 頂点 i の代わりに j を採るべきか
        c = cross(p, hull[i], hull[j])
        if c != 0:
            return c < 0
        if between(hull[i], hull[j]):               # p が辺の内部 → CCW で次の端点
            return j == (i + 1) % n
        return better(p, hull[j], hull[i])          # 延長線上 → 遠い方

    def fix(i):
        while True:
            for j in ((i + 1) % n, (i - 1) % n):
                if prefer(j, i):
                    i = j
                    break
            else:
                return i

    if below(hull[1], hull[0]) and not above(hull[n - 1], hull[0]):
        return fix(0)
    a, b = 0, n
    while b - a > 1:
        c = (a + b) // 2
        dnC = below(hull[(c + 1) % n], hull[c])
        if dnC and not above(hull[c - 1], hull[c]):
            return fix(c)
        upA = above(hull[(a + 1) % n], hull[a])
        if upA:
            if dnC:
                b = c
            elif above(hull[a], hull[c]):
                b = c
            else:
                a = c
        else:
            if not dnC:
                a = c
            elif below(hull[a], hull[c]):
                b = c
            else:
                a = c
    return fix(a)


def chan(pts):
    n = len(pts)
    if n <= 2:
        return sorted(pts)
    start = min(pts)
    t = 1
    while True:
        m = min(n, 1 << (1 << t))                   # m = 2^(2^t): 4, 16, 256, 65536, ...
        hulls = [monotone_chain(pts[i:i + m]) for i in range(0, n, m)]
        pos = [{q: i for i, q in enumerate(h)} for h in hulls]   # cur を含む群では次頂点は隣
        hull = [start]; cur = start
        for _ in range(m):                          # m 歩以内に閉じなければ失敗
            cand = None
            for h, ps in zip(hulls, pos):
                if cur in ps:
                    if len(h) == 1:
                        continue
                    q = h[(ps[cur] + 1) % len(h)]
                else:
                    q = h[tangent_index(h, cur)]
                if cand is None or better(cur, q, cand):
                    cand = q
            if cand == start:
                return hull
            hull.append(cand); cur = cand
        t += 1


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    pts = [(int(data[1 + 2 * i]), int(data[2 + 2 * i])) for i in range(n)]
    hull = chan(pts)
    out = [str(len(hull))] + [f"{x} {y}" for x, y in hull]
    sys.stdout.write("\n".join(out) + "\n")


main()

計算量: ラウンド $t$ のコストは群の凸包 $O(n\log m)$ + Jarvis $m$ 歩 × 群数 $n/m$ × 二分探索 $\log m$ $= O(n\log m)$。$m=2^{2^t}$ なので $\log m=2^t$。成功するのは $2^{2^t}\ge h$ の最初の $t^*=\lceil\log\log h\rceil$ で、総和 $\sum_{t\le t^*} n2^t = O(n2^{t^*})=O(n\log h)$。

Step-by-Step 解説

1単調鎖 $O(n\log n)$ と Jarvis $O(nh)$ のどちらが速いかは $h$ 次第

乱数点(正方形内一様)では $h=O(\log n)$、円板内一様では $h=O(n^{1/3})$(Rényi–Sulanke 1963)、円周上では $h=n$。$h<\log n$ なら Jarvis が、そうでなければ単調鎖が速い。出力依存アルゴリズムは 両方の良いところを取り $O(n\log h)$ にする。

2Jarvis の 1 歩を「群ごとの接線」に置き換える

現在の頂点 $p$ から次の頂点は「全点が有向直線 $p\to q$ の左側にある $q$」。点集合を凸多角形(群の凸包)に分けておけば、各凸多角形に対する $p$ からの接点は 二分探索で求まる(凸多角形上で「$p$ から見た時計回り度合い」は単峰)。群ごとの接点を比べて最も時計回り側のものが全体の次の頂点。

3$h$ を知らずに $m$ を選ぶ——二重指数の当て方

$m=h$ が理想だが $h$ は未知。$m=2,4,8,\dots$ と倍にすると失敗コストの総和が $O(n\log h\cdot\log\log h)$ になり最適でない。$m=2^{2^t}$ と平方していけば各ラウンドが $O(n2^t)$ で等比級数になり、最後のラウンド $O(n\log h)$ に吸収される。失敗判定は「$m$ 歩で閉じないこと」で、$h\le m$ なら必ず閉じる。

4接線二分探索の退化(本問で最も時間を使った箇所)

Sunday の二分探索は「$p$ が凸多角形の 外部 にあり、辺の延長線上にない」ことを前提とする。Chan の中では次の 2 つの退化が実際に起きた(実測:退化を扱わない版は 2000 件中 1388 件で単調鎖と不一致):

  1. $p$ が群の凸包の頂点($p$ 自身の群)—— 接線ではなく単に CCW の次頂点。pos 辞書で $O(1)$ 判定
  2. $p$ が別の群の辺の延長線上/辺の内部(格子点では頻発)—— 二分探索が隣接頂点を返すことがある。返った位置から「隣が優先されるなら移る」を繰り返す fix で吸収。共線時の優先規則は「辺の内部なら CCW 次端点、延長線上なら遠い方」

修正後: 接線の二分探索を総当たりと 3000 件比較して不一致 0、凸包全体も 2000 件で不一致 0。大きい入力での fix の移動回数は 0(退化は小さい格子でしか起きない)。

5Python での実測——理論と実装言語のずれ
入力$h$単調鎖Chanラウンド $(t,m)$
円板内一様 $N=2\times10^5$1990.57 s3.87 s$(1,4),(2,16),(3,256)$
正方形内一様 $N=2\times10^5$300.72 s3.37 s同上
円周上 $N=2\times10^4$200000.026 s0.69 s$(1,4),(2,16),(3,256),(4,20000)$

Chan が すべてのケースで 7〜27 倍遅い。理由: 単調鎖の主コストは sorted()(C 実装)で、Chan の Jarvis 部分は Python レベルのループ。$O(n\log h)$ vs $O(n\log n)$ の差($\log h/\log n\approx 5/18$)は定数倍の差 30 倍に負ける。理論上の最適性と実装環境での速さは別物で、競プロで Chan を書く場面は事実上ない。本問の価値は「出力依存」「二重指数の当て方」「退化の扱い」の 3 つの技法にある。

迷った他案と採らなかった理由

案計算量採否理由
Andrew の単調鎖$O(n\log n)$比較用(実務ではこちら)上表のとおり Python では最速。10 行で書ける
Jarvis march 単体$O(nh)$不採用円周上 $h=n$ で $O(n^2)=4\times10^8$
Kirkpatrick–Seidel(marriage-before-conquest)$O(n\log h)$不採用同じ計算量だが「橋(bridge)を線形時間中央値で探す」分割統治で実装が重い。Chan は既存部品の組合せで済む
Chan(本解)$O(n\log h)$採用(学習目的)出力依存設計の教材として最も簡潔。実行速度では単調鎖に負けることを明記した
Chan + 群の凸包を Jarvis で使い回す最適化同上扱わないラウンド間で群の凸包を再利用する版(Chan の論文 §2 末尾の注記)。定数倍の改善で本質でない
単調鎖 + 事前フィルタ(Akl–Toussaint の四角形除去)$O(n)$ 前処理扱わない内部点を最初に落とす実用的ヒューリスティック。乱数点では点の大半を落とせる。競プロでは不要

よくあるミス

ミス原因正しい書き方
現在頂点を含む群にも接線二分探索をかける「外部点」の前提が崩れて無限ループ/誤答(実測:接線の局所探索で 1 ケース 158 s)群内の位置辞書で判定し、CCW の次頂点を返す
共線点を群の凸包に残す(< 0 で pop)接線の二分探索が単峰性を失う群の凸包は狭義凸(<= 0 で pop)に統一
$m$ を倍々で増やす失敗の総コストが $O(n\log h\log\log h)$$m=2^{2^t}$ で平方する
失敗判定を「$m$ 歩」でなく「$n$ 歩」にする失敗ラウンドが $O(n^2/m)$ になるちょうど $m$ 歩で打ち切る
$\gcd$ や浮動小数で角度比較座標 $10^9$ で外積は $4\times10^{18}$、float は 53 ビットで足りない整数の外積のみで比較する(Python は多倍長なので溢れない)
開始点を「$y$ 最小」にする出力仕様は $x$ 最小・同点 $y$ 最小min(pts)

次のステップ

  • 発展1: 3 次元凸包の出力依存版——Chan の論文は同じ枠組みで 3 次元も $O(n\log h)$ にしている(群ごとの 3D 凸包 + Dobkin–Kirkpatrick 階層で接平面を探す)。Day014 Q5 の 3D 凸包を部品にして骨格だけ書いてみる
  • 発展2: 動的凸包(点の追加)との比較——Overmars–van Leeuwen(Day045 Q3)は $O(\log^2 n)$ 更新。静的な出力依存と動的の設計思想の違いを整理する
  • 扱わなかった範囲: Kirkpatrick–Seidel の橋探索、Akl–Toussaint フィルタの実測、退化がさらに多い入力(全点共線など)の網羅的な扱い

検証ログ(本セッションで実行した数値)

  • 入力例1: [(0,0),(4,0),(4,3),(0,3)]、$h=4$(共線点 $(2,3)$ を除外)
  • 接線二分探索: ランダム凸多角形(3〜40 頂点、座標範囲 10〜$10^6$)と外部点 3000 件で総当たりと比較 → 修正前 5 件不一致(すべて $p$ が辺の延長線上)→ fix 追加後 不一致 0 件
  • 凸包全体: ランダム 2000 件($N\le60$、座標範囲 5/20/1000、共線多発)で単調鎖と巡回列として比較 → 修正前 1388 件不一致($p$ が自群の頂点のケース)→ pos 辞書追加で 44 件 → fix 追加で 不一致 0 件
  • 性能(実測、Python 3.14): 上表のとおり。円板 $N=2\times10^5$ で単調鎖 0.57 s / Chan 3.87 s、正方形で 0.72 s / 3.37 s、円周 $N=2\times10^4$ で 0.026 s / 0.69 s。3 ケースとも結果一致、fix の移動回数 0
  • 修正前の記録: $p$ が自群頂点の場合を局所探索で処理していた版は円周 $N=2\times10^4$ で 158 s(局所探索が $O(n)$ に退化)。退化処理の設計が計算量を左右する実例
  • 出所ラベル: すべて実測。$h$ の期待値($\log n$、$n^{1/3}$)は Rényi–Sulanke の引用(本セッションで数えた実測値は 30・199)
  • 検証スクリプト: スクラッチパッドの q5.py

自己評価

自分の回答

気づき・メモ