Fachbereich Elektrotechnik & Informatik PARALLELE TIEFENSUCHE IN JAVA MÖGLICHKEITEN UND GRENZEN Kai Oberbeckmann ∗ Eingereicht am 7. September 2014 Hausarbeit im Rahmen der Veranstaltung PARALLELE PROGRAMMIERUNG und VERTEILTE SYSTEME von Prof. Dr. rer. nat. Jörg Frochte und Prof. Dr.-Ing. Ludwig Schwoerer ∗ [email protected] Copyright und Bildquellen Text: Copyright (c) 2014 KAI OBERBECKMANN Bilder und Skizze: Copyright (c) 2014 KAI OBERBECKMANN Es ist Ihnen gestattet diese Arbeit zu vervielfältigen, zu verbreiten und öffentlich zugänglich zu machen sofern Sie folgende Bedingungen einhalten: • Namensnennung: Sie müssen die Urheberschaft ausreichend deutlich benennen. Diese Angaben dürfen in jeder angemessenen Art und Weise gemacht werden, allerdings nicht so, dass der Eindruck entsteht, der Lizenzgeber unterstütze gerade Sie oder Ihre Nutzung des Werks besonders. • Keine kommerzielle Nutzung: Sie dürfen das Material nicht für kommerzielle Zwecke nutzen. • Keine Bearbeitung: Wenn Sie das Material remixen, verändern oder darauf anderweitig direkt aufbauen, dürfen Sie die bearbeitete Fassung des Materials nicht verbreiten. Abweichende Lizenzen für einzelnen Bilder und Skizzen werden ggf. separat angegeben. Es wurde jedoch darauf geachtet, dass keine dieser Lizenzen die Möglichkeiten der Rechte im obigen Sinne einschränken. Codebeispiele dürfen unter der BSD-3-Clause http://opensource.org/licenses/BSD-3-Clause verwendet und weitergegeben werden. Eidesstattliche Erklärung Ich versichere, dass ich die Arbeit selbständig verfasst und keinen als die angegebenen Quellen und Hilfsmittel benutzt sowie Zitate kenntlich gemacht habe. Die Regelungen der geltenden Prüfungsordnung zu Versäumnis, Rücktritt, Täuschung und Ordnungsverstoß habe ich zur Kenntnis genommen. Diese Arbeit hat in gleicher oder ähnlicher Form keiner Prüfungsbehörde vorgelegen. Haan, den Unterschrift 1 Inhaltsverzeichnis 1 Einleitung 2 Grundlagen 2.1 Graphen . . . . . . . . . . . . . . . 2.1.1 Bäume . . . . . . . . . . . . 2.2 Suchverfahren . . . . . . . . . . . . 2.2.1 Allgemein: Suchalgorithmen 2.2.2 Tiefensuche . . . . . . . . . 2.2.3 Parallele Tiefensuche . . . . 3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 4 6 7 7 9 12 3 Parallele Tiefensuche in Java: Implementierung 3.1 Vorarbeit . . . . . . . . . . . . . . . . . . . . 3.1.1 Aufgabenverteilung . . . . . . . . . . . 3.1.2 Basiswissen: Parallelisieren in Java . . 3.2 Implementierung . . . . . . . . . . . . . . . . 3.2.1 Tiefensuche in Java . . . . . . . . . . . 3.2.2 Tiefensuche-Thread . . . . . . . . . . . 3.2.3 Zusammenführung der Prozesse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 15 15 15 17 17 18 18 4 Ergebnisse 4.1 Problemtik bei der Parallelisierung der Tiefensuche . . . . . . . . . . . . . 4.2 Laufzeitanalyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2.1 Analyse: Verhalten von Java . . . . . . . . . . . . . . . . . . . . . . 21 21 21 22 5 Fazit 25 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 Einleitung Die hier vorliegende Arbeit befasst sich mit einem sehr zentralen Thema der modernen Informationstechnologie, der Suche nach Objekten. Seit digitale Daten an Bedeutung gewonnen haben, ist eine automatische Verarbeitung essentiell geworden. Die Informationen sollen für die einzelnen Systeme und letztlich für den Menschen nutzbar gemacht werden. Hierzu muss ein Datensatz gefunden werden, um dessen Inhalt wiedergeben zu können. Folglich muss in einer endlichen Datenmenge ein vordefiniertes Objekt möglichst schnell und Ressourcen sparend gefunden werden. In großen Datenmengen, wie sie auf Servern und PCs aber auch auf mobilen Endgeräten zu finden sind, ist ein effektives Vorgehen gefragt, um den Aufwand in Grenzen zu halten und Zeit oder ggf. Energie zu sparen. Zu Beginn muss die heutige Software-Architektur betrachtet werden. Sie baut auf die Objektorientierung auf, welche Datenstrukturen zu etwas Greifbarem macht und Bezüge unter den Elementen herstellt. Dies beschleunigt sowohl die Entwicklung als auch die Wartung von Software und vermeidet Fehler bei der Implementierung. Ein solches Netz an Informationen, sei es hierarchisch oder von anderer Beschaffenheit, lässt sich durch sogenannte Graphen darstellen. Diese visualisieren Objektbezüge und müssen auf der Suche nach dem Zieldatensatz durchkämmt werden. Anschauliche Beispiele hierfür sind U-Bahn- und Straßennetze. Wenn ein bestimmtes Element gesucht wird, soll dieses mit möglichst wenigen Schritten gefunden werden. Zu diesem Zweck gibt es zahlreichen Ansätze zu denen auch die Breiten- und die Tiefensuche zählen. Letztere soll in dieser Arbeit im Bezug auf ihre Parallelisierbarkeit hin geprüft werden. Es soll erforscht werden inwiefern eine Aufteilung der Tiefensuche auf mehrere Prozesse einen Geschwindigkeitsvorteil bietet, wie groß dieser ist und wie er beeinflusst werden kann. Hierzu wird eine Implementierung in Java vorgenommen und untersucht wie weit sich diese Programmiersprache zu solch einem Zweck eignet und was beim Umgang mit dieser zu beachten ist. 3 2 Grundlagen 2.1 Graphen Grundsätzlich verkörpern Graphen Datenstrukturen. Anwendung finden diese vor allem bei Sortier- und Suchverfahren, Sprachverarbeitung und Speicherverwaltung (vgl. [Ger96]). Faktisch sind die Objekte, welche in Graphen als Knoten bezeichnet werden, die Datensätze. Diese können direkt oder über andere Knoten zusammenhängen oder auch keine Verbindung zueinander haben. Ein Graph ist stark zusammenhängend, wenn von jedem Knoten alle anderen erreicht werden können. Die Verknüpfungen hierzu werden Kanten genannt. Diese bestimmen, ob ein Graph gerichtet oder ungerichtet ist. Sind alle Kanten in beide Richtungen durchlaufbar handelt es sich um einen ungerichteten Graphen, wie er in Abbildung 2.1 gezeigt wird. Sollte dies nicht der Fall sein, spricht man von einem gerichteten Graphen (s. Abbildung 2.2), wie es bei einer Einbahnstraße auf der Straßenkarte der Fall wäre. Abbildung 2.1: Beispiel eines ungerichteten Graphen, Quelle: yEd Graph Editor 4 Abbildung 2.2: Beispiel eines gerichteten Graphen, Quelle: yEd Graph Editor Kanten können sowohl ein- (sogenannte Schlingen, vgl. [Ger96]) als auch zweielementig sein. Bei gerichteten Graphen können so maximale und minimale Knoten entstehen. Ein maximaler Knoten besitzt keinen Nachfolger (s. Abbildung 2.2 Knoten 1). Alle Kanten zeigen also in seine Richtung. Ein minimaler Knoten ist genau der umgekehrte Fall und ist in Abbildung 2.2 bei Knoten 10 zu finden. Zum Abbilden eines einfachen Graphen in einer Datenstruktur können Inzidenz- oder Adjazenzmatrizen verwendet werden. Bei einer Inzidenzmatrix wird jeder Kante und jedem Knoten eine fortlaufend Zahl zugeordnet. Im Falle einer Adjazenzmatrix werden nur die Knoten nummeriert. In der Matrix kann so vermerkt werden welches Element mit einem anderen verbunden ist. Auch die Richtung der Verbindung kann so registriert werden. Inzidenzmatrizen setzen Knoten mit den verbundenen Kanten ins Verhältnis (vgl. (2.2)). Bei Adjazenzmatrizen geschieht dies mit zwei Knoten (vgl. (2.1)). Druch das Einsparen der Nummerierung der Kanten wird die Matrix schlussendlich kleiner. 0 1 0 1 0 Adjazenzmatrix = 0 0 0 1 0 0 0 1 0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 1 1 0 0 1 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 (2.1) 5 +1 +1 +1 0 0 0 0 0 0 Inzidenzmatrix = 0 0 0 0 0 0 0 0 T −1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 −1 0 0 0 −1 0 0 0 0 0 0 0 0 +1 0 0 0 0 −1 0 0 0 0 0 +1 0 0 −1 0 −1 0 0 0 0 +1 0 0 0 +1 0 0 0 0 0 0 0 −1 +1 −1 0 0 0 0 0 0 0 −1 +1 0 0 0 0 0 0 0 0 0 0 0 0 +1 −1 0 0 0 0 0 0 0 +1 0 0 −1 0 0 0 0 +1 0 −1 0 0 0 0 0 0 0 0 +1 0 −1 0 +1 0 0 0 0 0 0 −1 0 0 0 −1 +1 0 0 0 0 0 0 0 +1 −1 0 0 0 0 0 0 0 +1 0 0 0 0 −1 (2.2) Die Benennung der Matrizen rührt daher, dass Knoten, welche eine gemeinsame Kante besitzen, als adjazenz bezeichnet werden. Hingegen werden Kanten mit einem gemeinsamen Knoten oder Kanten und Knoten, bei denen der Knoten einen Endpunkt der Kante stellt, „inzident“ genannt (vgl. [Kä99]). Neben dieser Darstellungsart ist außerdem eine Darstellung mit verketteten Listen denkbar. Solche Listen benötigen weniger Speicherplatz, jedoch können sie bei den Zugriffszeiten nicht bei den Matrizen mithalten. 2.1.1 Bäume Bäume sind ein Sonderfall eines Graphen. Aufgrund ihrer „Verästelung“ kann eine stark hierarchische Struktur abgebildet werden. Zudem sind alle Bäume kreisfrei und zusammenhängend. Kreisfrei bedeutet, dass es keinen Weg über den Graphen gibt, welcher zum Ausgangspunkt wieder zurückführt. Sollte es nur einen minimalen Knoten im Baum geben, so handelt es sich um einen Wurzelbaum. Die maximalen Knoten sind dessen Blätter. Der Grad eines Knotens gibt bei einem Baum die Anzahl der Verzweigungen an, die dieser hat. Ein vollständiger k-Baum ist folglich einer, dessen Knoten, ausgenommen der Blätter, alle einen Grad k bieten. Ein unvollständiger Baum ist in Abbildung 2.3 zu finden. Ein weiterer Sonderfall von Bäumen sind die binären Bäume, welche an all ihren Knoten einen maximalen Grad von 3 besitzen. 6 Abbildung 2.3: Beispiel eines unvollständigen ungerichteten 4-Wurzelbaumes, Quelle: yEd Graph Editor 2.2 Suchverfahren 2.2.1 Allgemein: Suchalgorithmen Eine Suche definiert sich als „Selektieren von Elementen mit einer vorgegebenen Eigenschaft über einer ungeordneten oder geordneten Datenmenge. Durch eine bestimmte Ordnung kann die Suche deutlich effektiver durchgeführt werden“ [Ger96]. Somit ist ein Vorgehen/ Algorithmus zu entwickeln, welcher diese Aufgabe möglichst schnell mit dem Gebrauch weniger Ressourcen ausführt. Hierzu gibt es zunächst einige Ansätze. Handelt es sich bei der zu durchsuchenden Datenmenge um eine geordnete, wird die Suche erleichtert und es kann bspw. ein binäres Suchen angewandt werden. Es wird iterativ oder rekursiv ein Teil der Datenmenge ausgeschlossen und so eine geringe Komplexität erreicht, wie sie in (2.3) gezeigt wird. Komplexität = O(log2 n) (2.3) Anders Verhalten sich Suchalgorithmen auf unsortierten bzw. nicht nach der gesuchten Eigenschaft sortierten Datenmengen. Ein lineares Suchverfahren ist gefragt, welches jedes einzelne Element überprüft, bis das entsprechende gefunden wurde. Die gemittelte Komplexität in diesem Fall stellt sich in (2.4) dar. n (2.4) Komplexität = O( ) 2 Breiten- und Tiefensuche gehören zu dieser Familie. Je nach Suchmenge terminiert der 7 Algorithmus früher oder später. Der Aufwand skaliert jedoch mit der Anzahl der zu durchsuchenden Objekt, also mit der Größe des Graphen. Über dies hinaus gibt es die sogenannte Mustersuche, welche nach Mustern wie beispielsweise Strings suchen. Hierauf möchte ich im Rahmen dieser Arbeit jedoch nicht eingehen. Begrifflichkeiten Eine Suche auf einem Graphen kann in mehrere Mengen aufgeteilt werden. Zunächst existiert die Menge V0 (Engl.: „vertex“, z. Dt: „Knoten“), die ausschließlich den Startknoten enthalten. Darüber hinaus wird die Menge der Kanten Et (Engl.: „edge“, z. Dt. „Kante“) und der Knoten Vt genutzt, die die bereits besuchten Elemente enthält (vgl. [Kä99]). Die Teilmengen, welche sich aus E\Et und V \Vt ergeben, gelten als unbesucht. Als aktive Knoten gelten solche, die inzident zu einer unbesuchten Kante liegen und in Vt enthalten sind. Andere Knoten, die in Vt enthalten sind, diese Anforderung jedoch nicht erfüllen, sind vollkommen abgearbeitet. Der aktuelle Knoten ist der zum Zeitpunkt t untersuchte Knoten, von dem die nächste Aktivität ausgeht. Ablauf Nach [Kä99] lässt sich eine Graphensuche in zwei Schritte aufteilen: 1. Falls noch nicht alle Kanten besucht sind, wird ein aktiver Knoten ausgewählt. Hierzu bedarf es einer Knotenauswahlregel. 2. Aus E\Et wird eine Kante ausgesucht und vom aktuellen Punkt auslaufend orientiert. Es wird eine Kantenauswahlregel angewandt. Eine solche Suche kann folglich von drei Komponenten verkörpert werden: 1. Kantencontainer M 2. Knotenauswahl ν 3. Kantenauswahl α Ein solcher Ablauf ist in Abbildung 2.4 aufgezeigt. Em ist hier die zuletzt besuchte Kante, wenn die Tiefensuche beendet wird. 8 Abbildung 2.4: Grundsätzlicher Verlauf einer Graphensuche unabhängig vom Algorithmus (vgl. [Kä99]), Quelle: yEd Graph Editor 2.2.2 Tiefensuche Grundidee Die Tiefensuche möchte den Graphen vorrangig in der „Tiefe“ untersuchen. Es wird unter Berücksichtigung der Kanten- und Knotenauswahlregel immer weiter fortgeschritten bis ein Knoten keine unbesuchten adjazenten Knoten mehr aufweist. In diesem Fall wird ein Schritt zurück zum vorher besuchten Knoten gemacht und von diesem aus weitergearbeitet. Die Kantenauswahlregel kann sich je nach Implementation des Algorithmus unterscheiden und sich an verschiedenen Parametern der Objekte orientieren. Für die Knotenauswahlregel ist jedoch der Tiefensuchealgorithmus selbst zuständig. Anders als beispielsweise bei der Breitensuche, welche versucht die aktiven Knoten möglichst auf gleichem Abstand zum Ausgangsknoten zu halten, wählt die Tiefensuche soweit möglich den am weitesten entfernten Knoten als nächsten aktuellen Knoten. In dieser Manier kann lediglich im Falle eines notwendigen Rückschrittes nicht gearbeitet werden. Mit diesem Algorithmus entsteht nach [SK00] schlussendlich für den besuchten Teilgraphen ein Wald aus Wurzelbäumen. Solange es sich um einen zusammenhängenden Graphen handelt, wird zudem jedes Element erreicht. Der Algorithmus terminiert, sobald er das Element mit der gesuchten Eigenschaft gefunden hat oder keine „Arbeit“ mehr hat. Dieser Fall tritt ein, falls die Tiefensuche erneut am Ausgangsknoten (oder bei der parallelen Ausführung beim letzten Element des Stacks) angekommen ist und auch dieser keine unbesuchten adjazenten Knoten mehr besitzt. Diese Art der Tiefensuche wird Knoten-Tiefensuche genannt und findet in dieser Arbeit Anwendung. Neben dieser existiert auch eine Kanten-Tiefensuche, welche so lange fortschreitet, 9 wie eine unbesuchte Kante vorhanden ist. Algorithmus Es existieren verschiedene Varianten des Tiefensuche-Algorithmus. Von diesen soll hier jedoch lediglich die Knoten-Tiefensuche betrachtet werden. Sie bietet ein effektives Vorgehen, da kein Datensatz/ Objekt mehrmals besucht werden kann. Somit besitzt diese Ausführung einen Geschwindigkeitsvorteil gegenüber der Kanten-Tiefensuche. Grundsätzliches Vorgehen nach [Kä99]: Knotenauswahl 1. Endknoten der Kante, welche zuletzt in M eingefügt wurde („tiefster“ Knoten) Kantenauswahl 1. Falls v aktiv, 2. wähle eine unbesuchte und zu v inzidente Kante v, w und orientiere sie (v, w) 3. falls w unbesucht 4. lege (v, w) in M ab 5. ansonsten 6. 10 entferne (u, v) aus Der Algorithmus ist, wie folgt: Algorithmus 1 : Ausführung einer Knoten-Tiefensuche Data : V0 = {v}, GesuchteEigenschaf t, Vt = {}, Et = {}, M = {}, G Result : Vm 1 while v hat nicht die gesuchte Eigenschaft & V \Vt 6= {} do 2 while v ist aktiv & v hat nicht die gesuchte Eigenschaft do 3 wähle eine eine Kante (v, w) aus E\Et 4 Vt = Vt ∪ {v} 5 Et = Et ∪ {(v, w)} 6 if w 6∈ Vt then 7 M = M ∪ {(v, w)} 8 v←w 9 end 10 end 11 end 12 if v hat die gesuchte Eigenschaft then 13 gib v aus 14 else 15 Element wurde nicht gefunden 16 end Bei dem in Algorithmus 1 aufgezeigten Vorgehen können je nach Beschaffenheit des Graphen und nach Position des gesuchten Objektes große Laufzeitunterschiede auftreten. Statistisch kann jedoch ermittelt werden, dass die Suche, wie andere solcher Suchverfahren auch, linear mit der Anzahl der Knoten skaliert (vgl. (2.4)). Diese Auswahl einer noch unbesuchten Kante vom aktuellen Knoten aus, ist ein Punkt, den jede Implementierung anders handhaben kann. Es kann nach einer Nummerierung oder gleich zufällig entschieden werden. Auf die Laufzeit sollte dies bei unsortierten Graphen keinen Einfluss haben. Um das Durchlaufen von Kreisen und zu langen Wege zu vermeiden, definiert man sogenannte Cutoff-Tiefen (vgl. [VK88]). Diese begrenzen die maximale Länge eines Weges durch den Graphen, bis ein Rückschritt erfolgen muss. Diese maximale Tiefe muss mindestens gleich dem „Durchmesser“ des Graphen entsprechen, damit mit Sicherheit das gesuchte Element gefunden wird. Andernfalls könnte ein Teilgraph ausgelassen werden. Der IDA*-Suchalgorithmus verwendet hier keinen statischen Wert, sondern passt diesen nach Bedarf an. Iterative-Deepening-A*-Search Die IDA*-Suche ist ein „kostenorientierter“ Algorithmus. Grundlagen dieses Algorithmus ist die Möglichkeit einer Abschätzung der „Kosten“ einen bestimmten Knoten n zu erreichen und von diesem aus zum gesuchten Objekt zu gelangen. Zu diesem Zweck werden 11 drei Funktionen definiert (nach [VK87]): • g(n): Stellt die „Kosten“ für das Erreichen des Knotens n vom Ausgangspunkt dar • h(n): Ist eine Abschätzung für die „Kosten“ von n an den Knoten mit der gesuchten Eigenschaft zu kommen • f (n) = g(n) + h(n): Summe der „Kosten“ Während der Durchführung führt ein Überschreiten des Schwellwertes zu einem Rückschritt. Bei jedem Schritt wird der Schwellwert neu gebildet. 2.2.3 Parallele Tiefensuche Um die Rechenzeit herunter zu setzen, sollen in der Informatik Mehrkernprozessoren mit ebenfalls mehreren Threads genutzt werden. Dazu ist der einzelne Algorithmus genau zu betrachten, um herauszufinden, wie dieser mit mehreren Prozessen gleichzeitig abgehandelt werden kann. Anders als beim Durchsuchen von einfachen Gebilden wie beispielsweise Listen kann die „Arbeit“ im Fall des Durchsuchens eines Graphen nicht von vorne hinein unter den Prozessen geteilt werden, sodass jeder Prozess seinen Teil autark abhandeln kann. Bei der Parallelisierung der Tiefensuche ist ein aufwändigeres Vorgehen gefragt, da der Graph in seiner Komplexität nicht zwangsweise von Anfang an bekannt ist. Er muss im Laufe der Tiefensuche „erkundet“ werden und eine Möglichkeit gefunden werden, die „Arbeit“ trotzdem sinnvoll aufzuteilen. Aufgrund dessen zählt die Aufgabenteilung zu einem der entscheidenden Faktor bei einer erfolgreichen Implementierung der parallelen Tiefensuche. Ein weiterer bedeutsamer Parameter mit Blick auf die Effektivität und Geschwindigkeit des Algorithmus ist offensichtlich die Beschaffenheit des Graphen. Zunächst dauert eine Suche durch einen großen Graphen länger als die durch einen kleinen. Darüber hinaus ist allerdings auch die Anordnung der Kanten entscheidend. Es gibt Graphen, welche sich für die Parallelisierung besser eignen als andere, weil sie beispielsweise eine gewisse Symmetrie aufweisen und weniger Kommunikation zwischen den einzelnen Prozessoren erforderlich ist. Was zu einer zusätzlichen einflussreichen Größe führt, die Geschwindigkeit der Kommunikation zwischen den Prozessen. Je nach Anzahl der Kommunkationsvorgänge zwischen den Prozessen kann die benötigte Zeit einen nicht zu vernachlässigenden Teil der Rechenzeit ausmachen. Aufgabenverteilung Um die genannte „Arbeit“ zu verteilen, muss eine passende Architektur gewählt werden. Dies kann in der Software oder auch direkt in der Hardware geschehen. Hier kämen beispielsweise 1-Ring-, 2-Ring-, Hypercube oder Shared-Memory-Architekturen in Betracht. 12 Die letzten beiden sind nach [VK88] zu bevorzugen und weisen deutliche PerformanceVorteile auf. Die genannten Architekturen unterscheiden sich wie folgt: • 1-Ring: Es kann nur mit einem anderen Nachbar kommuniziert werden • 2-Ring: Von beiden Nachbarn kann „Arbeit“ abgerufen werden • Hypercube: Eine Anfrage kann an log(N ) der N Prozesse gestellt werden • Shared-Memory: Alle Threads stehen für Anfragen offen Ersichtlich ist, dass bei einer höheren Anzahl von Prozessen auf die zugegriffen werden kann weniger Schritte nötig sind, um die „Arbeit“ sinnvoll zu verteilen. Dies bildet einen großen Vorteil der Shared-Memory-Variante. Außerdem sind zwei weitere Aspekte zu betrachten, um eine sinnvolle Aufgabenverteilung zu implementieren. In dem Moment, in dem die „Arbeit“ geteilt wird, muss entschieden werden, welcher Teil beim ursprünglichen Prozess verbleibt und welcher dem anfragenden Prozess übergeben wird. Diesbezüglich wurde in [VK88] festgestellt, dass ein Halbieren die effektivste Lösung darstellt. Beim Abweichen in die eine oder andere Richtung wird einer der Prozesse zu schnell ohne „Arbeit“ sein. Dies gilt jedoch nur für Shared-Memory- oder Hypercube-Architekturen. Bei diesen können anschließen alle anderen Prozesse auf beide zugreifen und nach „Arbeit“ fragen. Ist dies nicht der Fall, wie bei den Ringarchitekturen, ist eine andere Herangehensweise gefragt, welche beispielsweise mehr „Arbeit“ an den nächsten Prozess weiterreicht, um die Zahl der Kommunikationsvorgänge gering zu halten. Laufzeitbetrachtung, Effizienz und Isoefficiency Funktionen Die Laufzeit ist im Vorhinein nur statistisch abzuschätzen. Wenn der gesamte Graph durchsucht werden muss, um erfolgreich zu sein, kann die Geschwindigkeit durch die Parallelisierung bei N Prozesse maximal um den Faktor N zunehmen. Sollte dies jedoch nicht der Fall sein und die parallele Suche weniger Schritte zum finden des Elementes brauchen als die Suche mit einem einzelnen Prozess, kann der Faktor sogar größer als N werden (vgl. [VK87]). Diesen Effekt nennt man „Accelaration Anomaly“ (z. Dt.: „Beschleunigungsanomalie“). Bei der Parallelisierung muss außerdem bei ungefähr gleichschnellen Prozessen nicht mit einer Verschlechterung der Laufzeit gerechnet werden. Die Zeiten für die Kommunikation zwischen den Prozessen sind hierbei vernachlässigbar (vgl. [VK87]). Um zu beurteilen wie „gut“ der Algorithmus in seiner parallelen Form arbeitet, berechnet man seine Effizienz. Diese lässt sich aus der Anzahl der Prozessoren und dem Laufzeitunterschied errechnen. Wenn die Laufzeit mit dem Faktor N abnimmt, handelt es sich um 13 eine Suche mit einer Effizienz von 1. Bei allen Werten darüber oder darunter errechnet sich der Wert prozentual. Dabei ist zu beachten, dass diese Zahl stark von der Kommunikationszeit zwischen den Prozessen beeinflusst wird. Aus diesem Grund nimmt die Effizienz mit zunehmender Prozesszahl ab. Steigert man allerdings zusätzlich auch die Größe des Graphen und so die zur Verfügung stehende „Arbeit“ , so kann die Effizienz auf dem gleichen Level gehalten werden. Die sich daraus ergebende Funktion nennt sich „Isoefficiency function“ (vgl.[VK87]). Sie kann je nach Prozessorarchitektur unterschiedliche Verläufe annehmen: • 1-Ring: Abhängig vom Aufteilungsverhältnis, tendenziell exponentiell • 2-Ring: Ähnliches Verhalten wie die 1-Ring-Architektur • Hypercube: Funktion ist polynomisch (Grad hängt vom Aufteilungsverhältnis ab) • Shared-Memory - Jeder Prozess fragt bei allen anderen nach „Arbeit“ , falls er welche benötigt – Ein logarithmischer Faktor trennt von der Linearität: W = O(Uc omm · V (N ) · log(V (N )) – V (N ) ≥ N : Anzahl der Abfrage bis zu der jeder Prozessor mindestens einmal abgefragt wurde • Enhanced-Shared-Memory - Ein Prozess fragt immer nur bei einem anderen nach „Arbeit“. Sollte er dort keine „Arbeit“ erhalten, fragt er den nächsten an. – Funktion weist ebenfalls ein nahezu lineares Verhalten auf (Faktor leicht verbessert): W = O(N 2 · log(N ) 14 3 Parallele Tiefensuche in Java: Implementierung Der Kernpunkt dieser Arbeit ist die Parallelisierung der Tiefensuche in Java und die anschließende Analyse der Ergebnisse hinsichtlich der Umsetzbarkeit, der Performance und der Wahl der Programmiersprache. Dies möchte ich in den nun folgenden Abschnitten anhand meiner eigenen Implementierung erläutern. 3.1 Vorarbeit 3.1.1 Aufgabenverteilung Da die verwendete Programmiersprache ohnehin auf einer virtuellen Maschine läuft und folglich alles andere als hardwarenah ist, sind der Wahl des Algorithmus für die Aufgabenverteilung von Seiten der Architektur keine Grenzen gesetzt. Aus diesem Grund habe ich mich für das schnellste bisher vorgestellte Vorgehen (Enhanced-Shared-Memory) entschieden. Wie zuvor genannt, muss dabei eine globale Variable etabliert werden, welche den als nächstes anzufragenden Prozess bestimmt. Sollte jeder Prozess selbst entscheiden, wann er eine neue Anfrage nach „Arbeit“ stellt, können viele simultane Zugriffe auf einen einzelnen Prozess auftreten. Dies kann zu Konflikten führen, die aufwändig gelöst werden müssten, um alle Prozesse gleichmäßig zu bedienen. Deshalb wird dieser Implementierung der Parallelisierung eine andere Herangehensweise gewählt. Es soll ein weiterer Prozess hinzugefügt werden, welcher eine organisierende Funktion einnimmt. Dieser soll der selbe Strang sein, der für das Starten und Initialisieren der anderen Prozesse zuständig ist. Dieser ist somit ohnehin notwendig. Die von ihm übernommenen Punkte sind die Initialisierung, die Aufgabenverteilung und die Einleitung der Terminierung aller Prozesse. Zu Beginn der Suche erhält der erste Prozess den gesamten Graphen. Es sind somit einige Umschichtungsprozesse erforderlich, um eine gleichmäßige Verteilung der „Arbeit“ zu erreichen. Die Aufgaben werden in Form eines Stapels (Stacks) aus Knoten verwaltet. Dieser beinhaltet den bisher abgelaufenen Weg. Im Falle einer Anfrage gibt ein Prozess einen Teil dieses Stapels (Größe ist abhängig vom definierten Aufteilungsverhältnis) an einen anderen weiter. Außerdem sollen die schon besuchten Knoten übermittelt werden, um Mehrfachbesuche zu vermeiden und die Zeit für die Suche zu verkürzen. 3.1.2 Basiswissen: Parallelisieren in Java Wie man es von modernen Hochsprachen gewohnt ist stellt Java Unmengen an Funktionen in seinem API zur Verfügung. So auch für das Implementieren von Threads. Hierfür 15 kann ein Thread-Object erzeugt oder von diesem abgeleitet werden, um die sich ergebenen Funktionen zu nutzen. Um zu bestimmen welche Aufgaben auf dem neu erzeugten Thread ausgeführt werden, kann beim Aufruf des Konstruktors ein Runnable übergeben werden (s. Listing 3.1) oder gleich die run-Methode des Thread-Objektes implementiert werden. Nach dem Aufruf der start-Methode wird der hier eingefügt Code anschließend sequenziell abgearbeitet. Da für die Tiefensuche Anweisung vielmals wiederholt werden, muss hier mit Schleifen hantiert werden. Listing 3.1: Erstellen und Starten eines Threads in Java 1 Runnable myRunnable = new Runnable () { 2 3 @Override 4 public void run () { 5 doSomeWork (); 6 } 7 }; 8 9 Thread myThread = new Thread ( myRunnable ); 10 myThread . start (); Interessant ist beim Multithreading mit Java jedoch die Synchronisierung und die Terminierung von Threads. Ein synchrones Verhalten zweier Stränge kann vor allem durch Wartezeiten erreicht werden. Ein Thread wartet auf ein Ereignis. Sollte dieses eintreten, läuft er weiter. In Java gibt es die Methoden wait und join, welche das Warten auf ein Benachrichtigung eines anderen Threads bzw. auf die Terminierung eines anderen Strangs warten. Für die Benachrichtigung können die Methoden notify und notifyAll genutzt werden (s. Listing 3.2). Wie diese Funktion in diesem Projekt verwendet wurde, soll später näher erläutert werden. Listing 3.2: Thread-Synchronisierung in Java 1 Thread 1: 2 synchronized ( notifier ) { 3 try { 4 notifier . wait (); 5 } catch ( I n t e r r u p t e d E x c e p t i o n e ) { 6 e . printStackTrace (); 7 } 8 } 9 10 Thread 2: 11 synchronized ( notifier ) { 12 notifier . notify (); 13 } 16 3.2 Implementierung 3.2.1 Tiefensuche in Java Um die Tiefensuche umzusetzen, mussten zunächst die einzelnen Bestandteile als Datentypen abgebildet werden. Vor allem der Graph sowie die schon öfter genannte „Arbeit“ können in der objektorientierten Umgebung gut dargestellt werden. Für den Graph kam eine Implementation der Princeton University zum Einsatz. Durch das Erben von dieser Klasse und Nutzen ihrer Funktionen wurde erreicht, dass ein binärer Baum beliebiger Größe erzeugt werden kann. Dieser Baum wird hier in den Laufzeittests verwendet, da das Verhalten des Algorithmus einfach nachvollziehbar ist. Im Falle der „Arbeit“ ist eine eigene Lösung gefunden worden, welche lediglich einen Stack mit den Knoten des bisher zurückgelegten Weges und ein Set mit den schon besuchten Knoten enthält. Mit Kenntnis des Graphen, welcher später jedem Prozess übergeben wird, kann jeder Thread mit diesen Informationen die Tiefensuche durchführen. Nachdem die DFS mit Graph, Arbeit, Zielknoten und Cutoff-Tiefe initialisiert wurde, kann sie schrittweise ihre Suche vollführen. Für den nächsten Schritt ist immer ein Aufruf seitens des zuständigen Threads nötig. Ein einzelner Schritt ist in Abbildung 3.1 dargestellt. Es wird die zuvor beschriebene herkömmliche Tiefensuche verwendet. Abbildung 3.1: Ablauf eines Schrittes der Tiefensuche in der Java Implementation, Quelle: Libre Office Draw Falls eine Aufgabenteilung stattfindet und der Stack geteilt wird, muss das Objekt nicht neu erzeugt werden. Es wird der entsprechende Teil des Stack entfernt und die Verarbeitung kann anschließend weitergehen. Das Gleiche gilt für den Fall, wenn „Arbeit“ für die 17 Tiefensuche bereitgestellt wird. Ohne Neuerzeugen kann diese eingefügt werden. 3.2.2 Tiefensuche-Thread Ein einzelner Thread, der die Suche abarbeitet, bildet einen einzelnen Prozess im Gesamtprogramm. Damit der organisierende Strang sinnvoll auf den Tiefensuche-Thread zugreifen kann und dieser die Suche autark durchführt, müssen noch weitere Funktionalitäten bereitgestellt werden. Zunächst muss der Thread eine Schleife ausführen, welche wiederholt die Tiefensuche aufruft (s. Abbildung 3.2). Dies sollte solange geschehen bis er das entsprechende Element findet, aus irgendeinem Grund vom organisierenden Thread unterbrochen wird oder keine Arbeit mehr hat. Ein Grund für die Unterbrechung durch den übergeordneten Thread wäre das Finden des Elementes durch einen anderen Prozess. Die Arbeit geht dem Strang aus, sobald der Stack der Tiefensuche leer ist. Um dies zu beobachten, muss der Zustand des Stack in jedem Zyklus abgefragt werden. Sollte keine Arbeit vorliegen, wird die Schleife beendet und der Thread fällt in einen Wartemodus. Dort verweilt er bis er entweder unterbrochen wird oder Arbeit vom organisierenden Thread bereitgestellt wird (und die entsprechende Benachrichtigung an den Thread gerichtet wird). Dann könnte die Arbeit unverzüglich wieder aufgenommen werden. Eine weitere benötigte Funktion ist das Sperren (Engl.: locking) des Strangs. Falls Arbeit von einem anderen Prozess benötigt und aufgrund dessen angefragt wird, darf der angefragte Thread im Zeitraum des Abrufs keine Änderungen mehr an der „Arbeitsmenge“ vornehmen. Hierzu wird der Strang für die erforderliche Zeit angehalten. Sobald der Vorgang abgeschlossen ist, kann der Thread ungehindert weiterarbeiten. 3.2.3 Zusammenführung der Prozesse Um einen reibungslosen Ablauf der Tiefensuche in mehreren separaten Prozessen zu gewährleisten, bedarf es einem Strang, welcher organisatorische Funktionen übernimmt. Dieser wird als Erstes gestartet und terminiert als Letztes. Der Thread hat drei Hauptaufgaben, die Initialisierung der Prozesse, die Arbeitsverteilung und die Terminierung aller Threads (s. Abbildung 3.3). Als aller Erstes müssen die Stränge erstellt und initialisiert werden. Hierzu werden DFSTdObjekte (Td=Thread) erzeugt, denen durchgängig der Graph und der Zielknoten zur Verfügung gestellt wird. Nur der erste Prozess erhält allerdings „Arbeit“ in Form eines Stacks, welcher ausschließlich mit dem Startknoten gefüllt ist. Nach dem Start des Strang wird von hier aus die Suche begonnen. Die anderen Prozesse fallen unverzüglich in den Wartemodus bis ihnen „Arbeit“ bereitgestellt wird. 18 Abbildung 3.2: Ablauf des Threads zur Steuerung der Tiefensuche in der Java Implementation, Quelle: Libre Office Draw Darauf folgend übernimmt der übergeordnete Strang eine überwachende Funktion ein. Es werden zwei Parameter eines jeden Threads beobachtet. Zum einen wird jeder zyklisch abgefragt, ob er das gesuchte Element gefunden hat. In diesem Fall kann die Tiefensuche sofort abgebrochen werden. Alle Threads werden beendet und das Ergebnis inklusive der benötigten Zeit wird ausgegeben. Den anderen Parameter stellt das Vorhandensein von „Arbeit“ dar. Nacheinander werden die Threads auf diesen Zustand hin überprüft. Sollte ein Prozess keine „Arbeit“ mehr besitzen, startet der organisierende Thread die Suche danach. Hierfür wurde zuerst eine Variable gespeichert, welche den Index des nächsten Prozesses enthält, der „Arbeit“ bereitstellen soll. Diese Variable hilft dabei alle Prozesse gleichmäßig stark mit Anfragen nach „Arbeit“ zu belasten und somit dessen Suche zu verlangsamen. Folglich wird der entsprechende Prozess nach „Arbeit“ gefragt. Im Falle einer Ablehnung wird der Index inkrementiert und der nächste Prozess zu Rate gezogen. Dieser Vorgang wird so lange fortgesetzt bis Arbeit von einem Prozess erlangt werden konnte oder alle anderen Prozess erfolglos abgefragt wurden. Anschließend wird überprüft, ob der nächste Prozess in der Reihe „Arbeit“ benötigt. Der übergeordnete Prozess terminiert folglich, falls das passende Element gefunden wurde oder aber wenn alle Suchthreads nacheinander abgefragt wurde und sie weder selbst „Arbeit“ besaßen noch selbiges von den anderen erhalten konnten. In diesen Fällen wird der Nutzer anschließend mittels einer Textausgabe darüber informiert, ob die Suche erfolgreich war oder fehlgeschlagen ist, da das gesuchte Element nicht gefunden werden konnte. 19 Abbildung 3.3: Ablauf des übergeordneten Threads in der Java Implementation, Quelle: Libre Office Draw 20 4 Ergebnisse 4.1 Problemtik bei der Parallelisierung der Tiefensuche Das Parallelsieren des Tiefensuche-Algorithmus stellt an sich keine einfache Aufgabe dar. Der zu durchsuchende Graph kann sehr schwierig in gleichgroße „Arbeitspakete“ aufgeteilt werden, da dieser zunächst „erkundet“ werden muss. Nur so könnte jedoch die Kommunikation zwischen den Threads so klein wie möglich gehalten werden. Auch wenn das gewählte Verfahren eines der effektivsten für diesen Zweck ist, werden durch dessen Anwendung viele Umschichtungsprozesse notwendig. Jeder einzelne verlangsamt den Suchvorgang. Die Zahl der nötigen Kommunikationsvorgänge hängt nicht nur vom Aufteilungsverhältnis des Stacks ab, sondern auch sehr bedeutend von der Beschaffenheit des Graphen. Hierfür ist der hier implementierte binäre Baum ein Vertreter, der sehr viele Umschichtungsprozesse verursacht, da der untere Teil des Stacks, welcher bei einer Anfrage weitergegeben wird, eine um ein Vielfaches größere „Arbeitsmenge“ aufweist als der restliche Teil. Wie zuvor beschrieben werden die Stränge bei einer Anfrage nach „Arbeit“ gesperrt, um unvorhersehbare Ereignisse zu vermeiden. So können diese Threads zu dieser Zeit ihre Suche nicht fortsetzen. Sollten nun aus den genannten Gründen viele Umschichtungen notwendig sein oder zu einem späteren Zeitpunkt ohnehin wenig „Arbeit“ übrig sein, häufen sich die Anfragen. Falls jede sofort behandelt werden würde, käme schlussendlich der Suchvorgang zum Erliegen, da ein Thread mit „Arbeit“ fast ausschließlich gesperrt wäre. Als Konsequenz muss eine Wartezeit eingefügt werden, welche die Anzahl der Anfragen pro Sekunde begrenzt. So kann überhaupt erst ein Fortschritt in der Tiefensuche erzielt werden. 4.2 Laufzeitanalyse Der Zweck der Parallelsierung der Tiefensuche ist die Verbesserung der Laufzeit, welche sich möglichst linear zu Anzahl der Prozessoren verhalten sollte. Wie zuvor erwähnt, kann mit dem verwendeten Verfahren ein solch lineares Verhalten nicht erzielt werden, jedoch ist eine Annäherung daran möglich. Bei durchgeführten Laufzeittests konnte jedoch keine signifikante Verbesserung durch das Erhöhen der Anzahl der Prozesse erreicht werden. Die benötigte Zeit für einen Suchvorgang blieb nahezu identisch. Zumal die Rechenzeit wohl möglich aufgrund anderer Prozesse auf dem Computer ohnehin schwankte und ein einzelner Wert schwer zu ermitteln war. Durch Mitteln der erreichten Zeiten konnte jedoch ein Wert errechnet werden, der als Richtwert 21 gesehen werden kann. Auf Gründe für dieses ungünstige Verhalten wird im nächsten Abschnitt eingegangen. Starke Unterschiede bezüglich der Laufzeit konnten allerdings bei variierender Größe des Baumes beobachtet werden. Hier kam es zu einem nachvollziehbaren Verhalten. Beim Zusammenhang zwischen Laufzeit und Knotenanzahl kann ein lineares Verhalten erkannt werden (s. Abbildung 4.1). Da dies für bis zu 15 Prozesse gilt (maximal getestete Anzahl), kann davon ausgegangen werden, dass der Einfluss der Kommunkationsvorgänge vernachlässigbar ist. Abbildung 4.1: Gemessener Zusammenhang zwischen der Anzahl der Knoten des binären Baumes und der Laufzeit, Quelle: Libre Office Calc Falls die Laufzeit hingegen gegenüber der Höhe des durchsuchten Baumes aufgetragen wird, kommt es zu einem quadratischen Verlauf der Funktion (s. Abbildung 4.2). Dies ist ebenfalls verständlich, da sich die Anzahl der Knoten mit jeder weiteren Stufe des Baumes verdoppelt und so auch doppelt so viele Schritte nötig sind, um den ganzen Baum zu durchsuchen. 4.2.1 Analyse: Verhalten von Java Java ist grundsätzlich keine hardwarenahe Sprache. Der generierte Bytecode wird auf der JVM, einer virtuellen Maschine, ausgeführt. Alle Ressourcen werden von dieser verwaltet. 22 Abbildung 4.2: Gemessener Zusammenhang zwischen der Höhe des binären Baumes und der Laufzeit, Quelle: Libre Office Calc Um es dem Programmierer einfacher zu gestaltet Software zu schreiben, beinhaltet diese virtuelle Maschine auch einen Garbage Collect (kurz GC), welcher auf einem separaten Thread ausgeführt wird und für die Rückgewinnung von Speicher zuständig ist. Somit laufen beim Start einer Java Anwendung automatisch mehrere Threads, welche anschließend vom darunterliegenden Betriebssystem verwaltet werden. Zusätzliche Stränge entstehen durch das Nutzen von Objekte, welche nicht selbst implementiert wurden. Dabei können teilweise zusätzliche Threads eröffnet werden. Solche Objekte werden auch in der hier vorgestellten Implementierung verwendet. Aus diesem Grund läuft auch eine Tiefensuche mit nur einem Thread auf allen 8 Hyperthreads des Testrechners. Alle Ressourcen werden somit ausgeschöpft, da die Prozessorkerne vollständig ausgelastet werden. Die Idee hinter der parallelen Implementierung ist hingegen, die ungenutzte Rechenleistung der Kerne, welche bei einer Implementierung mit einem einzigen Thread inaktiv sind, zu erschließen und so den Prozess zu beschleunigen. Wenn nun jedoch keine ungenutzten Ressourcen mehr zur Verfügung stehen, führt das Erzeugen mehrerer Threads für die Durchführung der Tiefensuche eher zu einer Verlangsamung des Vorgangs, da zusätzlich zum Ablauf der Tiefensuche noch die Kommunikationsvorgänge abzuarbeiten sind. Somit sollte eine Ausführung mit mehreren Prozessen theoretisch betrachtet langsa- 23 mer sein als eine mit einem einzelnen. Offenbar sind die Kommunikationsschritte jedoch zu vernachlässigen und schlagen sich nicht in den Laufzeiten nieder. Als Lösungsansatz kann ein anderes Vorgehen bei der Implementierung des Algorithmus gesehen werden. Es sollten möglichst nur primitive Datentypen benutzt werden, um die Verwendung von Threads überschaubarer zu machen. Die Verwendung von Inzidenz- oder Adjazenzmatrizen ist denkbar. Das Erstellen einer solchen Ausführung der parallelen Tiefensuche würde jedoch einen deutlich höheren Aufwand bedeuten. Aufgrund dessen sollte in Erwägung gezogen werden, eine anderen Programmiersprache, welche hardwarenäher ist, zu verwenden, um so das Verhalten der Tiefensuche besser beurteilen zu können. Leider konnte mit dem hier geschriebenen Programm keine Verbesserung durch die Verwendung von Multithreading bezüglich der Geschwindigkeit erzielt werden. 24 5 Fazit In der hier vorliegenden Arbeit konnten einige Aspekte der parallelen Tiefensuche näher beleuchtet werden. Zunächst ist eine Struktur erstellt worden die einzelnen Elemente der Tiefensuche objektorientiert abbildet und so für eine Suche nutzbar macht. Anschließend wurden passende Programmteile in Java implementiert. Hierbei wurde Wert auf eine schnelle Suche gelegt, die sparend mit den Ressourcen des Rechners umgeht˙ Die spätere Untersuchung des Programms brachte keine Verbesserung durch die Parallelisierung ans Licht. Dies ist mit großer Wahrscheinlichkeit auf die genannten Problem mit zusätzlich entstehenden Threads zurückzuführen. Demnach bedarf es hier einer sorgsamen Überarbeitung der Implementierung zurück auf einfache Datentypen, um mehr Kontrolle zu erhalten. Andernfalls sollte festgehalten werden, dass Java für solch einen Zweck nicht die Sprache der ersten Wahl ist. Nichtsdestotrotz ist das Ergebnis dieser Arbeit eine funktionierende Tiefensuche, welche auch große Graphen schnell durchsuchen kann und zuverlässig arbeitet. Die Laufzeiten, welche bei unterschiedlich großen Graphen auftraten, zeigten zusätzlich das erwartete Verhalten. Die benötigte Zeit bewegte sich linear zur Anzahl der Knoten des durchsuchten Baumes. 25 Literaturverzeichnis [Ger96] Gerber, S.: Grundkurs Praktische Informatik. Vorlesung, 1996 [Kä99] Kääb, V.: Dualität von Suchstrategien auf planaren Graphen, Universität Konstanz, Diplomarbeit, 1999 [SK00] S. Krumke, H. W. H. Noltemeier N. H. Noltemeier: Graphentheoretische Konzepte und Algorithmen / Konrad-Zuse-Zentrum für Informationstechnik Berlin. 2000 (1). – Forschungsbericht [VK87] V. Kumar, V. Nageshwara R.: Parallel Depth First Search, Part I: Implementation / University of Texas at Austin, Department of Computer Science. 1987 (1). – Forschungsbericht [VK88] V. Kumar, V. Nageshwara R.: Parallel Depth First Search, Part II: Analysis / University of Texas at Austin, Department of Computer Science. 1988 (2). – Forschungsbericht 26 Abbildungsverzeichnis 2.1 2.2 2.3 2.4 3.1 3.2 3.3 4.1 4.2 Beispiel eines ungerichteten Graphen, Quelle: yEd Graph Editor . . . . . . Beispiel eines gerichteten Graphen, Quelle: yEd Graph Editor . . . . . . . Beispiel eines unvollständigen ungerichteten 4-Wurzelbaumes, Quelle: yEd Graph Editor . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Grundsätzlicher Verlauf einer Graphensuche unabhängig vom Algorithmus (vgl. [Kä99]), Quelle: yEd Graph Editor . . . . . . . . . . . . . . . . . . . Ablauf eines Schrittes der Tiefensuche in der Java Implementation, Quelle: Libre Office Draw . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Ablauf des Threads zur Steuerung der Tiefensuche in der Java Implementation, Quelle: Libre Office Draw . . . . . . . . . . . . . . . . . . . . . . . Ablauf des übergeordneten Threads in der Java Implementation, Quelle: Libre Office Draw . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Gemessener Zusammenhang zwischen der Anzahl der Knoten des binären Baumes und der Laufzeit, Quelle: Libre Office Calc . . . . . . . . . . . . . Gemessener Zusammenhang zwischen der Höhe des binären Baumes und der Laufzeit, Quelle: Libre Office Calc . . . . . . . . . . . . . . . . . . . . . 4 5 7 9 17 19 20 22 23 27 Liste der Algorithmen 1 28 Ausführung einer Knoten-Tiefensuche . . . . . . . . . . . . . . . . . . . . . 11 Listings 3.1 3.2 Erstellen und Starten eines Threads in Java . . . . . . . . . . . . . . . . . Thread-Synchronisierung in Java . . . . . . . . . . . . . . . . . . . . . . . 16 16 29