第23章

凸包の内側

(やさしい版) Pearls of Functional Algorithm Design(関数プログラミングによるアルゴリズム設計の真珠)

どんな問題?

平面や空間にいくつかの点が散らばっているとき、それらをぐるっと包み込む一番外側の輪郭凸包(とつほう、convex hull)と呼びます。平面ならゴム紐で点を囲んだときの形、空間なら点をシュリンクフィルムで包んだときの形をイメージしてください。

この章のテーマは、d 次元(2次元でも3次元でもよい一般の次元)の点集合について、次の二つを扱うことです。

この章の面白いポイント

準備 ― 点と単体を定義する

点は「有理数の座標」で表す

幾何アルゴリズムは、浮動小数点の丸め誤差が入ると簡単に壊れます。そこでこの章では、整数の演算だけで済ませるために、座標が有理数である点だけを扱います。d 次元の有理座標を持つ空間を Q(d) と書きます。

点は d+1 個の整数のリスト [x0, x1, ..., xd](ただし xd ≠ 0)で表します。実際の座標は最後の値で割った [x0/xd, ..., xd−1/xd] になります(分母を明示的に持つイメージ)。

dimension :: Point → Int dimension ps = length ps − 1
Dart // 点は分母を末尾に持つ整数リスト。次元は「要素数 − 1」。 typedef Point = List<int>; int dimension(Point ps) => ps.length - 1;

単体(simplex)とは何か

d-単体とは、Q(d) 上の d+1 個の点と、その並び順から決まる「向き」(+1 か −1)を組にしたものです。次元によって呼び名が変わります。

type Simplex = ([Point], Int)
Dart // 単体 = 点のリスト + 向き(+1 / −1) typedef Simplex = (List<Point> us, int b); typedef Facet = (List<Point> us, int b);
図 23.1   正の向きを持つ四面体の例。a, b, c が (x, y) 平面にあり、d がその上に浮かんでいる。
一般の位置(general position) 頂点が「一般の位置にある」とは、都合の悪い一致がないことです。つまり、 これは、頂点の座標を並べた行列 X = (xij) の行列式が 0 でないことと同値です。

向き(orientation)を行列式の符号で決める

行列式の符号を、そのまま単体の「向き」とします。1

orientation :: [Point] → Int orientation = fromIntegral · signum · det
Dart // 行列式:ライプニッツ展開(順列に沿って符号付きで加算)。次元は小さい想定。 int det(List<List<int>> m) { final n = m.length; final idx = [for (var i = 0; i < n; i++) i]; var total = 0; void perm(int k, int sign) { if (k == n) { var t = sign; for (var i = 0; i < n; i++) t *= m[i][idx[i]]; total += t; return; } for (var i = k; i < n; i++) { final t = idx[k]; idx[k] = idx[i]; idx[i] = t; perm(k + 1, (i == k) ? sign : -sign); final u = idx[k]; idx[k] = idx[i]; idx[i] = u; } } perm(0, 1); return total; } // 単体の向き = 行列式の符号(+1, 0, −1)。 int orientation(List<Point> us) => det(us).sign;

向きの直観的な意味は次のとおりです。

頂点を偶数回入れ替えても向きは変わらず、奇数回入れ替えると符号が反転します。

単体の面(facet)と、単体の中にあるかの判定

単体 smp面(facet)とは、頂点を1つ抜いてできる「一段低い次元の単体」に、元の向きから決まる向きを添えたものです。

facets :: Simplex → [Facet] facets (us, b) = zip (minors us) (cycle [b, −b]) minors :: [a] → [[a]] minors [] = [] minors (x : xs) = xs : map (x :) (minors xs)
Dart // 1要素ずつ抜いた部分列。minors([a,b,c,d]) = [[b,c,d],[a,c,d],[a,b,d],[a,b,c]] List<List<T>> minors<T>(List<T> xs) { if (xs.isEmpty) return []; final x = xs.first; final rest = xs.sublist(1); return [rest, ...minors(rest).map((m) => [x, ...m])]; } // 各面の向きは +b, −b, +b, −b, ... と交互。 List<Facet> facets(Simplex smp) { final (us, b) = smp; final ms = minors(us); return [for (var i = 0; i < ms.length; i++) (ms[i], i.isEven ? b : -b)]; }

