Laufzeitkomplexität von Algorithmen - die O-Notation



Die 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.

Gliederung


1.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 Historisches


Eingefü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/Ziele


Wie 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:
  • Es soll eine Funktion f in Abhängigkeit der Anzahl der Eingabewerte n gefunden werden, welche die Laufzeit eines Algorithmus' wiedergibt: f(n).
  • Die angegebene Funktion f(n) soll unabhängig sein von systemspezifischen Details (tatsächlicher Zeitaufwand), daher ist von konstanten Faktoren c zu abstrahieren.
  • Es soll eine Untersuchung des "ungünstigsten Falls" stattfinden und die Funktion f(n) soll mit einer Konstanten c als Faktor eine Asymptote bzw. Majorante zur tatsächlichen Laufzeit g(n) darstellen.
Man sollte sich nochmals vor Augen führen, dass die O-Notation "nur" eine mathematische Umsetzung dieser Ziele darstellt, wodurch allerdings exakte mathematische Beweise zu Aussagen über die Laufzeit von Algorithmen möglich sind.

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 Definition


Kommen 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:

Definition O-Notation


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:
g(n) könnte z.B. eine Laufzeit-Funktion eines Algorithmus' darstellen. Eindeutig zu sehen ist, dass g(n)<c*n4 ist, womit g(n)=O(n4) eine korrekte Aussage ist, jedoch sehr aufgerundet. Ebenfalls erkennbar ist, dass g(n)<c*n3 gilt und somit g(n)=O(n3) eine wahre Aussage darstellt. Dieser Term liegt schon näher an der kleinsten oberen Schranke. Der letzte Term stellt eine noch kleinere Majorante zu g(n) dar, ist jedoch nicht mehr komplett mittels O-Notation beschrieben. Will man eine exaktere Beschreibung der Laufzeit haben, so ist die Verwendung der O-Notation der falsche Weg. Wie schon erwähnt soll hierbei nur das allgemeine Wachstumsverhalten einer Laufzeit-Funktion gezeigt werden, jedoch nicht die exakte, von einem Rechner abhängige, Laufzeit.
An diesem eher trivialen Beispiel wird nochmals die Beziehung der O-Notation als Menge von Funktionen deutlich. g(n) ist dabei Element von O(n4) sowie O(n3) und vielen anderen Mengen O(f(n)) mit f(n) n3. Es kann gezeigt werden, dass c*n3 die kleinste Majorante der Funktion g(n) ist, die zu suchen eigentlich Sinn und Zweck jeder Komplexitätsuntersuchung mittels O-Notation ist. Auf eine Erläuterung dessen werde ich im nächsten Abschnitt noch eingehen...

Eine kleine Zusammenfassung dürfte an dieser Stelle wieder das Allgemeinverständnis verbessern:
  • O(f(n)) ist eine Menge aller Funktionen g(n), für die gilt: g(n) c*f(n).
  • g(n)=O(f(n)) bedeutet somit nichts anderes als: g(n) c*f(n).
  • Wie schon mehrfach erwähnt: c*f(n)=O(f(n)) bildet eine obere Schranke der Laufzeit im ungünstigsten Fall, d.h. die tatsächliche Laufzeit g(n) wird für alle n>n0 nie über c*f(n) liegen, wobei c eine rechner- und systemabhängige Konstante darstellt.
  • Ist g(n)=O(f(n)) und gilt: g(n) h(n), so gilt auch: h(n)=O(f(n)).
  • In umgekehrter Weise: ist g(n)=O(f(n)) und gilt: f(n) e(n), so gilt auch: g(n)=O(e(n)).

1.4 Interpretation


In 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:

Funktionsgraph

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:
  • Sei g(n)=4n2-2n+2. g(n) ist ein Polynom 2-ten Grades, womit sich o.a. Aussage für Polynome anwenden lässt, wonach gilt: g(n)=O(n2). Dies lässt sich auch nochmals nachvollziehen: g(n) = 4n2-2n+2 (4-2+2)*n2 = 4*n2. Da c=4 eine Konstante ist, kann man die Definition der O-Notation anwenden: g(n) c*n2, also g(n)=O(n2). Salopp ausgedrückt: "strebt die Anzahl der Eingabewerte n gegen unendlich, so wird der n2-Term dominant und alle anderen Terme sind zu vernachlässigen".
  • Betrachten wir als zweites Beispiel den Sortieralgorithmus "Bubblesort". Da dies hier kein "Lehrpfad" für Sortieralgorithmen werden soll, gebe ich zur Veranschaulichung nur den Sourcecode des Algorithmus' in Form einer C-Funktion an:
    void bubblesort(int *a, int n){
        int hilf, i, j;
        for(i=n-1; i>0; --i)
    	for(j=0; j<i; ++j)
    	    if(a[j]>a[j+1]){
    		hilf=a[j]; a[j]=a[j+1]; a[j+1]=hilf;
    	    }
    }
    
    Schön zu sehen ist, dass zwei ineinander verschachtelte Schleifen ausgeführt werden. In diesem Fall gleicht eine Betrachtung des ungünstigsten Falls einer Betrachtung des Durchschnitts-Falls und auch des besten Falls, da die Anzahl der Schleifendurchläufe konstant ist und nicht vom zu sortierenden Eingabefeld abhängt. In der Mehrzahl der Fälle ist dies bei einer solchen Untersuchung allerdings nicht der Fall. Nach ein wenig Nachdenken sieht man, dass die innere Schleife pro Durchlauf der äußeren Schleife i-mal ausgeführt wird und die äußere Schleife (n-1)-mal durchläuft. i wird in der äußeren Schleife dekrementiert, womit sich für die innere Schleife ein Durchschnitt von "n/2" Durchläufen ermitteln lässt Zwei verschachtelte Schleifen multiplizieren sich (siehe auch 2.2), was heißt, dass die innere Schleife beim gesamten Durchlauf von Bubblesort "(n*(n-1))/2"-mal ausgeführt wird. In der inneren Schleife wird eine if-Abfrage ausgeführt. Im Falle des "worst case" wird sie genau so oft positiv getestet werden, wie die innere Schleife durchlaufen wird. Im Körper der Abfrage werden 3 Wertzuweisungen vorgenommen. Da angenommen werden soll, dass Abfragen und Wertzuweisungen eine konstante Zeit in Anspruch nehmen, besitzt diese if-Anweisung folgende Zeitkomplexität: h(n) c1+c2+c3=c. D.h. die komplette if-Anweisung nimmt eine konstante Zeit c in Anspruch, womit sich nach Definition der O-Notation wiederum sagen lässt: h(n) c*1, also: h(n)=O(1).
    Wird weiterhin in Betracht gezogen, dass h(n) genau "n*(n-1)/2"-mal ausgeführt wird, so lässt sich für die Laufzeit g(n) des gesamten Sortieralgorithmus' sagen:
    g(n) c*n*(n-1)/2 = c/2*n2 - c/2*n
    Hierbei handelt es sich um ein Polynom 2. Grades. Wie zuvor behandelt kann man deshalb über die Laufzeit g(n) von Bubblesort aussagen: g(n)=O(n2), anders ausgedrückt: "Bubblesort ist von quadratischer Komplexität".
