Zum Hauptinhalt springen

2.4 Graham's Scan

Geschichte​

  • 1972 von Ronald L. Graham entwickelt.
  • 1979 von A. M. Andrew verbessert.

Die Folien zeigen die Variante nach Andrew (Monotone Chain): Sortierung nach xx-Koordinate, getrennte obere und untere Hülle. Grahams Original sortiert stattdessen nach dem Winkel um einen Startpunkt. Andrews Version kommt ohne Winkel aus und behandelt Sonderfälle einfacher.

Graham's Scan ist ein inkrementeller Algorithmus:

  • Die Punkte werden einzeln betrachtet und nacheinander zur Lösung hinzugefügt.
  • Das passiert von links nach rechts.
  • Dafür werden die Punkte nach ihrer xx-Koordinate sortiert.

Problem: Die Ecken der Hülle sollen in Randreihenfolge entstehen. Läuft man von links nach rechts, kommen aber oben und unten liegende Ecken durcheinander.

Lösung (Folie 14): Berechne zuerst die obere Hülle, dann die untere Hülle.

  • Getrennt wird an den Punkten mit minimaler und maximaler xx-Koordinate, p1p_1 und pnp_n nach dem Sortieren. Beide gehören sicher zur Hülle, denn ganz links und ganz rechts kann nichts mehr kommen.
  • Obere Hülle: der Randteil von p1p_1 über oben nach pnp_n.
  • Untere Hülle: der Randteil von pnp_n über unten zurück nach p1p_1.

Im Bild unten sind p1p_1 und pnp_n der linkeste und der rechteste Punkt. Dort zerfällt die Hülle in die obere Hülle (violett) und die untere Hülle (blau). Setz ruhig eigene Punkte.

p1p2p3p4p5p6p7p8p9p10
Ersten und letzten Punkt der unteren Hülle entfernen (stehen schon oben). Ergebnis im Uhrzeigersinn: p1 → p2 → p8 → p10 → p9 → p5.
Klicken: hinzufügen · Ziehen: verschieben · Rechtsklick: löschen

Die Schlüsselbeobachtung: nur Rechtskurven​

Läuft man die obere Hülle von links nach rechts ab, biegt man an jeder Ecke nach rechts ab, im Uhrzeigersinn um das Innere herum.

Daraus folgt die Regel: Bilden die letzten drei Punkte a,b,ca,b,c der aktuellen Kette keine Rechtskurve, dann liegt bb unterhalb der Strecke ac‾\overline{ac} (oder auf ihr). bb kann dann keine Ecke der oberen Hülle sein und wird gelöscht. Danach prüft man erneut die nun letzten drei Punkte, denn auch der Punkt vor bb könnte jetzt überflüssig sein.

vorher: Linkskurvenachher: b gelöschtabcabc

Für die untere Hülle gilt dasselbe, gespiegelt: Von rechts nach links abgelaufen, sind auch dort nur Rechtskurven erlaubt. Deshalb ist der Code für beide Hälften identisch, nur die Laufrichtung ist umgekehrt.

Der Rechtskurven-Test (Folie 17)​

Drei Punkte (p,w,q)(p,w,q) haben eine Rechtskurve, wenn ww links der Strecke pq‾\overline{pq} liegt.

Mit dem Orientierungstest:

a→b→c ist Rechtskurve  ⟺  orient⁡(a,b,c)<0.a\to b\to c \text{ ist Rechtskurve} \iff \operatorname{orient}(a,b,c) < 0.

(Beide Formulierungen sind gleichwertig: cc rechts von ab⃗\vec{ab} ⇔ bb links von ac⃗\vec{ac}.)

Algorithmus (Folie 15)​

 1  Polygon GrahamScan(Points P) {
2 Sortiere Punkte nach x-Koordinate (p1,...,pn);
3 Lupper.append(p1); Lupper.append(p2);
4 for (i = 3; i <= n; i++) {
5 Lupper.append(pi);
6 while (Lupper enthält mehr als zwei Punkte und
7 die letzten drei Punkte a,b,c machen eine Linkskurve)
8 Lupper.delete(b);
9 }
10 Llower.append(pn); Llower.append(pn-1);
11 for (i = n-2; i >= 1; i--) {
12 Llower.append(pi);
13 while (Llower enthält mehr als zwei Punkte und
14 die letzten drei Punkte a,b,c machen eine Linkskurve)
15 Llower.delete(b);
16 }
17 Llower.delete(ersten Punkt); Llower.delete(letzten Punkt);
18 return Lupper.append(Llower);
19 }

Zeile für Zeile:

ZeileBedeutung
2Sortieren – sorgt dafür, dass jeder neue Punkt der bisher rechteste ist
3Die obere Kette startet mit den zwei linkesten Punkten
5Neuer Punkt pip_i wird immer angehängt, er ist der rechteste bisher und damit sicher Ecke der Hülle der bisherigen Punkte
6–8Solange die letzten drei eine Linkskurve bilden: mittleren Punkt entfernen. Die Liste verhält sich wie ein Stack
10–16dasselbe für die untere Hülle, von rechts nach links
17Der erste Punkt von LlowerL_\text{lower} ist pnp_n, der letzte p1p_1. Beide stehen schon in LupperL_\text{upper} und werden entfernt, damit sie nicht doppelt vorkommen
18Obere + untere Hülle = alle Ecken im Uhrzeigersinn, beginnend bei p1p_1