ここで Facet = ([Point], Int)。minors は1要素ずつ抜いた部分列を並べる関数で、たとえば minors "abcd" = ["bcd", "acd", "abd", "abc"] となります。

各面には、元の向きに対して +1, −1, +1, −1, ... と交互の符号が付きます。たとえば正の向きの三角形 [a, b, c] の3つの面は、

([b, c], +1),    ([a, c], −1),    ([a, b], +1)
「中にあるか」の判定のアイデア

p が単体の中にあるかを判定するには、各面の向きと同じ側に p がいるかを、面ごとにチェックします。具体的には、面 (us, b) について、pus の先頭に付けた新しい単体の向きが b と一致すれば、p はその面の「内側」にあるとみなします。

これを述語として書くと次のようになります。

insideCS :: Simplex → Point → Bool insideCS smp p = and [0 ≤ b ∗ orientation (p : us) | (us, b) ← facets smp]
Dart // 点 p が単体 smp の内部か境界上にあれば true。 bool insideCS(Simplex smp, Point p) { for (final (us, b) in facets(smp)) { if (b * orientation([p, ...us]) < 0) return false; } return true; }

すべての面について同符号(積が 0 以上)ならば、p は単体の内部か境界上にいます。積がちょうど 0 になる面があれば境界上、すべての積が正なら厳密に内部です。

凸包の仕様

「あらゆる単体の和集合」として定義する

Q(d) 上の点集合 vs の凸包 CH(vs) は、次のように定義できます。

凸包の定義 vs の点から作れるすべての d-単体 smp」について、CS(smp)(単体の中の点全体)を集めた和集合

たとえば平面(Q(2))なら、vs の3点で作れるすべての三角形の内部を寄せ集めたものが凸包です。これをそのまま述語にすると:

insideCH :: [Point] → Point → Bool insideCH vs p = or [insideCS smp p | smp ← simplexes vs] simplexes :: [Point] → [Simplex] simplexes vs = [(us, b) | us ← tuples (d + 1) vs, let b = orientation us, b ≠ 0] where d = dimension (head vs)
Dart // vs から n 個を選ぶすべての組合せ(順序保持)。 Iterable<List<T>> tuples<T>(int n, List<T> vs) sync* { if (n == 0) { yield []; return; } for (var i = 0; i <= vs.length - n; i++) { for (final rest in tuples(n - 1, vs.sublist(i + 1))) { yield [vs[i], ...rest]; } } } // d+1 点で作れる、退化していないすべての単体。 List<Simplex> simplexes(List<Point> vs) { final d = dimension(vs.first); final result = <Simplex>[]; for (final us in tuples(d + 1, vs)) { final b = orientation(us); if (b != 0) result.add((us, b)); } return result; } // 仕様版:p がいずれかの単体に入っていれば凸包内。 bool insideCH(List<Point> vs, Point p) { for (final smp in simplexes(vs)) { if (insideCS(smp, p)) return true; } return false; }

tuples n vs は「vs から n 個選んだ部分列すべて」を返します。定義は演習として省略されています。

仕様の限界 この定義では、点がすべて同一直線上(Q(2))や同一平面上(Q(3))にあるなど、d-単体がひとつも作れない場合、凸包は空集合になります。次元を下げれば凸包を定義できますが、ここでは仕様をそのままにしておきます(あとでこの点がバグの引き金になります)。

逐次アルゴリズム

まずは効率を上げる方向を決める

n 点から d-単体は Ω(nd+1) 個も作れてしまうので、insideCH の素朴な実装は非常に遅くなります。多くの単体は重なっているので、重なりなく凸包全体を覆う「分割」が得られれば十分です。そこで simplexespartition(分割を返す関数)に取り替えます。

insideCH′ :: [Point] → Point → Bool insideCH′ vs p = or [insideCS smp p | smp ← partition vs]
Dart // 分割版:重ならない単体のリストで凸包を覆う。 bool insideCHp(List<Point> vs, Point p) { for (final smp in partition(vs)) { if (insideCS(smp, p)) return true; } return false; }

分割を作る戦略:1点ずつ足す

