7Eigenschaften von Algorithmen

Bisher wurden sowohl eher informelle als auch formale Beschreibungsmethoden für Algorithmen eingeführt und der Leser hat bereits einige Erfahrungen mit der Programmierung in Java gesammelt. Ziel dieses Kapitels ist es, einige Eigenschaften von Algorithmen und damit auch von Programmen zu betrachten.

Fragestellungen betreffend Eigenschaften von Algorithmen

In Abschnitt 7.1 beschäftigt uns die Frage, welche Leistungen Algorithmen prinzipiell erbringen können. Gibt es für jedes Problem auch einen Lösungsalgorithmus – muss dieser gegebenenfalls nur noch gefunden werden? Oder gibt es Fragestellungen, die sich der algorithmischen Lösbarkeit prinzipiell entziehen?

Im folgenden Abschnitt 7.2 geht es dann um die Korrektheit von Algorithmen und damit von Programmen. Kann man ein Programm zertifizieren, also gewisse Eigenschaften garantieren? Wie kann man nachweisen, dass ein Programm tatsächlich das tut, was es soll?

Nach der Behandlung der Korrektheit beschäftigt sich Abschnitt 7.3 dann mit einem anderen Aspekt der Güte eines Algorithmus, nämlich mit der Effizienz bzw. Komplexität. Grob gesprochen geht es darum, wie schnell ein Algorithmus ein Problem, beispielsweise das Sortieren einer Liste, in Abhängigkeit von der Eingabegröße lösen kann. Hier geht es dann sowohl um die Unterscheidung »schneller« und »langsamer« Lösungen desselben Problems als auch um prinzipielle Schranken für den Zeitbedarf der Lösung gewisser Probleme.

7.1Berechenbarkeit und Entscheidbarkeit

Gibt es Problemstellungen, für die es keinen Algorithmus gibt, der sie löst? Eine auf den ersten Blick schwierige Frage – man weiß ja in einem konkreten Fall, in dem man keine Lösung gefunden hat, nicht, ob es nicht doch eine gibt, die man nur noch nicht entdeckt hat. Hier verhält es sich ähnlich wie bei mathematischen Sätzen, die oft Jahrzehnte als Problem formuliert waren, bevor ein erster Beweis geführt werden konnte.

Wir gehen daher das Problem von einer anderen Seite her an, bevor wir uns auf die Suche nach konkreten nichtberechenbaren Fragestellungen machen. Hierzu überlegen wir uns zuerst, wie viele Algorithmen es denn überhaupt geben kann.

7.1.1Existenz nichtberechenbarer Funktionen

Um über die Berechenbarkeit von Problemstellungen Aussagen treffen zu können, konkretisieren wir die Fragestellung derart, dass wir die Frage betrachten, welche mathematischen Funktionen sich überhaupt durch Algorithmen – seien es applikative, imperative oder andere – berechnen lassen. Gibt es also mathematische Funktionen, die sich nicht durch irgendeinen Algorithmus berechnen lassen?

Wir können aus den geforderten Basiseigenschaften von Algorithmen schließen, dass dies tatsächlich der Fall sein muss. Wir benutzen hierzu nur eine – sicherlich unabdingbare – Eigenschaft eines jeden Algorithmus, nämlich:

Jeder Algorithmus lässt sich durch einen endlichen Text über einem festen, endlichen Alphabet beschreiben.

Endliche Texte über einem Alphabet

Wir beginnen damit, derartige Texte mathematisch exakt zu definieren. Sei A = {a1, …, an} ein Alphabet mit der alphabetischen Ordnung a1 < a2 < … < an. Mit A* bezeichnen wir die Menge der Texte (auch Zeichenketten, endliche Folgen, Worte genannt) über A :

A* = {ε, a1, …, an, a1a1, a1a2, …, a5a3a3a1a5a2, …}

Hierbei ist ε der leere Text der Länge 0.

Wir können die Texte in A* nun in einer festen Ordnung auflisten, und zwar

  1. der Länge nach. Zu jeder Länge l gibt es nl verschiedene Texte über A, also endlich viele.
  2. lexikographisch innerhalb der Texte gleicher Länge:

    b1b2bl < c1c2cl

    gilt genau dann, wenn

    b1 < c1

    (b1 = c1b2 < c2)

    (b1 = c1b2 = c2b3 < c3)

    (b1 = c1 ∧ … ∧ bl−1 = cl−1bl < cl)

    gilt.

Ordnung auf A*

Hierdurch ist eine Ordnung auf A* eindeutig bestimmt, so dass wir von dem ersten, zweiten, dritten etc. Text über A (gemäß dieser Ordnung) reden können. Die derart definierte Ordnung der Texte fester Länge kann auch auf Texte unterschiedliche Länge erweitert werden und ist uns als lexikographische Ordnung etwa aus der Sortierung von Namen etwa in Telefonbüchern vertraut.

Aus der Existenz dieser lexikographischen Ordnung folgt eine mathematische Charakterisierung der Anzahl der Texte:

Satz 7.1 Abzählbarkeit von A

Die Menge A* aller Texte ist abzählbar.

image

Die Anzahl der Texte über einem festen Alphabet entspricht somit der Anzahl der natürlichen Zahlen. Abzählbarkeit bedeutet, dass man die Menge A* der Reihe nach durchnummerieren kann, sie also wie in einem Kinderreim »abzählen« kann. Mathematisch entspricht eine derartige Abzählung einer bijektiven Funktion von A* auf die natürlichen Zahlen.

Da es nicht mehr berechenbare Funktionen als sie berechnende Algorithmen und nicht mehr Algorithmen als sie beschreibende Texte geben kann, gilt offenbar auch folgende Aussage:

Satz 7.2 Abzählbarkeit der berechenbaren Funktionen

Die Menge der durch Algorithmen definierten berechenbaren Funktionen ist abzählbar.

image

Nicht jeder Text in A* definiert einen Algorithmus, der eine Funktion berechnet – aber es kann auch nicht mehr berechnende Algorithmen als Texte geben.

Betrachten wir nun speziell einstellige Funktionen auf image, f : imageimage. Wie bewiesen, gibt es nur abzählbar viele solcher berechenbaren Funktionen. Funktionen insgesamt gibt es jedoch weit mehr, wie der folgende Satz zeigt:

Satz 7.3 Überabzählbarkeit der einstelligen Funktionen

Die Menge F = {f | f : imageimage} der einstelligen Funktionen auf image ist überabzählbar.

Beweis: Wir nehmen an, F sei abzählbar, so dass wir nun alle f F auflisten können, F = {f0, f1, …}, d.h., jedes f hat eine Nummer i in dieser Folge. Sei nun g : imageimage definiert durch

g(x) = fabs(x)(x) + 1

Dann ist für i = 1, 2, …, g(i) ≠ fi(i), also ist für alle i = 1, 2, … immer gfi. Die Funktion g kommt demnach in der obigen Folge nicht vor. Offensichtlich ist g aber eine einstellige Funktion auf image und müsste somit in der obigen Folge aller dieser Funktionen vorkommen. Der Widerspruch lässt sich nur lösen, wenn wir die Annahme fallen lassen, F sei abzählbar.

image

Berechenbare Funktionen sind also unter allen Funktionen genauso »seltene« Ausnahmen wie ganze (oder natürliche oder rationale) Zahlen unter den reellen.

Cantor’sches Diagonalverfahren

Die obige Beweismethode ist unter dem Namen »Cantor’sches Diagonalverfahren« bekannt. Der Begriff Diagonalverfahren kommt von der grafischen Darstellung der fi(j) in einer (i, j)-Matrix – der Beweis konstruiert jeweils Unterschiede entlang der Diagonalen (also für i = j). Auf ähnliche Weise hat Cantor erstmals bewiesen, dass die reellen Zahlen überabzählbar sind.

Nachdem wir wissen, dass berechenbare Funktionen eher seltene Ausnahmen sind, ist die Frage nahe liegend, ob sich nichtberechenbare Funktionen konkret angeben lassen. Um zu beweisen, dass eine gegebene Funktion berechenbar ist, braucht man nur einen Algorithmus anzugeben, der sie berechnet. So schwierig dies im Einzelfall sein mag, so ist es doch prinzipiell schwieriger zu beweisen, dass eine gegebene Funktion nicht berechenbar ist. Dieses erfordert nämlich eine Beweisführung über alle denkbaren und möglichen Algorithmen!

Derartige Ergebnisse über Algorithmen lassen sich nur gewinnen, wenn man den Begriff des Algorithmus mit hinreichender mathematischer Genauigkeit definiert, wie dies im vorigen Kapitel geschah.

7.1.2Konkrete nichtberechenbare Funktionen