Interaktiv durchspielen: Setze eigene Punkte und gehe den Algorithmus Schritt für Schritt durch. Violett ist die obere Kette, blau die untere, das Dreieck zeigt den Test der letzten drei Punkte. ⏩ springt zum nächsten neuen Punkt.

p1p2p3p4p5p6p7p8p9p10
Schritt 1/49: Punkte lexikographisch sortiert: p1, p2, p3, p4, p5, p6, p7, p8, p9, p10 (erst nach x, bei Gleichstand nach y).
Klicken: hinzufügen · Ziehen: verschieben · Rechtsklick: löschen
obere Hülleuntere HülleRechtskurve: okkeine Rechtskurve: löschen

Durchgerechnetes Beispiel​

Punkte (bereits lexikographisch sortiert): A(0,0)A(0,0), B(1,2)B(1,2), C(2,1)C(2,1), D(3,3)D(3,3), E(4,0)E(4,0).

Obere Hülle (links → rechts):

SchrittListe nach AnhängenTest der letzten dreiAktionListe danach
StartA B––A B
+CA B Corient(A,B,C) =1⋅1−2⋅2=−3=1\cdot1-2\cdot2=-3: Rechtskurve–A B C
+DA B C Dorient(B,C,D) =1⋅1−(−1)⋅2=3=1\cdot1-(-1)\cdot2=3: LinkskurveC löschenA B D
A B Dorient(A,B,D) =1⋅3−2⋅3=−3=1\cdot3-2\cdot3=-3: Rechtskurve–A B D
+EA B D Eorient(B,D,E) =2⋅(−2)−1⋅3=−7=2\cdot(-2)-1\cdot3=-7: Rechtskurve–A B D E

Untere Hülle (rechts → links, Reihenfolge E, D, C, B, A):

SchrittListeTestAktiondanach
StartE D––E D
+CE D Corient(E,D,C) =(−1)(1)−(3)(−2)=5=(-1)(1)-(3)(-2)=5: LinkskurveD löschenE C
+BE C Borient(E,C,B) =(−2)(2)−(1)(−3)=−1=(-2)(2)-(1)(-3)=-1: Rechtskurve–E C B
+AE C B Aorient(C,B,A) =(−1)(−1)−(1)(−2)=3=(-1)(-1)-(1)(-2)=3: LinkskurveB löschenE C A
E C Aorient(E,C,A) =(−2)(0)−(1)(−4)=4=(-2)(0)-(1)(-4)=4: LinkskurveC löschenE A

Zeile 17: E und A entfernen, LlowerL_\text{lower} ist leer.

Ergebnis: A → B → D → E (im Uhrzeigersinn). C liegt innen. Die untere Kante EA‾\overline{EA} schließt das Polygon. ✔

Dasselbe Beispiel zum Nachklicken (um (5,2)(5,2) verschoben, die orient-Werte bleiben gleich):

ABCDE
Schritt 1/19: Punkte lexikographisch sortiert: A, B, C, D, E (erst nach x, bei Gleichstand nach y).
Klicken: hinzufügen · Ziehen: verschieben · Rechtsklick: löschen
obere Hülleuntere HülleRechtskurve: okkeine Rechtskurve: löschen

Laufzeit (Folie 16)​

Satz 2

Die konvexe Hülle von nn Punkten in der Ebene kann in O(nlog⁡n)O(n\log n) berechnet werden.

TeilAufwand
Lexikographisches SortierenO(nlog⁡n)O(n\log n)
Obere Hülle, for-Schleife (Zeile 4)n−2n-2 Durchläufe
Obere Hülle, while-Schleife (Zeile 6)insgesamt höchstens nn Löschungen
⇒ obere HülleO(n)O(n)
Untere Hülleanalog O(n)O(n)
GesamtO(nlog⁡n)O(n\log n)

Warum ist die while-Schleife insgesamt nur O(n)O(n)?​

Auf den ersten Blick sieht es nach O(n2)O(n^2) aus: eine while-Schleife in einer for-Schleife, und die while-Schleife kann in einem Schritt viele Punkte löschen.

Amortisierte Argumentation: Jeder Punkt wird genau einmal angehängt und höchstens einmal gelöscht, denn gelöschte Punkte kommen nie zurück. Jeder while-Durchlauf, dessen Test zutrifft, löscht einen Punkt. Also gibt es über den gesamten Algorithmus höchstens nn erfolgreiche Tests. Dazu kommt pro for-Durchlauf ein Test, der fehlschlägt und die Schleife beendet. Zusammen sind das höchstens 2n2n Tests, also O(n)O(n).

Das Sortieren dominiert. Sind die Punkte schon sortiert, läuft der Scan in O(n)O(n).

Mit der unteren Schranke folgt: Graham's Scan ist worst-case-optimal.

