問題
平面上の相異なる $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 歩
ヒント(クリックで段階的に開示)
ヒント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 解説
乱数点(正方形内一様)では $h=O(\log n)$、円板内一様では $h=O(n^{1/3})$(Rényi–Sulanke 1963)、円周上では $h=n$。$h<\log n$ なら Jarvis が、そうでなければ単調鎖が速い。出力依存アルゴリズムは 両方の良いところを取り $O(n\log h)$ にする。
現在の頂点 $p$ から次の頂点は「全点が有向直線 $p\to q$ の左側にある $q$」。点集合を凸多角形(群の凸包)に分けておけば、各凸多角形に対する $p$ からの接点は 二分探索で求まる(凸多角形上で「$p$ から見た時計回り度合い」は単峰)。群ごとの接点を比べて最も時計回り側のものが全体の次の頂点。
$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$ なら必ず閉じる。
Sunday の二分探索は「$p$ が凸多角形の 外部 にあり、辺の延長線上にない」ことを前提とする。Chan の中では次の 2 つの退化が実際に起きた(実測:退化を扱わない版は 2000 件中 1388 件で単調鎖と不一致):
- $p$ が群の凸包の頂点($p$ 自身の群)—— 接線ではなく単に CCW の次頂点。
pos辞書で $O(1)$ 判定 - $p$ が別の群の辺の延長線上/辺の内部(格子点では頻発)—— 二分探索が隣接頂点を返すことがある。返った位置から「隣が優先されるなら移る」を繰り返す
fixで吸収。共線時の優先規則は「辺の内部なら CCW 次端点、延長線上なら遠い方」
修正後: 接線の二分探索を総当たりと 3000 件比較して不一致 0、凸包全体も 2000 件で不一致 0。大きい入力での fix の移動回数は 0(退化は小さい格子でしか起きない)。
| 入力 | $h$ | 単調鎖 | Chan | ラウンド $(t,m)$ |
|---|---|---|---|---|
| 円板内一様 $N=2\times10^5$ | 199 | 0.57 s | 3.87 s | $(1,4),(2,16),(3,256)$ |
| 正方形内一様 $N=2\times10^5$ | 30 | 0.72 s | 3.37 s | 同上 |
| 円周上 $N=2\times10^4$ | 20000 | 0.026 s | 0.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