Nun kehren wir zu dem Problem zurück, eine nichtberechenbare Funktion konkret anzugeben. Wir benutzen dazu ein beliebiges Algorithmenmodell, welches folgenden Kriterien genügt:

  1. Berechnet werden partielle Funktionen f : A* → A* über einem festen Alphabet A.
  2. Auch die Algorithmen selbst lassen sich als Text über A darstellen.

Durch diese Festlegungen können Algorithmentexte wiederum als Eingaben für Algorithmen genommen werden. Diese Eigenschaft ist wichtig für die folgenden Betrachtungen.

Als Beispiel lassen sich Markov-Algorithmen leicht als Text über einem geeigneten Alphabet A kodieren, in dem auch die Eingabe- und Ausgabewerte kodiert werden können.

Gödelisierung

Alternativ kann man statt Algorithmen auf A* auch Algorithmen über den natürlichen Zahlen betrachten. Hierbei müssen dann allerdings auch die Algorithmentexte als Zahlen verschlüsselt werden. Kodierungen von Algorithmen durch natürliche Zahlen gehen auf K. Gödel (1931) zurück. Man spricht daher bei dieser Variante auch von »Gödelisierung«.

Vor der eigentlichen Konstruktion einer konkreten nichtberechenbaren Funktion legen wir einige formale Konzepte im Zusammenhang mit den betrachteten Algorithmen fest.

Definition 7.1 Von x berechnete Funktion

Sei x A* ein beliebiger Text. Dann bezeichnet ϕx die vom Algorithmus mit Text x berechnete Funktion. Ist x kein sinnvoller Algorithmentext, so sei ϕx überall undefiniert.

image

Natürlich sind die meisten Texte in A* keine sinnvollen Algorithmentexte – dies ist aber für die folgenden Betrachtungen nicht relevant.

Definition 7.2 Urbildbereich einer Funktion

Sei f : A* → A* eine partielle Funktion. Dann ist

dom f := {x A* | f(x) ist definiert}

der Urbildbereich von f (dom für »domain«).

image

Der Urbildbereich einer Funktion gibt uns also genau diejenigen Eingabewerte, bei der die Funktion einen Wert berechnet. Anders formuliert: Für alle Werte außerhalb des Urbildbereichs hält der Algorithmus nicht.

Nun sind wir endlich in der Lage, eine nichtberechenbare Funktion konkret anzugeben. Dazu sei a A ein fest gewählter Buchstabe.

Satz 7.4 Halteproblem

Die (totale) Funktion h : A* → A*,

image

ist nicht berechenbar.

image

Die Werte ε, also das leere Wort, und a sind dabei willkürlich gewählt – wichtig ist, dass es sich um zwei unterschiedliche Werte aus A* handelt, die die Rolle von true und false übernehmen können.

Spezielles Halteproblem

Die obige Funktion h ist mathematischer Ausdruck des Spezialfalls eines recht anschaulichen Problems, des sogenannten Halteproblems. Die Funktion h entscheidet durch die Ausgaben ε oder a, ob x dom ϕx ist oder nicht. Nun bedeutet y dom ϕx, dass ϕx(y) definiert ist, und dies wiederum heißt, dass der Algorithmus mit Text x bei Eingabe von y irgendwann anhält. Betrachtet wird nun der Fall x = y, also die Anwendung eines Algorithmus auf die eigene Beschreibung. Die Funktion h drückt also die Frage aus:

»Hält ein Algorithmus irgendwann an, wenn man ihm seinen eigenen Text eingibt?«

Diese Frage ist auch als spezielles Halteproblem bekannt. Da die Funktion h total ist, ist sie für alle Texte von Algorithmen definiert und ihre Berechnung würde daher in allen Fällen terminieren. Diese Fragestellung sieht auf den ersten Blick etwas esoterisch aus. Ein Compiler für Java kann aber durchaus in Java programmiert sein – ein Programm, das seinen eigenen Text als Eingabe verarbeitet, ist also durchaus nicht ungewöhnlich.

7.1.3Das Halteproblem

Probleme, deren Entscheidungsfunktion nicht berechenbar ist, nennt man nichtentscheidbar. Es gibt eine große Fülle nichtentscheidbarer Probleme, von denen wir einige genauer betrachten werden. Das Problem x dom ϕx ist ein spezielles Entscheidungsproblem, auch Selbstanwendungsproblem oder spezielles Halteproblem genannt. Das Selbstanwendungsproblem ist nun gerade eines dieser nichtentscheidbaren Probleme.

Allgemeines Halteproblem

Um die Nichtentscheidbarkeit des speziellen Halteproblems zu zeigen, betrachten wir zuerst das allgemeine Halteproblem. Das allgemeine Halteproblem ist y dom ϕx, also:

»Hält Algorithmus x bei der Eingabe von y?«

Beweisen wollen wir nun die folgende Aussage:

Satz 7.5 Allgemeines Halteproblem

Das allgemeine Halteproblem ist nichtentscheidbar.

image

Beweis: Gäbe es einen Entscheidungsalgorithmus für y dom ϕx, so könnte man damit auch speziell x dom ϕx entscheiden.

image

Bemerkung: So einfach dieser Beweis erscheint, er zeigt ein typisches Grundmuster, nämlich »Reduktion auf ein als nichtentscheidbar bewiesenes Problem«. Fast alle Nichtentscheidbarkeitsbeweise sind so konstruiert: »Wäre das (neue) Problem Y entscheidbar, so auch das (alte) Problem X«. Das Halteproblem spielt hierbei die Rolle einer Wurzel, auf die letzten Endes alle Nichtentscheidbarkeitsprobleme reduziert werden.

image

Wir haben uns bisher um einen wirklichen Beweis gedrückt – Satz 7.4 wurde ja noch gar nicht bewiesen und damit hängt der Beweis von Satz 7.5 noch genauso in der Luft. Diesen Beweis müssen wir jetzt natürlich nachholen.

Wegen der grundlegenden Bedeutung des Halteproblems für die Theorie der Entscheidbarkeit wollen wir zunächst einen »anschaulichen« Beweis für seine Nichtentscheidbarkeit liefern.

Angenommen, wir hätten eine Maschine (Algorithmus) STOP mit zwei Eingaben, nämlich einem Algorithmentext x und einer Eingabe y für x, sowie zwei Ausgaben:

JA: x stoppt bei Eingabe von y

NEIN: x stoppt nicht bei Eingabe von y

In Abbildung 7–1 wird diese Maschine grafisch dargestellt.

image

Abb. 7–1 Die Maschine STOP

Mit dieser Maschine STOP könnten wir dann eine neue Maschine SELTSAM konstruieren, die in Abbildung 7–2 dargestellt ist.

image

Abb. 7–2 Die Maschine SELTSAM

Bei Eingabe von x wird getestet, ob x bei Eingabe von x stoppt. Im JA-Fall wird in eine Endlosschleife gegangen. Im NEIN-Fall hält SELTSAM an mit der Anzeige OK. Eine derartige Konstruktion ist in jedem der bisher betrachteten Algorithmenmodelle leicht durchführbar.

Nun geben wir den Algorithmentext von SELTSAM als Eingabe für sich selbst ein und fragen:

Hält SELTSAM bei der Eingabe von SELTSAM?

Bei genauerer Betrachtung kommen wir zu folgenden Antworten:

  1. Wenn die Frage mit ja beantwortet wird, so wird der JA-Ausgang von STOP angelaufen und SELTSAM gerät in die Endlosschleife, hält also nicht. Dies ist ein Widerspruch!
  2. Wenn nein, so wird der NEIN-Ausgang von STOP angelaufen und SELTSAM stoppt mit OK. Dies ist ebenfalls ein Widerspruch (Beantwortung der Frage mit nein, die Maschine hält also nicht)!

Diese Widersprüche folgen allein aus der Annahme, dass eine STOP-Maschine existiert, was daher verneint werden muss.

Auch wenn diese Beweisführung für das intuitive Verständnis ausreicht, wollen wir nun doch der Vollständigkeit halber einen mathematischen Beweis »nachschieben«.

Beweis der Nichtberechenbarkeit des speziellen Halteproblems

Beweis: (formaler Beweis von Satz 7.4): Wir nehmen an, die Funktion h : A* → A* mit

image

sei berechenbar. Dann ist aufgrund der Church’schen These auch die Funktion g : A* → A*

image

berechenbar, da eine derartige Fallunterscheidung in den uns bekannten Algorithmenmodellen leicht berechnet werden kann.

Nun ist sicherlich aufgrund der Definition g(x)≠ ϕx(x) für alle x A*. Da g berechenbar ist, gibt es einen Algorithmus mit einem Text y A*, der g berechnet, d.h., es gibt ein y mit g = ϕy. Damit folgt nun:

ϕy(y) = g(y) ≠ ϕy(y)

Dies ist offenbar ein Widerspruch, der sich nur dadurch lösen lässt, dass wir die Annahme aufgeben, h sei berechenbar.

image

Dies ist übrigens wiederum ein Diagonalbeweis.

7.1.4Nichtentscheidbare Probleme

Das Halteproblem ist ein Beispiel für ein semantisches Problem von Algorithmen, nämlich ein Problem der Art:

Kann man anhand eines Programmtextes (Syntax) entscheiden, ob die berechnete Funktion (Semantik) eine bestimmte Eigenschaft hat?

Beim Halteproblem ist dies die Eigenschaft, ob die berechnete Funktion an einer bestimmten Stelle definiert ist. Wie steht es nun mit anderen semantischen Eigenschaften von Algorithmen?

Aus einem grundlegenden Satz der Algorithmentheorie (Satz von Rice) folgt die folgende bemerkenswerte Aussage:

Jede nichttriviale semantische Eigenschaft von Algorithmen ist nichtentscheidbar.

Dabei ist eine Eigenschaft genau dann trivial, wenn entweder jede oder keine berechnete Funktion sie hat. Nichttriviale semantische Eigenschaften sind demnach solche, die manche berechnete Funktionen haben und manche nicht.

Nichtentscheidbare semantische Eigenschaften von Algorithmen

Nicht entscheidbar sind also u.a. die folgenden Probleme:

  1. Ist die berechnete Funktion total? Überall undefiniert? Injektiv? Surjektiv? Bijektiv? etc. etc.
  2. Berechnen zwei gegebene Algorithmen dieselbe Funktion?
  3. Ist ein gegebener Algorithmus korrekt, d.h., berechnet er die gegebene (gewünschte) Funktion?

Diese Ergebnisse bedeuten nicht, dass man solche Fragen nicht im Einzelfall entscheiden könnte! Dies ist durchaus möglich. So behandeln wir z.B. im folgenden Abschnitt 7.2 das Problem, die Korrektheit von Algorithmen nachzuweisen. Es ist jedoch prinzipiell unmöglich, eine allgemeine Methode hierfür zu finden, also z.B. einen Algorithmus, der die Korrektheit aller Algorithmen nachweist (und damit auch seine eigene!).

Man könnte nun den Eindruck gewinnen, dass die Nichtentscheidbarkeit nur semantische, eher »esoterische« Probleme betrifft. Im folgenden Abschnitt werden wir sehen, dass auch sehr konkrete und einfach klingende Probleme nichtentscheidbar sein können.

7.1.5Post’sches Korrespondenzproblem

Post’sches Korrespondenzproblem

Ein besonders einfaches und verblüffendes nichtentscheidbares Problem ist das sogenannte Post’sche Korrespondenzproblem (E. Post): Gegeben seien ein Alphabet A und zwei gleich lange Listen von Worten über A:

α

=

(α1, α2, …, αn)

β

=

(β1, β2, …, βn)

wobei αi, βi A+ = A* − {ε} und n ≥ 1. Das Problem besteht nun darin, eine »Korrespondenz« zu finden, d.h. eine endliche Folge (i1, i2. …, ik), ij {1, …, n} für j = 1, 2, …, k, so dass gilt:

αi1 αi2αik = βi1 βi2βik

Die folgenden zwei Beispiele erläutern dies näher.

Beispiel 7.1 Post’sches Korrespondenzproblem

Wir betrachten ein Post’sches Korrespondenzproblem über einem zweielementigen Alphabet mit Wortlisten der Länge 3.

A = { 0, 1 }

α = (1, 1 0 1 1 1, 1 0)

β = (1 1 1, 1 0, 0)

Lösung

Dieses Post’sche Korrespondenzproblem besitzt eine Lösung, nämlich die Korrespondenz (2,1,1,3):

1 0 1 1 1 . 1 . 1 . 1 0 = 1 0 . 1 1 1 . 1 1 1 . 0

Mit dem Finden einer Lösung hat man natürlich immer gleich unendlich viele Lösungen gefunden, indem man die Lösung iteriert.

image

Der positive Schluss kann einfach durch Angabe einer Lösung erfolgen. Der Nachweis des Fehlens einer Lösung muss mittels eines Beweises über alle möglichen Korrespondenzlisten erbracht werden.

Beispiel 7.2 Post’sches Korrespondenzproblem ohne Lösung

Wir betrachten wieder ein Post’sches Korrespondenzproblem über einem zweielementigen Alphabet mit Wortlisten der Länge 3.

A = { 0, 1 }

α = (1 0, 0 1 1, 1 0 1)

β = (1 0 1, 1 1, 0 1 1)

Dieses Post’sche Korrespondenzproblem besitzt keine Lösung. Gäbe es nämlich eine, so müsste sie mit dem Index 1 anfangen:

image

Als zweites müsste nun ein Index i mit αi = 1 … gewählt werden, also 1 oder 3. Aber (1, 1, …) ergibt

1 0 1 0 … ≠ = 1 0 1 1 0 1 …

und (1, 3, …) ergibt

image

Die gleiche Überlegung wie oben führt nun dazu, dass wieder der Index 3 gewählt werden muss. (1, 3, 3, …) ergibt

image

Wieder müsste 3 gewählt werden usw. Es ist nun zu sehen, dass niemals eine Lösung entstehen kann, da die rechte Seite der linken immer um eine Stelle voraus ist.

image

Während wir in diesen Beispielen die Lösbarkeit entscheiden können, ist das allgemeine Problem, nämlich zu entscheiden, ob eine Lösung für ein gegebenes Post’sches Korrespondenzproblem über einem Alphabet mit mindestens zwei Buchstaben existiert, nicht entscheidbar. Auch der Beweis dieser Aussage wird auf eine Kodierung eines speziellen Halteproblems als Post’sches Korrespondenzproblem zurückgeführt.

Wir überlassen Einzelheiten und den Beweis dieser Aussage Lehrbüchern der theoretischen Informatik – hier war es nur unser Ziel, zu zeigen, wie verblüffend einfach nichtentscheidbare Fragestellungen sein können.

7.2Korrektheit von Algorithmen

Korrektheit von Algorithmen

Es ist ganz wesentlich, dass Algorithmen, die wir für eine bestimmte Aufgabe entworfen haben, korrekt sind, d.h., dass sie sich genauso verhalten, wie wir es beabsichtigt hatten. Bei einigen hochsensiblen und kritischen Anwendungen (z.B. in der medizinischen Informatik, bei der Steuerung von Robotern und Fahrzeugen, aber auch in der Baustatik) können durch nicht korrekte Algorithmen hervorgerufene Fehlfunktionen schwere Schäden anrichten oder gar Katastrophen auslösen.

Nun haben wir im vorherigen Abschnitt gesehen, dass die Korrektheit von Programmen im Allgemeinen nicht entscheidbar ist. In vielen Anwendungsfällen wird man mit pragmatischem Austesten eine hinreichende Sicherheit erreichen können. Will man jedoch die Korrektheit eines Algorithmus mit mathematischer Exaktheit und Strenge nachweisen, so ist man auf eine Beweisführung im Einzelfall angewiesen. Dies ist nicht nur bei sicherheitskritischen Anwendungen angezeigt, sondern z.B. auch bei Standardalgorithmen, die hardwaremäßig, etwa als VLSI-Chip, realisiert und in großer Serie produziert werden sollen. Ein Beispiel hierfür sind Kommunikationsprotokolle. Ein großer Aufwand bei dem Nachweis der Korrektheit lohnt sich hier, da Fehler große wirtschaftliche Verluste bewirken.

In diesem Abschnitt wollen wir einige grundlegende Konzepte und Vorgehensweisen für Korrektheitsbeweise für Algorithmen erörtern. Die Problematik kann hier nicht tief und ausführlich abgehandelt werden. Wir beschränken uns darauf, Problematik und Vorgehensweise anhand einfacher Beispiele zu demonstrieren.

7.2.1Relative Korrektheit

Relative Korrektheit

Der Nachweis der Korrektheit eines Algorithmus bezieht sich immer auf eine Spezifikation dessen, was er tun soll. Insofern handelt es sich immer um eine relative Korrektheit. Ein Algorithmus kann also nicht als korrekt an sich bewiesen werden, sondern nur in Bezug auf eine Spezifikation des beabsichtigten Verhaltens.

Verifikation