Korrektheit (Folie 16)​

Beweis durch Induktion über die Anzahl der verarbeiteten Punkte.

Invariante: Nach Verarbeitung von p1,…,pip_1,\dots,p_i enthält LupperL_\text{upper} genau die Ecken der oberen Hülle von {p1,…,pi}\{p_1,\dots,p_i\}, von links nach rechts.

  • Anfang (i=2i=2): p1,p2p_1,p_2 ist trivialerweise die obere Hülle zweier Punkte.
  • Schritt i→i+1i\to i+1: pi+1p_{i+1} ist der rechteste Punkt und daher sicher eine Ecke der neuen oberen Hülle. Ecken der alten Hülle, die jetzt unter der neuen Kante zu pi+1p_{i+1} liegen, sind keine Ecken mehr. Das sind genau die Punkte am Ende der Liste, an denen beim Anhängen von pi+1p_{i+1} eine Linkskurve entsteht. Die while-Schleife entfernt sie von hinten. Sobald eine Rechtskurve entsteht, ist die restliche Kette nach Induktionsvoraussetzung schon konvex und bleibt unverändert.

Untere Hülle analog.

Sonderfälle (Folie 18)​

„Der Algorithmus ist nicht korrekt!“

1. Mehrere Punkte mit gleicher xx-Koordinate​

Dann ist die Ordnung „nach xx sortiert“ nicht eindeutig. Liegen z.B. zwei Punkte senkrecht übereinander ganz links, ist nicht klar, welcher p1p_1 ist, und der Scan kann je nach Reihenfolge falsch abbiegen.

Lösung: lexikographisch sortieren. Bei gleicher xx-Koordinate entscheidet die yy-Koordinate:

p<q  ⟺  px<qx ∨ (px=qx∧py<qy).p < q \iff p_x<q_x \ \lor\ (p_x=q_x \land p_y<q_y).

Das entspricht einer infinitesimal kleinen Drehung des Koordinatensystems: Jetzt haben alle Punkte verschiedene „xx-Werte“.

2. Drei Punkte im Rechtskurven-Test liegen auf einer Geraden​

Ist orient⁡(a,b,c)=0\operatorname{orient}(a,b,c)=0, macht der Weg a→b→ca\to b\to c weder eine Links- noch eine Rechtskurve. Die Folie sagt: „Keine Rechtskurve!“ Also wird bb gelöscht. Der Punkt liegt auf der Kante ac‾\overline{ac} und ist keine echte Ecke.

Daraus folgt: Die Löschbedingung ist nicht „Linkskurve“ (orient⁡>0\operatorname{orient}>0), sondern „keine Rechtskurve“, also orient⁡(a,b,c)≥0\operatorname{orient}(a,b,c)\ge0.

Weitere Fälle (Ergänzung)​

FallVerhalten
n≤2n\le 2Es gibt kein Polygon. Die Punkte direkt zurückgeben
Doppelte PunkteDie Problemstellung verlangt paarweise verschiedene Punkte. In der Praxis Duplikate nach dem Sortieren entfernen
Alle Punkte kollinearObere Hülle wird [p1,pn][p_1,p_n], untere [pn,p1][p_n,p_1]. Nach Zeile 17 bleibt [p1,pn][p_1,p_n], eine Strecke. Funktioniert ohne Sonderbehandlung

In C++ (Ergänzung)​

Umsetzung von Folie 15 mit den Sonderfällen
struct PointF { double x, y; };

// > 0: Linkskurve, < 0: Rechtskurve, == 0: kollinear
double orient(const PointF& a, const PointF& b, const PointF& c)
{
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}

bool lexLess(const PointF& a, const PointF& b)
{
return a.x < b.x || (a.x == b.x && a.y < b.y);
}

bool samePoint(const PointF& a, const PointF& b)
{
return a.x == b.x && a.y == b.y;
}

std::vector<PointF> grahamScan(std::vector<PointF> P)
{
std::sort(P.begin(), P.end(), lexLess); // Zeile 2, lexikographisch
P.erase(std::unique(P.begin(), P.end(), samePoint), P.end()); // Duplikate entfernen
const int n = int(P.size());
if (n < 3) return P;

std::vector<PointF> upper{P[0], P[1]}; // Zeile 3
for (int i = 2; i < n; ++i) { // Zeile 4
upper.push_back(P[i]);
while (upper.size() > 2 && // "keine Rechtskurve"
orient(upper[upper.size() - 3], upper[upper.size() - 2], upper.back()) >= 0)
upper.erase(upper.end() - 2); // b löschen
}

std::vector<PointF> lower{P[n - 1], P[n - 2]}; // Zeile 10
for (int i = n - 3; i >= 0; --i) { // Zeile 11
lower.push_back(P[i]);
while (lower.size() > 2 &&
orient(lower[lower.size() - 3], lower[lower.size() - 2], lower.back()) >= 0)
lower.erase(lower.end() - 2);
}

upper.insert(upper.end(), lower.begin() + 1, lower.end() - 1); // Zeilen 17–18
return upper; // im Uhrzeigersinn
}