Zusammenfassung:
  • Die O-Notation gibt eine obere Schranke für die Laufzeit eines Algorithmus' im ungünstigsten Fall an.
  • O(f(n)) beschreibt nicht eine Funktion, sondern eine ganze Menge von Funktionen.
  • g(n)=O(f(n)) heißt eigentlich g(n) O(f(n)), das sollte man sich immer wieder klar machen. Aufgrund dessen dürfen die Seiten einer Gleichung in O-Notation auch nie vertauscht werden, eine solche Gleichung ist immer nur "von links nach rechts" gültig!
  • Ein Polynom k-ten Grades besitzt die asymptotische Laufzeitkomplexität O(nk).

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:
  1. Die O-Notation gibt eine "obere Schranke" an, das heißt aber nicht, dass der Algorithmus auch jemals soviel Zeit benötigt, es heißt nur, dass garantiert NIE mehr Zeit benötigt wird. Vielleicht tritt der untersuchte ungünstigste Fall in der Praxis nie auf. Deshalb sollte man immer auch noch eine Betrachtung des durchschnittlichen und besten Falls in Erwägung ziehen.
  2. Die systemabhängige Konstante c ist im Allgemeinen unbekannt, muss aber nicht unbedingt klein sein. Dies birgt ein erhebliches Risiko. Z.B. würde man natürlich einen Algorithmus, welcher n2 Nanosekunden benötigt, einem solchen vorziehen, der n Jahrhunderte läuft, anhand der O-Notation könnte man diese Entscheidung allerdings nicht treffen, da eine lineare Komplexität besser zu sein scheint als eine quadratische. Hier ist also höchste Vorsicht geboten!
  3. Die Konstante n0, ab der für alle nn0 gilt: g(n)c*f(n), ist ebenfalls unbekannt und muss genau wie c nicht unbedingt klein sein. So könnte n0
  4. 1 Million betragen, in der Praxis könnten allerdings meist nur weniger Eingabedaten vorkommen. D.h. dass die Laufzeit des Algorithmus' über der mittels O-Notation angegebenen Schranke liegt und somit ein falsches Ergebnis vorliegt.
  5. Schließlich ist die O-Notation eine Abschätzung der Laufzeit bei einer unendlichen Eingabemenge. Da jedoch keine Eingabe unendlich ist, sollte man bei der Wahl von Algorithmen die realistische Eingabelänge mit einbeziehen.
All dies sollte ein Informatiker respektive Algorithmenanalytiker berücksichtigen, bevor er sich vorschnell scheinbaren Tatsachen hingibt, sind Skepsis und Scharfsinn doch Eigenschaften, die stets präsent sein sollten.

2.1 Wichtige Rechenregeln


Zur 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:
  • "c=O(1)", da von der Konstanten c abstrahiert werden soll
  • "c*f(n)=O(f(n))", wie schon mehrfach besprochen
  • "O(f(n))+O(f(n))=O(f(n))", was klar wird, da O(f(n))+O(f(n))=c1*f(n)+c2*f(n)=(c1+c2)*f(n)=c*f(n)=O(f(n))
  • "O(O(f(n)))=O(f(n))" ist trivial erfüllt
  • "g(n)=ak*nk+ak-1*nk-1+...+a0 = O(nk)", wie bereits im Beispiel unter 1.4 gezeigt wurde
  • "O(f(n))*O(g(n))=O(f(n)*g(n))", was auch nahezu trivial erfüllt ist und hier sicher nicht weiter gezeigt werden muss
  • "O(f(n))+O(g(n))=O(max{f(n),g(n)})"; hierbei gebe max{...} das Maximum der übergebenen Parameter zurück; die Gültigkeit der Rechenregel lässt sich wieder mithilfe der Definition der O-Schreibweise zeigen:
    O(f(n))+O(g(n)) = c1*f(n)+c2*g(n) 2*max{c1,c2}*(f(n)+g(n)) = c*(f(n)+g(n)) c*2*max{f(n),g(n)} = O(max{f(n),g(n)})
    Zum Verständnis der Ungleichungskette: es lässt sich sagen, dass die Summe von zwei Funktionen "f(n)+g(n)" (bzw. von zwei Konstanten "c1+c2") in jedem Fall kleiner ist als wenn die größere der beiden Funktionen zweimal ausgeführt wird, wodurch gilt: f(n)+g(n) 2*max{f(n),g(n)}

2.2 Laufzeit der Grundbausteine einer Programmiersprache