Der Nachweis der Korrektheit kann auf verschiedene Arten erfolgen. Unter der Verifikation verstehen wir einen formalen, mathematisch rigiden Beweis, dass ein Algorithmus korrekt bezüglich einer Spezifikation ist. Verifikation erfordert eine Formalisierung der Algorithmensprache sowie eine Spezifikation in einem mathematisch exakten Formalismus. Unter Validation verstehen wir einen nichtformalen und in der Regel nicht hundertprozentig sicheren Nachweis der Korrektheit, wie er etwa durch systematisches Testen erfolgen kann.

Die für die Korrektheit eines Algorithmus wichtigen Begriffe können wir also wie folgt zusammenfassen:

Große Teilgebiete der Softwaretechnik beschäftigen sich mit Spezifikationssprachen, semiautomatischer und automatischer Verifikation und mit Testverfahren. Wir können hier nur exemplarisch einige Grundprinzipien anhand von Beispielen erläutern.

7.2.2Korrektheit von imperativen Algorithmen

Vor- und Nachbedingungen

Wir behandeln zunächst die imperativen Algorithmen, die ja vielen kommerziellen Programmiersprachen zugrunde liegen. Eine verbreitete Methode, die gewünschten Eigenschaften von Algorithmen zu spezifizieren und die gerade für imperative Algorithmen gut eingesetzt werden kann, ist die Angabe von Vor- und Nachbedingungen :

{ VOR }ANW{ NACH }

VOR und NACH sind dabei Aussagen über den Zustand vor bzw. nach Ausführung der Anweisung ANW. Der Bezug auf den Zustand verdeutlicht, dass diese Spezifikationsform gerade für imperative Algorithmen geeignet ist, deren Semantik von uns ja als Zustandstransformation definiert wurde. Genauer bedeutet diese Aussage:

Semantik von Vor- und Nachbedingungen

Gilt die Bedingung VOR unmittelbar vor Ausführung von ANW und terminiert ANW, so gilt die Bedingung NACH unmittelbar nach Ausführung von ANW.

Terminiert die Anweisungsfolge ANW nicht, so ist eine derartige Bedingung also trivialerweise wahr, wie auch immer VOR und NACH aussehen.

Entsprechend ist die Bedingung auch trivialerweise wahr, wenn die Vorbedingung VOR nicht gilt, gleichgültig, ob ANW terminiert oder nicht und ob NACH gilt oder nicht.

Vor- und Nachbedingungen und imperative Algorithmen

Vor- und Nachbedingungen basieren auf dem Konzept eines Zustands, der durch eine Anweisung verändert wird. Der Zustand ist bei imperativen Algorithmen über die Werte der Variablen bestimmt. Vor- und Nachbedingungen können somit als logische Formeln der Prädikatenlogik unter Verwendung dieser Variablen notiert werden. Man muss dabei beachten, dass diese Variablen in der Vorbedingung einen anderen Wert annehmen können als in der Nachbedingung, da ihr Wert durch eine Wertzuweisung modifiziert worden sein kann. Wir werden dieses Konzept nun an einigen Beispielen erläutern.

Beispiel 7.3 Anweisungen über Variablen

Wir betrachten Anweisungen über Variablen, die Werte aus dem Bereich der ganzen Zahlen Z annehmen.

  1. Die Bedingung

    {X = 0}X := X + 1{X = 1 }

    ist wahr. Diese Spezifikation entspricht genau der Bedeutung der Wertzuweisung in imperativen Algorithmen.

  2. Die Bedingung

    {true}X := Y{X = Y}

    ist ebenfalls wahr.

  3. Die Bedingung

    {Y = a}X := Y{X = aY = a}

    ist wahr für alle a image.

  4. Die Bedingung

    {X = aY = bab}X := Y; Y := X{X = bY = a}

    ist falsch für alle a, b image, da nach dem ersten Schritt beide Variablen den Wert b angenommen haben (ein typischer Anfängerprogrammierfehler beim Wertetausch).

  5. Die Bedingung

    {X = aY = b}Z := X; X := Y; Y := Z{X = bY = a}

    ist wahr für alle a, b image – dies ist die korrekte Realisierung des Wertetauschs.

  6. Die Bedingung

    {false}ANW{NACH}

    ist wahr für alle ANW und NACH, da eine falsche Vorbedingung die Implikation immer erfüllt.

  7. Die Bedingung

    {true}ANW{false}

    ist aufgrund unserer Definition genau dann wahr, wenn ANW nicht terminiert.

  8. Die Bedingung

    {X = a}whileX ≠ 0 do X := X − 1od{X = 0}

    ist wahr für alle a image – insbesondere auch für a < 0, da dann die while-Schleife nicht terminiert.

image

Bei der Korrektheit imperativer Algorithmen bzgl. gegebener Vor- und Nachbedingungen unterscheidet man zwischen partieller und totaler Korrektheit. Zur Definition dieser Begriffe sei ein Algorithmus PROG gegeben:

PROG:

varX,Y, ...: int;

P,Q ...: bool;

inputX1, …, Xn;

α;

outputY1, …, Ym.

Ferner seien eine Vorbedingung VOR und eine Nachbedingung NACH für die Anweisung α von PROG gegeben. Die Vorbedingung spiegelt dabei die Belegung der Variablen mit Werten wider, während die Nachbedingung die zu beweisende Berechnung ausdrückt.

Definition 7.3 Partielle Korrektheit

Ein Programm PROG heißt partiell korrekt bzgl. VOR und NACH gdw.

{VOR} α {NACH}

wahr ist (siehe informelle Definition der Bedeutung von Vor- und Nachbedingungen).

Totale Korrektheit

PROG heißt total korrekt bzgl. VOR und NACH gdw. PROG partiell korrekt ist bzgl. VOR und NACH, und wenn α darüber hinaus immer dann terminiert, wenn vorher VOR gilt.

image

Im Rahmen dieses Buches wird darauf verzichtet, ein Regelwerk zum Beweisen von Vor- und Nachbedingungen anzugeben. Es sei stattdessen auf spätere Vorlesungen und weiterführende Literatur vertröstet. Hier sei nur bemerkt, dass das Finden von geeigneten Vor- und Nachbedingungen nicht einfach ist. Für jedes Konstrukt imperativer Algorithmen muss eine Beweisvorgehensweise entwickelt werden. Wir werden dies nur für den Fall der Schleifen etwas ausführlicher demonstrieren.

7.2.3Korrektheitsbeweise für Anweisungstypen

Imperative Algorithmen haben vier grundlegende Anweisungstypen: Wertzuweisungen als atomare Anweisungen, Sequenz, Selektion und Iteration. Wir werden für diese vier Anweisungsmuster kurz erläutern, wie bei einem Beweis vorgegangen werden muss – auf eine intensive Diskussion werden wir wie bereits erwähnt verzichten und stattdessen anschließend zwei Beispiele vorstellen.

Atomare Anweisungen

Transformation bei Wertzuweisung

Für eine atomare Anweisung X := t kann man eine Vorbedingung in eine Nachbedingung transformieren, indem jedes Auftreten des Terms t in der Vorbedingung V OR durch X ersetzt wird. So kann man etwa die folgende Bedingungen zeigen:

{3 > 0} X := 3{X > 0}

Beachten muss man dabei, dass die Variablen der Vorbedingung den alten Zustand betreffen, die der Nachbedingung den neuen. Daher kann man dieses Prinzip auch auf folgendes Beispiel übertragen, bei dem die Variable X in beiden Bedingungen auftaucht:

{X + 1 > 0} X := X + 1{X > 0}

Man kann sich die Korrektheit des Beispiels dadurch verdeutlichen, dass man die »neuen« Werte von X als X′ markiert:

{X + 1 > 0} X′ := X + 1{X′ > 0}

Die Ersetzung ist natürlich oft nicht direkt möglich, da der Term t in der Vorbedingung auftauchen muss, um eine geeignete Nachbedingung zu erzeugen. Dies kann man allerdings mittels logischer Tautologien und Umformungen erreichen. Als Beispiel tauchen in der Formel

{X > 0} X := X − 1 {X ≥ 0 }

keine geigneten Terme auf, aber nach Äquivalenzumformungen können wir trotzdem den Beweis führen:

{(X − 1) + 1 > 0} X := X − 1 {X + 1 > 0}

Hier haben wir die Äquivalenzen (X > 0) ((X − 1) + 1 > 0) und (X ≥ 0) ≡ (X + 1 > 0) ausgenutzt.

Sequenzen von Anweisungen

Korrektheit einer Sequenz

Besteht die Anweisung aus der Sequenz zweier Schritte, so muss man den Beweisschritt in zwei Schritte aufteilen. Um also

{VOR} α; β {NACH}

zu zeigen, müssen wir eine Zwischenbedingung MITTE finden, so dass