凸包の分割は、次の手順で作れます。

  1. 最初に、単体をひとつ見つけて出発点とする(findSimplex)。
  2. その頂点を除いた残りの点を1つずつ順に取り出し、現在の分割を更新していく(update)。
partition :: [Point] → [Simplex] partition vs = case findSimplex vs of Nothing → [ ] Just [smp] → foldl update [smp] (vs \\ vertices smp) vertices :: Simplex → [Point] vertices = sort · fst
Dart // 点リストの辞書順比較。 int cmpPoint(Point a, Point b) { for (var i = 0; i < a.length && i < b.length; i++) { final c = a[i].compareTo(b[i]); if (c != 0) return c; } return a.length.compareTo(b.length); } // 単体の頂点集合(ソート済み)。 List<Point> vertices(Simplex smp) { final vs = [...smp.$1]; vs.sort(cmpPoint); return vs; } // ソート済みリストの差集合 xs \ ys。 List<Point> diff(List<Point> xs, List<Point> ys) { return [for (final x in xs) if (!ys.any((y) => cmpPoint(x, y) == 0)) x]; } List<Simplex> partition(List<Point> vs) { final smp = findSimplex(vs); if (smp == null) return []; var acc = [smp]; for (final v in diff(vs, vertices(smp))) { acc = update(acc, v); } return acc; }

vs をソート済みにしておけば、\\(差集合)は順序付きリストの差として効率よく行えます。あとは findSimplexupdate を作れば完成です。

単体を見つける ― findSimplex

素朴な定義とその弱点

まず一番素直な定義は次のようになります。

findSimplex vs = if null smps then Nothing else Just (head smps) where smps = simplexes vs
Dart // 素朴版:すべての単体を列挙して先頭を返す。 Simplex? findSimplexNaive(List<Point> vs) { final smps = simplexes(vs); return smps.isEmpty ? null : smps.first; }

ところが、最悪の場合 Ω(nd+1) ステップかかってしまい、せっかく効率化しようとした意味が薄れます。実用上は問題ないことが多いですが、別のやり方も紹介されています。

「点を1つずつ足しながら一般の位置になるか調べる」方法

方針はこうです。

  1. 先頭の v0 から始める。
  2. 次に v0 と重ならない v1 を探す。
  3. その次に v0, v1 と同一直線上にない v2 を探す。
  4. これを繰り返し、一般の位置にある d+1 点が揃うまで続ける。
難しいところ Q(d) 上の k+1 点(k < d)が一般の位置にあるかを判定するとき、点の座標行列は (k+1) × (d+1) の正方でない行列になるので、単純に行列式では判定できません。

正方部分行列を全部見る「退化判定」

そこで、最初の d 列から k 列を選ぶあらゆる組合せに、最終列(分母の列)を合わせて、(k+1) × (k+1) の正方部分行列を作ります。これらの行列式がすべて 0 なら退化している、と判定します。

degenerate k = all (== 0) · map det · submatrices k · transpose submatrices k vs = map (++ [last vs]) (tuples k (init vs))
Dart // 行列の転置。 List<List<int>> transpose(List<List<int>> m) { if (m.isEmpty) return []; return [for (var j = 0; j < m.first.length; j++) [for (var i = 0; i < m.length; i++) m[i][j]]]; } // 最初の要素から k 個を選び、最終列(分母列)を必ず付ける。 List<List<List<int>>> submatrices(int k, List<List<int>> vs) { final init = vs.sublist(0, vs.length - 1); final last = vs.last; return [for (final t in tuples(k, init)) [...t, last]]; } // k+1 点(それを転置した行列)が退化しているか。 bool degenerate(int k, List<Point> us) { for (final sm in submatrices(k, transpose(us))) { if (det(sm) != 0) return false; } return true; }

転置してから列ではなく行を選ぶ形にしていますが、行列式は転置しても変わらないので同じことです。部分行列は O(dk) 個、行列式の計算に O(k3) かかるので、退化判定は kd のとき O(dd+3) ステップで行えます。

