Eidgenössische Technische Hochschule Zürich Ecole polytechnique fédérale de Zurich Politecnico federale di Zurigo Federal Institute of Technology at Zurich Institut für Theoretische Informatik Peter Widmayer Michael Gatto Datenstrukturen & Algorithmen Lösungen 7 SS 07 Aufgabe 7.1: Der Algorithmus folgt dem Scan-Line Prinzip. Man betrachte eine Gerade aus der Lichtquelle hinaus. Der Lichtstrahl belichtet dann einen Punkt auf dem ersten Segment, das von der Lichtquelle weg entlang der Gerade getroffen wird. Die anderen Segmente, die getroffen werden, sind Kandidaten für eine zukünftige Belichtung: Endet nämlich das jetzige belichtete Segment, so wird das nächste (falls immer noch vorhanden) belichtet. Analog beim Starten eines neuen Segmentes. Kreuzen sich das erste und das zweite Segment auf dem Lichtstrahl, und es beginnt kein weiteres Segment dazwischen, so ist jetzt das zweite Segment belichtet. Aufgrund dieser Erkenntnisse lässt sich das Scan-line Prinzip anwenden. Die Idee ist, dass man die Scan-line nicht von links nach rechts durchlaufen lässt, sondern radial, mit Zentrum in der Lichtquelle. Analog zur Berechnung der existierenden Schnittpunkte in einer Menge beliebig orientierter Liniensegmente sind die interessanten Stellen die Anfangs- und Endpunkte der Segmente, sowie die Kreuzungspunkte der Segmente. In all diesen Punkten ändert sich die Nachbarschaft einiger Segmente auf der Scan-Line L. Dazu sortiert man alle Anfangs- und Endpunkte der Segmente bezüglich des Winkels, den sie mit der Lichtquelle bezüglich einer fixen Richtung (z.B. horizontal) bilden. Dies benötigt O(n log(n)) Zeit. Die Operationen bei den Haltepunkten sind, analog wie für das Kreuzungsproblem: Ein Segment a startet: • a in L einfügen. • Prüfen, ob a das oberste Element ist. Falls ja, ist a jetzt das belichtete Segment, und das Segment, das vorher belichtet war, ist bis zum Kreuzungspunkt mit der aktuellen Scan-Line belichtet. • Neue Nachbarschaft prüfen: Kreuzt a den Vorgänger oder den Nachfolger in L? Falls ja, in die Liste der Haltepunkte Punkte aufnehmen. Aufwand: 2 Schnittprüfungen. Ein Segment a endet: • a aus L entfernen. • Prüfen, ob sich das oberste Element auf der Scan-Line geändert hat (a war belichtet). Falls ja, so beginnt die Belichtung des jetzt obersten Segment ab dem Kreuzpunkt mit der Scan-line L. • Prüfe, ob die ursprünglichen Nachbarn von a sich kreuzen. Falls ja, und die Kreuzung nicht schon mal berechnet wurde, so wird sie in der Liste der Haltepunkte aufgenommen. Aufwand: 1 Schnittprüfung. Zwei Segmente kreuzen: • Tausche die Reihenfolge der sich kreuzenden Segmente in L. • Falls eine der zwei Geraden belichtet war, so wird jetzt die andere belichtet. • Prüfe die Nachbarschaft der zwei getauschten Segmente auf Kreuzungen, und nehme die Kreuzungspunkte (falls solche existieren und nicht schon vorhanden sind) in die Liste der Haltepunkte auf. Aufwand: 2 Schnittprüfungen. Den Start- und Endpunkt der Belichtung eines Segments bezüglich einer Scan-Line berechnet man, indem man den Kreuzungspunkt zwischen der Geradengleichung, die das Segment definiert, und der Geradengleichung der Scan-Line berechnet. Dies erfolgt in konstanter Zeit. Für L benutzen wir einen AVL-Suchbaum, wo die Segmente in den Blättern gespeichert sind, und die Blätter mittels einer doppelt verkettete Liste verbunden sind. Um die Position eines Elementes zu bestimmen, wertet man die in einem Knoten des Suchbaumes gespeicherte Geradengleichung am Schnittpunkt mit der Scan-Line aus. Somit ist eine Ordnung der Segmente auf der Scan-Line definiert, und man kann suchen, einfügen und löschen. Bei einem Kreuzpunkt werden zwei benachbarte Geraden vertauscht. Dies kann effizient durchgeführt werden, indem man nach einer Gerade sucht, und mittels der verketteten Liste den Nachbarn (d.h. den Nachfolger oder den Vorgänger) sucht. Der Baum braucht O(n) Speicher, das Suchen, Einfügen und Löschen erfolgt aufgrund des AVL-Baumes in O(log n) Zeit. Die Haltepunkte müssen auch gespeichert werden, z.B. in einem Minimum Heap, mit Schlüssel den Winkel der Geraden zwischen Lichtquelle und Haltepunkt benutzt. Existieren k Kreuzungspunkte, so braucht diese Struktur O(n + k) zusätzlichen Speicher. Einfügen und Löschen erfolgen in O(log(n + k)) Zeit. Der Algorithmus hat im Ganzen die Laufzeit O((n + k) log(n + k)). Um jeden Haltepunkt abzuarbeiten wird je O(log(n)) Zeit benötigt, und es gibt 2n + k Haltepunkte. Jeder Haltepunkt muss in den Heap eingefügt und daraus entfernt werden. Jede solche Operation kostet O(log(n + k)) Zeit, und es gibt O(n + k) davon. Aufgabe 7.2: Die Aufgabe löst man, indem man die Studenten zuerst aufsteigend nach Notenschnitt sortiert, und bei gleichem Notenschnitt absteigend nach der Anzahl freier Stunden. Danach geht man die sortierte Liste der Reihe nach durch und merkt sich jeweils das Minimum der Anzahl freier Stunden. Jedes mal, wenn man einen Studenten trifft, der eine niedrigere oder gleiche Anzahl freier Stunden hat wie das aktuelle Minimum, so handelt es sich um einen unzufriedenen Studenten: Es gibt keinen anderen Studenten, der sowohl einen kleineren Schnitt hat als auch eine niedrigere Anzahl freier Stunden. Das Sortieren kostet O(n log(n)) Zeit, der lineare Scan O(n) Zeit. Der Algorithmus hat deshalb eine Laufzeit von O(n log n). In der Erweiterung sortiert man die Studenten wie oben beschrieben. Danach führt man einen Scan durch die sortierte Liste. Die Scanline führt einen AVL-Baum mit sich, der die Studenten enthält, als Schlüssel die Anzahl freier Stunden verwendet und zusätzlich in jedem Knoten die Anzahl Knoten im Teilbaum speichert. Dies erlaubt, ähnlich wie in der Aufgabe 3 des 5. Übungsblattes, effizient die Anzahl der Schlüssel zu berechnen, die kleiner als der Knoten selbst sind. Beim Treffen eines neuen Studenten mit der Scanline fügt man ihn in den AVL-Baum ein; gleichzeitig zum Einfügen, berechnet man die Anzahl Knoten im Baum, die einen kleineren Schlüssel haben (in unserem Beispiel die Anzahl freier Stunden). All diese Knoten haben sowohl eine kleinere Anzahl freier Stunden (gemäss Gestaltung des AVL-Baumes) als auch einen kleineren Schnitt, da sie früher in den AVL-Baum eingefügt wurden. Ist diese Anzahl kleiner als k, so gibt man den Student aus, und weiter geht’s mit der Scanline. Dieses Verfahren hat eine Laufzeit von O(n log(n)): wie oben benötigt man O(n log(n)) Zeit um die Knoten zu sortieren; weiter verursacht das Einfügen und das Berechnen der Anzahl kleineren Knoten im AVL-Baum bei jedem der n Haltepunkten der Scanline O(log(n)) Zeit, was insgesamt wieder zu O(n log(n)) führt. Alternativ kann man sich während dem Scan die k Studenten mit bisher niedrigste Anzahl an freier Stunden in einem Maximum-Heap merken. Beim treffen eines Studenten mit einer kleineren Anzahl an freier Stunden als das oberste Element im Heap, so handelt es sich offensichtlich um einen unzufriedenen Student. Wir entfernen das bisherige Maximum aus dem Heap, und fügen das getroffene Element ein. Dieses Verfahren führt zu einer Laufzeit von O(n log n + n log k) = O(n log n), wobei man O(n log n) Zeit zum sortieren benötigt und jeweils O(log k) zum Einfügen und Entfernen eines Elements im Heap. Aufgabe 7.3: Wir beschreiben einen rekursiven Aufbau eines Range-trees auf der Menge von d-dimensionalen Punkten P , wobei wir jeden Punkt als (xd , · · · , x2 , x1 ) repräsentieren. Zuerst erstellen wir d Arrays, in denen die Punkte P jeweils nach einer anderen Koordinate sortiert sind: im ersten nach xd , im zweiten nach xd−1 , im letzten nach x1 . Dies kostet O(dn log(n)) Zeit. Danach bauen wir den Range-tree rekursiv auf. Jeder rekursive Aufruf zur Erstellung eines d-dimensionalen Baums auf n Knoten bekommt beim Aufruf die oben definierten d sortierten Arrays, die sich jeweils genau auf die n einzufügende Punkte beschränken. 2 Die Hauptidee ist, einen balancierten Baum der Dimension d zu erstellen, der als Schlüsseln die Koordinate xd verwendet. Die Punkte werden in den Blättern gespeichert. Wenn man die Knoten sortiert hat, so kann dieser rekursiv effizient erstellt werden. In jedem Knoten k dieses Baums wird einen balancierten Range-tree der Dimension d − 1 gespeichert. Der Baum der Dimension d − 1 enthält k selbst und die Punkte, die im d-dimensionale Baum unter k gespeichert sind, mit der d − 1 Koordinate als Schlüssel. Dies erfolgt rekursiv weiter bis zur Dimension 1. Der Pseudocode zum effizienten Aufbau des Range trees sieht wie folgt aus. Algorithmus recursive_Tree(d,P , AP d ) Input: d: Dimension der Punkte P : Punktmenge zum Bilden des range-trees AP d : d Arrays mit Punkten P jeweils nach einer Koordinate sortiert. Output: Range-Tree Td der Dimension d. m 1.) Finde den Punkt m = {xm d , · · · , x1 }, der Median bezüglich der Koordinate xd in P . 2.) Teile alle d Arrays in zwei sortierte Arrays, wobei für jedes Teil-Array gilt: die eine Hälfte enthält die Punktmenge P ≤ = {p ∈ P : xpd ≤ xm d } mit kleineren oder gleichen xd -Koordinate wie m, die zweite die Punkte mit P > = {p ∈ P : xpd > xm d }. 3.) Bilde den Baum rekursiv auf, auf die Knoten mit kleineren oder gleichen d Koordinate: ≤ Tleft := recursive_Tree(d,P ≤ , AP ) d 4.) Bilde den Baum rekursiv auf, auf die Knoten mit grösseren d Koordinate: > Tright := recursive_Tree(d,P > , AP d ) 5.) Bilde den Baum zur Dimension d − 1 mit der gesamten Knotenmenge rekursiv auf: Td−1 := recursive_Tree(d − 1,P , AP d−1 ) . 6.) Bilde einen Knoten mit Schlüssel xm d 7.) Hänge die zwei Teilbäume Tleft und Tright als linken bzw rechten Sohn an den Knoten. 8.) Setzte Td−1 als Range-tree der Dimension d − 1 des Knotens, und gib den Baum mit Wurzel m zurück. Sei T (n, d) den Zeitaufwand um einen d-dimensionalen Range-tree zu bauen. Wir betrachten zuerst die Laufzeit T (n, 1) zum Aufbauen eines 1-dimensionalen range-tree mit n Knoten. Da das Array sortiert ist, erfolgt die Berechnung des Medians in konstanter Zeit durch eine Indexberechnung. Die Aufteilung (Schritt 2) eines eindimensionalen sortierten Array kann in O(1) Zeit durchgeführt werden durch das neue Setzen der Indizes im Array. Schritt 5 wird nicht ausgeführt, da wir die kleinste Dimension erreicht haben. Schritte 6 bis 8 erfolgen (auch für Dimension > 1) in konstanter Zeit. Bis auf die rekursiven Aufrufe erfolgt jeder Rekursionsaufruf in konstanter Zeit. Da jeder Knoten genau einmal als Median auftaucht, werden n rekursive Aufrufe ausgeführt, was zu einer Laufzeit von O(n) führt. Im d-Dimensionalen Fall erfolgt bis auf die Schritte 2 und 5 auch alles in Zeit O(n) für den gesamten Aufbau der d-ten Dimension. Es bleibt abzuschätzen, wieviel Schritt 2 kostet und wieviele rekursive Aufrufe von Schritt 5 gemacht werden. Der Aufteilungsschritt 2 erfolgt im d-dimensionalen Fall in O(dn log(n)) Zeit: für jede Tiefe des d-dimensionalen Baums braucht man insgesamt O(dn) Zeit, um die d − 1 Arrays mittels eines linearen Durchlaufs in zwei sortierte Arrays zu teilen (um genau zu sein, kostet die Aufteilung jedes der d − 1 Arrays in der Wurzel des Baumes n vergleiche, bei Tiefe 1 kostet es 2(d − 1) (n−1) 2 , da man für die zwei Söhne der Wurzel insgesamt d Arrays mit n − 1 Knoten aufteilen muss, und so weiter. Man beachte, dass das sortierte Array zur Dimension d in O(1) aufgeteilt werden kann, da man lediglich die Indizes des Arrays auf den relevanten Teil anpassen muss). Wir schätzen nun noch die Rekursionen auf Dimension d − 1 ab. Es erfolgt eine Rekursion mit n Knoten (für die Wurzel), zwei mit höchstens n2 Knoten (die Söhne der Wurzel), vier mit höchstens n4 Knoten, und so weiter. Wenn wir (zu unserem Ungunsten) die Anzahl Knoten auf die nächstgrössere Zweierpotenz N , so ist der gesamte Aufwand: 3 ( Plog N O(dN log(N )) + i=0 2log N −i T (2i , d − 1), T (N, d) = O(N ), d>1 . d=1 Diese Rekursion führt unter der Annahme d < logd−2 (n) zu einer Laufzeit von O(N logd−1 (N )) = O(n logd−1 (n)). Die Laufzeit für das Sortieren am Anfang ist durch diese Annahme berücksichtigt. Weiter wird unter dieser Annahme auch die Zeit für die Aufteilung der Arrays berücksichtigt. Wir verzichten auf einen formalen Beweis für diese Laufzeit, da die Verwendung der O-Notation in der Rekursionsformel einen sauberen Beweis unmöglich macht. Aufgabe 7.4: Wir erweitern einen d-dimensionalen Range-tree und nehmen an, wir suchen nach dem Bereich [xa1 , xb1 ], · · · , [xad , xbd ], wobei xai < xbi , ∀i ∈ {1, · · · , d}. Zur Erinnerung: In einem normalen Range-tree erfolgt die Suche nach den Knoten im obigen Bereich, indem man zuerst im Suchbaum zur (ersten) Koordinate x1 nach dem Knoten v sucht, wo sich die Suchpfade nach xa1 und xb1 trennen. Danach verfolgt man die Suche nach xa1 im linken Teilbaum von v, und jedesmal, wenn man in der Suche nach links geht, gibt man das Resultat der Suche nach dem Bereich [xa2 , xb2 ], · · · , [xad , xbd ] im (d − 1)-dimensionalen Range-tree des rechten Bruders aus. Wenn man im eindimensionalen Fall ist, so gibt es im rechten Bruder keinen range tree, und man gibt die Punkte zurück, die in diesem Teilbaum gespeichert sind. Für xb1 geht man symmetrisch vor. Wir erweitern nun den d-dimensionale Range-tree, indem wir in jedem Knoten des 1-dimensionalen Rangetrees zur Koordinate xd zusätzlich die Anzahl Knoten in diesem Teilbaum speichern. Um die Anfrage nach der Anzahl Punkte zu beantworten, erfolgt die Suche nach dem Bereich wie gewohnt. Anstatt jedoch die Knoten im Bereich auszugeben, gibt man nur deren Anzahl aus, und summiert jeweils im d-dimensionalen Baum die Resultate der Suche auf allen besuchten d − 1-dimensionalen Bäumen. Im eindimensionalen Baum wird jeweils anstatt der Knoten im Bereich die Summe der relevanten Zähler zurückgegeben. Diese Anfrage kann deshalb in O(logd (n)) Zeit beantwortet werden, und die Erweiterung des Range-trees braucht immer noch O(n logd−1 (n)) Speicher, da man im 1-dimensionalen Baum konstant viel Speicher pro Knoten braucht. 4