{VOR} α {MITTE}

und

{MITTE} β {NACH}

gezeigt werden kann.

Selektion

Korrektheit einer Selektion

Bei der Beweisführung im Fall der Selektion ist eine Fallunterscheidung notwendig. Um die Formel

{VOR} if B then α else β fi {NACH}

beweisen zu können, muss

{VORB} β {NACH}

und

{VOR ∧ ¬B} β {NACH}

gezeigt werden. Wichtig ist also, dass die Nachbedingung gilt, egal welcher Zweig selektiert wurde.

Iteration und Schleifeninvarianten

Korrektheit einer Iteration

Erwartungsgemäß ist die Beweisführung für den Fall der Iteration aufwendiger. Um eine Bedingung über eine Schleife der Form

{ VOR }

while B do β od

{ NACH }

Schleifeninvariante

zu überprüfen, müssen wir eine sogenannte Schleifeninvariante P finden, die auch bei mehrfachen Schleifendurchläufen jeweils ihre Gültigkeit bewahrt. Im Einzelnen müssen wir dabei Folgendes prüfen:

  1. Als erster Schritt ist zu prüfen, ob

    VORP

    gilt. Die Vorbedingung garantiert also die Gültigkeit von P beim ersten Eintritt in die Schleife.

  2. Für den Schleifenrumpf ist Folgendes zu überprüfen:

    {P ∧ B} β {P}

    Der Schleifenrumpf bewahrt die Schleifeninvariante in den Fällen, in denen der Rumpf tatsächlich durchlaufen wird. Da uns nur dieser Fall interessiert, können wir für die Beweisführung annehmen, dass vor dem Eintritt in die Schleife sowohl die Schleifeninvariante als auch die Bedingung B gilt.

  3. Die Nachbedingung muss nach Verlassen der Schleife gelten, also in dem Fall, dass B den Wert false annimmt:

    {P ∧ ¬B} ⇒ NACH

Übertragbarkeit auf imperative Programmiersprachen

Die vorgestellten Beweismuster können direkt auf die entsprechenden Grundmuster in imperativen Programmiersprachen übertragen werden. Einige weitere verbreitete Konstrukte wie die case-, for- und until-Konstrukte können auf die vorgestellten vier Basismuster zurückgeführt werden.

Mehr Aufwand bedarf es, den Aufruf von Prozeduren oder exit- oder gar goto-Anweisungen in den Formalismus zu integrieren. Hier muss unter anderem der doch einfache Zustandsbegriff deutlich erweitert werden.

7.2.4Korrektheit imperativer Algorithmen an Beispielen

Um die Grundprinzipien der Verifikation von Vor- und Nachbedingungen zu zeigen, führen wir Beweise für zwei einfache Algorithmen. Der erste Beweis zeigt den Einsatz von Schleifeninvarianten an einem einfachen Beispiel.

Beispiel 7.4 Korrektheit von MULT

Wir betrachten als Beispiel den Algorithmus MULT, der die Multiplikation nur mittels Addition und Subtraktion realisiert.

MULT:

varW,X,Y,Z :int;

inputX, Y

Z:=0;

W:=Y;

whileW0doZ:=Z+X;W:=W-1od;

outputZ

Der Schleifenrumpf sei wieder mit β bezeichnet. Die Spezifikation lautet wie folgt:

Um diese Spezifikationen beweisen zu können, benötigen wir eine Schleifeninvariante P. P muss durch den Schleifenrumpf erhalten bleiben, und bei Verlassen der Schleife (also im Fall W = 0) die Nachbedingung implizieren. Durch Nachdenken kommen wir auf die folgende Bedingung (leider gibt es kein automatisches Verfahren zur Bestimmung einer geeigneten Nachbedingung für alle Situationen):

P = (X · Y = Z + W · X)

Wir müssen nun die drei Aussagen beweisen, die die Bedeutung einer Schleifeninvariante ausmachen:

  1. Die Schleifeninvariante muss beim Eintritt in die Schleife gelten. Hierzu notieren wir uns die Nachbedingungen mit den neuen Werten der Variablen (also X′ als neuer Wert von X) und formulieren dies als Nachbedingung der beiden Anweisungen vor der Schleife:

    {Y ≥ 0}Z := 0; W := Y {X′ · Y′ = Z′ + W′ · X′}

    Nun ersetzen wir die neuen Werte durch die alten Werte gemäß den Zuweisungen (X und Y wurden nicht verändert):

    {X · Y = X′ · Y′ = Z′ + W′ · X′ = 0 + Y · X}

    Dies ist sicherlich eine korrekte Aussage. P gilt also vor dem ersten Schleifendurchlauf.

  2. Nun müssen wir zeigen, dass wenn PB vor dem Schleifenrumpf gilt, P auch nach dem Ausführen des Schleifenrumpfs gilt, und notieren das Ganze mit als neu markierten Variablen:

    {X · Y = Z + W · XW ≠ 0}

    Z := Z + X; W := W − 1

    {X′ · Y′ = Z′ + W′ · X′}

    Wir ersetzen wieder in der Nachbedingung die als neu markierten Variablen durch ihre Werte basierend auf den Zuweisungen im Schleifenrumpf:

    X · Y = X′ · Y′

    =

    Z′ + W′ · X′

    =

    (Z + X) + (W − 1) · X

    =

    Z + X + W · X − X

    =

    Z + W · X

    Der Rumpf erhält also die Schleifeninvariante P.

  3. Es bleibt als dritter Schritt nur noch die Aufgabe, zu zeigen, dass (P ∧ ¬B) die ursprüngliche Nachbedingung Z = X · Y impliziert:

    (P ∧ ¬B) ≡ (X · Y = Z + W · XW = 0) ≡ (X · Y = Z)

Dies beweist natürlich nur die partielle Korrektheit. Einen Beweis einer totalen Korrektheit zeigt das nächste Beispiel.

image

Als zweites Beispiel wird die Korrektheit des Algorithmus XYZ aus dem Beispiel 3.23 (Abschnitt 3.3, Seite 81) betrachtet. Um die Korrektheit von XYZ zu beweisen, müssen wir dessen Spezifikation kennen, die wir bisher verschwiegen haben. Tatsächlich berechnet XYZ den ganzzahligen Anteil der Quadratwurzel des Eingabewertes – eine Spezifikation, auf die man nur durch Anschauen des Algorithmentextes wahrscheinlich nicht so schnell gekommen wäre. Dies verdeutlicht erneut eine grundlegende Eigenschaft von Korrektheitsbeweisen: Es ist immer nur möglich, die relative Korrektheit betreffend einer gegebenen Spezifikation zu prüfen!

Beispiel 7.5 Korrektheit von XYZ

Der zu prüfende Algorithmus XYZ (vergleiche Beispiel 3.23 von Seite 81) ist wie folgt formuliert:

XYZ:

var W,X,Y,Z : int;

input X

Z:=0; W:=1; Y:=1;

while WX

do Z:=Z+1; W:=W+Y+2; Y:=Y+2 od;

output Z.

Zu prüfen ist die Korrektheit bezüglich Vor- und Nachbedingungen, die die Eigenschaft ausdrücken, dass XYZ den ganzzahligen Anteil der Quadratwurzel von X berechnet. Seien also die Vorbedingung

VOR (X ≥ 0)

und die Nachbedingung

NACH (Z2X < (Z + 1)2) ≡ (Z = image)

gegeben. Wir wollen insgesamt beweisen, dass XYZ total korrekt ist bzgl. VOR und NACH. Hierzu beweisen wir in diesem Beispiel vorerst nur die partielle Korrektheit, der Beweis der Terminierung folgt in Beispiel 7.6.

Wir benutzen die folgenden Abkürzungen für Teile des Algorithmentextes:

α

=

(Z := 0; W := 1; Y := 1)

β

=

(Z := Z + 1; W := W + Y + 2; Y := Y + 2)

Wir benötigen wieder eine Schleifeninvariante P als »Zwischenbedingung«, von der wir zeigen, dass sie am Anfang jeden Durchlaufs durch die while-Schleife gilt und dass sie nach Ablauf der while-Schleife die Nachbedingung NACH impliziert.

P ≡ (Z2X ∧ (Z + 1)2 = W ∧ 2Z + 1 = YY > 0)

Für die Schleifeninvariante müssen wir die folgenden drei Eigenschaften zeigen:

  1. {VOR} α {P}
  2. {PWX} β {P}
  3. PW > X) ⇒ NACH

Haben wir diese drei Formeln bewiesen, so folgt daraus, dass XYZ partiell korrekt ist bzgl. VOR und NACH.