findSimplex :: [Point] → Maybe Simplex findSimplex [] = Nothing findSimplex (v : vs) = search (length v − 1) 1 [v] vs search d k us vs | k == d + 1 = Just (us, orientation us) | null vs = Nothing | degenerate k (v : us) = search d k us (tail vs) | otherwise = search d (k + 1) (v : us) (tail vs) where v = head vs
Dart // 1点ずつ足しながら、一般の位置にある d+1 点が揃うまで探す。 Simplex? findSimplex(List<Point> vs) { if (vs.isEmpty) return null; final v0 = vs.first; return _search(v0.length - 1, 1, [v0], vs.sublist(1)); } Simplex? _search(int d, int k, List<Point> us, List<Point> vs) { while (true) { if (k == d + 1) return (us, orientation(us)); if (vs.isEmpty) return null; final v = vs.first; if (degenerate(k, [v, ...us])) { vs = vs.sublist(1); // v を使わず次へ } else { us = [v, ...us]; // v を採用して次元を上げる vs = vs.sublist(1); k++; } } }
計算量のまとめ n 点に対する findSimplexO(dd+3n) ステップ。n に関して線形だが、次元 d に関して非常に急速に増える定数を持つ。

更新 ― update

面には「内部」と「外部」の2種類がある

いま扱っている点に対する分割 smps を考えます。すべての単体の面を並べると、面は次の2種類に分けられます。

例:正方形 [a, b, c, d] の三角形分割 それ以外の辺(外周の4辺)は外部面です。

外部面を取り出すには、すべての面を並べてソートし、隣り合う同じ頂点集合の面を打ち消し合わせます。

external :: [Simplex] → [Facet] external = foldr op [ ] · sort · concatMap facets op smp [ ] = [ ] op smp (smp′ : smps) = if vertices smp == vertices smp′ then smps else smp : smp′ : smps
Dart // 面の頂点集合(ソート済み)で比較。 int cmpFacetVerts(Facet a, Facet b) { final av = [...a.$1]..sort(cmpPoint); final bv = [...b.$1]..sort(cmpPoint); for (var i = 0; i < av.length && i < bv.length; i++) { final c = cmpPoint(av[i], bv[i]); if (c != 0) return c; } return av.length.compareTo(bv.length); } // すべての面を並べ、同じ頂点集合の面(内部面)を打ち消し、外部面だけ残す。 List<Facet> external(List<Simplex> smps) { final all = <Facet>[for (final s in smps) ...facets(s)]; all.sort(cmpFacetVerts); final result = <Facet>[]; for (final f in all) { if (result.isNotEmpty && cmpFacetVerts(result.last, f) == 0) { result.removeLast(); // ペアで消える → 内部面 } else { result.add(f); } } return result; }

コストはソートが支配的で O(dS log dS) です(S は単体の個数)。

電球のたとえ ― 可視な面を見つける

新しい点 v が凸包の外側に落ちたとき、外部面は「v から見える面(可視)」と「見えない面(不可視)」に分かれます。

電球のたとえv の位置に電球があると想像しましょう。この電球が照らせるのが可視な面、影になるのが不可視な面です。式で言うと、面 (us, b) について orientation(v : us) の符号が b反対のとき可視です。
visible :: Point → [Facet] → [Facet] visible v fs = [(us, b) | (us, b) ← fs, b ∗ orientation (v : us) < 0]
Dart // v から「見える」面:v を面の前に付けた向きが元の向きと反対のもの。 List<Facet> visible(Point v, List<Facet> fs) { return [for (final (us, b) in fs) if (b * orientation([v, ...us]) < 0) (us, b)]; }

v が現在の凸包の中や境界にあるときは可視な面がありません。ですから、入力に重複点があっても構いません(凸包は変わらない)。

可視な面ごとに新しい単体を追加

更新は「可視な面それぞれに、新しい点 v を頂点として付け加えた単体を作る」だけです。

newSimplex :: Point → Facet → Simplex newSimplex v (us, b) = (v : us, −b) update :: [Simplex] → Point → [Simplex] update smps v = smps ++ map (newSimplex v) (visible v (external smps))
Dart // 可視な面それぞれに v を頂点として付けて新しい単体を作る。向きは反転。 Simplex newSimplex(Point v, Facet f) { final (us, b) = f; return ([v, ...us], -b); } List<Simplex> update(List<Simplex> smps, Point v) { final vis = visible(v, external(smps)); return [...smps, for (final f in vis) newSimplex(v, f)]; }