Um 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:
  1. einfache Anweisung:
    Hierzu gehören beispielsweise Wertzuweisungen, Deklarationen bzw. Definitionen, einfache Arithmetik (+,-,*,/), Vergleiche (==,!=,<,>,<=,>=), Sprunganweisungen, Eingabe/Ausgabe, etc. . Die Laufzeit dieser Grundoperationen ist rechnerabhängig und soll als Konstante betrachtet werden, womit ihre asymptotische Laufzeitkomplexität O(1) beträgt.
  2. Sequenz:
    Hierunter versteht man die Nacheinander-Ausführung mehrerer in 1. aufgeführter Grundoperationen oder aber die Nacheinander-Ausführung mehrerer von n abhängiger Algorithmen. Gesetzt den letzteren Fall, so stellt sich bei drei auszuführenden Algorithmen folgendes Bild dar: "alg1; alg2; alg3;": O(f(n))+O(g(n))+O(h(n)) = O(max{f(n),g(n),h(n)})" gemäß der in 2.1 aufgeführten Rechenregel der Addition von O-Kalkülen. Es sind also zunächst einmal die asymptotischen Komplexitäten der drei Algorithmen getrennt zu bestimmen und dann mittels Rechenregel zu vereinen.
  3. Schleife
    Innerhalb einer Schleife werden meist die unter 1. gelisteten Basis-Operationen ausgeführt, womit der Schleifenkörper in konstanter Zeit durchlaufen wird. Bei n Durchläufen bedeutet dies: c*n=O(n). Eine Verallgemeinerung scheint sinnvoll, da der Schleifenkörper wiederum von n abhängig sein könnte, z.B. durch eine zweite innere Schleife oder weitere auszuführende Algorithmen. D.h. wenn der Schleifenkörper eine Zeitkomplexität von O(f(n)) hat und n-mal durchlaufen wird, so kann der gesamten Schleife O(n*f(n)) zugeordnet werden.
  4. if-else-Anweisung:
    Bei positiv getesteter if-Bedingung werde alg1 ausgeführt, sagen wir mit O(f(n)). Bei negativem Test hingegen soll alg2 durchlaufen werden mit O(g(n)). Da mit der O-Notation der "worst case" betrachtet werden soll, ist klar, dass sich die Zeitkomplexität nach dem Algorithmus richtet, welcher von höherer Ordnung ist als der andere, da die Ausführung dessen genau diesen ungünstigsten Fall darstellt. Daher gilt für die if-else-Anweisung mit alg1=O(f(n)) und alg2=O(g(n)): O(max{f(n),g(n)})
Dies ist nicht schwer, wenn man erst einmal das Prinzip verstanden hat...

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äten


Eine kurze Zusammenfassung der wichtigsten Laufzeitkomplexitäten und in welchen praktischen Anwendungen man sie wiederfinden kann:
  • logarithmische Komplexität: O(log(n)); kommt z.B. bei der "binären Suche" vor sowie bei allen "square-and-multiply"-Verfahren; diese Komplexität ist sehr günstig, da die Laufzeit noch nicht einmal linear wächst, beispielsweise kommt es durch Quadrieren von n gerade einmal zu einer Verdopplung der Laufzeit
  • lineare Komplexität: O(n); z.B. bei der Suche in einem unsortierten Feld; diese Komplexität sollte das primäre Ziel eines Algorithmenentwurfs sein, ist aber nur selten erreichbar
  • n*log(n)-Komplexität: O(n*log(n)); die Laufzeit einiger der schnellsten Sortieralgorithmen wird dadurch charakterisiert (z.B. Mergesort); ist noch sehr günstig, wenn auch nicht mehr linear
  • quadratische Komplexität: O(n2); in der Praxis charakterisierend für den Bubblesort-Algorithmus oder z.B. die Multiplikation einer Matrix mit einem Vektor; ist nicht mehr so günstig, da eine Verdopplung der Eingabedaten eine Vervierfachung der Laufzeit nach sich zieht, jedoch immernoch vertretbar
  • kubische Komplexität: O(n3); z.B. bei der Multiplikation zweier Matrizen; ist noch ungünstiger
  • exponentielle Komplexität: O(cn); katastrophal schon für kleine n
  • gemäß Fakultätsfunktion wachsende Komplexität: O(n!); katastrophal schon für kleine n
  • superexponentielle Komplexität: O(nn); katastrophal schon für kleine n
  • hyperexponentielle Komplexität: O(nc1c2..cn); katastrophal schon für kleine n
Die folgende Abbildung stellt das generelle Wachstumsverhalten der wichtigsten Funktionen noch einmal grafisch dar:
Funktionengraph
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 Schlussbemerkungen


Im 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
Dieses Dokument unterliegt der GNU Free Documentation License