5 Algorithmen und Programmierung 5.1 Einführung Die Programmierung ist ein Teilgebiet der Informatik, das sich im weiteren Sinne mit Methoden und Denkweisen bei der Lösung von Problemen mit Hilfe von Computern und im engeren Sinne mit dem Vorgang der Programmerstellung befasst. Unter einem Programm versteht man dabei eine in einer speziellen Sprache verfasste Anleitung zum Lösen eines Problems durch einen Computer. Programme werden auch unter dem Begriff Software subsumiert. Konkreter ausgedrückt ist das Ziel der Programmierung bzw. Softwareentwicklung, zu gegebenen Problemen Programme zu entwickeln, die auf Computern ausführbar sind und die Probleme korrekt und vollständig lösen, und das möglichst effizient. Die hier angesprochenen Probleme können von ganz einfacher Art sein, wie das Addieren oder Subtrahieren von Zahlen oder das Sortieren einer gegebenen Datenmenge. Komplexere Probleme reichen von der Erstellung von Computerspielen oder der Datenverwaltung von Firmen bis hin zur Steuerung von Raketen. Von besonderer Wichtigkeit für ein systematisches Programmieren ist die Herausarbeitung der fundamentalen Konzepte einer Programmiersprache. Eine Programmiersprache ist eine zum Formulieren von Programmen geschaffene künstliche Sprache. Die Anweisungen, die wir dem Computer geben, werden als Text formuliert, man nennt jeden solchen Text ein Programm. Der Programmtext wird nach genau festgelegten Regeln formuliert. Diese Regeln sind durch die Grammatik einer Programmiersprache festgelegt. Im Gegensatz zur Umgangssprache verlangen Programmiersprachen das exakte Einhalten der Grammatikregeln. Jeder Punkt, jedes Komma hat seine Bedeutung, selbst ein kleiner Fehler führt dazu, dass das Programm als Ganzes nicht verstanden wird. In frühen Programmiersprachen standen die verfügbaren Operationen eines Rechners im Vordergrund. Diese mussten durch besonders geschickte Kombinationen verbunden werden, um ein bestimmtes Problem zu lösen. Moderne höhere Programmiersprachen orientieren sich stärker an dem zu lösenden Problem und gestatten eine abstrakte Formulierung des Lösungswegs, der die Eigenarten der Hardware, auf der das Programm ausgeführt werden soll, nicht mehr in Betracht zieht. Dies hat den Vorteil, dass das gleiche Programm grundsätzlich auf unterschiedlichen Systemen ausführbar ist. 70 5.2 Übersicht der Programmiersprachen 71 5.3 Definition von Programmiersprachen Programmiersprachen sind sehr exakte künstliche Sprachen zur Formulierung von Programmen. Sie dürfen keine Mehrdeutigkeiten bei der Programmerstellung zulassen, damit der Computer das Programm auch korrekt ausführen kann. Bei der Definition einer Programmiersprache müssen deren Lexik, Syntax, Semantik und Pragmatik definiert werden: Lexik: Die Lexik einer Programmiersprache definiert die gültigen Zeichen zw. Wörter, aus denen Programme der Programmiersprache zusammengesetzt sein dürfen. Syntax: Die Syntax einer Programmiersprache definiert den korrekten Aufbau der Sätze aus gültigen Zeichen bzw. Wörtern, d.h. sie legt fest, in welcher Reihenfolge lexikalisch korrekte Zeichen bzw. Wörter im Programm auftreten dürfen. Semantik: Die Semantik einer Programmiersprache definiert die Bedeutung syntaktisch korrekter Sätze, d.h. sie beschreibt, was passiert, wenn bspw. bestimmte Anweisungen ausgeführt werden. Pragmatik: Die Pragmatik einer Programmiersprache definiert ihren Einsatzbereich, d.h. sie gibt an, für welche Arten von Problemen die Programmiersprache besonders gut geeignet ist. 5.4 Klassifikation von Programmiersprachen Eine durchaus berechtigte Frage wäre: Wieso gibt es eigentlich nicht nur eine einzige Programmiersprache, mit der alle Programmierer arbeiten? Da Programmiersprachen anders als natürliche Sprachen, die sich über Jahrhunderte hinweg entwickelt haben, ja künstlich definiert werden müssen, hätte man sich doch von Anfang an auf eine einheitliche Programmiersprache festlegen können. Eine mögliche Klassifizierung unterscheidet so genannte niedere Maschinensprachen (maschinennahe Programmiersprachen) und höhere problemorientierte Programmiersprachen. Maschinensprachen ermöglichen die Erstellung sehr effizienter Programme. Sie sind jedoch abhängig vom speziellen Computertyp. Dahingegen orientieren sich die höheren Programmiersprachen nicht so sehr an 72 den vom Computer direkt ausführbaren Befehlen, sondern eher an den zu lösenden Problemen. Sie sind für Menschen verständlicher und einfacher zu handhaben. Ein weiterer Grund für die Existenz der vielen verschiedenen Programmiersprachen liegt in der Tatsache, dass die zu lösenden Probleme nicht alle gleichartig sind. So werden häufig neue Programmiersprachen definiert, die speziell für bestimmte Klassen von Problemen konzipiert sind. Den höheren Programmiersprachen liegen bestimmte Konzepte zugrunde, mit denen die Lösung von Problemen formuliert wird. Im Wesentlichen lassen sich hier fünf Kategorien - oder auch Programmierparadigmen genannt - unterscheiden: Imperative Programmiersprachen: Programme bestehen aus Folgen von Befehlen (BASIC, PASCAL, MODULA2). Funktionale Programmiersprachen: Programme werden als mathematische Funktionen betrachtet (LISP, MIRANDA). Prädikative/deklarative Programmiersprachen: Programme bestehen aus Fakten (gültige Tatsachen) und Regeln, die beschreiben, wie aus gegebenen Fakten neue Fakten hergeleitet werden können (PROLOG). Regelbasierte Programmiersprachen: Programme bestehen aus "Wenn-Dann-Regeln"; wenn eine angegebene Bedingung gültig ist, dann wird eine angegebene Aktion ausgeführt (OPS5). Objektorientierte Programmiersprachen: Programme bestehen aus Objekten, die bestimmte (Teil-)Probleme lösen und zum Lösen eines Gesamtproblems mit anderen Objekten über Nachrichten kommunizieren können (SMALLTALK). Nicht alle Programmiersprachen können eindeutig einer dieser Klassen zugeordnet werden. So ist bspw. LOGO eine funktionale Programmiersprache, die aber auch imperative Sprachkonzepte besitzt. Java und C++ können als imperative objektorientierte Programmiersprachen klassifiziert werden, denn Java und C++-Programme bestehen aus kommunizierenden Objekten, die intern mittels imperativer Sprachkonzepte realisiert werden. Programmiersprachen einer Kategorie unterscheiden sich häufig nur in syntaktischen Feinheiten. Die grundlegenden Konzepte sind ähnlich. Von daher ist es im Allgemeinen nicht besonders schwierig, eine weitere Programmiersprache zu erlernen, wenn man bereits eine Programmiersprache derselben Kategorie beherrscht. Anders verhält es sich jedoch beim Erlernen von Programmiersprachen anderer Kategorien, weil hier die zugrunde liegenden Konzepte stark voneinander abweichen. 73 5.5 Vom Programm zur Maschine Programme, die in einer höheren Programmiersprache geschrieben sind, können nicht unmittelbar auf einem Rechner ausgeführt werden. Sie sind anfangs in einer Textdatei gespeichert und müssen erst in Folgen von Maschinenbefehlen übersetzt werden. Maschinenbefehle sind elementare Operationen, die der Prozessor des Rechners unmittelbar ausführen kann. Sie beinhalten zumindest Befehle, um Daten aus dem Speicher zu lesen elementare arithmetische Operationen auszuführen Daten in den Speicher zu schreiben die Berechnung an einer bestimmten Stelle fortzusetzen (Sprünge) Die Übersetzung von einem Programmtext in eine Folge solcher einfacher Befehle (auch Maschinenbefehle oder Maschinencode genannt), wird von einem Compiler durchgeführt. Das Ergebnis ist ein Maschinenprogramm, das in einer als "ausführbar" (executable) gekennzeichneten Datei gespeichert ist. Eine solche ausführbare Datei muss noch von einem Ladeprogramm in den Speicher geladen werden und kann erst dann ausgeführt werden. Ladeprogramme sind im Betriebssystem enthalten, der Benutzer weiß oft gar nichts von deren Existenz. So sind in den Betriebssystemen der Windows-Familie ausführbare Dateien durch die Endung ".exe" oder ".com" gekennzeichnet. Tippt man auf der Kommandozeile den Namen einer solchen Datei ein und betätigt die Eingabetaste, so wird die ausführbare Datei in den Hauptspeicher geladen und ausgeführt. 5.5.1 Virtuelle Maschinen Die Welt wäre einfacher, wenn sich alle Programmierer auf einen Rechnertyp und eine Programmiersprache einigen könnten. Man würde dazu nur einen einzigen Compiler benötigen. Die Wirklichkeit sieht anders aus. Es gibt (aus gutem Grund) zahlreiche Rechnertypen und noch viel mehr verschiedene Sprachen. Fast jeder Programmierer hat eine starke Vorliebe für eine ganz bestimmte Sprache und möchte, dass seine Programme auf möglichst jedem Rechnertyp ausgeführt werden können. Bei n Sprachen und m Maschinentypen würde dies, wie in Abbildung 21 dargestellt, n x m viele Compiler erforderlich machen. 74 Abbildung 21: Kombination/Zuordnung von Programmiersprachen und Rechnern Schon früh wurde daher die Idee geboren, eine virtuelle Maschine V zu entwerfen, die als gemeinsames Bindeglied zwischen allen Programmiersprachen und allen konkreten Maschinensprachen fungieren könnte. Diese Maschine würde nicht wirklich gebaut, sondern man würde sie auf jedem konkreten Rechner emulieren, d.h. nachbilden. Für jede Programmiersprache müsste dann nur ein Compiler vorhanden sein, der Code für V erzeugt. Statt n x m vieler Compiler benötigte man jetzt nur noch n Compiler und m Implementierungen von V auf den einzelnen Rechnertypen, insgesamt also nur n + m viele Übersetzungsprogramme - ein gewaltiger Unterschied: Abbildung 22: Idee einer universellen virtuellen Maschine Leider ist eine solche virtuelle Maschine nie zu Stande gekommen. Neben dem Verdacht, dass ihr Design eine bestimmte Sprache oder einen bestimmten Maschinentyp bevorzugen könnte, stand die begründete Furcht im Vordergrund, dass diese Zwischenschicht die Geschwindigkeit der Programmausführung beeinträchtigen könnte. 75 Außerdem verhindert eine solche Zwischeninstanz, dass spezielle Fähigkeiten eines Maschinentyps oder spezielle Ausdrucksmittel einer Sprache vorteilhaft eingesetzt werden können. In Zusammenhang mit einer festen Sprache ist das Konzept einer virtuellen Maschine jedoch mehrfach aufgegriffen worden, z.B. bei der objektorientierten Sprache Java. Ein Java-Compiler übersetzt ein in Java geschriebenes Programm in einen Code für eine virtuelle Java-Maschine. Auf jeder Rechnerplattform, für die ein Emulator dieser virtuellen Java-Maschine verfügbar ist, ist das Programm dann lauffähig. Weil man also bewusst auf die Ausnutzung besonderer Fähigkeiten der jeweiligen Hardware verzichtet, wird die Sprache plattformunabhängig. Abbildung 23: Reale virtuelle Maschine 5.5.2 Interpreter Ein Compiler übersetzt immer einen kompletten Programmtext in eine Folge von Maschinenbefehlen, bevor die erste Programmanweisung ausgeführt wird. Ein Interpreter dagegen übersetzt immer nur eine einzige Programmanweisung in ein kleines Unterprogramm aus Maschinenbefehlen und führt dieses sofort aus. Anschließend wird mit der nächsten Anweisung genauso verfahren. Interpreter sind einfachen zu konstruieren als Compiler, haben aber den Nachteil, dass ein Befehl, der mehrfach ausgeführt wird, jedes Mal erneut übersetzt werden muss. Grundsätzlich können fast alle Programmiersprachen compilierend oder interpretierend implementiert werden. Trotzdem gibt es einige, die fast ausschließlich mit Compilern arbeiten. Dazu gehören Pascal, Modula, COBOL, Fortran, C und C++. Andere, darunter BASIC, APL, LISP und Prolog, werden überwiegend interpretativ bearbeitet. Sprachen wie Java und Smalltalk beschreiten einen Mittelweg zwischen compilierenden und interpretierenden Systemen - das Quellprogramm wird in Code für die virtuelle Java bzw. Smalltalk-Maschine, den so genannten Bytecode, 76 compiliert. Dieser wird dann von der virtuellen Maschine interpretativ ausgeführt. Damit ist die virtuelle Maschine nichts anderes als ein Interpreter für Bytecode. 5.6 Programmentwicklung Ziel der Programmierung ist die Entwicklung von Programmen, die gegebene Probleme korrekt und vollständig lösen. Ausgangspunkt der Programmentwicklung ist also ein gegebenes Problem, Endpunkt ist ein ausführbares Programm, das korrekte Ergebnisse liefert. Den Weg vom Problem zum Programm bezeichnet man auch als Problemlöse- oder Programmentwicklungsprozess oder kurz Programmierung. Der Problemelöseprozess kann in mehrere Phasen zerlegt werden, die in der Regel nicht streng sequentiell durchlaufen werden. Durch neue Erkenntnisse, aufgetretene Probleme und Fehler wird es immer wieder zu Rücksprüngen in frühere Phasen kommen. 5.6.1 Analyse Eine Analyse ist eine vollständige, detaillierte und eindeutige Spezifikation eines Problems. In der Analysephase wird das zu lösende Problem bzw. das Umfeld des Problems genauer untersucht. Insbesondere folgende Fragestellungen sollten bei der Analyse ins Auge gefasst und auch mit anderen Personen diskutiert werden: Ist die Problemstellung exakt und vollständig beschrieben? Was sind mögliche Initialzustände bzw. Eingabewerte (Parameter) für das Problem? Welches Ergebnis wird genau erwartet, wie sehen der gewünschte Endzustand bzw. die gesuchten Ausgabewerte aus? Gibt es Randbedingungen, Spezialfälle bzw. bestimmte Zwänge (Constraints), die zu berücksichtigen sind? Lassen sich Beziehungen zwischen Initial- und Endzuständen bzw. Eingabeund Ausgabewerten herleiten? Erst wenn alle diese Fragestellungen beantwortet sind und eine exakte Problemspezifikation vorliegt, sollte in die nächste Phase verzweigt werden. Es hat sich gezeigt, dass Fehler, die aus einer nicht ordentlich durchgeführten Analyse herrühren, zu einem immensen zusätzlichen Arbeitsaufwand in späteren Phasen führen können. Deshalb sollte in dieser Phase mit größter Sorgfalt gearbeitet werden. 77 5.6.2 Entwurf Nachdem in der Analysephase das Problem in einer Spezifikation genau beschrieben worden ist, geht es darum, einen Lösungsweg zu entwerfen. Dies geschieht in der Entwurfsphase. Da die Lösung von einem Rechner durchgeführt wird, muss jeder Schritt exakt vorgeschrieben sein. Wir kommen zu folgender Begriffsbestimmung: Ein Algorithmus ist eine detaillierte und explizite Vorschrift zur schrittweisen Lösung eines Problems. Im Einzelnen beinhaltet diese Definition: Die Ausführung des Algorithmus erfolgt in einzelnen Schritten. Jeder Schritt besteht aus einer einfachen und offensichtlichen Grundaktion. Zu jedem Zeitpunkt muss klar sein, welcher Schritt als nächster auszuführen ist. Der Entwurfsprozess kann im Allgemeinen nicht mechanisch durchgeführt werden, vor allen Dingen ist er nicht automatisierbar. Vielmehr kann man ihn als kreativen Prozess bezeichnen, bei dem Auffassungsgabe, Intelligenz und vor allem Erfahrung des Programmierers eine wichtige Rolle spielen. Diese Erfahrung kann insbesondere durch fleißiges Üben erworben werden. Für den Entwurf eines Algorithmus können die folgenden Ratschläge nützlich sein: Informieren Sie sich über möglicherweise bereits existierende und verfügbare Lösungen für vergleichbare Probleme und nutzen Sie diese. Schauen Sie sich nach allgemeineren Problemen um und überprüfen Sie, ob Ihr Problem als Spezialfall des allgemeinen Problems betrachtet werden kann. Versuchen Sie, das (komplexe) Problem in einfachere Teilprobleme aufzuteilen. Wenn eine Aufteilung möglich ist, wenden Sie den hier skizzierten Programmentwicklungsprozess zunächst für die einzelnen Teilprobleme an und setzen dann die Teillösungen zu einer Lösung für das Gesamtproblem zusammen. Eine Möglichkeit zur grafischen Darstellung von Algorithmen sind Flussdiagramme. Sie haben den Vorteil, unmittelbar verständlich zu sein. Der Nachteil ist, dass sie für komplexere Algorithmen schnell unübersichtlich werden. Flussdiagramme setzen sich zusammen aus folgenden Symbolen: 78 Abbildung 24: Elemente von Flussdiagrammen Beispiel für einen Algorithmus Bei dem folgenden Beispiel geht es um die Lösung des Problems, die Summe aller natürlichen Zahlen bis zu einer vorgegebenen Natürlichen Zahl n zu berechnen. Mathematisch definiert ist also die folgende Funktion f zu berechnen: Das folgende Flussdiagramm stellt einen Algorithmus zur Lösung des obigen Problems dar. Die Aktionen, die in den Rechtecken dargestellt sind, werden als elementare Handlungen des Rechners verstanden, die nicht näher erläutert werden müssen. In unserem Falle handelt es sich dabei um so genannte Zuweisungen, bei denen ein Wert berechnet und das Ergebnis gespeichert wird. So wird z.B. durch die Zuweisung "erg = erg + i" der Inhalt der durch erg und i bezeichneten Speicherplätze addiert und das Ergebnis in dem Speicherplatz erg abgelegt. 79 Abbildung 25: Flussdiagramm zum Beispielalgorithmus Algorithmen als Lösung einer Spezifikation Eine Spezifikation beschreibt also ein Problem, ein Algorithmus gibt eine Lösung des Problems an. Ist das Problem durch ein Paar {P} {Q} aus einer Vorbedingung P und einer Nachbedingung Q gegeben, so schreiben wir: o {P} A {Q}, falls der Algorithmus A die Vorbedingung P in die Nachbedingung Q überführt. Genauer formuliert bedeutet dies: Wenn der Algorithmus A in einer Situation gestartet wird, in der P gilt, dann wird, wenn A beendet ist, Q gelten. In diesem Sinne ist ein Algorithmus eine Lösung einer Spezifikation. Man kann eine Spezifikation als eine Gleichung mit einer Unbekannten ansehen: Zu der Spezifikation {P} {Q} ist ein Algorithmus X gesucht mit {P} X {Q}. 80 Nicht jede Spezifikation hat eine Lösung. So verlangt {M<0} {x = log M}, den Logarithmus einer negativen Zahl zu finden. Diese Spezifikation kann also nicht gelöst werden. Wenn eine Spezifikation aber eine Lösung hat, dann gibt es immer unendlich viele Lösungen. So ist jeder Algorithmus, der das gewünschte Ergebnis liefert - ganz egal, wie umständlich er dies macht - eine Lösung für unsere Spezifikation. Terminierung In einer oft benutzten strengeren Definition des Begriffes Algorithmus wird verlangt, dass ein solcher nach endlich vielen Schritten terminiert, also beendet ist. Diese Forderung steht aber vor folgenden Schwierigkeiten: Manchmal ist es erwünscht, dass ein Programm bzw. ein Algorithmus nicht von selber abbricht. Ein Texteditor, ein Computerspiel oder ein Betriebssystem soll im Prinzip unendlich lange laufen können. Es ist oft nur schwer oder überhaupt nicht feststellbar, ob ein Algorithmus in endlicher Zeit zum Ende kommen wird. Verantwortlich dafür ist die Möglichkeit, Schleifen zu bilden, so dass dieselben Grundaktionen mehrfach wiederholt werden. 5.6.3 Implementierung Der Entwurf eines Algorithmus sollte unabhängig von einer konkreten Programmiersprache erfolgen. Die anschließende Überführung des Algorithmus in ein in einer bestimmten Programmiersprache verfasstes Programm wird als Implementierung bezeichnet. Anders als der Entwurf eines Algorithmus ist die Implementierung in der Regel ein eher mechanischer Prozess. Die Implementierungsphase besteht selbst wieder aus zwei Teilphasen: Editieren: Zunächst wird der Programmcode mit Hilfe eines Editors eingegeben und in einer Datei dauerhaft abgespeichert. Compilieren: Anschließend wird de rProgrammcode mit Hilfe eines Compilers auf syntaktische Korrektheit überprüft und - falls keine Fehler vorhanden sind - in eine ausführbare Form (ausführbares Programm) überführt. Liefert der Compiler eine Fehlermeldung, muss in die Editierphase zurückgesprungen werden. Ist die Compilation erfolgreich, kann das erzeugte Programm ausgeführt werden. Je nach Sprache und Compiler ist die Ausführung entweder mit Hilfe des Betriebssystems durch den Rechner selbst oder aber durch die Benutzung eines Interpreters möglich. 81 5.6.4 Test In der Testphase muss überprüft werden, ob das entwickelte Programm die Problemstellung korrekt und vollständig löst. Dazu wird das Programm mit verschiedenen Initialzuständen oder Eingabewerten ausgeführt und überprüft, ob es die erwarteten Ergebnisse liefert. Man kann eigentlich immer davon ausgehen, dass Programme nicht auf Anhieb korrekt funktionieren, was zum einen an der hohen Komplexität des Programmentwicklungsprozesses und zum anderen an der hohen Präzision liegt, die die Formulierung von Programmen erfordert. Insbesondere die Einbeziehung von Randbedingungen wird von Programmieranfängern häufig vernachlässigt, so dass das Programm im Normalfall zwar korrekte Ergebnisse liefert, in Ausnahmefällen jedoch versagt. Genauso wie der Algorithmusentwurf ist auch das Testen eine kreative Tätigkeit, die viel Erfahrung voraussetzt und darüber hinaus ausgesprochen zeitaufwendig ist. Im Durchschnitt werden ca. 40% der Programmentwicklungszeit zum Testen und Korrigieren verwendet. Auch durch noch so systematisches Testen ist es in der Regel nicht möglich, die Abwesenheit von Fehlern zu beweisen. Es kann nur die Existenz von Fehlern nachgewiesen werden. Aus der Korrektheit des Programms für bestimmte überprüfte Initialzustände bzw. Eingabewerte kann nicht auf die Korrektheit für alle möglichen Initialzustände bzw. Eingabewerte geschlossen werden! 5.6.5 Dokumentation Parallel zu den eigentlichen Programmentwicklungsphasen sollten alle Ergebnisse dokumentiert, d.h. schriftlich festgehalten werden. Die Dokumentation besteht also aus: einer exakten Problemstellung, einer verständlichen Beschreibung der generellen Lösungsidee und des entwickelten Algorithmus, dem Programmcode sowie einer Erläuterung der gewählten Testszenarien und Protokollen der durchgeführten Testläufe Außerdem sollten weitergehende Erkenntnisse, wie aufgetretene Probleme oder alternative Lösungsansätze, in die Dokumentation mit aufgenommen werden. Die Dokumentation dient dazu, dass andere Personen bzw. der Programmierer selbst, auch zu späteren Zeitpunkten, das Programm noch verstehen bzw. den Programmentwicklungsprozess nachvollziehen können, um z.B. mögliche Erweiterungen oder Anpassungen vornehmen oder die Lösung bei der Bearbeitung vergleichbarer Probleme wieder verwenden zu können. 82 5.7 Elementare Aktionen in Programmiersprachen Wir haben bisher noch nicht erklärt, welche "elementaren Aktionen" wir voraussetzen, wenn wir Algorithmen formulieren. In der Tat sind hier eine Reihe von Festlegungen denkbar. Wir könnten zum Beispiel in einem Algorithmus formulieren, wie man ein bestimmtes Gericht zubereitet. Die Grundaktionen wären dann einfache Aufgaben, wie etwa "Prise Salz hinzufügen", "umrühren" und "zum Kochen bringen". Der Algorithmus beschreibt dann, ob, wann und in welcher Reihenfolge diese einfachen Handlungen auszuführen sind. In einer Programmiersprache kann man Speicherzellen für Datenwerte mit Namen kennzeichnen. Diese nennt man auch Variablen. Man darf den Inhalt einer Variablen lesen oder ihr einen neuen Wert zuweisen. Der vorher dort gespeicherte Wert geht dabei verloren, man sagt, er wird überschrieben. Eine Grundaktion besteht jetzt aus drei elementaren Schritten: einige Variablen lesen die gelesenen Werte durch einfache Rechenoperationen verknüpfen das Ergebnis einer Variablen zuweisen. Eine solche Grundaktion heißt Zuweisung. In Java und C++ wird sie als x = y geschrieben. Dabei ist x eine Variable, das Zeichen "=" ist der Zuweisungsoperator und die rechte Seite y kann ein beliebiger (arithmetischer) Ausdruck sein, in dem auch Variablen vorkommen können. Es handelt sich nicht um eine Gleichung, denn die Variablen, die auf der rechten Seite des Zuweisungszeichens vorkommen, stehen für den alten Wert und die Variable auf der linken Seite für den neuen Wert nach der Zuweisung. Am besten man ignoriert die Ähnlichkeit des Zuweisungsoperators mit dem Gleichheitszeichen und spricht es als "erhält" aus: o "x erhält (den Wert) y" für x = y. Hat man erst einmal einige nützliche Algorithmen programmiert, kann man diese in anderen Programmen benutzen - oder " aufrufen" - und wie eine elementare Aktion behandeln. Dazu muss man sie nur mit einem Namen versehen und kann danach diesen Namen anstelle des Algorithmus hinschreiben. Einige solcher zusätzlicher Aktionen, in Java und C++ Methoden bzw. Funktionen genannt, sind bei allen Sprachen bereits "im Lieferumfang" enthalten. So ist die Funktion printf standardmäßig in C++ enthalten. Ihre Wirkung ist die Ausgabe von Werten in einem Terminalfenster. Ein Aufruf, wie etwa printf ("Hallo Welt!"), ist also auch eine elementare Aktion. 83 5.8 Programmierumgebungen Für fast alle Sprachen gibt es heute "integrierte Entwicklungsumgebungen" (integrated development environment - IDE), die alle zur Programmerstellung notwendigen Werkzeuge beinhalten: o o o einen Editor zum Erstellen und Ändern eines Programmtextes, einen Compiler bzw. Interpreter zum Ausführen von Programmen, einen Debugger für die Fehlersuche in der Testphase eines Programms. Kern dieser Systeme ist immer ein Texteditor zum Erstellen des Programmtextes. Dieser hebt typischerweise nicht nur die Schlüsselworte der Programmiersprache farblich hervor, er markiert auch zugehörige Klammerpaare und kann auf Wunsch den Programmtext auch übersichtlich formatieren. Klickt man auf den Namen einer Variablen oder einer Funktion, so wird automatisch deren Definition im Programmtext gefunden und angezeigt. Soll das zu erstellende Programm zudem eine moderne graphische Benutzeroberfläche erhalten, so kann man diese mit einem GUI-Editor erstellen, indem man Fenster, Menüs, Buttons und Rollbalken mit der Maus "zusammenklickt", beliebig positioniert und anpasst. 5.9 Datentypen und Variablen 5.9.1 Datentypen Daten sind die Objekte, mit denen ein Programm umgehen soll. Man muss verschiedene Sorten von Daten unterscheiden, je nachdem, ob es sich um Wahrheitswerte, Zahlen, Geburtstage, Texte, Bilder, Musikstücke oder Videos handelt. Alle diese Daten sind von verschiedenem Typ, insbesondere verbrauchen Sie unterschiedlich viel Speicherplatz und unterschiedliche Operationen sind mit ihnen durchführbar. So lassen sich zwei Geburtstage oder zwei Bilder nicht addieren, wohl aber zwei Zahlen. Andererseits kann ein Bild komprimiert werden, bei einer Zahl macht dies aber keinen Sinn. Zu einem bestimmten Typ von Daten gehört also immer auch ein charakteristischer Satz von Operationen, um mit diesen Daten umzugehen. Jede Programmiersprache stellt eine Sammlung von Datentypen samt der zugehörigen Operationen bereit und bietet zugleich Möglichkeiten, neue Datentypen zu definieren. 5.9.2 Variablen Eine Variable in einer Programmiersprache ist eine benannte Speicherstelle im Arbeitsspeicher des Rechners. Über den Variablennamen kann der Programmierer auf die entsprechende Speicherzelle zugreifen. 84 Eine Variable hat vier Kennzeichen: Variablennamen Datentyp Wert Adresse (der Speicherzelle) Der Datentyp ist der Bauplan für eine Variable. Der Datentyp legt fest, welche Operationen auf einer Variablen möglich sind und wie die Darstellung (Repräsentation) der Variablen im Speicher des Rechners erfolgt. Mit der Darstellung wird festgelegt, wie viele Bytes die Variable im Speicher einnimmt und welche Bedeutung ein jedes Bit dieser Darstellung hat. Variablen braucht man, um in ihnen Werte abzulegen. Eine Variable ist eine veränderliche Größe - ihr Wert kann also in ihrem Speicherbereich nach Bedarf verändert werden. Der Wert einer Variablen muss der Variablen in der Regel explizit zugewiesen werden. Es gibt aber auch Fälle, bei denen von der Programmiersprache aus eine Variable in impliziter Weise mit einem Wert vorbelegt wird. Ein solcher Vorbelegungs-Wert wird als Default-Wert oder Standardwert bezeichnet. Wird einer Variablen weder explizit, noch defaultmäßig - d.h. durch Vorbelegung - ein Wert zugewiesen, so ist ihr Wert undefiniert. Da im Arbeitsspeicher die Bits immer irgendwie ausgerichtet sind, hat jede Variable automatisch einen Wert, auch wenn ihr noch kein definierter Wert zugewiesen wurde. Ein solcher Wert ist jedoch rein zufällig und führt zu einer Fehlfunktion des Programms. Daher darf es der Programmierer nicht versäumen, den Variablen die gewünschten Startwerte (Initialwerte) zuzuweisen, d.h. die Variable zu initialisieren. Variablen liegen während der Programmausführung in Speicherzellen des Arbeitsspeichers. Die Speicherzellen des Arbeitsspeichers sind durchnummeriert. Die Nummern der Speicherzellen werden Adressen genannt. Eine Variable kann natürlich mehrere Speicherzellen einnehmen. Abbildung 26: Variable im Arbeitsspeicher 85 Die Variable aus der obigen Abbildung belegt die Speicherzellen mit den Adressen 5 und 6 und hat den Wert 3. Über den Namen der Variablen kann man ihren Wert aus den Speicherzellen auslesen und verändern. 5.9.3 Primitive Datentypen Im Folgenden wollen wir die wichtigsten Datentypen, die auch in den meisten Programmiersprachen vorhanden sind, vorstellen. Manche dieser Typen wie z.B. die der ganzen oder der reellen Zahlen, umfassen theoretisch unendlich viele Werte. Die meisten Programmiersprachen schränken daher die verfügbaren Werte auf verschieden große endliche Bereiche ein. Dies hat zur Folge, dass bei der Überschreitung dieser Bereiche Fehler auftreten können, die sich je nach Anwendungsfall mehr oder weniger katastrophal äußern können. 5.9.3.1 Datentypen für Boolesche Werte Der einfachste Datentyp besteht aus den booleschen Werten true und false. Man bezeichnet diesen Datentyp mit dem englischen Ausdruck boolean (Java) oder mit bool (C++). Der benötigte Speicherplatz für diesen Datentyp beträgt in Java 2 Bytes und in C++ 1 Byte. Für die Durchführung von logischen Operationen stehen in Java und C++ folgende Operationen zur Verfügung: Operator (Java/c++) & / && | / || ^/ !/! Verwendung op1 & op2 op1 | op2 op1 ^ op2 !op1 Operation AND OR XOR NOT 5.9.3.2 Datentypen für ganze Zahlen Zur Darstellung ganzer Zahlen ohne Nachkommastelle gibt es o o in Java die Datentypen byte, short, int und long und in C++ short int, int und long int Als elementare Operationen auf diesen Datentypen gelten die arithmetischen Operationen +, -, *, /. In Java sind alle ganzzahligen Datentypen vorzeichenbehaftet, wohingegen in C++ dies durch die Schlüsselwörter signed und unsigned angegeben wird. In Java ergeben sich folgende ganzzahlige Datentypen: 86 Datentyp byte short int long Größe in Bytes 1 2 4 8 Wertebereich -27...27-1 -215...215-1 -231...231-1 -263...263-1 5.9.3.3 Datentypen für reelle Zahlen Datentypen für reelle Zahlen dienen der Speicherung von Zahlen mit Nachkommastellen. In Java stehen hierzu die Typen float und double und in C++ float, double und long double zur Verfügung. Als elementare Operationen auf diesen Datentypen gelten ebenfalls die arithmetischen Operationen +, -, *, /. In Java gibt es die folgenden Gleitkommatypen: Datentyp float double long double (C++) Größe in Bytes 4 8 10 Wertebereich -3,4x10 bis +3,4x1038 -1,7x10308 bis +1,7x10308 -1,1x104932 bis +1,1x104932 38 5.9.3.4 Datentypen für Zeichen Sowohl in Java als auch in C++ wird ein Zeichen durch den Datentyp char (engl. character) dargestellt. Während C++ dazu die 256 ASCII-Zeichen verwendet, stellt Java bereits den gesamten UNICODE-Zeichensatz zur Verfügung. Zur Darstellung eines Zeichens werden daher in Java 2 Byte und in C++ 1 Byte benötigt. 5.9.4 Zusammengesetzte Datentypen Die bisher besprochenen Datentypen sind in einem gewissen Sinne atomar, d.h. ihre Werte sind nicht weiter in Bestandteile zerlegbar. Zusammengesetzte Datentypen bestehen aus einer Menge gleichartiger Datentypen. 5.9.4.1 Strings Als erstes Beispiel eines zusammengesetzten Typs betrachten wir Strings. Ein String ist eine Zeichenkette und besteht daher aus einer Folge von Zeichen. Strings kann man direkt als Stringliterale angeben. Dazu schließt man eine beliebige Folge von Zeichen in besondere Begrenzungszeichen ein. Java benutzt dafür doppelte Anführungszeichen: o "Ich bin ein Java-Stringliteral" 87 Durch Aneinanderhängen (Konkatenieren) zweier Strings s1 und s2 entsteht ein neuer String s1+s2. Auf die einzelnen Zeichen eines Strings kann man zugreifen, Java bietet dafür die Methode s1.charAt(i) an. i ist dabei eine Variable, die die Position des gewünschten Zeichens im String angibt. Mit s.length() kann man die Länge des Strings anzeigen lassen. s.indexOf("test") liefert die Position des Strings "test" innerhalb des Strings s oder 1, wenn der gesuchte String nicht enthalten ist. 5.9.4.2 Arrays Ein Array ist ein Objekt, das aus Komponenten (Elementen) zusammengefasst ist, wobei jedes Element eines Arrays vom selben Datentyp sein muss: int int int int Ein Array aus 5 Elementen des Datentyps integer int In Java können Arrays aus Elementen eines primitiven Datentyps oder aus Elementen eines Referenztyps bestehen. Ein Element eines Arrays kann auch selbst wieder ein Array sein. Dann entsteht ein mehrdimensionales Array. Im Folgenden werden zunächst eindimensionale Arrays betrachtet. Die Länge oder Größe eines Arrays legt die Anzahl der Elemente des Arrays fest. Die Länge muss als Wert immer eine ganze positive Zahl haben. Ist laenge die Länge des Arrays, so werden die Elemente von 0 bis laenge-1 durchgezählt. Die Nummer beim Durchzählen wird als Index des Arrays bezeichnet. Über den Index kann man auf ein Element zugreifen. Der Zugriff auf das i-te Element des Arrays mit dem Namen arrayName erfolgt durch arrayName[i-1] (weil die Indizierung mit 0 beginnt!). Der Vorteil eines Arrays gegenüber mehreren einfachen Variablen ist, dass Arrays sich leicht mit Schleifen bearbeiten lassen, da der Index einer Array-Komponente eine Variable sein und als Laufvariable in einer Schleife benutzt werden kann. char-Arrays in C++ Arrays aus Elementen des Typs char nehmen in C++ eine Sonderstellung ein. Einerseits kann man in ihnen - in Analogie zu Arrays aus Elementen eines der anderen Datentypen - einzelne Datenobjekte vom Typ der Arrayelemente ablegen, also etwa Zeichen oder auch kleine ganze Zahlen, andererseits dienen sie aber auch zur Speicherung eines speziellen Typs von Datenobjekten: Strings. In C++ ist ein String eine Folge von Zeichen, die in einem Array aus Elementen vom Typ char gespeichert ist und mit dem Nullzeichen '\0' 88 terminiert ist. Fehlt das Nullzeichen, hat man lediglich einzelne Zeichen in einem charArray vor sich, jedoch noch keinen String. Wegen des zusätzlichen abschließenden Nullzeichens belegt ein String stets ein Byte mehr Platz im Speicher, als die eigentliche Zeichenfolge Zeichen hat. 5.9.5 Variablen und Speicher Um mit einem Computer Algorithmen zu definieren, ist es notwendig, Zwischenwerte zu speichern und gespeicherte Zwischenwerte für die weitere Berechnung zu verwenden. Für die Speicherung von Werten steht der Hauptspeicher zur Verfügung. Es wäre aber mühsam, wenn sich der Programmierer darum kümmern müsste, an welcher Stelle im Speicher ein Zwischenwert steht, wie viele Bytes (etwa im Falle einer Gleitpunktzahl) dazu gehören, welche Speicherplätze noch frei sind etc. Daher bieten alle Programmiersprachen das Konzept der Variablen an. Aus der Sicht des Programmierers sind Variablen Behälter für Werte eines bestimmten Datentyps. Der Compiler sorgt dafür, dass zur Laufzeit eines Programms für alle Variablen Speicherplatz reserviert ist und zwar soviel, wie für die Aufnahme von Werten des jeweiligen Datentyps benötigt wird. Er setzt automatisch jeden Bezug (Referenz) auf eine Variable in die entsprechende Hauptspeicheradresse um. Programmierer können mit Variablen so umgehen wie mit Werten des Datentyps. Man kann mit ihnen rechnen wie mit Unbestimmten in der Mathematik. Wenn ein Ausdruck, wie z.B. x*(y+1/y) ausgerechnet wird, so werden für die Variablen x und y immer die Werte eingesetzt, die sich zur Zeit an den ihnen zugewiesenen Speicherplätzen befinden. 5.9.6 Deklaration von Variablen In den meisten höheren Programmiersprachen müssen Variablen vor ihrer ersten Benutzung deklariert werden. Dies bedeutet, dass man dem System mitteilen muss, welche Variablen man benötigt und von welchem Datentyp die Werte sein sollen, die in der Variablen gespeichert werden sollen. Generell gilt (und das nicht nur bei Variablennamen), dass C++ und Java zwischen Groß- und Kleinschreibung unterscheiden: o o o o o int a, b, c; int A, B, C; boolean test; float x, y; char c; 89 Da nicht alle Sprachen solche Variablendeklarationen verlangen, stellt sich die Frage, welchen Vorteil eine solche Deklaration mit sich bringt. Zunächst hilft eine Deklaration dem Compiler, weil er zur Compilierungszeit bereits weiß, wie viel Speicherplatz er reservieren muss. Für die obige Java-Deklaration ergibt sich ein Speicherplatzbedarf von: 3x4 Byte + 3x4 Byte + 1 Byte + 2x4 Byte + 2 Byte = 35 Byte. Zweitens kann der Compiler bereits festlegen, wo die Variablen (relativ zueinander) im Hauptspeicher angeordnet werden sollen. In dem Programm kann er dann jede Erwähnung einer Variablen bereits durch eine Referenz auf den entsprechenden Speicherplatz ersetzen. Dies führt zu einer Zeitersparnis für jeden Variablenzugriff. Vor allem aber hilft die Deklaration dem Programmierer - viele Fehler, die aus einer falschen Benutzung von Variablen entstehen, werden bereits zur Compilierungszeit erkannt. 5.9.7 Initialisierung von Variablen Bevor zum ersten Mal ein Wert in einer Variablen gespeichert wurde, ist der darin befindliche Wert (wie bereits ausgeführt) vom Zufall bestimmt. Daher ist es sinnvoll, jede Variable möglichst frühzeitig mit einem Ausgangswert zu versehen, d.h. sie zu initialisieren. Manche Programmiersprachen initialisieren Variablen automatisch mit einem Standardwert (engl. Default), die meisten überlassen dies aber dem Programmierer. C++ und Java bieten die Möglichkeit, Variablen gleichzeitig zu deklarieren und optional auch mit einem Anfangswert zu initialisieren: o o o o int a,b,c = 6; boolean test = false; double pi = 3.14; char c = 'p'; 5.9.8 Typkorrekte Ausdrücke Variablen bezeichnen Werte von bestimmten Datentypen. Die allgemeinste Form, einen Wert zu bezeichnen, erlaubt neben Variablen und Elementen der Datenstruktur auch Operationszeichen. Eine solche Konstruktion nennt man (typkorrekten) Ausdruck, in der Mathematik auch wohlgeformten Term. Konstanten und Variablen sind demzufolge Ausdrücke. Wenn mehrere Ausdrücke durch ein Operationszeichen verknüpft werden, ist die resultierende Konstruktion wieder ein Ausdruck. Bedingung dabei ist, dass nur Ausdrücke passender Sorten verknüpft werden Nehmen wir z.B. an, dass wir in Java folgende Variablen deklariert haben: 90 o o o o o int a,b,c = 6; double pi = 3.14; double t = 3.00; char ch = 'c'; char d = 'd'; Dann sind folgende Ausdrücke Beispiele für typkorrekte Ausdrücke: o o a + 2 * (b + c) || nur ganze Zahlen t = pi + t || nur double-Werte Nicht typkorrekt ist dagegen: o a + ch || Addition von Zahl und Zeichen = sinnlos) a = b + pi || Zuweisung von double zu Integer führt zu Genauigkeitsverlusten 5.9.9 Typfehler Die meisten gängigen Programmiersprachen sind statisch getypt, was bedeutet, dass der Typ jedes Ausdrucks bereits zur Compilierzeit, also vor der Ausführung des Programms, feststehen muss. Viele Fehler können daher frühzeitig erkannt werden, weil sie bewirken, dass Ausdrücke inkompatibler Typen verknüpft werden. Hätte man sich z.B. vertippt und "length(s+1)" geschrieben, statt "length(s)+1", so würde der Compiler bereits den Fehler erkennen, denn wenn s als String deklariert ist, macht s+1 keinen Sinn, und wenn s als Integer deklariert ist, ist die Länge von s+1 ebenfalls sinnlos. Ist eine Sprache nicht statisch getypt oder gar völlig untypisiert, so würde der obige Fehler erst zur Laufzeit oder gar nicht auffallen. Egal ob s einen Integer oder einen String darstellt, er ist durch eine Bitfolge repräsentiert - und length arbeitet im Endeffekt auf Bitfolgen. Es käme also irgendetwas heraus bei length(s+1), nur wäre es vermutlich nicht das was man eigentlich wollte. Wird mit diesem sinnlosen Ergebnis weitergerechnet, wird es möglicherweise nicht einmal als falsch erkannt! 5.10 Der Kern imperativer Sprachen Programmiersprachen erweitern die Möglichkeiten, die Datenstrukturen durch ihre Operationen bieten, um die Fähigkeit, Zwischenwerte zu speichern und später wieder zu verwenden. Zwischenwerte werden dabei in Variablen abgespeichert. 91 Die jeweilige Belegung einer Variablen mit konkreten Werten nennen wir Speicherzustand oder kurz Speicher. Eine Berechnung besteht dann aus einer Folge von elementaren Aktionen, die immer wieder einen Ausdruck auswerten das Ergebnis speichern Solche elementaren Aktionen werden Zuweisung genannt. Da der Wert eines Ausdruckes von den darin enthaltenen Variablen, oder genauer von den in diesen Variablen gespeicherten Zwischenwerten, abhängig ist, können sich durch geschickte Kombination von Zuweisungen komplexe Berechnungen ergeben. Ein Programm enthält somit einfache oder zusammengesetzte Anweisungen an die Maschine, die insgesamt eine gezielte Veränderung des Speicherzustandes herbeiführen. Solche Anweisungen nennt man auch Befehle und spricht von befehlsorientierten oder imperativen Sprachen. 5.10.1 Zuweisungen Zuweisungen sind die einfachsten Anweisungen zur Veränderung des Speichers. Sie bewirken die gezielte Veränderung des Wertes einer einzigen Variablen. Eine Zuweisung besteht aus einer Variablen v und einem Ausdruck t, die durch ein Zuweisungszeichen verbunden sind: v=t (wir gehen hier davon aus, dass "=" der Zuweisungsoperator ist (wie in Java oder C++). Das ist aber nicht zwangsläufig der Fall: In PASCAL ist ":=" der Zuweisungsoperator.) Eine solche Zuweisung wird ausgeführt, indem der Ausdruck t ausgewertet wird und der resultierende Wert anschließend in v gespeichert wird. Dabei wird der alte Wert von v überschrieben (siehe nachfolgendes Beispiel): 92 5.10.2 Kontrollstrukturen Das Wesen einer imperativen Programmiersprache besteht darin, Folgen von Anweisungen zu neuen Anweisungen zu gruppieren. Wie bei Zuweisungen ist der Netto-Effekt solcher zusammengesetzter Anweisungen eine Veränderung des Speicherinhalts. Selbst eine Bildschirmausgabe ist letztlich Ausdruck einer Speicherveränderung, denn sie beruht auf einer Veränderung des Bildschirmspeichers, der im Textmodus für jede Bildschirmposition ein Zeichen, sowie einen Farb- und Helligkeitswert enthält. Die Möglichkeiten, die eine Programmiersprache anbietet, um gezielt und kontrolliert Anweisungen zu neuen komplexeren und abstrakteren Anweisungen zusammenzusetzen, nennt man Kontrollstrukturen. Der Begriff Kontrollstrukturen fasst spezielle Anweisungen zusammen, die der Steuerung (Kontrolle) des Programmflusses dienen. Sie ermöglichen es dem Programmierer, nicht nur streng sequentiell ablaufende Programme zu schreiben, sondern auch solche, in denen Anweisungen unter bestimmten Bedingungen übersprungen oder wiederholt ausgeführt werden können, wenn die Logik des betreffenden Programms dies erfordert. Die Kontrollstrukturen in Java und C++ lassen sich in drei Gruppen einteilen: Auswahl- oder Selektionsanweisungen Machen die Ausführung von Anweisungen vom Erfülltsein einer Bedingung abhängig und reduzieren so die generelle Ausführbarkeit einer Anweisung auf ihre Ausführbarkeit in lediglich einer bestimmten Auswahl von Fällen. Wiederholungs- oder Iterationsanweisungen Gestatten es, eine oder mehrere Anweisungen beliebig oft auszuführen. Sprung- oder Kontrollanweisungen Bewirken Sprünge - vorwärts oder rückwärts - im Programm (wonach das Programm mit der Ausführung der Anweisungen fortfährt, die sich an der Stelle befinden, zu der gesprungen wurde). Mit diesen drei Typen von Kontrollstrukturen werden wir uns in den nächsten Abschnitten beschäftigen. 5.10.2.1 Auswahlanweisungen Es gibt drei Auswahlanweisungen in Java und C++, die mit den Schlüsselwörtern if, if else und switch bezeichnet sind. 93 Die if-Anweisung Die if-Anweisung stellt den einfachsten Fall einer bedingten Anweisung dar: Ist die Bedingung erfüllt, werden die betreffenden Anweisungen ausgeführt. Wenn nicht, findet keinerlei Operation statt. Die if-Anweisung gestattet die Ausführung einer oder mehrer Anweisungen nur dann, wenn die Überprüfung einer zuvor formulierten Bedingung ergeben hat, dass diese erfüllt ist. Gibt es nur eine Anweisung, die von dieser Bedingung abhängig ist, kann man die ifAnweisung mit folgender Syntax ausdrücken: if (ausdruck) anweisung Sollen mehrere Anweisungen von der Bedingung abhängig sein, müssen diese als Block in geschweiften Klammern notiert werden: if (ausdruck) { anweisung1 anweisung2 anweisung3 ... } Die Bedingung der if-Anweisung ist durch den Syntaxteil ausdruck gegeben: Ergibt ausdruck den Wahrheitswert TRUE, werden die abhängigen Anweisungen ausgeführt. Ist ausdruck FALSE, gelangt keine der abhängigen Anweisungen zur Ausführung. 94 Abbildung 27: Ist ausdruck TRUE, werden die abhängigen Anweisungen ausgeführt. Ist ausdruck FALSE, wird das Programm mit der auf die ifAnweisung folgenden Anweisung fortgesetzt. Wird das untenstehende Programmstück ausgeführt, wird die Meldung "a ist größer als b" auf den Bildschirm ausgegeben, da der Wert von a in der Tat größer als der von b ist, und somit die Bedingung a > b = TRUE ausgewertet wird. int a = 2; int b = 1; if ( a > b) cout << "a ist größer als b"; 95 Ändert man jedoch den Wert von b mit b = a von 1 in 2, sind die Werte der beiden Variablen gleichgroß und somit die Bedingung a > b nicht erfüllt, also FALSE. In diesem Falle unterbleibt die Ausgabe auf den Bildschirm. Die if else-Anweisung Sollen auch, wenn die Bedingung nicht erfüllt ist, bestimmte Operationen ausgeführt werden, verewndet man die if else-Anweisung, die sich von der einfachen ifAnweisung durch das Vorhandensein konkreter Alternativanweisungen für diesen Fall unterscheidet. Es kommt nicht selten vor, dass eine Situation eintritt, in der nicht nur für den Fall, dass die Bedingung einer if-Anweisung TRUE ist, irgendwelche Operationen stattfinden sollen, sondern auch dann, wenn die Bedingung FALSE ist. Die passende Kontrollstruktur für derartige Fälle ist die if else-Anweisung. Gibt es nur eine abhängige Anweisung, wird folgende Syntax verwendet: o Sollen mehrere Anweisungen von der Bedingung abhängig sein, müssen diese wieder als Block in geschweiften Klammern notiert werden: o if (ausdruck) anweisung else anweisung if (ausdruck) { anweisung } else { anweisung } Ist ausdruck TRUE, werden die Anweisung(en) im so genannten if-Zweig der if else-Anweisung ausgeführt, andernfalls diejenigen des else-Zweiges (siehe nächste Abbildung) Die switch-Anweisung Die switch-Anweisung realisiert eine so genannte Mehrfachauswahl, bei der Aktionen nicht nur in einem oder zwei, sondern auch in einer größeren Anzahl von Fällen stattfinden können. Die switch-Anweisung realisiert eine Auswahl zwischen beliebig vielen Alternativen und ist insofern mit einer geschachtelten if else-Anweisung vergleichbar. Diese so genannte Mehrfach-Auswahl wird gemäß der folgenden Syntax durchgeführt: 96 Der Ausdruck nach dem Schlüsselwort switch muss von ganzzahligem Typ sein und wird der Reihe nach mit jeder der ebenfalls ganzzahligen und voneinander verschiedenen Konstanten verglichen, die Bestandteil der so genannten caseLabels im Block der switch-Anweisung sind (ein Label ist eine Art Kennung oder Markierung für einen bestimmten Programmpunkt). Stimmt der Wert von ausdruck mit dem Wert einer der case-Konstanten überein, wird das Programm mit den Anweisungen fortgesetzt, die unmittelbar auf das entsprechende case-Label folgen, wobei es auch möglich ist, dass ein Label keine Anweisungen besitzt. Gibt es keine Übereinstimmung von ausdruck mit einer der case-Konstanten, werden die Anweisungen nach dem default-Label ausgeführt, sofern ein solches überhaupt in der betreffenden switch-Anweisung enthalten ist (Die eckigen Optionalitäts-Klammern in der Syntax bedeuten, dass etwas nicht zwingend vorgeschrieben ist. Jede switch-Anweisung darf maximal einen default-Zweig besitzen, der nicht unbedingt als letzter Zweig notiert werden muß, sondern an einer beliebigen Position im switch-Block stehen kann). Stimmt ausdruck mit keiner der case-Konstanten überein und gibt es keinen default-Zweig, wird keine der im switch-Block enthaltenen Anweisungen ausgeführt und das Programm mit der auf die switch-Anweisung folgenden Anweisung fortgesetzt. 97 Abbildung 28: Switch-Anweisung Das nachfolgende Programmfragment soll eine Anwender-Eingabe in der Variablen direction aufnehmen, die aus einem der Buchstaben l, r, o, u für die Richtungen links, rechts, oben und unten bestehen darf. Anschließend soll ausgegeben werden, welche Richtung der Anwender gewählt hat: 98 5.10.2.2 Die Wiederholungsanweisungen Die Notwendigkeit, bestimmte Verarbeitungsschritte mehrfach hintereinander auszuführen, ergibt sich bei der Entwicklung von Programmen ausgesprochen häufig. Ein Programm soll z.B. bis 100 zählen und dabei jeden gezählten Wert ausgeben. Natürlich denkt man bei derartigen Aufgaben gewöhnlich nicht daran, einhundert einzelne Anweisungen zu codieren, sondern vertraut vielmehr stillschweigend darauf, dass die gewählte Programmiersprache für solche Fälle etwas Passendes bereithält. In der Tat verfügen Java und C++ über drei Wiederholgungs- oder Iterationsanweisungen: die while-Schleife die for-Schleife die do while-Schleife Mit diesen Schleifen lassen sich eine oder mehrere Anweisungen beliebig oft ausführen, ohne das man diese Anweisungen jedes Mal neu hinschreiben muss. Wiederholungsanweisungen werden aufgrund ihres zirkulären Ablaufs auch als Schleifen (loops) bezeichnet. Allen drei Schleifentypen in Java und C++ ist gemeinsam, dass die (wiederholte) Ausführung von Anweisungen ähnlich wie bei den Auswahlanweisungen vom Erfülltsein einer Bedingung abhängt. Die while-Schleife Die while-Anweisung hat die folgende Syntax: while (ausdruck) anweisung Sollen mehrere Anweisungen innerhalb einer while-Schleife wiederholt werden, wird wie üblich mit geschweiften Klammern ein Block gebildet: while (ausdruck) { anweisung1 anweisung2 anweisung3 anweisung4 } Die while-Anweisung führt die in ihr enthaltenen Anweisungen aus, wenn die Auswertung von ausdruck, also die Bedingung für die Ausführung der Schleife TRUE ist. Anschließend wird ausdruck erneut überprüft und die Ausführung der Anweisungen wiederholt, wenn ausdruck immer noch TRUE ist. 99 Dieses Prozedere von Überprüfen der Laufbedingung für die Schleife und anschließendem Ausführen der in der Schleife enthaltenen Anweisungen wird so lange fortgesetzt, bis die Auswertung von ausdruck nicht mehr TRUE zurückliefert. Liefert die Auswertung der Schleifenbedingung FALSE zurück, werden die Anweisungen in der Schleife nicht (mehr) ausgeführt. Ergibt bereits die erste Auswertung der Schleifenbedingung den Wert FALSE, werden die Anweisungen in der Schleife überhaupt nicht ausgeführt! Die while-Schleife ist also eine so genannte abweisende Schleife, da evtl. die in Ihr enthaltenen Anweisungen kein einziges Mal ausgeführt werden, wenn die Bedingung nicht erfüllt ist (siehe Abbildung 29). Das nachfolgende Programmstück erzeugt die Ausgabe: 1 2 3 4 5 6 7 8 9 10: Dies kommt auf die folgende Weise zustande: Da i zunächst den Wert 1 hat, ist die Schleifenbedingung erfüllt (TRUE) und die Anweisungen im Rumpf der Schleife werde ausgeführt. Das Resultat ist die Ausgabe des aktuellen Werts von i und die Inkrementierung der Variablen auf den Wert 2. Da 2 kleiner ist als 11, ergibt auch die erneute Auswertung der Schleifenbedingung TRUE. Nach diesem Prinzip wird die Schleife insgesamt zehnmal durchlaufen. Danach hat i aufgrund der Inkrementierung im 10. Durchgang den Wert 11 und die nächste Auswertung der Schleifenbedingung ergibt daher FALSE, da i nun nicht mehr kleiner ist als 11. Als Folge davon werden die Anweisungen des Schleifenrumpfs nicht mehr ausgeführt, die Schleife wird beendet und das Programm fährt mit der auf die whileSchleife folgenden Anweisung fort. 100 Abbildung 29: While-Schleife Die for-Schleife Die for-Anweisung hat die folgende Syntax: for (initialisierung;abbruchbedingung;inkrementierung) anweisung Soll mehr als eine Anweisung im Schleifenrumpf iteriert werden, wird wie üblich mit geschweiften Klammern ein Block gebildet: 101 for (initialisierung;abbruchbedingung;inkrementierung) { anweisung1 anweisung2 anweisung3 anweisung4 ... } Die for-Schleife in Java und C++ kann auch als so genannte Zählschleife bezeichnet werden. Sie wird meist verwendet, wenn die Anzahl der Schleifendurchläufe im Vorhinein feststeht, wobei man eine so genannte Laufvariable verwendet, um die Anzahl der Schleifendurchläufe zu zählen. Die for-Schleife besteht aus folgenden vier Teilen: 1. aus einem Initialisierungsteil, der vor Betreten der Schleife ausgeführt wird und in dem die Laufvariable(n) einen Wert bekommt(en); 2. aus einer Abbruchbedingung, die jedes Mal vor Betreten der Schleife geprüft wird; 3. aus einem Inkrementierungsteil, der am Ende jedes Schleifendurchlaufs ausgeführt wird und zum Beispiel die Laufvariable erhöhen kann; 4. aus dem Schleifenrumpf. Abbildung 30: For-Schleife Das folgende Programmstück erzeugt die Ausgabe 12 17 76 99: 102 Das kommt folgendermaßen zustande: i wird zunächst mit dem Wert 0 initialisiert. Anschließend wird in der Bedingung überprüft, ob i kleiner ist, als die Länge des Arrays a (Länge = 4). Ergibt die Auswertung der Bedingung TRUE, wird die Anweisung im Schleifenrumpf ausgeführt, d.h. der Wert an der Indexposition i des Arrays wird ausgegeben und i wird inkrementiert (im Schleifenkopf). Nach diesem Prinzip wird die Schleife insgesamt 4x durchlaufen. Danach hat i aufgrund der Inkrementierung den Wert 4, ist damit also nicht mehr kleiner als die Länge des Arrays und somit ergibt die darauf folgende Auswertung der Schleifenbedingung FALSE. Als Folge davon werden die Anweisungen des Schleifenrumpfs nicht mehr ausgeführt, die Schleife endet und das Programm fährt mit der auf die forAnweisung folgenden Anweisung fort. Die do while-Schleife Im Unterschied zu den beiden anderen Wiederholungsanweisungen führt die die do while-Schleife zunächst die in ihr enthaltenen Anweisungen aus und wertet dann erst die Schleifen-Bedingung aus! Die do while-Schleife ist also eine so genannte annehmende Schleife. Sie hat die folgende Syntax: do anweisung while (ausdruck) Sollen mehrere Anweisungen in der Schleife ausgeführt werden, müssen diese mit geschweiften Klammern in einem Block geklammert werden: do { anweisung1 103 anweisung2 anweisung3 anweisung4 ... } while (ausdruck) Der Umstand, dass ausdruck (die Bedingung der Schleife) erst am Ende der do while-Schleife ausgewertet wird, hat zur Folge, dass die Anweisung(en) in der Schleife - im Unterschied zur for- oder while-Schleife - in jedem Fall mind. einmal ausgeführt werden, unabhängig davon, ob ausdruck, d.h. die Bedingung TRUE ist oder nicht. Ergibt die Überprüfung von ausdruck den Wert TRUE, werden die Anweisungen in der Schleife erneut ausgeführt. Ist der Wert von ausdruck FALSE, endet die Schleife und das Programm fährt mit der auf die do while-Schleife folgenden Anweisung fort. Abbildung 31: Do-While-Schleife Das folgende Programmstück nimmt zunächst im do-Block in der Variablen x eine Benutzereingabe auf, addiert dann die Variablen x und y und speichert das Ergebnis in der Variablen x: 104 Anschließend wird überprüft, ob der Wert von x ungleich 0 ist. Ist dies der Fall, wird der do-Block erneut ausgeführt. Nach diesem Prinzip wird der do-Block solange ausgeführt, bis der Benutzer den Wert 0 eingibt. Dann ist x = 0 und die darauf folgende Auswertung der Schleifenbedingung ergibt FALSE. Die Schleife endet und das Programm fährt mit der auf die do while-Schleife folgenden Anweisung fort. 5.10.2.3 Sprunganweisungen Sprunganweisungen verlagern die Ausführungskontrolle in einem Programm von einer Stelle an eine andere (ohne dass dazu eine besondere Bedingung erfüllt sein müßte). Die Sprache C++ verfügt über vier Sprunganweisungen, zu denen die break-, die continue-, die goto- und die return-Anweisung gehören. In Java existiert die goto-Anweisung nicht. Mit Ausnahme der return-Anweisung führen Sprunganweisungen nur einen Transfer der Ausführungskontrolle innerhalb ein- und derselben Funktion durch. Die return-Anweisung überträgt die Kontrolle von einer aufgerufenen Funktion zurück an die aufrufende Funktion (oder das Betriebssystem). Wir werden im folgenden exemplarisch lediglich die break- und die continueAnweisung betrachten. Die break-Anweisung Die break-Anweisung kann ausschließlich innerhalb einer switch-Anweisung oder innerhalb einer der drei Schleifenarten auftreten und hat die folgende Syntax: break; Innerhalb eines case-Zweigs einer switch-Anweisung bewirkt die break-Anweisung das sofortige Ende der switch-Anweisung und den Transfer der Ausführungskontrolle zu der auf die switch-Anweisung unmittelbar folgende Anweisung. Eine break-Anweisung in einer Schleife beendet die Schleife und übergibt die Programmkontrolle an die Anweisung, die unmittelbar auf die Schleife folgt. 105 In dem obigen Beispiel zur switch-Anweisung bewirkt die break-Anweisung, dass nach der Ausführung einer case-Anweisung die switch-Anweisung verlassen wird, ohne die darauf folgenden case-Klauseln auszuführen. Die continue-Anweisung Die continue-Anweisung hat die folgende Syntax: continue; Sie kann ausschließlich in Wiederholungsanweisungen (Schleifen) verwendet werden. Sie bewirkt dort den Abbruch eines einzelnen Schleifendurchgangs (der unmittelbar umgebenden Schleife). In einer while- oder do while-Anweisung fährt die Programmausführung danach mit dem nächsten Schleifendurchgang fort, d.h. genauer: mit der erneuten Überprüfung der Schleifenbedingung. In einer for-Schleife wird zuvor noch der Inkrementierungsteil ausgeführt. while (ausdruck_1) { if (ausdruck_2) continue; } 5.11 Datenstrukturen In der Anfangszeit des Programmierens kam den verwendeten Daten nur eine zweitrangige Bedeutung zu. Der Schwerpunkt lag auf der Formulierung sauberer Algorithmen. Daten waren schlicht Bitfolgen, die von dem Algorithmus manipuliert wurden. Ein Programmierer hat aber stets eine Interpretation der Bitfolgen, d.h. eine bestimmte Abstraktion im Auge: Gewisse Bitfolgen entsprechen Zahlen, andere stellen Wahrheitswerte, Zeichen oder Zeichenketten dar. Die ersten erfolgreichen höheren Programmiersprachen gestatteten daher eine Typdeklaration. Variablen konnten als Integer, Boolean, usw. deklariert werden. Der Compiler reservierte automatisch den benötigten Speicherplatz und überprüfte auch noch, dass die auf die Daten angewandten Operationen dem Datentyp angemessen waren. Eine Addition etwa einer Integer-Größe zu einem Wahrheitswert wurde als fehlerhaft zurückgewiesen. Dies erleichterte es dem Programmierer, Denkfehler früh zu erkennen und zu beheben. 106 Als Programme größer und unübersichtlicher wurden und daher möglichst in einzelne Teile zerlegt werden sollten, erschien es sinnvoll, Daten und Operationen, die diese Daten manipulieren, als Einheit zu sehen und auch als abgeschlossenen Programmteil formulieren und compilieren zu können. Ein solcher Programmteil, im Allgemeinen Modul genannt, konnte von einem Programmierer erstellt werden und dann anderen in compilierter Form zur Verfügung gestellt werden. Es stellt sich die Frage, was man den Benutzern des Moduls mitteilen soll. Damit diese einen Gewinn aus der geleisteten Arbeit ziehen können, sollten sie nicht mit den Interna des programmierten Moduls belästigt werden, sie sollten nur wissen, was man damit anfangen kann. Ein konkretes Beispiel wären Kalenderdaten: Ein Benutzer sollte lediglich wissen, wie man damit umgehen kann, nicht aber, wie die Kalenderdaten intern repräsentiert sind. Aus der Kenntnis der Repräsentation könnte sogar eine Gefahr erwachsen: Eine Änderung des Moduls könnte Programmen, die von einer speziellen internen Repräsentation ausgehen, den Garaus machen. Die Forderung, dem Anwender das (und nur das) mitzuteilen, was das Modul als Funktionalität anbietet, nicht aber Interna der Implementierung, wird in dem Schlagwort "information hiding" (oder Kapselung von Information) zusammengefasst. In einer neueren Programmiersprache wie Java werden Datenstrukturen und darauf operierende Methoden in Modulen zusammengefasst, die als Klassen bezeichnet werden. Als Datenstruktur definieren wir demnach nicht nur die Daten selbst, sondern auch die Operationen, die darauf ausgeführt werden können. Im Folgenden werden wir uns mit einigen der wichtigsten Datenstrukturen befassen. 5.11.1 Stacks Ein Stack ist ein abstrakter Datentyp, bei dem Elemente eingefügt und wieder entfernt werden können. Derartige Datentypen, die als Behälter für Elemente dienen, fasst man oft unter Oberbegriffen wie Container oder Collection zusammen. Unter den Behälter-Datentypen zeichnen sich Stacks dadurch aus, dass immer nur auf dasjenige Element zugegriffen werden kann, das als letztes eingefügt wurde. Dafür gibt es das Schlagwort: LIFO = Last-In First-Out. 107 Das englische Wort Stack kann man mit Stapel übersetzen. Dabei liegt es nahe, an einen Stapel von Tellern oder Tabletts zu denken (--> z.B. die "Tellerspender" in der Mensa) bei dem man auch immer nur auf das oberste Element zugreifen kann dasjenige also, welches als letztes auf den Stapel gelegt wurde. In einem Stack s sind Elemente x eines beliebigen, aber festen Datentyps gespeichert. "Speichern" heißt hier Ablegen auf dem Stack und "Entfernen" heißt Entnehmen des obersten Elements. Stackoperationen Die fundamentalen Stackoperationen, die auf einem Stack s angewendet werden können, sind (die Bezeichnungen für diese Operationen können je nach Programmiersprache variieren!): push(x,s) legt ein Element x auf den Stack s top(s) liefert das zuletzt auf den Stack s gelegte Element pop(s) entfernt das zuletzt auf den Stack s gelegte Element isEmpty(s) gibt an, ob der Stack s leer ist Abbildung 32: Stack und Stack-Operationen Anwendungsbeispiel für einen Stack Ein wichtiges Anwendungsbeispiel für die Datenstruktur Stack ist die Auswertung von arithmetischen Ausdrücken. 108 Jeder arithmetische Ausdruck in normaler Schreibweise kann in eine PostfixNotation umgewandelt werden, bei der sich die Operatoren stets rechts von den Operanden befinden. Dabei kommt man gänzlich ohne Klammern aus. Bei einer Auswertung von links nach rechts bezieht sich ein Operator immer auf die unmittelbar links von ihm stehenden Werte. Beispielsweise wird aus dem arithmetischen Ausdruck (2+4)2/(16-7) der PostfixAusdruck: 2 4 + 2 16 7 - / Dieser kann mittels eines Stacks von links nach rechts abgearbeitet werden: Ist das gelesene Datum ein Operand, so wird es mit push auf den Stack gelegt. o Ist das gelesene Datum ein n-stelliger Operator, dann wird er auf die obersten n Elemente des Stacks angewandt. Das Ergebnis ersetzt diese n Elemente. o Abbildung 33 zeigt die 9 Schritte zur Berechnung des obigen Ausdrucks: Abbildung 33: Beispiel für Stack-Operationen Jeder Compiler wandelt Ausdrücke bei der Compilierung in Postfix-Ausdrücke um. 109 5.11.2 Queues Ähnlich einem Stack ist eine Queue ein Behälter, in den Elemente eingefügt und nur in einer bestimmten Reihenfolge wieder entnommen werden können. Einfügung eines Elements - "enQueue" - erfolgt an einem, Entfernung - "deQueue" - an dem anderen Ende der Queue. Dies bewirkt, dass man immer nur auf das Element zugreifen kann, das am längsten im Behälter ist (FIFO = First in-First Out). Abbildung 34: Queue Anwendung von Queues Queues werden oft eingesetzt, um ansonsten unabhängige Prozesse miteinander kommunizieren zu lassen, bzw. um kooperierende Prozesse zu entkoppeln. Üblicherweise produziert ein Prozess eine Folge von Daten, während der zweite Prozess diese Daten entgegennimmt und weiter verarbeitet. Der erste Prozess heißt dann Erzeuger oder Produzent (engl. producer) und der zweite heißt Verbraucher oder Konsument (engl. consumer). Man verwendet eine Queue, in die der Produzent die erzeugten Daten ablegt (enQueue) und aus dem der Konsument die benötigten Daten entnimmt (deQueue). Die Queue dient der zeitlichen Entkopplung - der Erzeuger kann weiter produzieren, auch wenn der Verbraucher die Daten noch nicht alle entgegengenommen hat, und der Verbraucher hat Daten, mit denen er weiterarbeiten kann, auch wenn der Erzeuger für die Produktion gewisser Daten gelegentlich eine längere Zeit benötigt. Beispiele für eine solche Erzeuger-Verbraucher Situation sind: Die Druckerwarteschlange unter Windows ist eine Queue für Druckaufträge. Erzeuger sind die Druckprozesse unterschiedlicher Programme (im Netzwerk) - Verbraucher ist der Druckertreiber. Die Queue dient hier zur zeitlichen Entkopplung. 110 Ähnlich legt der Festplatten-Controller als Produzent die gelesenen Daten in einer Queue ab - dort kann das Betriebssystem sie abholen. Beim Schreiben auf die Platte vertauschen Produzent und Konsument die Rollen. Queues können auch zur logischen Entkopplung von Prozessen dienen. So gibt es z.B. in UNIX eine Sammlung nützlicher kleiner Programme, welche einen Input in einen Output transferieren. Diese Tools nennt man Pipes. Mithilfe einer Pipe, die nichts anderes ist, als eine Queue, kann man die Ausgabe eines Programms mit der Eingabe des nächsten verknüpfen. 5.11.3 Listen Eine Liste ist eine Folge von Elementen, in der an beliebiger Stelle neue Elemente eingefügt oder vorhandene Elemente entfernt werden können. Im Gegensatz dazu darf man bei Stacks und Queues nur am Anfang oder am Ende Elemente einfügen oder entnehmen. Listen sind also allgemeinere Datenstrukturen als Stacks und Queues, in der Tat kann man sowohl Stacks als auch Queues mithilfe von Listen realisieren. Die Spezifikation eines abstrakten Datentyps Liste bietet viele Variationsmöglichkeiten. Es stellen sich unter anderem folgende Fragen: Welche Operationen sollen dazugehören? Wie sollen wir uns auf bestimmte Elemente in der Liste beziehen? Wir entscheiden uns hier dafür, Listenelemente anhand ihrer Position in der Liste anzusprechen - wenn die Liste n Elemente hat, dann nummerieren wir die Elemente von 1 bis n durch. Dazu benötigen wir: eine Operation laenge, um festzustellen, wie viele Elemente die Liste L hat, eine Methode Inhalt, die zu einer gültigen Position p mit 1 <= p <= laenge(L) den Inhalt des Elements an der Position p findet und eine Operation suche, die die Position p eines Elements mit Inhalt e bestimmt, sofern es denn ein solches gibt. Einfach verkettete Listen Wir implementieren eine Liste als eine Folge von Elementen. Jedes Element wird zum Teil einer Kette. Zu diesem Zweck trägt jedes Element nicht nur einen Inhalt e, sondern auch einen Zeiger next, der auf das folgende Element der Liste verweist. Manch einem mag die Analogie eines Elements zu einem Eisenbahnwaggon hilfreich 111 sein. Jeder Waggon hat einen Inhalt und eine Kupplung, an die der nächste Waggon gehängt werden kann. Auf diese Weise lassen sich beliebig lange Züge - Listen - zusammenstellen: Abbildung 35: Liste Um die ganze Liste inspizieren zu können, müssen wir nur ihr erstes Element finden. Dessen next-Zeiger führt uns auf das folgende Element, dessen next-Zeiger wiederum auf das dritte und so weiter. Das letzte Glied der Kette erkennen wir daran, dass sein next-Zeiger auf null verweist. Von einem Element aus kann man die Liste immer nur den next-Zeigern folgend in einer Richtung durchlaufen. Um danach wieder in die Liste einsteigen zu können, benötigen wir einen festen Zeiger anfang auf das erste Element der Liste. Hilfszeiger auf innere Elemente nennt man Cursor. Viele Operationen auf Listen beginnen an dem durch einen Cursor bezeichneten Element. Abbildung 36: Einfach verkettete Liste Doppelt verkettete Listen In einer einfach verketteten Liste sind die Elemente direkt nach dem Cursor einfach erreichbar. Will man allerdings auf das Element vor dem Cursor zugreifen, so muss die Liste vom Anfang her komplett durchlaufen werden! Durch die Richtung der Verkettung ergibt sich eine Asymmetrie im Aufwand des Listendurchlaufes. Spendiert man jedem Element noch einen Zeiger auf seinen Vorgänger, so stellt sich für den Aufwand des Listendurchlaufs die Symmetrie wieder her. Eine solche Implementierung (mit oder ohne Cursor) nennt man doppelt verkettete Liste. 112 Abbildung 37: Doppelt verkettete Liste 5.12 Bäume Bäume gehören zu den fundamentalen Datenstrukturen der Informatik. In gewisser Weise kann man sie als mehrdimensionale Verallgemeinerung von Listen auffassen. In Bäumen kann man nicht nur Daten, sondern auch relevante Beziehungen der Daten untereinander, wie Ordnungs- oder hierarchische Beziehungen, speichern. Daher eignen sich Bäume besonders, gesuchte Daten rasch wieder aufzufinden. Ein Baum besteht aus einer Menge von Knoten (Punkten), die untereinander durch Kanten (Pfeile) verbunden sind. Führt von Knoten A zu Knoten B eine Kante, so schreiben wir dies als A --> B und sagen A ist Vater/Mutter von B oder B ist Sohn/Tochter von A. Einen Knoten ohne Söhne nennt man ein Blatt oder terminalen Knoten. Alle anderen Knoten heißen innere Knoten. Ein Pfad von A nach B ist eine Folge von Knoten und Kanten, die von A nach B führen: A --> X1 --> X2 --> ... --> B Die Länge des Pfades definieren wir als die Anzahl der Knoten (0 oder mehr). Gibt es einen Pfad von A nach B, so ist B ein Nachkomme von A und A ein Vorfahre von B. Ein Baum muss folgende Bedingung erfüllen: Es gibt genau einen Knoten ohne Vater. Dieser Knoten heißt Wurzel (root). Jeder andere Knoten ist ein Nachkomme der Wurzel und hat genau einen Vater. 113 Weitere wichtige Eigenschaften eines Baumes ergeben sich hieraus automatisch. Dazu gehören: o o o Es gibt keinen zyklischen Pfad. Von der Wurzel gibt es zu jedem anderen Knoten genau einen Pfad. Die Nachkommen eines beliebigen Knotens K zusammen mit allen ererbten Kanten bilden wiederum einen Baum, den Unterbaum mit Wurzel K. Letzteres ist eine wichtige kennzeichnende Eigenschaft, die zeigt, dass Bäume rekursiv definiert werden können: Rekursive Baumdefinition: Ein Baum ist leer oder er besteht aus einer Wurzel W und einer leeren oder nichtleeren Liste B1, B2, ..., Bn von Bäumen. Von W zur Wurzel Wi von Bi führt jeweils eine Kante. Hinweis: Ein Objekt (oder eine Definition) heißt rekursiv, wenn es sich selbst als Teil enthält oder mithilfe von sich selbst definiert ist. Da Bäume hierarchische Strukturen sind, lässt sich jedem Knoten eine Tiefe zuordnen. Diese definieren wir als die Länge des Pfades von der Wurzel zu diesem Knoten. Die Tiefe eines Baumes definieren wir als 0, falls es sich um einen leeren Baum handelt, andernfalls als das Maximum der Tiefen seiner Knoten. Abbildung 38: Baum als Datenstruktur Beispiele für Bäume Viele natürliche hierarchische Strukturen sind Bäume. Dazu gehören unter anderem: 114 Stammbäume Knoten sind Frauen, Kanten führen von einer Mutter zu jeder ihrer Töchter. Die Wurzel (im abendländischen Kulturkreis) ist Eva. Dateibäume Knoten sind Dateien oder Verzeichnisse, Kanten führen von einem Verzeichnis zu dessen direkten Unterverzeichnissen oder Unterdateien. Die Wurzel heißt häufig C: oder "/". Listen Listen sind Bäume, bei denen jeder Knoten höchstens einen Nachfolger hat. Bäume stellt man gewöhnlich grafisch dar, indem jeder Knoten durch einen Kreis und jede Kante durch eine Linie dargestellt wird. Dabei wird ein Vater-Knoten immer über seinen Söhnen platziert, so dass die Wurzel der höchste Punkt ist. 5.12.1 Binärbäume Im Allgemeinen können Baumknoten mehrere Söhne haben. Ein Binärbaum ist dadurch charakterisiert, dass jeder Knoten genau zwei Söhne hat. Eine rekursive Definition ist: Ein Binärbaum ist leer oder besteht aus einem Knoten - Wurzel genannt - und zwei Binärbäumen, dem linken und dem rechten Teilbaum. Abbildung 39: Binärbaum (I) Binärbäume sind also 2-dimensionale Verallgemeinerungen von Listen. Ähnlich wie die Elemente einer Liste kann man in den Knoten eines Baumes beliebige Informationen speichern. Im Unterschied zu den Listenelementen enthält ein Knoten eines Binärbaumes zwei 115 Zeiger, einen zum linken und einen zum rechten Teilbaum. Abbildung 40: Binärbaum (II) Eine wichtige Anwendung von Bäumen, insbesondere auch von Binärbäumen, ist die Repräsentation von arithmetischen Ausdrücken. Innere Knoten enthalten Operatoren, Blätter enthalten Werte oder Variablennamen. Einstellige Operatoren werden als Knoten mit nur einem nichtleeren Teilbaum repräsentiert. In der Baumdarstellung sind Klammern und Präzedenzregeln überflüssig. Erst wenn wir einen "zweidimensionalen" Baum eindimensional (als String) darstellen wollen, sind Klammern notwendig. Beachten Sie, dass das Vorzeichen "-" und das Quadrieren einstellige Operatoren sind. Im Baum stellen wir sie durch "+/-" bzw. ()2 dar: Abbildung 41: Binärbaum zur Repräsentation eines arithmetischen Ausdruckes 116 5.12.2 Traversierungen Listen konnten wir auf natürliche Weise von vorne nach hinten durchlaufen - bei Bäumen können wir ähnlich einfach von der Wurzel zu jedem beliebigen Knoten gelangen, sofern wir seine "Baumadresse" kennen. Um den Vorgänger eines Elementes e in einer Liste zu finden, mußten wir am Anfang einsteigen und nach rechts laufen, bis wir zu einem Element z gelangten für das galt: z.next = e. Um in einem Baum den Vater eines bestimmten Knotens zu finden, haben wir es schwerer - wir müssen an der Wurzel einsteigen und in jedem Schritt raten, ob wir nach rechts oder nach links gehen sollen. Schlimmstenfalls müssen wir den ganzen Baum durchsuchen. Spätestens hier erhebt sich die Frage, wie wir systematisch alle Knoten eines Baumes durchlaufen - oder traversieren - können. Traversieren bedeutet, jeden Knoten eines Baumes systematisch zu besuchen. Diese Operationen sind für lineare Listen aufgrund ihrer Definition trivial, doch für Bäume gibt es eine Reihe verschiedener Möglichkeiten. Diese unterscheiden sich vor allem hinsichtlich der Reihenfolge, in der die Knoten besucht werden. Die wichtigsten Vorgehensweisen sind Pre-Order, Post-Order, InOrder und Level-Order, auf die hier aber nicht detailliert eingegangen werden soll. 5.13 Suchalgorithmen Gegeben sei eine Sammlung von Daten. Wir suchen nach einem oder mehreren Datensätzen mit einer bestimmten Eigenschaft. Dieses Problem stellt sich zum Beispiel, wenn wir im Telefonbuch die Nummer eines Teilnehmers suchen. Zur raschen Suche nutzen wir aus, dass die Einträge geordnet sind, z.B. nach Name, Vorname, Adresse. Wenn wir Namen und Vornamen wissen, finden wir den Eintrag von Herrn Sommer sehr schnell durch binäres Suchen: Dazu schlagen wir das Telefonbuch in der Mitte auf und vergleichen den gesuchten Namen mit einem Namen auf der aufgeschlagenen Seite. Ist dieser zufällig gleich dem gesuchten Namen, so sind wir fertig. Ist er in der alphabetischen Ordnung größer als der gesuchte Name, brauchen wir für den Rest der Suche nur noch die erste Hälfte des Telefonbuchs zu berücksichtigen. Wir verfahren danach weiter wie vorher, schlagen also bei der Mitte der ersten Hälfte auf und vergleichen wieden den gesuchten mit einem gefundenen Namen und so fort. 117 Dieser Algorithmus heißt binäre Suche. Bei einem Telefonbuch mit ca. 1000 Seiten Umfang kommen wir damit nach höchstens 10 Schritten zum Ziel, bei einem Telefonbuch mit 2000 Seiten nach 11 Schritten. Noch schneller geht es, wenn wir die Anfangsbuchstabenmarkierung auf dem Rand des Telefonbuchs ausnutzen. Wenn wir umgekehrt eine Telefonnummer haben und mithilfe des Telefonbuchs herausbekommen wollen, welcher Teilnehmer diese Nummer hat, bleibt uns nichts anderes übrig, als der Reihe nach alle Einträge zu durchsuchen. Diese Methode heißt lineare Suche. Bei einem Telefonbuch mit 1000 Nummern müssen wir im Schnitt 500 Vergleiche durchführen, im schlimmsten Fall sogar alle 1000. Müssen wir diese Art von Suche öfters durchführen, so empfiehlt sich eine zusätzliche Sortierung (einer Kopie) des Telefonbuchs nach der Rufnummer, so dass wir wieder binär suchen können. 5.13.1 Lineare Suche Allgemein lässt sich das Suchproblem wie folgt formulieren: Suchproblem: In einem Behälter A befindet sich eine Reihe von Elementen. Prüfe, ob Element e in A existiert, das eine bestimmte Eigenschaft p(e) besitzt. "Behälter" steht hier allgemein für Strukturen wie: Arrays, Dateien, Mengen, Listen, Stacks, Queues, etc. Wenn nichts näheres über die Struktur des Behälters oder die Platzierung/Sortierung der Elemente bekannt ist, dann müssen wir den folgenden Algorithmus anwenden: Entferne der Reihe nach Elemente aus dem Behälter, bis dieser leer ist oder ein Element mit der gesuchten Eigenschaft gefunden wurde. Schlimmstenfalls müssen wir den ganzen Behälter durchsuchen, bis wir das gewünschte Element finden. Hat dieser N Elemente, so werden wir bei einer zufälligen Verteilung der Daten erwarten, nach ca. N/2 Versuchen das gesuchte Element gefunden zu haben. In jedem Fall ist die Anzahl der Zugriffe proportional zur Elementzahl. Arrays als Behälter werden in den folgenden Such- und Sortier-Algorithmen eine besondere Rolle spielen. In Java ist die Indexmenge eines Arrays a mit n = a.length Elementen stets das Intervall [0...n-1]. Besonders für die rekursiven Algorithmen wird es sich als günstig herausstellen, wenn wir sie so verallgemeinern, dass sie nicht nur ein ganzes Array, sondern auch einen Abschnitt (engl. slice) eines Arrays sortieren können. 118 5.13.2 Binäre Suche Wenn auf dem Element-Datentyp eine Ordnung definiert ist und die Elemente entsprechend ihrer Ordnung in einem Array gespeichert sind, dann nennt man das Array geordnet oder sortiert. Für die Suche in solchen sortierten Arrays können wir die binäre Suche einsetzen, wie wir sie aus dem Telefonbuch-Beispiel kennen. Dazu sei x das gesuchte Element. Wir suchen also nach einem Index i mit a[i] = x. Wie bei der Namenssuche im Telefonbuch wollen wir den Bereich, in dem sich das gesuchte Element noch befinden kann, in jedem Schritt auf die Hälfte verkleinern. Dazu verallgemeinern wir das Problem dahingehend, dass wir i in einem beliebigen Indexbereich [min...max] des Arrays a suchen, angefangen mit min = 0 und max = n-1. Mit anderen Worten: Wenn das gesuchte Element x überhaupt in dem Array vorhanden ist, dann muss es (auch) im Abschnitt [min...max] zu finden sein. Davon ausgehend, dass das gesuchte Element überhaupt in a enthalten ist, wählen wir im ersten Schritt einen Index m, mit min ≤ m ≤ max. Anschließend gehen wir folgendermaßen vor: Falls x = a[m] gilt, sind wir fertig; Wir geben m als Ergebnis zurück. falls x < a[m], suche weiter im Bereich min...m-1, setze also max = m-1 falls x > a[m], suche weiter im Bereich m+1...max, setze also min = m + 1 Für den Index m zwischen min und max nimmt man am besten einen Wert nahe der Mitte, also z.B. m = (min + max) / 2. Auf diese Weise halbiert sich in jedem Schritt der noch zu betrachtende Bereich, und damit der Aufwand für die Lösung des Problems. Wenn der Bereich min...max aus n Elementen besteht, können wir den Bereich höchstens log2(n)-mal halbieren. Um 1000 Elemente zu durchsuchen, genügen also log2(1000) = 10 Vergleiche. Wir illustrieren die Methode mit 19 Elementen: Abbildung 42: Binäre Suche 119 5.14 Komplexität von Algorithmen Unter der Komplexität eines Algorithmus versteht man grob seinen Bedarf an Ressourcen in Abhängigkeit vom Umfang der Inputdaten. Die wichtigsten Ressourcen sind dabei die Laufzeit und der Speicherplatz. Lineare Suche Wenn wir davon ausgehen, dass die lineare Suche mit einer while-Schleife bewerkstelligt wird, hängt der Zeitbedarf im Wesentlichen davon ab, wie oft die whileSchleife durchlaufen wird. Dabei nehmen wir an, dass das gesuchte Element vorhanden ist, und unterscheiden drei mögliche Fälle: Best case: Im günstigsten Fall wird ein Element e mit p(e) beim ersten Versuch gefunden. Die Schleife wird nur einmal durchlaufen. Average case: Im Schnitt kann man davon ausgehen, dass das Element etwa nach der halben Maximalzahl von Schleifendurchläufen gefunden wird. Worst case: Im schlimmsten Fall wird das Element erst beim letzten Versuch oder überhaupt nicht gefunden. Die Maximalzahl der Schleifendurchläufe ist durch die Anzahl der Elemente begrenzt. Binäre Suche Bei der binären Suche benötigen wir im worst case log2(n) Schleifendurchläufe, bzw. rekursive Aufrufe - im average case kann man sich überlegen, dass man im vorletzten Schritt, also nach log2(n)-1 Schritten erwarten kann, das Element zu finden. Wir können die Anzahl der Schleifendurchläufe also folgendermaßen tabellieren: lineare Suche binäre Suche Best Case 1 1 Average Case n log2(n)-1 Worst Case n log2(n) Der genaue Zeitaufwand im worst case für die lineare Suche in einem Behälter mit n Elementen setzt sich zusammen aus einer Initialisierungszeit CI und aus dem Zeitbedarf für die while-Schleife, den wir mit Cw x n ansetzen können, wobei Cw die Zeitdauer eines Schleifendurchlaufes bedeutet. Wir erhalten für den Zeitbedarf tL(n) der linearen Suche also die Formel: tL(n) = CI + CW * n Zum Vergleich berechnen wir den Zeitbedarf tB(n) für die binäre Suche. Auch hier haben wir eine konstante Initialisierungszeit KI und eine konstante Zeit KW für jeden Schleifendurchlauf, bzw. für jeden rekursiven Aufruf. Dies ergibt: tB(n) = KI + KW * log2(n) 120 Offensichtlich ist die binäre Suche schneller - egal welchen Wert die einzelnen Konstanten haben. Wenn n nur groß genug ist, wird tL(n) größer als tB(n) sein. Allgemein interessiert uns beim Laufzeitvergleich verschiedener Algorithmen nur das Verhalten "für große" n. Auf diese Weise können wir die Güte von Algorithmen beurteilen, ohne die genauen Werte der beteiligten Konstanten zu kennen. 5.15 Sortieralgorithmen Viele Sortieralgorithmen übernehmen Strategien, die Menschen bereits im täglichen Leben anwenden - zum Beispiel beim Sortieren von Spielkarten. Wenn wir Kartenspieler beim aufnehmen einer "Hand" beobachten, können wir unter anderem folgende "Algorithmen" beobachten: Der Spieler nimmt eine Karte nach der anderen auf und sortiert sie in die bereits aufgenommenen Karten ein. Dieser Algorithmus wird InsertionSort genannt. Der Spieler nimmt alle Karten auf, macht eine Hand daraus und fängt jetzt an, die Hand zu sortieren, indem er benachbarte Karten solange vertauscht, bis alle in der richtigen Reihenfolge liegen. Dieser Algorithmus wird BubbleSort genannt. Bei Kartenspielen, bei denen die Karten zunächst aufgedeckt auf dem Tisch liegen können: Der Spieler nimmt die jeweils niedrigste der auf dem Tisch verbliebenen Karten auf und kann sie in der Hand links (oder rechts) an die bereits aufgenommenen Karten anfügen. Dieser Algorithmus wird SelectionSort genannt. Die oben genannten Algorithmen gehören zu den einfachen Sortierstrategien. Es gibt aber noch schnellere Algorithmen, die zudem auf eleganten mathematischen Ideen beruhen. Dazu gehören HeapSort, QuickSort und MergeSort mit einer Laufzeit von n*log(n). Aufgrund der Komplexität dieser Algorithmen werden wir uns im Folgenden auf BubbleSort als Beispiel eines Sortieralgorithmus beschränken. Bei Interesse an anderen Sortieralgorithmen sei hier auf die Literaturangaben zum Thema verwiesen, besonders auf: Sedgewick, Robert: Algorithmen. AddisonWesley, 2002. 5.15.1 BubbleSort Dieser Algorithmus sortiert z.B. ein Array von Datensätzen durch wiederholtes Vertauschen von Nachbarfeldern, die in falscher Reihenfolge stehen. Dies wiederholt man so lange, bis das Array vollständig sortiert ist. Dabei wird das Array in mehreren Durchgängen von links nach rechts durchwandert. Bei jedem Durchgang werden alle Nachbarfelder verglichen und ggf. vertauscht. Nach dem 1. Durchgang hat man folgende Situation: Das größte Element ist ganz rechts. 121 Alle anderen Elemente sind zwar zum Teil an besseren Positionen (also näher an der endgültigen Position), im Allgemeinen aber noch unsortiert. Die folgende Abbildung illustriert den ersten Durchlauf: Abbildung 43: Erster Durchlauf beim Sortieralgorithmus BubbleSort Das Wandern des größten Elements ganz nach rechts kann man mit dem aufsteigen von Luftblasen in einem Aquarium vergleichen: Die größte Luftblase (engl. bubble) steigt nach oben (bzw. nach rechts ;o). Nach dem ersten Durchgang ist das größte Element also an seiner endgültigen Position. Für die restlichen Elemente müssen wir nun den gleichen Vorgang anwenden. Nach dem zweiten Durchgang ist auch das zweitgrößte Element an seiner endgültigen Position. Dies wiederholt sich für alle restlichen Elemente mit Ausnahme des letzten. In unserem konkreten Beispiel sind spätestens nach 14 Durchgängen alle Elemente an ihrer endgültigen Position, folglich ist das Array geordnet. Wenn wir uns das Ergebnis der einzelnen Durchgänge anschauen, stellen wir fest, daß der Sortiervorgang nicht erst nach dem 14ten Durchgang beendet ist, sondern bereits nach dem 10ten. Dies liegt daran, dass sich, wie oben schon erwähnt, bei jedem Durchlauf auch die Position der noch nicht endgültig sortierten Elemente verbessert. Wir können zwar den ungünstigsten Fall konstruieren, bei dem tatsächlich n-1 122 Durchgänge benötigt werden, im Allgemeinen können wir aber BubbleSort bereits nach einer geringeren Anzahl von Durchgängen abbrechen - im günstigsten Fall, wenn die Daten bereits nach dem 1. Durchgang sortiert sind. Abbildung 43: Vollständiger Durchlauf beim Sortieralgorithmus BubbleSort 123