付ける向きが −b になる理由は簡単です。可視の定義から orientation(v : us) = −b となっているからです。

計算量 update のコストは可視面の計算が支配的で O(dS log dS)。全体としては O(dnS log dS)。単体の最大個数 S = O(ne)(e = ⌊d/2⌋)が知られているので、insideCH′O(ne+1 log n) となります。insideCH よりずっと速いですが、まだ改善できます。

さらなる改良 ― 単体ではなく「面」を保持する

アイデア:単体は捨てて、外部面だけを持ち続ける

アルゴリズムを見直すと、毎回外部面だけを使って可視性を判定していることに気づきます。だったら、最初から単体ではなく外部面だけを保持すればいいはず。faces = external · partition と置けば、次の insideCH″ ができます。

insideCH″ vs p = and [0 ≤ b ∗ orientation (p : us) | (us, b) ← faces vs]
Dart // 面リスト版:外部面だけで凸包を判定する(注意:バグあり版)。 bool insideCHppBuggy(List<Point> vs, Point p) { for (final (us, b) in faces(vs)) { if (b * orientation([p, ...us]) < 0) return false; } return true; // 面が空でも true になってしまう! }

これは計算幾何学で凸包を「外部面のリスト」として表す一般的なやり方に対応しています。

foldl の融合則で導出

faces の効率的な計算は、foldl の融合則から次を満たす update′ を求めれば得られます。

external (update smps v) = update′ (external smps) v
faces vs = case findSimplex vs of Nothing → [ ] Just [smp] → foldl update′ (facets smp) (vs \\ vertices vs) update′ fs v = (fs \\ fs′) ++ map (newFacet v) (external fs′) where fs′ = visible v fs newFacet v (us, b) = (v : us, b)
Dart // 新しい面は「可視面の外周 + 新点 v」で張る。向きはそのまま。 Facet newFacet(Point v, Facet f) { final (us, b) = f; return ([v, ...us], b); } // external を Facet に対して適用(内部面のペアを打ち消す)。 List<Facet> externalF(List<Facet> fs) { final all = [...fs]..sort(cmpFacetVerts); final result = <Facet>[]; for (final f in all) { if (result.isNotEmpty && cmpFacetVerts(result.last, f) == 0) { result.removeLast(); } else { result.add(f); } } return result; } List<Facet> updateP(List<Facet> fs, Point v) { final vis = visible(v, fs); final kept = [ for (final f in fs) if (!vis.any((g) => cmpFacetVerts(f, g) == 0)) f ]; return [...kept, for (final f in externalF(vis)) newFacet(v, f)]; } List<Facet> faces(List<Point> vs) { final smp = findSimplex(vs); if (smp == null) return []; var fs = facets(smp); for (final v in diff(vs, vertices(smp))) { fs = updateP(fs, v); } return fs; }
言葉で言うと

面全体を毎回スキャンして可視面を探しているので、まだ非効率です。可視面は局所的にまとまっているので、隣接情報を使えばもっと速くできます。Blelloch et al. (2001)Bulldozer アルゴリズムなどがその方向の代表例ですが、本章では立ち入りません。

QuickCheck で試す ― そしてバグ発見

ランダムに点を作って比較する

Claessen と Hughes による QuickCheck を使うと、ランダムなテストケースで性質を確かめられます。まず、点とその集合を生成するジェネレータを用意します。

point :: Int → Gen [Integer] point d = do {xs ← vector d; return (xs ++ [1])} points :: Int → Int → Gen [[Integer]] points d 0 = return [ ] points d (n + 1) = do {p ← point d; ps ← points d n; return (p : ps)}
Dart import 'dart:math'; // d 次元のランダムな点(分母は 1 で固定)。座標範囲は −range..range。 Point randomPoint(Random rng, int d, {int range = 5}) { return [for (var i = 0; i < d; i++) rng.nextInt(2 * range + 1) - range, 1]; } // n 個の点。 List<Point> randomPoints(Random rng, int d, int n, {int range = 5}) { return [for (var i = 0; i < n; i++) randomPoint(rng, d, range: range)]; }