Im Einzelnen läuft der Beweis wie folgt ab:

  1. Mit den Werten Z = 0 und W = Y = 1 nach α gilt P, da

    (02X ∧ (0 + 1)2 = 1 ∧ 2 · 0 + 1 = 1 ∧ 1 > 0)

  2. Die Schleifeninvariante P besteht aus vier konjunktiv verbundenen Teilen. Wir zerlegen unseren Beweis in vier Teilbeweise und nutzen dabei jeweils nur Teile der Vorbedingung:
    1. Der erste Teil gilt, da Z im Schleifenrumpf um 1 erhöht wird:

      {(Z + 1)2 = WWX} β {Z2X}

    2. Zu zeigen ist für das zweite Konjunkt Folgendes:

      {(Z + 1)2 = W ∧ 2Z + 1 = Y} β {W = (Z + 1)2}

      Dass dies gilt, kann man sich an folgender Rechnung verdeutlichen, die Zuweisungen an Z und W im Schleifenrumpf berücksichtigt:

      W = Z2 + 2 (Z − 1) + 1 + 2 = Z2 + 2Z + 1 = (Z + 1)2

    3. Analog betrachtet man das dritte Konjunkt:

      {2Z + 1 = Y} β {Y = 2(Z − 1) + 1 + 2 = 2Z + 1}

    4. Der letzte Formelteil gilt trivialerweise, da Y in jedem Durchlauf um 2 erhöht wird:

      {Y > 0} β {Y > 0}

      Aus den vier Teilbeweisen folgt insgesamt {PWX} β {P}.

  3. Es bleibt nur noch der dritte Bestandteil der Prüfung einer Schleifeninvariante übrig:

    PW > XZ2XX < (Z + 1)2NACH

Damit ist die partielle Korrektheit bewiesen. Man beachte, dass hierfür die Bedingung Y > 0 nicht benötigt wurde. Sie ist erst zum Beweis der Terminierung erforderlich.

image

Beweis der Terminierung von imperativen Algorithmen

Terminierung von imperativen Algorithmen

Die Terminierung einer Schleife kann man zum Beispiel dadurch zeigen, dass man eine Folge von Werten u0u1u2u3u4 … derart konstruiert, dass Folgendes gilt:

Diese Bedingungen zusammen konstruieren eine garantiert terminierende Folge, da die Werte gegen 0 streben und bis ins Infinitesimale schrumpfende Differenzen ausgeschlossen sind. Im folgenden Beispiel zeigen wir nur den interessanten Teil der Beweisführung. Formal müssten wir die Folge der Werte von u+1 betrachten, um unserer Bedingung gerecht zu werden.

Beispiel 7.6 Terminierung von XYZ

Um die Terminierung des Algorithmus XYZ aus Beispiel 7.5 zu beweisen, zeigen wir, dass für den Wert von u = X − W gilt:

  1. Es gilt WXu ≥ 0, d.h., u bleibt bei allen Schleifendurchläufen nichtnegativ.
  2. u wird bei jedem Schleifendurchlauf echt kleiner.

Haben wir dies bewiesen, so folgt daraus, dass es nur endlich viele Schleifendurchläufe geben kann, dass XYZ also (bzgl. VOR) terminiert. Die beiden Bedingungen werden wie folgt bewiesen:

  1. Dies ist offensichtlich.
  2. Sei u der Wert unmittelbar vor Ablauf von β und sei u′ der Wert unmittelbar danach. Dann gilt (für die Werte von W und Y vor Durchlauf der Schleife):

    u

    =

    X − W

    u′

    =

    X − (W + Y + 2) = X − W − Y − 2

    Da Y > 0 ist (!), ist u′ < u.

Dies vervollständigt der Beweis der totalen Korrektheit von XYZ bzgl. VOR und NACH.

image

7.2.5Korrektheit applikativer Algorithmen

Korrektheit applikativer Algorithmen

Für den Nachweis von Eigenschaften – wie z.B. der Korrektheit – applikativer Algorithmen verwendet man typischerweise Induktionsbeweise, die der Struktur der rekursiven Funktionsdefinition folgen. Dies geschieht analog zu klassischen mathematischen Beweisen mit vollständiger Induktion und wird daher hier nicht vertieft. Stattdessen betrachten wir ein einfaches Beispiel für einen derartigen Beweis.

Beispiel 7.7 Beispiel für Induktionsbeweis

Wir betrachten die aus Beispiel 3.13 von Seite 69 bekannte McCarthys 91-Funktion:

f(x) =if x > 100 then x − 10 else f(f(x + 11)) fi

Unser Ziel ist es nun, zu beweisen, dass die Funktion f äquivalent zur folgenden Funktion g ist:

g(x) =if x > 100 then x − 10 else 91 fi

Es muss also gezeigt werden, dass f(x) = g(x) für alle x image. Der Beweis folgt dem klassischen Muster der Beweise mit vollständiger Induktion:

Als Induktionsanfang betrachten wir die Werte über 100: Für x > 100 ist f(x) = x − 10 = g(x).

Die Induktion selbst verläuft nun »rückwärts«, d.h., wir zeigen:

Gilt f(y) = g(y) für alle yx, so gilt auch f(x − 1) = g(x − 1).

Die Induktionsannahme lautet somit wie folgt:

Es gelte f(y) = g(y) für alle yx.

Für den eigentlichen Induktionsschritt unterscheiden wir zwei Fälle:

  1. Fall: Sei 101 > x ≥ 91. Dann gilt:

    f(x − 1)

    =

    f(f(x − 1 + 11))

    =

    f(f(x + 10))

    =

    f(x + 10− 10)

    =

    f(x) = g(x) = 91 = g(x − 1)

  2. Fall: Sei 90 ≥ x. Dann gilt: f(x − 1) = f(f(x + 10)).

    Aufgrund der Induktionsannahme ist f(x + 10) = g(x + 10) = 91, also folgt:

    f(x − 1) = f(91) = g(91) = 91 = g(x − 1)

Dies beweist f(x) = g(x) für alle x image.

image

Dieses Kapitel kann nur einen sehr oberflächlichen Eindruck von den Methoden vermitteln, die zur Verifikation von Algorithmen und damit von Programmen eingesetzt werden können. Allerdings sollte deutlich geworden sein, dass ein formaler Beweis der Korrektheit eines Programms möglich ist trotz der eher negativen Aussagen über die Entscheidbarkeit derartiger Fragestellungen im letzten Kapitel. Solche Beweise lassen sich aufgrund der Entscheidbarkeitsfrage nicht vollständig automatisieren, aber der Aufwand lohnt sich insbesondere bei sicherheitskritischen Anwendungen (bei der Software einer Sonde auf dem Weg zum Jupiter können wir Fehlersituationen, wie sie in verbreiteten PC-Betriebssystemen immer noch regelmäßig auftreten, sicher nicht tolerieren).

7.3Komplexität

Komplexität

Für die algorithmische Lösung eines gegebenen Problems ist es unerlässlich, dass der gefundene Algorithmus das Problem korrekt löst. Darüber hinaus ist es natürlich wünschenswert, dass er dies mit möglichst geringem Aufwand tut. Manchmal ist dies auch unerlässlich – etwa beim automatischen Abschalten eines Kernkraftwerkes im Falle einer Havarie. Die Theorie der Komplexität von Algorithmen beschäftigt sich damit, gegebene Algorithmen hinsichtlich ihres Aufwandes abzuschätzen und – darüber hinaus – für gegebene Problemklassen zu bestimmen, mit welchem Mindestaufwand Probleme dieser Klasse gelöst werden können.

7.3.1Motivierendes Beispiel

Aufwand der sequenziellen Suche

Die Bestimmung des Aufwandes wollen wir zunächst an einem einfachen Beispiel verdeutlichen: der sequenziellen Suche in Folgen. Gegeben seien hierzu

Gesucht wird der Index i = 1, 2, …, n, so dass b = ai ist, sofern ein solcher Index existiert. Sonst soll i = n + 1 ausgegeben werden.

Eine sehr einfache Lösung dieses Suchproblems ist die folgende (wobei standardmäßig an+1 = 0 gesetzt sei):

i := 1; while inbai do i := i + 1 od

Am Ende hat i den gesuchten Ausgabewert.

Der Aufwand der Suche, d.h. die Anzahl der Schritte, hängt von der Eingabe ab, d.h. von n, a1, …, an und b. Es gilt:

Erfolgreiche Suche

  1. Bei erfolgreicher Suche, wenn b = ai ist, werden S = i Schritte benötigt.

Erfolglose Suche

  1. 2.Bei erfolgloser Suche werden S = n + 1 Schritte benötigt.

