5 Algorithmen und Programmierung

Werbung
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
Herunterladen