Zum Hauptinhalt springen

4.3 kkd-Bäume

Das ist der Inhalt von Praktikumsaufgabe 3. Im Folgenden k=2k=2 (2d-Baum). Höhere Dimensionen gehen genauso.

Idee (Folie 10)​

  • Die Ebene so aufteilen wie ein binärer Suchbaum eine Zahlengerade aufteilt.
  • Als Schlüssel abwechselnd die xx- und die yy-Koordinate verwenden.
  • Jeder Knoten entspricht einem der nn Punkte. Der Punkt teilt die Zelle des Knotens durch eine achsenparallele Gerade in zwei Hälften.
Welche Koordinate zuerst?

Die Folien zählen Ebenen ab 1 (level = depth + 1) und nehmen auf geraden Ebenen xx, auf ungeraden yy. Die Wurzel (Ebene 1) teilt also nach yy. Das passt zu Algorithmus 1 und 2, die mit vertical (= yy vergleichen) starten. De Berg et al. beginnen mit xx. Für Laufzeit und Korrektheit ist das egal, man sollte es nur konsistent machen. In der Demo unten kann man beides umschalten.

Eigenschaften (Folie 12)​

Für einen Knoten vxv_x mit Punkt pxp_x, der nach xx teilt:

  • linker Teilbaum = Punkte links von pxp_x,
  • rechter Teilbaum = Punkte rechts von pxp_x,
  • die Kinder teilen dann nach yy.

Für einen Knoten vyv_y mit Punkt pyp_y, der nach yy teilt:

  • linker Teilbaum = Punkte unterhalb von pyp_y,
  • rechter Teilbaum = Punkte oberhalb von pyp_y,
  • die Kinder teilen dann nach xx.

Jeder Knoten gehört also zu einem achsenparallelen Rechteck (seiner Zelle / Region), und die Zellen der Kinder zerlegen die Zelle des Elternknotens.

Interaktiv: Aufbau und Bereichssuche​

Die farbigen Linien sind die Teilungsgeraden, je dicker, desto näher an der Wurzel. Darunter steht der Baum. Ziehe die blauen Ecken des Anfragebereichs DD und gehe die Suche Schritt für Schritt durch.

ABCDEFGHIJKDyAxFxJyIxCyByKxExHyGx
Schritt 1/12: RangeSearch(Wurzel, D) mit D = [4, 10] × [2, 6].
Wurzel teilt nachBlaue Punkte = Ecken von D (ziehen). Klick setzt Punkt, Rechtsklick löscht.
Anfragebereich Daktueller Knotenbesuchtin Dabgeschnittener Teilbaum

Bereichssuche (Folien 13–14)​

Wie die Suche im binären Suchbaum, mit zwei Unterschieden:

  1. In jedem Knoten wird je nach Ebene die xx- oder die yy-Koordinate verglichen.
  2. Bei einer Bereichssuche muss man oft beide Teilbäume besuchen: links, wenn DD in der Teilungskoordinate unter den Schlüssel reicht, rechts, wenn DD darüber reicht.
Algorithmus 1: RangeSearch(Knoten k, Richtung d, Bereich D)
1: if (k ≠ NULL) {
2: if (d == vertical) { // y vergleichen
3: (l, r) = (D.y1, D.y2);
4: coord = k.y;
5: dNew = horizontal;
6: } else { // x vergleichen
7: (l, r) = (D.x1, D.x2);
8: coord = k.x;
9: dNew = vertical;
10: }
11: if (k ∈ D) Füge k zur Ausgabe hinzu;
12: if (l < coord) RangeSearch(k.left, dNew, D);
13: if (r > coord) RangeSearch(k.right, dNew, D);
14: }
Aufruf: RangeSearch(root, vertical, D)
Gleichheit

Mit den strikten Vergleichen in Zeilen 12–13 gehen Punkte verloren, wenn ein Punkt im linken Teilbaum dieselbe Koordinate hat wie der Knoten und genau auf dem Rand von DD liegt. Die Folien schließen das durch die Annahme verschiedener Koordinaten aus. In der Implementierung (Abgabe 3) sicherer: l <= coord und r >= coord. Die Demo macht es so.

Balancierter Aufbau am Median (Folien 16–19)​

Problem: Ist der Baum unbalanciert, kann ein Pfad zur linearen Liste entarten, und die Suche wird linear.

Lösung: Immer am Median teilen. Dann haben beide Teilbäume (fast) gleich viele Punkte, und die Höhe ist ⌈log⁡2(n+1)⌉\lceil\log_2(n+1)\rceil.

Vorgehen:

  1. Alle Punkte einmal nach xx und nach yy sortieren: zwei Folgen XX und YY.
  2. YY am Median teilen, der Median wird die Wurzel. Das ergibt Y1Y_1 (darunter) und Y2Y_2 (darüber).
  3. XX so in X1X_1, X2X_2 aufteilen, dass X1X_1 dieselben Punkte wie Y1Y_1 enthält und X2X_2 dieselben wie Y2Y_2 (stabil, die xx-Sortierung bleibt erhalten).
  4. Rekursiv weiter, jetzt am Median von X1X_1 bzw. X2X_2, usw., bis nur noch ein Punkt übrig ist (Blätter).