そして、仕様 insideCH と実装 insideCH′ が一致することを性質として書きます。

prop_Hull :: Int → Int → Property prop_Hull d n = forAll (points d n) $ λvs → forAll (point d) $ λv → insideCH vs v == insideCH′ vs v OK, passed 100 tests.
Dart // 仕様 insideCH と実装 insideCHp が一致するかをランダムに検証。 bool propHull(int d, int n, {int trials = 100, int seed = 42}) { final rng = Random(seed); for (var t = 0; t < trials; t++) { final vs = randomPoints(rng, d, n); final v = randomPoint(rng, d); if (insideCH(vs, v) != insideCHp(vs, v)) return false; } return true; }

insideCH″ に置き換えると…失敗!

ところが、prop_Hull の中で insideCH′insideCH″ に置き換えるとテストが落ちます。

Main> quickCheck (prop_Hull 2 4) Falsifiable, after 2 tests: [[0,0,1],[0,0,1],[−1,−1,1]] [1,0,1]
バグの原因

反例の4点はすべて同一直線上にあります。したがって分割も面も作れず、faces vs は空リスト。

つまり、「面がひとつもない」場合を特別扱いする必要があるのに、それを忘れていたわけです。

修正

insideCH″ vs v = if null fs then False else and [0 ≤ b ∗ orientation (v : us) | (us, b) ← fs] where fs = faces vs
Dart // 修正版:面がひとつも作れなければ False を返す。 bool insideCHpp(List<Point> vs, Point v) { final fs = faces(vs); if (fs.isEmpty) return false; for (final (us, b) in fs) { if (b * orientation([v, ...us]) < 0) return false; } return true; }
Dart void main() { // 平面 (d=2) の正方形の4頂点で凸包を作り、点の内外を判定する。 final square = <Point>[ [0, 0, 1], [2, 0, 1], [2, 2, 1], [0, 2, 1], ]; final inside = [1, 1, 1]; // 正方形の中央 final outside = [3, 1, 1]; // 右外 print(insideCH(square, inside)); // true print(insideCH(square, outside)); // false print(insideCHp(square, inside)); // true print(insideCHpp(square, outside)); // false // 反例:4点すべて同一直線上 → faces は空 → 修正版は false を返す。 final degen = <Point>[ [0, 0, 1], [0, 0, 1], [-1, -1, 1], ]; print(insideCH(degen, [1, 0, 1])); // false print(insideCHpp(degen, [1, 0, 1])); // false(修正版で正しく) }

面が空なら False を返すように直せば QuickCheck も通ります。この間違い、あなたは気づけたでしょうか?

おわりに

凸包アルゴリズムを扱う計算幾何学の教科書は数多くあり、O'Rourke (1998)Preparata and Shamos (1985) はそのうちの2冊です。特に O'Rourke の本には凸包に丁寧な2章が割かれ、参考文献も充実しています。ただし Dijkstra (1976) の3次元の扱いは載っていません。この真珠は Karimipour and Frank (2009) の詳細に取り組む過程で生まれましたが、細部はかなり異なります。凸包について有益な議論を交わしてくれた Irina Voiculescu 氏に感謝します。

1det(行列式)の定義は前章(第22章)で与えました。

参考文献

Claessen, K. and Hughes, J. (2000). QuickCheck: a lightweight tool for random testing of Haskell programs. ACM SIGPLAN International Conference of Functional Programming, Montreal, Canada, pp. 268–79. http://www.cs.chalmers.se/~rjmh/QuickCheck/ も参照。

Blelloch, G., Burch, H., Crary, K., et al. (2001). Persistent triangulations. Journal of Functional Programming 11 (5), 441–66.

Dijkstra, E. W. (1976). A Discipline of Programming. Englewood Cliffs, NJ: Prentice-Hall.

Karimipour, F. and Frank, A. U. (2009). A dimension independent convex hull algorithm. Unpublished.

O'Rourke, J. (1998). Computational Geometry, second edition. Cambridge, UK: Cambridge University Press.

Preparata, F. P. and Shamos, M. I. (1985). Computational Geometry. New York, NY: Springer-Verlag.