Die erste Aussage hängt noch von zu vielen Parametern ab, um aufschlussreich zu sein. Man ist bemüht, globalere Aussagen zu finden, die nur von einer einfachen Größe abhängen. Hier bietet sich die Länge n der Eingabeliste an und man beschränkt sich auf Fragen der Art:

  1. A: Wie groß ist S für ein gegebenes n im schlechtesten Fall?
  2. B: Wie groß ist S für ein gegebenes n im Mittel?

Wir analysieren nun den Fall der erfolgreichen Suche:

zu A: Im schlechtesten Fall wird b erst im letzten Schritt gefunden, d.h. b = an. Also gilt:

S = n im schlechtesten Fall.

zu B: Um eine mittlere Anzahl von Schritten zu berechnen, muss man Annahmen über die Häufigkeit machen, mit der – bei wiederholter Verwendung des Algorithmus mit verschiedenen Eingaben – b an erster, zweiter, …, letzter Stelle gefunden wird. Wird b häufiger am Anfang der Liste gefunden, so ist die mittlere Anzahl von Suchschritten sicherlich kleiner, als wenn b häufiger am Ende der Liste gefunden wird. Als einfaches Beispiel nehmen wir die Gleichverteilung an. Läuft der Algorithmus N-mal, N 1, so wird b gleich häufig an erster, zweiter, …, letzter Stelle gefunden, also jeweils image-mal.

Dann werden für alle N Suchvorgänge insgesamt

image

Schritte benötigt. Die Auswertung ergibt:

image

Für eine Suche werden im Mittel demnach image Schritte benötigt, also:

image im Mittel (bei Gleichverteilung)

Unser Algorithmus ist für das gestellte Problem keineswegs der schnellste, sondern eher einer der langsameren. Es gibt Algorithmen, die die Suche in Listen (oder Tabellen) mit n Einträgen unter speziellen Voraussetzungen i.W. mit einer konstanten, nicht von n abhängigen Anzahl von Schritten lösen! Die besten Suchverfahren für direkten Zugriff, die auch sequenzielle Verarbeitung in der Ordnung der Schlüssel zulassen, benötigen eine logarithmische Anzahl von Schritten, d.h. S = a · log2(b · n) + c, wobei a, b, c image Konstanten sind. Konkrete Verfahren werden wir u.a. im Kapitel 14 kennen lernen.

7.3.2Asymptotische Analyse

Meist geht es bei der Analyse der Komplexität von Algorithmen bzw. Problemklassen darum, als Maß für den Aufwand eine Funktion

f : imageimage

Problemgröße
Aufwand

anzugeben, wobei f(n) = a bedeutet: »Bei einem Problem der Größe n ist der Aufwand a«. Die Problemgröße n bezeichnet dabei in der Regel ein Maß für den Umfang einer Eingabe, z.B. die Anzahl der Elemente in einer Eingabeliste oder die Größe eines bestimmten Eingabewertes. Der »Aufwand« a ist in der Regel ein grobes Maß für die Rechenzeit, jedoch ist auch der benötigte Speicherplatz zuweilen Gegenstand der Analyse. Die Rechenzeit wird meist dadurch abgeschätzt, dass man zählt, wie häufig eine bestimmte Art von Operation ausgeführt wird, z.B. Speicherzugriffe, Multiplikationen, Additionen, Vergleiche etc. Auf diese Weise erhält man ein maschinenunabhängiges Maß für die Rechenzeit, wie wir es beispielsweise im Zusammenhang mit der Laufzeit einer abstrakten Maschine (siehe auch Definition 6.5 auf Seite 166) bereits vorgestellt haben.

Zur Illustration betrachten wir einige Beispiele. Wir benutzen dabei eine einfache for-Schleife, die folgendermaßen definiert ist:

for i := 1 to u do α od

i := 1; while iu do α; i := i + 1 od

Beispiel 7.8 Aufwand für Schleifen

Wie oft wird die Wertzuweisung »x := x + 1« in folgenden Anweisungen ausgeführt?

1.

x := x + 1

1mal

2.

for i := 1 to n do x := x + 1 od

n-mal

3.

for i := 1 to n do

for j := 1 to n do x := x + 1 od od

n2-mal

image

Aufwandsfunktion

Die Aufwandsfunktion f : imageimage lässt sich in den wenigsten Fällen exakt bestimmen. Vorherrschende Analysemethoden sind

Selbst hierfür lassen sich im Allgemeinen keine exakten Angaben machen. Man beschränkt sich dann auf »ungefähres Rechnen in Größenordnungen«. Das folgende Beispiel illustriert die Schwierigkeit von exakten Analysen, insbesondere bei Abschätzungen im Mittel, bereits in recht einfachen Fällen.

Beispiel 7.9 Rechnen in Größenordnungen I

Gegeben:

n ≥ 0, a1, …, an image

Gesucht:

Der Index i der (ersten) größten Zahl

unter a1, i = 1, …, n.

Lösung

max := 1;

for i := 2 to n do

if amax < ai then max := i fi

od

Am Ende enthält max den gesuchten Index (bzw. die Zahl 1, falls n=0 ist).

Da der Aufwand schon vom Ansatz her ohnehin nur grob abgeschätzt werden kann, macht eine Feinheit wie »+γ« im obigen Beispiel nicht viel Sinn. Interessant ist lediglich, dass Tn logarithmisch von n abhängt, nicht so sehr, wie dieser Zusammenhang im Detail aussieht. Man schreibt dafür:

Tn = O(log n),

und dies bedeutet, dass Tn »von der Ordnung log n« ist, wobei multiplikative und additive Konstanten sowie die Basis des Logarithmus unspezifiziert bleiben.

image

Die O-Notation lässt sich mathematisch exakt definieren. Seien f, g : imageimage gegeben.

Definition 7.4 O-Notation

f(n) = O(g(n)) imagec, n0nn0 : f(n) ≤ c · g(n)

image

Das heißt, dass image für genügend große n durch eine Konstante c beschränkt ist. Anschaulich bedeutet dies, dass f »nicht stärker wächst« als g (Abbildung 7–3). Diese Begriffsbildung wendet man bei der Analyse von Algorithmen an, um Aufwandsfunktionen f : imageimage durch Angabe einer einfachen Vergleichsfunktion g : imageimage abzuschätzen, so dass f(n) = O(g(n)) gilt, also das Wachstum von f durch das von g beschränkt ist.

Asympotische Analyse

Man bezeichnet O(n) auch als asymptotische obere Schranke für f und die Beschreibung der Komplexität durch Angabe der Wachstumsgeschwindigkeit bzw. der Größenordnung mithilfe der O-Notation als asymptotische Analyse. Eine Asymptote ist eine Gerade, der sich eine Kurve bei deren immer größer werdender Entfernung vom Koordinatenursprung unbegrenzt nähert. Die Funktion f wächst damit asymptotisch nicht schneller als g.

Eigentlich ist die Angabe f = O(g) im mathematischen Sinn nicht exakt. Vielmehr müsste man f O(g) schreiben, um auszudrücken, dass f zur Klasse der Funktionen gehört, deren Wachstum asymptotisch durch das Wachstum von g beschränkt ist. Die Schreibweise mit dem »=« hat sich allerdings allgemein eingebürgert, so dass wir sie auch verwenden.

image

Abb. 7–3 Asymptotische obere Schranke: O-Notation

Da die O(g)-Notation eine obere Grenze für eine Klasse von Funktionen definiert, kann die Funktion g jeweils vereinfacht werden, indem konstante Faktoren weggelassen werden sowie nur der höchste Exponent berücksichtigt wird.

Für das Beipiel 7.9 gilt genauer

Beispiel 7.10 Rechnen in Größenordnungen II

Tn

=

Hn = ln n + γ

Tn

=

O(log n)

Die Basis des Logarithmus ist unerheblich, da ein Basiswechsel gleichbedeutend ist mit der Multiplikation mit einer Konstanten: logb n = loga n · logb a.

image

Das folgende Beispiel verdeutlicht die Beschränkung auf den höchsten Exponenten einer Funktion, da immer ein c gefunden wird, so dass ab einem n0 die Funktion g(n) obere Schranke ist.

Beispiel 7.11 Rechnen in Größenordnungen III

Es sei zu zeigen, dass

n2 + 3n − 3 = O(n2)

Demnach muss gelten, dass

n2 + 3n − 3 ≤ c · n2

Durch Umformen ergibt sich daraus

image

Diese Ungleichung wird für nn0 erfüllt, wenn man beispielsweise c = 2 und n0 = 1 einsetzt.

image

7.3.3Komplexitätsklassen

Einige Vergleichsfunktionen treten bei der Analyse von Algorithmen und Datenstrukturen recht häufig auf. Man spricht daher auch von Komplexitätsklassen. Die wichtigsten dieser Klassen sind in Tabelle 7–1 angegeben.