Algorithmus 2: ConstructBalanced2DTree(l, r, k, d)
// global vorsortierte Folgen X und Y
1: if (l ≤ r) {
2: m = ⌊(l + r) / 2⌋;
3: if (d == vertical) {
4: k.value = Y[m];
5: PartitionField(X, l, r, m); // X[l..r] passend zu Y[l..m-1], Y[m+1..r] aufteilen
6: } else {
7: k.value = X[m];
8: PartitionField(Y, l, r, m);
9: }
10: ConstructBalanced2DTree(l, m-1, k.left, !d);
11: ConstructBalanced2DTree(m+1, r, k.right, !d);
12: }
Aufruf: ConstructBalanced2DTree(1, n, root, vertical)

PartitionField ist ein stabiles Aufteilen in O(r−l)O(r-l): Man läuft durch X[l..r]X[l..r] und schreibt jeden Punkt nach links oder rechts, je nachdem, auf welcher Seite des Medians er in der anderen Koordinate liegt.

Einfacher in C++

Statt zwei vorsortierter Listen kann man in jedem Schritt std::nth_element nach der aktuellen Koordinate verwenden. Das findet den Median in erwartet O(n)O(n), Gesamtaufwand dann ebenfalls O(nlog⁡n)O(n\log n).

Laufzeit (Folien 20–21)​

Aufbau​

TeilAufwand
Sortieren von XX und YYO(nlog⁡n)O(n\log n)
Aufteilen pro RekursionsebeneO(n)O(n)
RekursionT(n)=2 T(n−12)+O(n)T(n)=2\,T\big(\tfrac{n-1}{2}\big)+O(n)
Lösung (Master-Theorem, Fall 2)O(nlog⁡n)O(n\log n)

Speicher: O(n)O(n), ein Knoten pro Punkt.

Bereichssuche: O(n+R)O(\sqrt n + R)​

Auf den Folien „Beweis: Tafel“. Hier ausgeführt (nach de Berg et al.):

1. Zerlegung der Kosten. Besuchte Knoten sind von drei Arten:

  • Knoten, deren Zelle ganz in DD liegt: Ihr ganzer Teilbaum wird ausgegeben. Kosten zusammen O(R)O(R).
  • Knoten, deren Zelle DD gar nicht schneidet: werden nicht besucht (der Elternknoten schneidet sie weg).
  • Knoten, deren Zelle vom Rand von DD geschnitten wird. Nur diese sind „teuer“.

2. Wie viele Zellen schneidet eine senkrechte Gerade? Sei Q(n)Q(n) die Zahl der Zellen, die eine senkrechte Gerade gg in einem 2d-Baum mit nn Punkten schneidet. Nach zwei Ebenen (einmal xx, einmal yy) ist die Zelle in vier Teile mit je etwa n/4n/4 Punkten zerlegt. Die xx-Teilung trennt sie in links und rechts, gg liegt nur auf einer Seite. Die yy-Teilung trennt oben und unten, gg schneidet beide. Also trifft gg höchstens zwei der vier Enkel:

Q(n)=2+2 Q(n/4),Q(1)=O(1).Q(n) = 2 + 2\,Q(n/4),\qquad Q(1)=O(1).

Master-Theorem mit a=2a=2, b=4b=4: log⁡42=12\log_4 2 = \tfrac12, also Q(n)=O(n1/2)=O(n)Q(n)=O(n^{1/2})=O(\sqrt n).

3. Der Rand von DD besteht aus vier achsenparallelen Strecken, jede schneidet höchstens O(n)O(\sqrt n) Zellen.

Ergebnis: O(n+R)O(\sqrt n + R).

Für kleines RR ist das viel schneller als alle Punkte zu testen (106=1000\sqrt{10^6}=1000 statt 10610^6), aber deutlich langsamer als O(log⁡n)O(\log n).

Höhere Dimensionen (Folie 21)​

Derselbe Algorithmus: Beim Aufbau zyklisch durch die kk Dimensionen gehen (x,y,z,x,y,z,…x, y, z, x, y, z,\dots), bei der Suche ebenso.

kkd-Baum
AufbauO(k⋅nlog⁡n)O(k\cdot n\log n)
BereichssucheO(k n1−1/k+R)O(k\,n^{1-1/k} + R)
SpeicherO(n)O(n)

Für k=2k=2 ergibt n1−1/2=nn^{1-1/2}=\sqrt n. Für großes kk geht n1−1/kn^{1-1/k} gegen nn, der kd-Baum hilft dann kaum noch (Fluch der Dimensionen).

Nächster-Nachbar-Suche (Ergänzung)​

Der häufigste praktische Einsatz von kd-Bäumen ist nicht die Rechteckanfrage, sondern die Nächste-Nachbarn-Suche (z.B. ICP, Punktwolken, k-NN in ML):

  1. Absteigen wie bei der Punktsuche bis zum Blatt, das den Anfragepunkt qq enthalten würde. Der Punkt dort ist der erste Kandidat mit Abstand dd.
  2. Beim Rücklauf in jedem Knoten prüfen, ob der Kreis um qq mit Radius dd die andere Teilungsseite schneidet (Abstand zur Teilungsgeraden <d<d). Nur dann auch den anderen Teilbaum durchsuchen.

Bei gleichmäßig verteilten Punkten ist das im Mittel O(log⁡n)O(\log n).