Laufzeitkomplexität von Algorithmen - die O-NotationDie Untersuchung der Effizienz von Algorithmen ist eine klassische und zugleich auch wichtige Aufgabe der Informatik, sind schnelle und stabile Algorithmen doch ein Muss jedes größeren Programms. Dementsprechend muss es auch Verfahren geben, wie die Laufzeit eines Algorithmus' untersucht werden kann. In diese Kerbe schlägt die O-Notation (engl.: Big-Oh-Notation), welche ein elegantes Hilfsmittel zur mathematischen Beschreibung der Laufzeit eines Algorithmus' darstellt. Historie, Definition, Anwendungsbeispiele sowie Rechenregeln und Gefahren dieser Schreibweise werde ich im nachfolgenden Artikel versuchen so verständlich wie möglich zu untersuchen. Gliederung1.1 Historisches 1.2 Grundgedanken/Ziele 1.3 Definition 1.4 Interpretation 1.5 Der O-Notation nicht blind vertrauen! 2.1 Wichtige Rechenregeln 2.2 Laufzeit der Grundbausteine einer Programmiersprache 2.3 Typische Zeitkomplexitäten 2.4 Schlussbemerkungen 1.1 HistorischesEingeführt wurde die Symbolik der O-Notation durch den deutschen Mathematiker Paul Bachmann (1837-1920) im Jahr 1894 in seinem Werk "Analytische Zahlentheorie". Später machte der Zahlentheoretiker Edmund Landau (1877-1938) davon Gebrauch, weshalb die O-Notation gern auch als Schreibweise Landau'scher Symbole bezeichnet wird... 1.2 Grundgedanken/ZieleWie schon erwähnt ist das zu verfolgende Ziel die Untersuchung der Laufzeitkomplexität von Algorithmen. Hierbei verfolgt man i.A. drei Grundgedanken: der erste ist die Betrachtung der Laufzeit in Abhängigkeit der Eingabedaten des Algorithmus'. Zunächst ist klar, dass ein praktischer Algorithmus Eingabedaten benötigt, die er verarbeiten kann. Diese Daten können allerdings sehr unterschiedlich sein: es könnte sich beispielsweise um Elemente eines Arrays oder einer benutzerdefinierten Datenstruktur, um zu parsende Wörter, ja selbst um die Bits einer Dezimalzahl handeln, je nach Algorithmustyp. Diese Abhängigkeitsuntersuchung ist wichtig, da man bei der Laufzeitbetrachtung das Verhalten eines Algorithmus' bei wachsender Anzahl der Eingabewerte untersuchen will, zusätzlich gibt dies einem Informatiker die Möglichkeit, das prinzipielle Problem mathematisch durch eine Funktion zu beschreiben, deren Parameter man gegen + unendlich streben lässt. Im Nachfolgenden wird so abstrahiert, dass n die Anzahl der eingegebenen Daten bezeichne (wobei n ist, da die Quantität "Anzahl" hier positiv ganzzahlig, in anderen Worten "abzählbar", sein muss), womit eine wichtige Basis für weitere Definitionen geschaffen ist. Zum Zweiten sollen "unwesentliche" Konstanten außer Acht gelassen werden. Dieses Ziel ist zu verfolgen, da mittels der O-Notation eine Klasse von Funktionen angegeben werden soll, die die Laufzeit eines Algorithmus' beschreiben, ohne von konstanten Faktoren wie dem verwendeten Rechnermodell, der eingesetzten Programmiersprache und deren Implementierung oder der Taktfrequenz der CPU abhängig zu sein. Bei der O-Notation soll ohne Bedeutung sein, wieviel Zeit ein Rechner für eine sogenannte "Basis-Operation" (Wertzuweisung/if-Abfrage/etc.) in Anspruch nimmt, ob der Algorithmus also auf einem Supercomputer oder einem PC läuft - es soll nur untersucht werden, wie sich der Algorithmus bei n Eingabewerten verhält, wieviele Basis-Operationen in diesem Fall ausgeführt werden müssen. Wenn ich die Interpretation der Definition in 1.4 besprechen werde, komme ich hierauf nochmals zurück... Der dritte Grundgedanke richtet sich an die Untersuchung einer oberen Schranke: d.h. dass die Funktion, welche mittels O-Notation angegeben wird, multipliziert mit einem rechnerabhängigen konstanten Faktor, stets ÜBER der eigentlichen Laufzeitfunktion liegen soll, wenn die Anzahl der Eingabewerte einmal einen bestimmten Wert überschritten haben. Also untersucht man mit der O-Notation immer die Laufzeit im ungünstigsten Fall, im "worst case", was vieles bedeuten kann: z.B. wäre es bei einem klassischen Sortieralgorithmus die Reihenfolge der Daten in einem zu sortierenden Feld, mit der ein "worst case" zu erzeugen ist. Auf jeden Fall ist dies problemabhängig zu betrachten und in den meisten Fällen nicht gerade das, was man trivial nennt. Aber Algorithmenanalyse ist ja auch kein triviales Gebiet der Informatik :o) Meist sucht man nicht irgendeine Funktion, welche (wiederum multipliziert mit der rechnerabhängigen Konstanten) die tatsächliche Laufzeit majorisiert, sondern die kleinste. So würde eine Funktion g(n), zu welcher n2 eine obere Schranke bildet, ebenfalls von n3 majorisiert werden, dies ist jedoch meist unerheblich, da n2 eine "kleinere" Majorante zu g(n) darstellt als n3. Allgemein sucht man also eine Funktion f(n), für welche gilt: "c*f(n) g(n)", wobei g(n) die eigentliche Laufzeit und c die systemabhängige Konstante bezeichnet. Ist c*f(n) die kleinste Majorante von g(n), so kann dieser Term auch "Asymptote" von g(n) genannt werden, womit die Angabe der Laufzeit mittels O-Notation oft auch als "asymptotische Laufzeitkomplexität" bezeichnet wird. Man kann auch sagen: "die exakte Laufzeit g(n) ist proportional zu f(n)"; hierbei habe ich zum ersten mal die O-Schreibweise verwendet, in den nächsten Abschnitten werde ich darauf noch detaillierter eingehen. Auf Basis der Proportionalität kann man schließlich auch sagen: "die O-Notation gibt eine 'Ungefähr'-Funktion an, sodass statt 'g(n)~f(n)' geschrieben werden kann: 'g(n)=O(f(n))' ". Ich denke, an dieser Stelle hilft eine kurze Zusammenfassung zum Verständnis der eben dargelegten Ziele der O-Notation:
Ebenso wie die O-Notation obere Schranken für die Laufzeit eines Algorithmus' angibt, definiert die sogenannte -Notation untere Schranken, dies sei hier aber nur am Rande erwähnt. Die -Notation bildet gemeinsam mit der O-Notation ein elegantes Werkzeug beim Erstellen von Algorithmen. Kann nämlich gezeigt werden, dass (f(n))=O(f(n)), so ist faktisch bewiesen, dass es keinen grundlegend schnelleren Algorithmus für das betreffende Problem geben kann. 1.3 DefinitionKommen wir zur mathematischen Definition der O-Notation sowie einer kurzen Erklärung. Was dies allgemein bedeutet, wird dann im nächsten Abschnitt ausführlicher beschrieben. Eine mögliche mathematische Definition und zugleich auch die gebräuchlichste ist folgende: Gesprochen würde dies heißen: "O(f(n)) ist die Menge aller der Funktionen g(n), für die es zwei Konstanten n0 und c gibt, sodass für alle n n0 gilt: 'g(n) c*f(n)' ". Neben dieser gibt es noch eine Reihe anderer Definitionen, die im Kern aber alle dasselbe aussagen und die ich deswegen hier nicht weiter behandeln will. Mathematisch sinnvoll wäre es höchstens, statt 'g(n) c*f(n)' zu schreiben: '|g(n)| c*|f(n)|', den Betrag der Funktionen also mit einzuschließen. Dies ist in der Praxis jedoch überflüssig, da es keinen sinnvollen Algorithmus gibt, dessen Laufzeit g(n) mit steigender Anzahl an Eingabewerten geringer wird. Aus diesem Grund wird auch auf diese Schreibweise verzichtet (aber es kann nicht schaden sie zu kennen...). Eingehend wird diese Definition wie gesagt erst im nächsten Abschnitt betrachtet, mithilfe von 1.2 sollte aber einiges schon jetzt einleuchten: z.B. ist c eine Konstante, die von rechnerspezifischen Details abhängt und von der mittels O-Notation abstrahiert wird. Somit wird die Problematik "Laufzeit" von der Hardware losgelöst und in eine mathematische Betrachtungsweise überführt. Also untersucht man nur noch das generelle Wachstumsverhalten und nicht mehr die eigentliche Steigung, man teilt praktisch mittels der O-Notation in sogenannte "Klassen" von Wachstumsverhalten ein, ohne quantitative Aussagen zum eigentlichen Anstieg zu machen. Quantität wird unwichtig, vielmehr will man mit der O-Notation zeigen, welchen Charakter die Laufzeit eines Algorithmus' in Abhängigkeit der Anzahl der Eingabewerte besitzt, ob ihr Wachstumsverhalten z.B. linear, konstant oder exponentiell ist... Diese "Klassenschreibweise" kommt auch in o.a. Definition zum Ausdruck: eine wichtige Aussage stellt dar, dass die O-Notation eine Menge von Funktionen ist, also nicht nur eine, sondern eine ganze Anzahl darstellt. Die Menge O(f(n)) enthält daher alle diejenigen Funktionen g(n), welche die Ungleichung 'g(n) c*f(n)' erfüllen, so wie sie in der Mengendefinition aufgeführt ist. Hiermit lässt sich z.B. sagen: gilt für zwei Funktionen g,h: g(n) h(n) und weiterhin: 'h(n)=O(f(n))', so ist ebenfalls 'g(n)=O(f(n))'. Der Grund liegt auf der Hand: da g eine Minorante von h ist und nach Definition der O-Notation c*f(n) h(n), gilt natürlich auch, dass c*f(n) g(n). Salopp ausgedrückt: wenn c*f(n) obere Schranke von h(n) ist und h(n) größer g(n) ist, so liegt c*f(n) natürlich auch über g(n). Im Umkehrschluss bedeutet dies weiterhin: gilt für zwei Funktionen e,f: f(n) e(n) und ist g(n)=O(f(n)), so ist ebenfalls g(n)=O(e(n)). Der Grund liegt auch hier auf der Hand: mittels Definition der O-Notation und einfacher Anwendung von Ungleichungen kann man sagen: g(n) c*f(n) c*e(n), da e(n) eben "über" f(n) liegt. Demnach ist auch gezeigt: g(n) c*e(n), also g(n)=O(e(n)). Dies bringt mich wieder zum Punkt 1.2 zurück: dort habe ich gesagt, dass eine Funktion g(n), welche z.B. durch n2 nach oben beschränkt wird, ebenfalls unter n3 liegt und dass somit g(n)=O(n2) ebenso wie g(n)=O(n3) gilt. Diese vage Aussage habe ich hier anhand der O-Notations-Definition bestätigt. An dieser Stelle tut ein kleines Beispiel sicher ganz gut: Gegeben sei die Funktion g(n) und ihre Zugehörigkeit zu 3 beispielhaften Klassen von O-Notationen:
Eine kleine Zusammenfassung dürfte an dieser Stelle wieder das Allgemeinverständnis verbessern:
1.4 InterpretationIn diesem Abschnitt soll die noch etwas schwammige Vorstellung über die O-Notation weiter gefestigt und um einige wichtige Details erweitert werden. Die in 1.3 vorgestellte Definition sagt offensichtlich nichts anderes aus, als dass die O-Notation eine obere Schranke zur Laufzeit eines Algorithmus' angibt, welche selbst im ungünstigsten Fall nicht überschritten wird. Anders ausgedrückt: "g(n) wächst höchstens so schnell wie f(n)". Schreibt man: g(n)=O(f(n)), so bedeutet dies, dass g(n) -also die eigentliche Laufzeit des Algorithmus'- ab einer gewissen Anzahl von Eingabewerten (n>n0) stets kleiner als c*f(n) ist, O(f(n)) eben die obere Schranke der Laufzeit angibt. Dies verdeutlicht folgende Grafik sehr anschaulich: Die grüne Kurve stellt eine kubische, die rote eine quadratische Funktion dar. O(f(n)) bildet hierbei nicht die kleinste Majorante, aber dies ist nur eine weitere Eigenschaft der O-Notation, die man sich einprägen muss: O(f(n)) gibt nicht eine Funktion g(n) an, für die die Ungleichung g(n) c*f(n) erfüllt ist, sondern es ist die Menge aller Funktionen g(n), die dieser Bedingung genügen. Und ebenso kann man im Umkehrschluss sagen, dass g(n)=O(f(n)) nicht nur für eine Funktion f(n) gilt, sondern für die Menge aller Funktionen, welche majorant zu f(n) sind. f(n) als kleinste obere Schranke ist nur von außergewöhnlicher Bedeutung, da es die Funktion angibt, welche bei einer Laufzeitbetrachtung meist gesucht wird. Diese Mengenschreibweise beinhaltet einen Stolperstein: wenn man schreibt: g(n)=O(f(n)), so würde das Gleichheitszeichen theoretisch implizieren, dass der zeitliche Aufwand von g(n) exakt O(f(n)) ist. Da g(n) jedoch eine Funktion und O(f(n)) eine Menge von Funktionen ist, müsste es korrekt ausgedrückt heißen: g(n) O(f(n)), da g(n) nur eine von vielen Funktionen ist, welche der Ungleichung in der Definition der O-Schreibweise genügen. Das Gleichheitszeichen hat sich allerdings als Vereinfachung der Schreibweise eingebürgert und sollte auch so verwendet werden. Allerdings ist es gut, sich jedesmal beim Auftreten eines solchen Terms klar zu machen, dass hier eigentlich ein Element-Symbol stehen müsste. Z.B. würde aus g(n)=O(f(n)) und g(n)=O(e(n)) nicht gleich folgen: O(f(n))=O(e(n)), eben da statt "=" ein "" stehen müsste. Weiterhin beinhaltet das Gleichheitszeichen den potentiellen Fehler, dass man statt g(n)=O(f(n)) schreibt: O(f(n))=g(n). Dies ist falsch! Denn es würde ja heißen: O(f(n)) g(n), was natürlich absoluter Blödsinn ist. Daher die Regel: "die Seiten einer Gleichung in O-Notation dürfen nie vertauscht werden - solche Gleichungen gelten immer nur 'von links nach rechts' ". Beispiel: Gesucht ist die kleinste Funktion f(n), für welche g(n)=O(f(n)) für folgendes g(n): g(n) = ak*nk + ak-1*nk-1 + ... + a0 g(n) ist also ein Polynom k-ten Grades und die Frage ist, welche kleinste Funktion f(n) existiert, sodass g(n) c*f(n) erfüllt ist. Mit ein paar Abschätzungen lässt sich das Problem lösen: g(n) = ak*nk + ak-1*nk-1 + ... + a0
(ak+ak-1+...+a0)*nk = c*nk mit
(ak+ak-1+...+a0) = c .Demnach lässt sich also allgemein sagen: für ein Polynom g(n) k-ten Grades gilt: g(n)=O(nk), f(n) ist demnach der Term des Polynoms mit der höchsten Potenz. 2 praktische Beispiele: diese Aussage ist wichtiger als man zunächst glauben möchte, lassen sich damit doch sehr gut kleinste obere Schranken vieler Algorithmen ermitteln:
1.5 Der O-Notation nicht blind vertrauen!Auch wenn die O-Notation ein wichtiges mathematisches Hilfsmittel zur Angabe oberer Laufzeitschranken darstellt, so ist sie doch keine "eierlegende Wollmilchsau". Vielmehr muss man an einigen Stellen aufpassen, nicht zu sorglos damit umzugehen und allein auf diese Schreibweise zu vertrauen. Ein wenig Gehirnschmalz ist immernoch erforderlich ;o) Insgesamt sollte man zumindest 4 Faktoren berücksichtigen:
2.1 Wichtige RechenregelnZur schnellen Vereinfachung von Gleichungen in O-Notation gibt es einen ganzen Satz von Regeln, ohne die man stets mittels der Definition der O-Schreibweise und Ungleichungsregeln arbeiten müsste. Die gebräuchlichsten und wichtigsten seien hier kurz vorgestellt:
2.2 Laufzeit der Grundbausteine einer ProgrammierspracheUm die asymptotische Laufzeitkomplexität eines Algorithmus' ermitteln zu können, bedarf es der Kenntnis der grundlegenden Syntaxbausteine einer höheren Programmiersprache, da in einer solchen doch oftmals ein zu untersuchender Algorithmus angegeben ist. Diese folgen den in 2.1 angegebenen Rechenregeln und sind nicht besonders schwierig zu merken:
Beispiel Betrachte man den folgenden Programmausschnitt: //... for(i=0; i<n; i++) for(j=0;j<n; j++) if(test) bubblesort(a[i][j], n); else{ perror("Fatal error\n"); continue; } //...Offensichtlich wird hier ein zweidimensionales Feld aus Feldern sortiert, über Sinn oder Unsinn dieses Codes will ich aber auch gar nicht diskutieren, mir ist nur die Untersuchung der Laufzeitkomplexität wichtig. Wie wir wissen, hat der Bubblesort-Algorithmus eine asymptotische Laufzeit von O(n2). Weiterhin haben wir 2 ineinander verschachtelte Schleifen, wobei jede der beiden Schleifen n-mal durchlaufen wird. Habe die innere Schleife allgemein gefasst eine noch zu bestimmende Komplexität von O(f(n)). Unter dieser Bedingung hat der gesamte Programmteil eine Laufzeit von "O(n*n*f(n))=O(n2*f(n))". Zur Bestimmung von O(f(n)) betrachtet man die if-Abfrage. Bei positivem Test wird Bubblesort ausgeführt, welches bekanntlich eine Komplexität von O(n2) hat, bei negativem werden zwei Grundoperationen gestartet, welche O(1) Zeit benötigen. Für die if-else-Anweisung gilt wie oben erörtert aufgrund der Betrachtung des ungünstigsten Falls: "O(f(n))=O(max{O(n2),O(1)})=O(n2)", womit sich für die Gesamtlaufzeit des Programmteils ergibt: "O(n2*f(n))=O(n2*n2)=O(n4)" .
2.3 Typische ZeitkomplexitätenEine kurze Zusammenfassung der wichtigsten Laufzeitkomplexitäten und in welchen praktischen Anwendungen man sie wiederfinden kann:
In der Praxis kann beim heutigen Stand der Technik als Faustregel gelten: eine Komplexität bis hin zu O(n2) kann toleriert werden. Aufwände, die darüber liegen, sind bereits bei kleinen bis mittleren Eingabemengen problematisch und Aufwände mit exponentiellem Wachstum oder höher sind in der Regel nicht zu verarbeiten (sondern allenfalls in Ausnahmefällen). 2.4 SchlussbemerkungenIm vorliegenden Text habe ich versucht, die Problematik der O-Notation so verständlich wie möglich und doch sehr umfassend darzustellen. Wer jetzt immernoch so schlau ist wie zuvor, dem kann ich leider auch nicht helfen =o) Wichtig war mir vor allem die kleinen versteckten Fallen aufzuzeigen und ein tieferes Verständnis für die Problematik der Untersuchung von Laufzeitkomplexitäten zu vermitteln. Die O-Notation stellt bei weitem kein Allheilmittel dar. So könnte ein Algorithmus, der im ungünstigsten Fall eine quadratische Laufzeitkomplexität aufweist, sich bei Eingaben in der Praxis doch viel besser verhalten, z.B. linear oder mit n*log(n). In Symbiose mit der -Notation sowie der Betrachtung des durchschnittlichen Falls stellt die O-Notation allerdings ein sehr nützliches mathematisches Werkzeug für Informatiker und Algorithmenanalytiker dar, dessen Mächtigkeit weder unter- noch überschätzt werden sollte. Für Vorschläge und Anregungen habe ich immer das obligatorische Ohr offen. Mail an mich ist zudem besonders bei Lob willkommen (schließt Kritik und Verbesserungsvorschläge aber keinesfalls aus) ;oP (c) 2004 by RTC, www.linux-related.de
|