Tab. 7–1 Typische Komplexitätsklassen

O(1)

konstanter Aufwand

O(log n)

logarithmischer Aufwand

O(n)

linearer Aufwand

O(n · log n)

O(n2)

quadratischer Aufwand

O(nk) für ein k ≥ 0

polynomialer Aufwand

O(2n)

exponentieller Aufwand

Zur Veranschaulichung des mit diesen Funktionen verbundenen Wachstums wollen wir in Tabelle 7–2 einige Werte für ein gegebenes n betrachten, wobei (ld = log2) sei. Die eingerahmten Werte liegen dabei jenseits der Grenze der »praktischen Berechenbarkeit«.

Tab. 7–2 Wachstum für ausgewählte Komplexitätsklassen

image

Recht illustrativ ist es auch, sich vor Augen zu führen, welche Größenordnung ein Problem haben kann, wenn man die Zeit durch eine maximale Dauer T begrenzt (Tabelle 7–3). Wir nehmen dazu an, dass ein »Schritt« des Aufwandes genau eine Mikrosekunde (1 µs) dauert. G sei dabei das größte lösbare Problem in der Zeit T. Den Aufwand g(n) = log n lassen wir weg, da man hier praktisch unbegrenzt große Probleme in vernünftiger Zeit bearbeiten kann.

Tab. 7–3 Zeitaufwand für einige Problemgrößen

image

Abschließend sind in Tabelle 7–4 zu den einzelnen Komplexitätsklassen noch einige typische Klassen von Problemen genannt, die sich mit diesem Aufwand, jedoch nicht mit geringerem, lösen lassen.

Tab. 7–4 Typische Problemklassen

Aufwand

Problemklasse

O(1)

einige Suchverfahren für Tabellen (Hashing)

O(log n)

allgemeine Suchverfahren für Tabellen (Baum-Suchverfahren)

O(n)

sequenzielle Suche, Suche in Texten, syntaktische Analyse von Programmen (bei »guter« Grammatik)

O(n · log n)

Sortieren

O(n2)

einige dynamische Optimierungsverfahren (z.B. optimale Suchbäume), Multiplikation Matrix-Vektor (einfach)

O(n3)

Matrizen-Multiplikation (einfach)

O(2n)

viele Optimierungsprobleme (z.B. optimale Schaltwerke), automatisches Beweisen (im Prädikatenkalkül 1. Stufe)

P
NP

Die Werte aus Tabelle 7–2 und 7–3 deuten schon einen Grenzbereich zwischen effizient (polynomialer Aufwand) und nicht effizient (exponentieller Aufwand) lösbaren Problemen an. Die Menge aller Probleme, die mithilfe deterministischer Algorithmen in polynomialer Zeit – und damit effizient – gelöst werden können, gehören zur Klasse P (für polynomial). Diese Definition schließt die meisten der bisher betrachteten Algorithmen ein. Die Klasse von Problemen, die nur mithilfe nichtdeterministischer Algorithmen in polynomialer Zeit gelöst werden können, bezeichnet man mit NP (für nichtdeterministisch polynomial). Nichtdeterminismus bezieht sich dabei auf die Fähigkeit, bei der Entscheidung zwischen mehreren Varianten die richtige zu »erraten«. Eine Umsetzung mit deterministischen Algorithmen würde dagegen exponentiellen Aufwand erfordern.

Erfüllbarkeitsproblem

Eine bekannte Problemstellung ist das Erfüllbarkeitsproblem. Gegeben ist eine aussagenlogische Formel aus Variablen a, b, c etc. und logischen Operatoren ∧, ∪, ¬ sowie Klammern. Es ist ein Algorithmus gesucht, der prüft, ob die Formel erfüllbar ist, d.h., ob es eine Belegung der Variablen mit booleschen Werten true und false gibt, für die die Formel den Wert true liefert. Eine »einfache«, aber sicher nicht effiziente Lösung ist das Testen aller möglichen Belegungen.

Offensichtlich gehört jedes Problem aus P auch NP an, d.h., es gilt P ⊆ NP. Es gibt eine Reihe von Problemen, von denen man weiß, dass sie NP angehören, aber nicht, ob sie auch P angehören. Das bedeutet, dass sie mithilfe eines nichtdeterministischen Algorithmus gelöst werden können, aber es noch nicht gelungen ist, einen effizienten deterministischen Algorithmus zu finden.

NP-vollständig

Es ist – streng genommen – ein offenes Problem, ob sich die exponentiellen Probleme nicht doch mit polynomialem Aufwand lösen lassen, d.h., ob P=NP gilt. Es wäre jedoch eine große Überraschung, wenn sich dies herausstellen sollte. Man kann allerdings beweisen, dass es eine Klasse von verwandten Problemen aus NP gibt, die folgende Eigenschaft aufweisen: Falls eines dieser Probleme in polynomialer Zeit mit einem deterministischen Algorithmus gelöst werden könnte, so ist dies für alle Probleme aus NP möglich (demzufolge wäre P=NP). Man bezeichnet Probleme mit dieser Eigenschaft als NP-vollständig. Ein bekanntes Problem aus dieser Klasse ist das Problem des Handlungsreisenden (siehe auch Abschnitt 16.6).

7.3.4Analyse von Algorithmen

Wie kann nun die Laufzeitkomplexität eines Algorithmus bestimmt werden? Die Probleme einer exakten Analyse haben wir bereits in Abschnitt 7.3.2 diskutiert. Die Bestimmung der Größenordnung ist dagegen wesentlich einfacher, da sich hierfür einige einfache Regeln angeben lassen:

(1) for-Schleifen

Der Laufzeitaufwand einer Schleife ergibt sich aus:

Laufzeit :=

max. Laufzeit der inneren Anweisung ·

Anzahl der Iterationen

So kann der Aufwand der inneren Anweisung im folgenden Beispiel als konstant (O(1)) angesehen werden:

for i := 1 to n do

a[i] := 0;

od

Da die Anzahl der Iterationen n beträgt, ergibt sich der Gesamtaufwand für die obige Schleife aus n · O (1) = O(n).

(2) Geschachtelte for-Schleifen

Bei geschachtelten Schleifen wird für jede Iteration der äußeren Schleife ein kompletter Durchlauf der inneren Schleife durchgeführt. Demnach ergibt sich die Gesamtlaufzeit wie folgt:

Laufzeit :=

Laufzeit der inneren Anweisung ·

Produkt der Größen aller Schleifen

Da im folgenden Beispiel für die innere Anweisung der Aufwand wieder O(1) beträgt, lässt sich der Gesamtaufwand aus n · n · O (1) = O(n2) bestimmen.

for i := 1 to n do

for j := 1 to n do

k := k + 1;

od

od

(3) Nacheinanderausführung

Bei einer Sequenz von Anweisungen werden die Laufzeiten zunächst addiert. Da konstante Faktoren weggelassen werden können und nur die jeweils höchsten Exponenten berücksichtigt werden, wird für die Gesamtlaufzeit nur die maximale Laufzeitkomponente verwendet. Im folgenden Beispiel lässt sich die Laufzeit für die erste Schleife unter Anwendung von Regel (1) mit O(n) angeben und für die zweite Schleife nach Regel (2) mit O(n2):

for i := 1 to n do

a[i] := 0;

od;

for i := 1 to n do

for j := 1 to n do

a[i] := a[i] + a[j] + i + j;

od

od

Die maximale Komponente des Ausdrucks n2 + n bestimmt den Gesamtaufwand, der danach O(n2) beträgt.

(4) if-else-Bedingungen

Der Laufzeitaufwand einer solchen Verzweigung ergibt sich aus der Summe des Aufwandes für den Test bedingung und dem Maximum der Aufwände beider Alternativen A1 und A2:

Laufzeit :=

Aufwand für Test +

max (Aufwand für A1, Aufwand für A2)

Im folgenden Beispiel ist der Aufwand für den Test sowie die erste Anweisung konstant (O(1)). Die zweite Anweisung ist mit einem Aufwand von O(n) verbunden, da hier eine Schleife durchlaufen wird, deren innere Anweisung wiederum einen konstanten Aufwand aufweist.

if x > 100 then

y := x;

else

for i := 1 to n do

if a[i] > y then y := a[i] fi

od

fi

Daraus ergibt sich für den Aufwand für die gesamte Anweisung entsprechend O(n).

Diese Regeln sind im Wesentlichen natürlich nur für die Aufwandsschätzung konkreter Programme gedacht. Die Abschätzung des Aufwandes für Problem klassen erfordert Techniken wie mathematische Beweise etc.