Kapitel 3 Datenstrukturen und Algorithmen 3.1 Elemente eines Programms 31 3.1.1 Programmkonstrukte ..................................................................................31 3.1.2 Daten...........................................................................................................32 3.1.3 Ein- und Ausgabe .......................................................................................33 3.1.4 Beispiel für ein Programm .........................................................................33 3.2 Datenstrukturen 34 3.2.1 Grundlagen der objektorientierten Datenmodellierung..............................34 3.2.2 Abstrakte Datentypen .................................................................................35 3.2.3 Graph ..........................................................................................................37 3.2.4 Liste ............................................................................................................40 3.2.5 Stapel ..........................................................................................................41 3.2.6 Schlange .....................................................................................................42 3.2.7 Baum...........................................................................................................43 3.3 Algorithmen 47 3.3.1 Komplexität ................................................................................................48 3.3.2 Algorithmenklassen....................................................................................53 3.4 Zusammenfassung 66 3.5 Literatur 67 Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 31 Kapitel 3 Datenstrukturen und Algorithmen 3.1 Elemente eines Programms Ein Prozess lässt sich durch die drei elementaren Bausteine Dateneingabe, Datenverarbeitung und Datenausgabe beschreiben. Diese Teilprozesse können beliebig aufeinander folgen oder geschachtelt sein. Die Reihenfolge und Schachtelung ist abhängig von der Art und Komplexität des Problems, welches durch den Prozeß beschrieben wird. 3.1.1 Programmkonstrukte Ein Programm ist eine Liste von Anweisungen, die bei ihrer Ausführung den Gesamtprozeß entstehen lassen. Reduziert man die Anordnung der Anweisungen oder Teilprozesse auf das Grundlegendste, erkennt man die drei Elementarbausteine, aus denen jedes Programm besteht. • Sequenz: Erst wenn eine Anweisung abgearbeitet ist, darf mit der nächsten begonnen werden. Dies schließt eine Parallelverarbeitung aus. Diese sequentielle Arbeitsweise wird in den meisten Programmiersprachen durch das Unter- bzw. Hintereinanderschreiben von einzelnen Befehlen erreicht. • Alternative: Aufgrund von logischen Bedingungen kann eine Wahl zwischen mehreren Teilprozessen getroffen werden. Dies ermöglicht es, bestimmte Teilprozesse in Abhängigkeit des Zustandes der bearbeiteten Objekte auszulassen oder einzufügen. • Wiederholung: Ein bestimmter Teilprozeß wird mehrfach abgearbeitet. Der Zustand der beteiligten Objekte läßt sich dabei durch Anweisungen ändern. Die Anzahl der Wiederholungen kann somit vom Zustand der Objekte abhängig gemacht werden, oder es wird eine feste Anzahl von Wiederholungen vorgegeben. Datenverarbeitung in der Konstruktion (DiK) 32 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 3.1.2 Daten Die Objekte, die einem Rechner zur Verarbeitung gegeben werden, können in der Realität sehr unterschiedliche Form haben, z.B. Meßreihen, Konstruktionspläne oder auch Zusammenhänge und Hierarchien. Da ein Computer nur Zahlen innerhalb eines bestimmten Wertebereichs verarbeiten kann, müssen die Objekte auf Zahlenwerte repräsentierende Variablen abgebildet werden. Dazu bieten Programmiersprachen abstrakte Datenstrukturen an, die einen Mittelweg zwischen den zu verarbeitenden Objekten und reiner Binärinformation bilden. Die Abbildung der Objekte auf die Datenstrukturen der Programmiersprachen ist neben dem eigentlichen Codieren für die Effizienz des fertigen Programms von entscheidender Bedeutung. Die Datenstrukturen sind in verschiedene Datentypen aufgeteilt, die jeweils einen begrenzten Wertebereich umfassen. Angesprochen werden die Daten über die Variablen. Eine Variable wird im Programm unter Angabe ihres frei wählbaren Namens deklariert, daß heißt, einem bestimmten Datentyp zugeordnet. Dann kann sie definiert werden, d.h. sie wird mit einem bestimmten Wert belegt. • Einfache Datentypen und Skalare: Dies ist eine geordnete Menge von Werten eines festen Wertebereichs. Eine skalare Variable repräsentiert einen einzelnen Wert aus diesem Wertebereich. Von den meisten Programmiersprachen werden folgende skalare Datentypen angeboten: integer: ganze Zahlen, deren Wertebereich von der Programmiersprache oder auch Hardware abhängt, real: reelle Zahlen in Gleitkommadarstellung, deren Wertebereich ebenfalls von der Programmiersprache und auch Hardware abhängt, boolean: logischer Typ mit den Werten false und true und char: ein einzelnes Zeichen, meist ASCII-codiert, z.B. Buchstaben, Ziffern oder Sonderzeichen. • Strukturierte Datentypen: Sie ermöglichen es, eine Anzahl von Werten zu einer übergeordneten Menge zusammenzufassen und sie als Gesamtheit oder einzeln zu bearbeiten. Eine solche Variable repräsentiert also eine ganze Struktur von Werten. Die grundlegenden strukturierten Datentypen sind: array (Feld, Vektor): Eine ein- oder mehrdimensionale Matrix, deren Elemente alle denselben skalaren Datentyp haben und durch Indizierung der Variable angesprochen werden. Um beispielsweise dem Feld, das durch die Variable Matrix repräsentiert wird, in der 5. Reihe und 3. Spalte den Wert 8 zuzuweisen, würde man "Matrix[5,3] = 8" schreiben. record (Verbund): Ein hierarchischer Datentyp, dessen Bestandteile aus verschiedenen Datentypen aufgebaut sein können. Der Zugriff auf einzelne Werte erfolgt durch Angabe des Weges durch die Hierarchie zum gewünschten Element. In der Programmiersprache PASCAL werden die Hierarchieebenen durch Punkte getrennt, so daß die Adresse eines bestimmten Angestellten in der Personaldatei beispielsweise mit "Abteilung.Name" angesprochen werden würde. file (Datei): Eine Reihe von Daten gleichen Typs, wobei es sich dabei auch um Verbunde oder Felder handeln darf. Im Unterschied zu den anderen Datentypen muß eine Datei zusätzlich zur Deklaration noch geöffnet und nach der Bearbeitung wieder geschlossen werden. Zur Verarbeitung von Dateien bieten die Programmiersprachen deshalb spezielle Befehle an, z.B. wie Öffnen, Schließen, Nächstes Element lesen und Zurücksetzen auf das erste Element. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 33 3.1.3 Ein- und Ausgabe Bei der Programmierung darf nicht die Art und Weise der Datenein- und der Datenausgabe übersehen werden. Es muß festgelegt werden, ob die zu verarbeitenden Daten ihre Werte z.B. durch interaktive Eingabe, über externe Speichermedien, von anderen Teilprogrammen oder per Definition im Programmtext erhalten. Ebenso muß klar sein, was mit den Ergebnisdaten geschieht und in welcher Form sie ausgegeben werden (z.B. über den Bildschirm, Drucker oder in eine Datei). 3.1.4 Beispiel für ein Programm Das folgende Pseudoprogramm soll die einzelnen Elemente eines Programms verdeutlichen. Deshalb wird hier kein Wert auf eine effiziente Codierung gelegt. Es soll eine Reihe von positiven ganzen Zahlen eingelesen, die Summe der Zahlen gebildet und ausgegeben werden. Bei Eingabe einer negativen Zahl soll das Programm beendet werden. Variablendeklaration: NÄCHSTER WERT = integer; // NÄCHSTER WERT erhält Typ integer SUMME = integer; // SUMME erhält Datentyp integer Programmcode: SUMME = 0; // Setze SUMME auf 0 NÄCHSTER WERT = 0; // Setze NÄCHSTER WERT auf 0 NÄCHSTER WERT = eingabe (Tastatur); // Gib nächste Zahl von der Tastatur // Wenn die Eingabe > 0 ist, dann summiere die Eingabe auf die Summe und // lasse dir die nächste Eingabe von der Tastatur geben. Wenn die Eingabe < 0 // ist, beende die Schleife und gehe zur Ausgabe solange NÄCHSTER WERT > -1 berechne SUMME = SUMME + NÄCHSTER WERT; NÄCHSTER WERT = eingabe ( Tastatur ); end_solange ausgabe(SUMME,bildschirm); // Gibt Summe auf dem Bildschirm aus Die Verarbeitung der Anweisungen eines Programmes erfolgt von oben nach unten, es sei denn, durch eine Alternative wird die sequentielle Verarbeitung unterbrochen und gemäß dieser festgesetzt. Es werden zunächst die Variablen SUMME und NÄCHSTER WERT auf 0 gesetzt. Die Funktion eingabe(Tastatur) liest eine Ziffer von der Tastatur und weist diese Ziffer NÄCHSTER WERT zu. Ist diese Ziffer größer als -1 wird diese Ziffer zur Summe hinzugezählt und eine weitere Ziffer von der Tastatur angefordert. Ist NÄCHSTER WERT kleiner als 0 wird die Summierung nicht vollzogen und die Ausgabe der Summe durch die Funktion ausgabe(SUMME) veranlaßt. Bei der Ausgabe wird definiert, wohin die Ausgabe erfolgt (z.B. Drucker, Bildschirm, etc.). Diese wird in der Funktion ausgabe später genau spezifiziert werden und ist auch für diesen Programmteil nicht relevant. Man sieht deutlich die Modularisierung des kleinen Programms: ohne auf die Detailfragen wie ausgabe oder eingabe einzugehen, wird die grobe Struktur des Programms sichtbar - anstelle von eingabe(Tastatur) könnte auch eingabe(Datei) stehen, falls die notwendigen Daten aus einer Datei gelesen werden sollen. Datenverarbeitung in der Konstruktion (DiK) 34 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 3.2 Datenstrukturen Datenstrukturen sind Anordnungen von Objekten, deren Beziehungen untereinander nach bestimmten Gesetzmäßigkeiten aufgebaut sind. Zur Beschreibung von Datenstrukturen werden in der objektorientierten Datenmodellierung Objektklassen verwendet. Die Verbindungen zwischen zwei Objekten einer Datenstruktur werden meist durch besondere Attribute dargestellt, die auf andere Objekte verweisen können (oft auch referenzieren genannt), die sogenannten Zeiger oder Referenzen. In vielen, nicht-objektorientierten Programmiersprachen werden jedoch anstelle der Objektklassen und Objekte einfachere Konstrukte zur Beschreibung einer Datenstruktur verwendet. Dabei entsprechen den Objekten die sogenannten Variablen und den Objektklassen die Datentypen. Eine Variable hat drei Bestandteile: ihren Namen, ihren Wert und ihren Datentyp. In einem Programm kann auf eine Variable durch Nennung ihres Namens Bezug genommen werden. Ihr Datentyp beschreibt die Struktur und den Wertebereich des Variablenwertes. Ausschließlich der Wert der Variablen kann durch Operationen, die für Variablen dieses Datentyps in der Programmiersprache definiert sind, manipuliert werden. Der Name und der Datentyp einer Variable werden explizit vor der ersten Benutzung durch eine Deklaration festgelegt. Beispiel: (Variablen-Deklaration) In der Programmiersprache FORTRAN können Variablen explizit deklariert werden. Variablen der Datentypen INTEGER (ganze Zahl) oder REAL (rationale Zahl als Annäherung zur reellen Zahl) können jedoch auch implizit bei der ersten Benutzung deklariert werden. Hier entscheidet der Anfangsbuchstabe des Variablennamens darüber, von welchem Typ die Variable ist: beginnt er mit I, J, K, L, M oder N, so hat die Variable den Typ INTEGER, beginnt er mit einem anderen Buchstaben, so ist die Variable vom Typ REAL. In der Programmiersprache PASCAL müssen alle Variablen vor der ersten Benutzung deklariert werden. Ein PASCAL-Programm besteht daher aus einem Deklarationsteil und einem Ausführungsteil (dem sogenannten Block). Eine Komponente des Deklarationsteils ist die Variablendeklaration, die mit dem Wort VAR eingeleitet wird. Die Deklaration einer REAL-Variablen 'Messwert' und einer INTEGER-Variablen 'Anzahl' hat beispielsweise das folgende Aussehen: VAR Messwert : REAL; Anzahl : INTEGER; Datenstrukturen werden ausführlich in [Knut-73, Kap.2], [Mehl-88, Kap. III], [Wirt-86, Kap.1 und 4] behandelt. Von der Seite der Programmiersprachen werden Datenstrukturen in [Loud-93, Kap.6] näher besprochen. Als richtungweisendes Standardwerk zu Datenstrukturen und deren Benutzung (und auch zu strukturierter Programmierung) ist [DaDH-74, Kap. II] zu nennen. 3.2.1 Grundlagen der objektorientierten Datenmodellierung Ausgangsphase für die Entwicklung eines Softwaresystems sind die Modellierungen eines Wirklichkeitsausschnitts, ohne auf dessen konkrete Implementierung besondere Rücksicht Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 35 zu nehmen. Der Wirklichkeitsausschnitt muss zunächst erklärt, analysiert, strukturiert und modelliert werden, bevor ein brauchbares Informationssystem erstellt werden kann. Das Ergebnis dieser Modellierung bezeichnet man als Datenmodell. Der Schlüssel der Datenmodellierung liegt darin, einen Wirklichkeitsausschnitt genau zu erfassen und in adäquater Weise abzubilden, d.h. den Ausschnitt widerspruchsfrei, vollständig, formal richtig und möglichst ohne Redundanz zu beschreiben [Nguy-99]. Im objektorientierte Datenmodell werden Informationen über einen Wirklichkeitsausschnitt als Objekte dargestellt. Daher wird in der objektorientierten Modellierung zunächst entschieden, welche realen Objekte und welche ihrer Eigenschaften relevant sind (Abstraktion). Darauffolgend wird eine Abbildung dieser Objekte, ihrer Eigenschaften und ihrer Beziehungen zueinander in eine implementierbare Form vorgenommen; es wird das objektorientierte Datenmodell für die Aufgabenlösung erstellt. Dazu werden die in dem Problem vorkommenden Objekttypen in Klassen ähnlicher Objekte, die sogenannten Objektklassen, eingeteilt. Klassen sind, wie in Kapitel 2.3 beschrieben, formalisierte Begriffe, die wir durch Abstraktionen von Gegenständen der Wirklichkeit gewonnen haben. Sowohl Klassen als auch Objekte erhalten eindeutige Namen. Die Objekte einer Objektklasse werden durch eine Menge von Eigenschaften, den Attributen, und eine Menge von Operationen beschrieben, die die Abfrage und Manipulation der Attribute erlauben. Die Erzeugung eines konkreten Objektes aus einer Objektklasse wird Ausprägung oder Instanziierung genannt, weshalb ein Objekt auch als Objektinstanz oder nur als Instanz bezeichnet wird. Von außerhalb der Objekte kann auf deren Attribute nur über die zugeordneten Operationen zugegriffen werden (Kapselung). Dies bedeutet insbesondere, daß im Objekt Attribute versteckt sein können, auf die ein Zugriff über Operationen nicht oder nur indirekt möglich ist, die aber für die Beschreibung des Objektzustandes wichtig sind. Zur objektorientierten Datenmodellierung werden in Kapitel 4 Entwicklungsmethoden vorgestellt die diesen Vorgang unterstützen. 3.2.2 Abstrakte Datentypen Datentypen, die in nicht-objektorientierten Programmiersprachen zur Verfügung stehen, setzen sich aus Elementartypen und strukturierten Datentypen zusammen. Komplexere Datenmodelle besitzen jedoch auch Strukturen, die oberhalb der Abstraktionsebene von strukturierten Datentypen angesiedelt sind. Diese Strukturen lassen sich in Klassen einteilen, zu denen jeweils ein generischer Datentyp definiert werden kann, auf den dann alle in einer Klasse vorkommende Strukturen zurückgeführt werden können. Solche generischen Datentypen können in objektorientierten als auch in nicht-objektorientierten Programmiersprachen als abstrakte Datentypen (ADT) realisiert werden. Ein abstrakter Datentyp präsentiert sich einem Benutzer wie ein in der Programmiersprache schon verfügbarer Datentyp, auf den eine definierte Menge von Operationen anwendbar sind. Der abstrakte Datentyp muß jedoch in nicht-objektorientierten Programmiersprachen basierend auf schon vorhandenen Datentypen definiert worden sein, wobei seine Operationen in Ablaufkonstrukten der Programmiersprache ausformuliert sein müssen. Beispiel: (Vektor als abstrakter Datentyp) Ein Vektor Vn ist ein n-Tupel von: Vn=(v1, v2, ..., vn). Beispielsweise kann ein Tripel V3=(x, y, z) verwendet werden, um einen Punkt im dreidimensionalen Raum zu beschreiben, während etwa V2=(x, y) einen Punkt in der Ebene bezeichnen könnte. Ob die Komponenten des Vektors ganze oder reelle Zahlen Datenverarbeitung in der Konstruktion (DiK) 36 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen sind, ist für die Operationen, die auf Vektoren ausführbar sind, unerheblich. Mit Vektoren können neben den Grundrechenarten Addition, Subtraktion und komponentenweise Multiplikation mit einem Faktor auch das Skalarprodukt und das Kreuzprodukt (für n=3) gebildet werden. Bei allen Operationen bis auf das Skalarprodukt ist das Ergebnis wieder ein Vektor, beim Skalarprodukt hat das Ergebnis den gleichen Typ wie die Komponenten. In der Programmiersprache ADA kann ein objektorientierter Datentyp Vektor wie folgt beschrieben werden. Die Deklarationen im generic-Teil legen die grundlegenden Typen, Variablen und Operationen fest, die der Anwender des abstrakten Datentyps bei dessen Benutzung näher zu spezifizieren hat. In der package-Deklaration werden der Datentyp vektor und die auf ihn anwendbaren Operationen beschrieben. Diese beiden Teile sind dem Benutzer sichtbar. Der darauffolgende Teil package body enthält die Implementation des abstrakten Datentyps. Er wird vor dem Benutzer verdeckt gehalten. Dieser Teil ist der Kürze wegen hier nur mit einer Funktion ausformuliert (Schlüsselworte, die zur Programmiersprache gehören, sind unterstrichen; das Beispiel ist an [KKUG-83, S. 186] angelehnt). generic type komponente is private; nullelement : komponente; with function "+"(x, y : komponente) return komponente is <>; with function "-"(x, y : komponente) return komponente is <>; with function "*"(x, y : komponente) return komponente is <>; package ADT_vektor is type vektor is array(integer range <>) of komponente; function "*"(s : komponente; x : vektor) return vektor; function "+"(x, y : vektor) return vektor; function "-"(x, y : vektor) return vektor; function "*"(x, y : vektor) return komponente; function kreuz(x, y : vektor) return vektor; end ADT_vektor; package body ADT_vektor is M function "*"(x, y : vektor) return komponente is resultat : komponente := nullelement; begin for index in x’range loop resultat := resultat + x(index) * y(index); end loop; return resultat; end "*"; M end ADT_vektor; In diesem Beispiel lassen sich zwei wichtige Eigenschaften moderner Programmiersprachen erkennen: der Polymorphismus von Operationen und das Überladen von Operatoren. Polymorphismus wurde schon in der objektorientierten Datenmodellierung erwähnt und setzt sich insbesondere bis in objektorientierte Programmiersprachen fort. Er drückt sich in die- Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 37 sem Beispiel darin aus, daß zwei Operationen "*" deklariert werden, die sich nur durch die Typen der Übergabe- und Rückgabeparameter unterscheiden. Dadurch können für vom Sinn her gleichartige Operationen auch die gleichen Namen benutzt werden. Das Überladen von Operatoren bezeichnet die Möglichkeit, Funktionsnamen nicht nur aus Buchstaben und Ziffern zu bilden, sondern auch Operationszeichen wie +, *, / zuzulassen, die normalerweise nur auf Werte von in der Programmiersprache eingebauten, elementaren Datentypen anwendbar sind. Dadurch können Operationen mit abstrakten Datentypen so kurz und prägnant wie mit den elementaren Datentypen ausgedrückt werden. Allerdings wird dadurch die Analyse von Programmen bei Fehlern erschwert, da neben den Operatoren auch die Operandentypen für eine eindeutige Kennzeichnung der aufgerufenen Operationen notwendig sind. Einige abstrakte Datentypen werden als generische Datentypen in der Praxis besonders häufig für die Datenmodellierung verwendet. Der grundlegende abstrakte Datentyp ist dabei der sogenannte Graph. Auf ihn bauen die abstrakten Datentypen Liste, Stapel, Schlange und Baum auf. Abstrakte Datentypen werden meist zusammen mit der Implementierung der zugehörigen Operationen in [Wirt-86], [Mehl-88] und [Knut-73] ausführlich betrachtet. Auf die formalen Grundlagen und die Realisierung von abstrakten Datentypen in verschiedenen Programmiersprachen geht [Loud-93, Kap. 8] ein. 3.2.3 Graph Ein ungerichteter Graph G besteht aus zwei Mengen: einer Menge N von Knoten (Punkten, engl. nodes) und einer Menge V von Kanten (engl. vertices), d.h. G=(N, V). Eine Kante a aus V verbindet stets zwei Knoten A und B aus N miteinander. Wir schreiben a={A,B}={B,A}. Die Anzahl der Kanten, die mit dem Knoten verbunden sind, wird der Grad eines Knotens genannt. Eine graphische Darstellung eines ungerichteten Graphen ist in dem folgenden Beispiel zu sehen. Beispiel: (Ungerichteter Graph) a B A c b C Dieser Graph besitzt die Knoten A, B und C und die Kanten a={A,B}={B,A}, b={A,C}={C,A} und c={B,C}={C,B}. Neben den ungerichteten Graphen existieren auch gerichtete Graphen, deren Kanten mit einer Vorzugsrichtung versehen sind. Bei ihnen sind die Kanten a={A,B} und a´={B,A} verschieden. Dies wird in der graphischen Darstellung durch einen Pfeil gekennzeichnet. Die Anzahl der auf einen Knoten zeigenden Kanten wird Ingrad des Knotens, die Anzahl der aus dem Knoten herausgehenden Kanten wird Ausgrad genannt. Datenverarbeitung in der Konstruktion (DiK) 38 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen Beispiel: (Gerichteter Graph) a B A c b C Der hier dargestellte Graph besitzt die Knoten A, B und C und die Kanten a={A,B}, b={A,C} und c={B,C}. Die Kanten ungerichteter Graphen stellen nur die Beziehungen zwischen den Knoten dar, weisen jedoch keine Richtung aus. Sie können durch äquivalente gerichtete Graphen ausgedrückt werden, bei denen jeder Kante des ungerichteten Graphen zwei Kanten des gerichteten Graphen zugeordnet werden, die jedoch einander entgegengesetzt ausgerichtet sind. Beispiel: Der zu dem ungerichteten Graphen des vorherigen Beispiel äquivalente gerichtete Graph besitzt die Knoten A, B und C und die Kanten a={A,B}, a´={B,A}, b={A,C}, b´={C,A}, c={B,C} und c´={C,B}. a A B a´ c´ c b b´ C Ein Pfad zwischen zwei Knoten A und B existiert genau dann, wenn eine Folge aus Kanten und Knoten existiert, die von Knoten A ausgeht und zu Knoten B führt. Ein gerichteter oder ungerichteter Graph besitzt einen Zyklus, wenn von einem Knoten ein Pfad über mindestens einen weiteren Knoten wieder zu dem ersten Knoten zurück existiert. Ein Graph, der keine Zyklen besitzt, wird zyklenfrei genannt. Im Gegensatz zu ungerichteten Kanten können gerichtete Kanten direkt auf Variablen eines Datentyps abgebildet werden, der in allen modernen Sprachen vorkommt: den Zeiger (engl. pointer, siehe Kapitel 2). Ein Knoten kann z.B. in die in PASCAL formulierte Datenstruktur abgebildet werden: TYPE Knoten Kantenliste = RECORD knoteninhalt kanten END; = RECORD kante nächsteKante END; : IrgendeinTyp; : ^Kantenliste : ^Knoten; : ^Kantenliste VAR graph, k, k1, k2 : ^Knoten Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 39 Jeder Knoten besitzt also einen Inhalt und eine Liste von Verweisen auf Knoten. Diese Verweise entsprechen den Kanten des Graphen. Wesentliche Operationen auf Graphen sind (in objektorientierter Schreibweise als Methodenaufruf ausgedrückt) • das Erzeugen von Knoten (neuerknoten(k)), • das Erzeugen einer Kante (k1.verweisezu(k2)), • das Entfernen einer Kante (k1.verweisenichtzu(k2)) und • das Traversieren des Graphen, d.h. das Besuchen aller Knoten. Das Traversieren von Knoten wird z.B. genutzt, um auf den Nutzinformationen jedes Knotens des Graphen Operationen auszuführen. Diese Operationen können wieder in einer Methode zusammengefaßt werden, die der TraversierMethode als Parameter mitgegeben wird: (graph.traversiere(manipulationsmethode)) Zum Traversieren existieren zwei prinzipiell verschiedene Vorgehensweisen: die sogenannte Breitensuche und die Tiefensuche. Bei der Breitensuche werden ausgehend von einem Anfangsknoten alle Knoten besucht, zu denen Kanten vom Anfangsknoten aus verlaufen. Danach werden all die Knoten besucht, auf die Kanten der eben besuchten Knoten verweisen. Der Graph wird somit schichtweise, in der Breite durchwandert. Die Tiefensuche geht anders vor. Von einem Anfangsknoten ausgehend wird eine Kante gewählt, dessen verbundener Knoten als nächstes besucht wird. Dort wird wiederum eine Kante ausgesucht, der den durch sie ausgewiesenen Knoten besucht, u.s.w., bis auf ein Knoten verwiesen wird, der schon besucht wurde. In diesem Fall wird die Kante des vorher besuchten Knotens verwendet. Es wird also zunächst tief in den Graph hineingewandert und erst, wenn alle Knoten dort besucht worden sind, wird sich aus der Tiefe näher an die Oberfläche bewegt. Beispiel: Die Knoten der beiden dargestellten Graphen sind mit Zahlen markiert, die die Reihenfolge des Besuchs der Knoten bei der Durchwanderung des Graphen angeben. Der linke Graph wurde nach der Breitensuche, der rechte Graph nach der Tiefensuche durchwandert. Da die Reihenfolge, in der die ausgehenden Kanten ausgewählt werden, bei beiden Suchmethoden beliebig ist, sind die gezeigten Numerierungen nicht die einzig möglichen. 1 1 9 2 4 2 6 3 7 5 7 6 3 5 8 9 8 4 Die Kanten eines Graphen können ebenso wie die Knoten mit Attributen versehen sein. Meist sind die Attribute Zahlen, die beispielsweise als Aufwand interpretiert werden können, um die zugehörige Kante entlangzuwandern. Solche Graphen werden daher gewichtete Graphen genannt. Zur Realisierung der Gewichte kann in dem oben beschriebenen Verbund Kantenliste eine Komponente gewicht vom Typ INTEGER oder REAL eingeführt werden. Gewichtete Graphen werden zur Modellierung von vernetzten Strukturen verwendet, Datenverarbeitung in der Konstruktion (DiK) 40 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen bei denen nicht jeder Weg zu einem Knoten gleich einfach begangen werden kann. Beispiel: In einer Produktionsstraße stehen für jeden Produktionsschritt mehrere Maschinen zur Verfügung. Jeder Produktionsschritt ist durch eine Zwischenstation getrennt, die die Bauteile aus dem vorhergehenden Produktionsschritt sammelt und sie auf die Maschinen des nächsten Produktionsschrittes verteilt. Es soll ein Datenmodell gefunden werden, das computergestützt eine gleichmäßige Auslastung der Maschinen erlaubt. Als Basis für das Datenmodell wird ein gewichteter Graph verwendet, bei dem die Knoten die Maschinen und Zwischenstationen darstellen und die gerichteten Kanten die Wege zwischen den Maschinen und Zwischenstationen repräsentieren. Die Kanten werden mit Gewichten versehen, die die Anzahl der vor den Maschinen wartenden Werkstücke angeben. Wenn nun ein Werkstück eine Zwischenstation passiert, bestimmt man das Minimum der Gewichte und schickt das Teil zu der momentan am wenigsten belasteten Maschine. Weiterführende Literatur zu Graphen ist z.B. [Jung-90], [AhHU-74, Kap. 5] und [Sedg-91, Kap. 29-34]. 3.2.4 Liste Die Liste (engl. list) ist ein vereinfachter Graph, denn jeder Knoten außer den beiden Endknoten ist mit genau zwei anderen Knoten über je eine Kante verbunden. Von den beiden Listenenden gehen jeweils nur eine Kante ab. Die Darstellung einer Liste in PASCAL könnte wie folgt aussehen: TYPE Liste = Listenknoten = VAR RECORD ende1, ende2 END; RECORD nutzinformation vorgänger nachfolger END; :^Listenknoten : IrgendeinTyp; :^Listenknoten; :^Listenknoten liste: ^Liste; l,l1,l2: ^Listenknoten; Der Knoten, auf den ende1 zeigt, hat keinen vorgänger, und der Knoten auf den ende2 zeigt, hat keinen nachfolger. In diesen Fällen besitzen die Komponenten vorgänger bzw. nachfolger den vordefinierten Wert NIL, der anzeigt, daß die Komponenten zur Zeit auf keinen Knoten verweisen. Die obige Datenstruktur wird auch doppelt verkettete Liste genannt. Dies ist eine Liste mit ungerichteten Kanten (siehe die äquivalente gerichtete Darstellung eines ungerichteten Graphen), die in beide Richtungen traversiert werden kann. Beispielhaft sei hier eine doppelt verkettete Liste graphisch dargestellt. Die gepunkteten Zeiger beschreiben die Änderungen, wenn ein neuer Knoten in die Liste eingefügt wird. Der Ort des Einfügens ist bei einer Liste beliebig. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen Ende 1 41 Ende 2 . . Neben der doppelt verketteten Liste existieren auch • die einfach verkettete Liste, die einer Liste mit gerichteten Kanten entspricht und nur einen Verweis nachfolger auf den nächsten Knoten besitzt; sie hat statt ende1 und ende2 nur einen Listenanfang, den kopf; • die Ringliste, die einen Einfüge- und gleichzeitig Entnahmepunkt hat und bei der jeder Knoten einen nachfolger und einen vorgänger besitzt (keine NIL-Werte an den Enden, da keine Enden mehr existieren). Gebräuchliche Operationen auf Listen sind • das Erzeugen eines Listenknotens (neuerknoten(l1)), • das Einfügen eines Knotens nach oder vor einem anderen Knoten (liste.einfügenach(l,l1),liste.einfügevor(l,l2)), • das Entfernen eines Knotens (liste.enfernen(l)), • das Verweisen auf den Vorgänger bzw. Nachfolger (liste.nächster(l),liste.voriger(l)), • das Erfragen ob die Liste leer ist (liste.leer) und • das Feststellen der Anzahl der Listenknoten (liste.länge). Beispiel: Die beim Kauf eines Kraftfahrzeuges ausgewählten Ausstattungsoptionen können als Liste dargestellt werden. Dabei sind in jedem Listenknoten die für die Option notwendigen Informationen abgelegt. Möchte der Kunde eine neue Option gegen eine schon ausgewählte Option eintauschen, so muß nur der Knoten für die alte Option aus der Liste genommen und der Knoten für die neue Option hineingenommen werden. 3.2.5 Stapel Der Stapel (engl. stack, bzw. last in first out, LIFO) oder auch Keller ist eine einfach verkettete Liste, bei der ein Knoten nur am Listenkopf eingefügt oder entfernt werden kann. Eine Darstellung eines Stapels in PASCAL könnte sein: Datenverarbeitung in der Konstruktion (DiK) 42 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen TYPE Stapelknoten VAR = RECORD nutzinformation nachfolger END; : IrgendeinTyp; :^Stapelknoten stapel, s : ^Stapelknoten; Ein Beispiel für einen Stapel mit drei Knoten und einem einzufügenden Knoten ist im folgenden graphisch dargestellt. Die für das Einfügen zu ändernden Zeiger sind gepunktet gezeichnet. S . Die Stapeloperationen haben üblicherweise englische Namen. Sie sind heute in jedem Computerprozessor als Maschineninstruktionen realisiert, um z.B. den Unterprogrammstapelspeicher zu verwalten: • das Erzeugen eines Stapelknotens (neuerknoten(s)), • das Einfügen eines Stapelknotens (stapel.push(s)), • das Entfernen eines Knotens (stapel.pop), • das Feststellen, welcher Knoten oben auf dem Stapel liegt (stapel.top), • das Feststellen, ob der Stapel leer ist (stapel.empty) und • das Feststellen der Anzahl Knoten auf dem Stapel (stapel.amount). Beispiel: Stapel werden in Prozessoren verwendet, um die Rücksprungadresse vor einem Unterprogrammaufruf abzulegen. Dadurch können Unterprogramme ineinander geschachtelt werden. Die Rücksprungadresse wird trotzdem korrekt aufgefunden, da immer die zuletzt auf den Stapel gelegte Adresse wieder heruntergeholt wird. Die Existenz eines Stapels für Rücksprungadressen ist Voraussetzung für die Realisierung rekursiver Algorithmen (siehe Abschnitt 3.4.2.3). 3.2.6 Schlange Die (Warte-) Schlange (engl. queue bzw. first in first out, FIFO) ist eine doppelt verkettete Liste, bei der Knoten nur an dem einen ende1 eingefügt und an dem anderen ende2 entfernt werden. Die Darstellung der Datenstruktur in PASCAL entspricht der Datenstruktur der doppelt verketteten Liste aus Abschnitt 3.3.3. Auch die graphische Darstellung einer Schlange unterscheidet sich nicht sehr von einer doppelt verketteten Liste bis auf die Tatsache, daß nur Knoten an einem Ende der Schlange eingefügt werden dürfen. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen Ende 1 43 Ende 2 . . . Die Operationen, die auf einer Schlange ausgeführt werden können, sind: • das Erzeugen eines Schlangenknotens (neuerknoten(q)), • das Einreihen eines Knotens an das Schlangenende (schlange.einreihe(q)), • das Entfernen eines Knotens am Schlangenanfang (schlange.ausreihe), • das Feststellen, ob die Schlange leer ist (schlange.leer) und • das Feststellen der Knotenanzahl in der Schlange (schlange.anzahl). Beispiel: In einem vorherigen Beispiel gaben die Gewichte der Kanten, die in die Knoten für die Maschinen eingingen, an, wie viele Werkstücke vor der Maschine auf die Bearbeitung warten. In einer Detaillierung des Datenmodells für die Produktionsstraße können Schlangen vor den Maschinen eingeführt werden, deren Knoten die wartenden Werkstücke repräsentieren. Das Werkstück, das am längsten in der Warteschlange steht, wird als nächstes bearbeitet, während ein von einer Zwischenstation kommendes Werkstück sich hinten anstellen muß, also am anderen Ende in die Schlange eingereiht wird. 3.2.7 Baum Ein Baum ist ein gerichteter Graph mit Knoten, auf die, bis auf eine Ausnahme, nur jeweils eine Kante zeigen. Die Ausnahme ist die Wurzel des Baumes. Sie besitzt keine eingehende Kante. Von den Knoten eines Baumes weisen ein oder mehrere Kanten zu weiteren Knoten, deren ausgehende Kanten wiederum auf Knoten verweisen können. Ein Knoten wird zusammen mit allen über seine Kanten referenzierten Knoten, dessen Referenzen, u.s.w. Teilbaum genannt. Knoten, von denen keine Kanten ausgehen, werden Blätter genannt, alle anderen Knoten heißen innere Knoten. Die Anzahl der Kanten von der Wurzel des Baumes zu einem Knoten heißt die Weglänge des Knotens. Die Wurzel hat demnach die Weglänge 0. Die größte Weglänge, über alle Knoten betrachtet, heißt Tiefe (oder Höhe) des Baumes. Einem Baum, dessen Knoten nur jeweils eine ausgehende Kante besitzen, entspricht eine einfach verketteten Liste. Sie wurde schon oben betrachtet. Die häufigste Baumart ist der binäre Baum, dessen Knoten genau zwei ausgehende Kanten besitzen, die meist mit links und rechts benannt werden. Besitzen Baumknoten mehr als zwei ausgehende Knoten, so werden die dazugehörenden Bäume B-Bäume oder Vielweg-Bäume genannt (sie werden im folgenden nicht mehr behandelt, können jedoch in der Literatur nachgelesen werden). Datenverarbeitung in der Konstruktion (DiK) 44 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen Bäume werden meist dazu benutzt, um Daten im Arbeitsspeicher oder auf Massenspeichern geordnet abzulegen und schnell wiederzufinden. Aus diesem Grund besitzen die Knoten eines Baumes neben den Nutzinformationen und den Verweisen auf die nachfolgenden Knoten auch einen oder bei B-Bäumen mehrere Schlüssel, nach deren Werten der Knoten in den Baum eingefügt wird. Zudem existiert eine Ordnungsrelation kleinergleich, die Antwort darauf gibt, ob der Schlüssel kleiner oder gleich einem anderen Schlüssel ist. Dies ist notwendig, da Schlüssel nicht nur Zahlen sondern auch beliebig komplexere Strukturen sein können, auf denen sich eine Ordnung definieren läßt. Binärer Baum Die Knoten eines binären Baums besitzen einen Ausgrad von 2, sie verweisen also auf zwei weitere Knoten, die Nachfolger des aktuellen Knotens, der seinerseits der Vorgänger der beiden Knoten ist. Ein binärer Baum kann in PASCAL wie folgt dargestellt werden: TYPE Knoten = RECORD schluessel: nutzinformation: links, rechts: END; wurzel, k: ^Knoten; VAR IrgendeinTyp; IrgendeinWeitererTyp; ^Knoten Die Operationen auf einen binären Baum sind: • das Erzeugen eines Baumknotens (neuerknoten(k)), • das Einfügen eines Knotens in einen Baum anhand des eingetragenen Schlüssels und der auf ihm definierten Ordnungsrelation (wurzel.einfuege(k,relation)), • das Löschen eines Knotens aus dem Baum (wurzel.loesche(k)), • das Suchen nach einem Knoten anhand eines Schlüssels (wurzel.suche(schluessel,relation)) (zurückgegeben wird ein Zeiger auf den gefundenen Knoten oder NIL, wenn kein passender Knoten gefunden wurde) und • das Traversieren des Baumes, d.h. das Ausführen einer Operation auf jeden Baumknoten (wurzel.traversiere(operation)). Das Suchen in einem Baum ist sehr einfach, da ab der Wurzel für jeden besuchten Knoten nur die folgenden Fragen beantwortet werden müssen: • Stimmen der vorgegebene Schlüssel und der Knotenschlüssel überein, so ist der Knoten gefunden. • Ist der vorgegebene Schlüssel von der Ordnungsrelation kleiner als der Knotenschlüssel, so befindet sich der gesuchte Knoten, wenn überhaupt, in dem linken Teilbaum des aktuellen Knotens. • Ist der vorgegebene Schlüssel größer als der Knotenschlüssel, so befindet sich der gesuchte Knoten, falls er existiert, im rechten Teilbaum des aktuellen Knotens. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 45 Da die Suche von der Wurzel ausgeht und spätestens in einem Blatt endet, wurden höchstens soviele Knoten besucht, wie der Baum tief ist. Da im optimalen Falle an jedem inneren t Knoten zwei Nachfolger hängen, befinden sich in einem Baum der Tiefe t n = 2 Knoten. Wenn also in einer Menge von n Knoten zu suchen ist, so müssen nur maximal t = log2 n Knoten besucht werden, um den passenden Knoten zu finden. Dies bedeutet beispielsweise bei einer Gesamtzahl von n=1.000.000 Knoten, daß nur t = log2 n = log 2 1.000.000 ≅ 20 Knoten besucht werden müssen. Das Traversieren eines Baumes kann auf drei Arten geschehen: • Bei der Präorder-Traversierung wird in einem Knoten die Operation zuerst durchgeführt, dann wird der linke Teilbaum traversiert und danach der rechte Teilbaum. • In der Inorder-Traversierung, wird zunächst der linke Teilbaum traversiert, dann die Operation auf dem Knoten ausgeführt und danach der rechte Teilbaum traversiert. • Die Postorder-Traversierung handelt zuerst die Traversierung des linken und des rechten Teilbaums ab und führt als letztes die Operation auf dem Knoten aus. Auch das Einfügen kann verschiedenartig realisiert werden: • Bei einem naturwüchsigen Baum werden neue Knoten stets als Blätter an den Baum gehängt. Dies kann bei einer auf- oder absteigend vorsortierten Folge von einzufügenden Blättern eine Degenerierung des Baums zu einer einfach verketteten Liste zur Folge haben. • Bei einem vollständig ausgeglichenen Baum können Knoten auch als innere Knoten einfügt werden, die prinzipiell zwei Nachfolger besitzen. Jeder innere Knoten eines vollständig ausgeglichenen Baums besitzt daher zwei Teilbäume mit gleicher Tiefe. Dies hat zur Folge, daß zwar der Aufwand sehr hoch ist, nach dem Einfügen eines Knotens diese Bedingung wiederherzustellen, der Suchaufwand aber nach wie vor außerordentlich gering ist. • Bei einem ausgeglichenen Baum (AVL-Baum, nach seinen Entdeckern Adelson-Velskii und Landis benannt) dürfen sich die Tiefen der Teilbäume jedes Knotens um 1 unterscheiden. Da dieser Unterschied zum vollständig ausgeglichenen Baum nur eine geringe Verschlechterung von ggf. einem zusätzlich zu untersuchenden Knoten darstellt, das Einfügen in den Baum jedoch erheblich vereinfacht wird, ist der AVL-Baum der derzeit am weitesten verbreitete binäre Baum. Beispiel: Die Verbundkomponente schluessel sei vom Typ INTEGER. Der folgende naturwüchsige Baum ist durch das Einfügen von Knoten in der folgenden Reihenfolge der Schlüssel entstanden: 9, 7, 12, 15, 1, 3. Datenverarbeitung in der Konstruktion (DiK) 46 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen Wurzel 9 7 12 . . 1 15 . . . 3 . . Beispiel: Um komplexe Volumina aus einfachen Körpern aufzubauen, kann man z.B. die Methode CSG (Constructive Solid Geometry) verwenden. Ihre Funktionalität kann man sich mit Hilfe eines Binärbaums veranschaulichen, dessen Blätter einfache Körper wie Quader, Prismen oder Zylinder sind, und desses sonstige Knoten aus Mengenoperationen bestehen. Betrachten wir das folgende Beispiel: Knoten Blatt Wenn man die beiden Operationen Mengendifferenz und Mengenvereinigung durchgeführt hat, erhält man etwa folgenden Körper als Ergebnis: Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 47 3.3 Algorithmen Die Methoden eines Objektes nehmen anhand von beim Aufruf mitgegebenen Parametern und dem aktuellen Zustand des Objektes Änderungen an den Objektattributen vor oder lassen Abfragen zum Inhalt der Attribute zu. Während der Entwurfsphase werden die Signatur und die Semantik einer Methode in der Spezifikation festgelegt. Die Signatur einer Methode besteht aus einer Zusammenstellung der Datentypen ihrer Ein- und Ausgabeparameter und deren Reihenfolge. Die Semantik einer Methode ist eine Beschreibung, was die Methode leistet (sog. Nachbedingung), wenn die Eingabeparameter bestimmte Eigenschaften erfüllen (sog. Vorbedingung). Vor- und Nachbedingung können umgangssprachlich oder formal (z.B. über Prädikatenlogik) formuliert werden. Letzteres eröffnet die Möglichkeit, die Korrektheit des gesamten Entwurfs mit mathematischen Methoden maschinell zu prüfen (verifizieren) oder zumindest rechnergestützt nachzuvollziehen (validieren). Die Verifikation eines Entwurfs und dessen Implementierung ist für Softwaresysteme eminent wichtig. Insbesondere dürfen bei fehlerhaften Softwarekomponenten Leben oder hohe Sachwerte nicht gefährdet werden. Beispiel: (Signatur und Semantik) Der in einem früheren Beispiel beschriebene abstrakte Datentyp Vektor kann als Objektklasse interpretiert werden, wobei die Typdeklaration vektor zu den Attributen der Klasse gehört und die Operationen Methoden der Klasse sind. Die Signatur der Operationen ergibt sich aus deren mathematischer Definition: +:Vektor × Vektor → Vektor −:Vektor × Vektor → Vektor ⋅:Komponente × Vektor → Vektor ⋅:Vektor × Vektor → Komponente ×:Vektor × Vektor → Vektor Die erste Multiplikation ist die Multiplikation mit einem Skalar, die zweite das Skalarprodukt und die dritte das Kreuzprodukt, im ADA-Programmbeispiel mit Cross bezeichnet. Die Semantik der Operationen kann informell beschrieben werden: z.B. ist das Skalarprodukt die Summe aus den Produkten der an den gleichen Positionen stehenden Vektorkomponenten. Die Definition der Semantik kann jedoch auch formal erfolgen (formuliert in einer Prädikatenlogik für den Fall des Skalarprodukts): n ∀ a, b ∈ Vektor ( x1 , x 2 , ..., x n ); c, xi ∈ Komponentec = a ⋅ b ⇔ c = ∑ a i ⋅ bi i =1 Aus der Semantik des Skalarprodukts ist erkennbar, daß dieses auf der Existenz von Addition und Multiplikation von Komponenten beruht. Daher sind diese Operationen im generic-Teil des ADA-Beispiels explizit angegeben. In der Implementierungsphase wird festgelegt, wie die Methode das leistet, was sie entsprechend ihrer Semantik leisten soll. Ein Verfahren zur Lösung einer Aufgabe wird Algorithmus genannt. Im allgemeinen existieren mehrere Möglichkeiten, eine Semantik zu implementieren. Diese Algorithmen, die bei Einhalten der Vorbedingung die Nachbedingung Datenverarbeitung in der Konstruktion (DiK) 48 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen einer Semantik erfüllen, können zu Klassen zusammengefaßt werden. Obwohl alle Algorithmen einer Klasse gleiche (oder ähnliche) Aufgaben lösen, tun sie dies teilweise auf vollkommen unterschiedliche Art und Weise. Der die Implementierungsphase bearbeitende Softwareentwickler wird daher einen Algorithmus nach verschiedenen Kriterien auswählen (in der Reihenfolge der Wichtigkeit): • Rechenzeitaufwand, • Speicherplatzaufwand für Zwischenspeicher, • Verständlichkeit und damit Wartbarkeit, • leichte Verifizierbarkeit der Korrektheit und • leichte Implementierbarkeit. Gerade der Rechenzeit- und Speicherplatzaufwand eines Algorithmus´ ist meist ausschlaggebend für dessen Auswahl, da während des Betriebs des Softwaresystems beides kostenerzeugende Größen sind, die von Rechenzentren abgerechnet werden. Zudem müssen beide Größen bei der Auswahl der Rechnerhardware berücksichtigt werden. Deshalb hat die Informatik seit den sechziger Jahren erhebliche Anstrengungen unternommen, neue Algorithmen für bekannte Aufgaben zu entwickeln, die in den beiden Aufwandsgrößen Verbesserungen aufweisen. Für die Einordnung verschiedener Algorithmen einer Klasse in Bezug auf ihren Rechenzeit- und Speicherplatzaufwand ist es wichtig zu wissen, wie der Aufwand eines Algorithmus´ gemessen wird und wie die Aufwandsgrößen zweier Algorithmen sinnvoll miteinander verglichen werden können. Zu Algorithmen existiert eine weiter Bereich von Literatur. Einige Standardwerke sind [DaDH-74], [Knut-73] und [AhHU-74]. In der anschaulichen Darstellung von grundlegenden Algorithmen nimmt [Wirt-86] einen besonderen Rang ein. Unter den neueren Publikationen ist [Sedg-91] empfehlenswert. Die formale Spezifikation von Algorithmen ist beispielsweise in [Loud-93, Kap.8] zu finden. Zum Entwurf von korrekten Programmen, insbesondere von Programmen, deren Korrektheit formal nachgewiesen werden kann, seien [Futs-89] und als Standardwerk [Grie-81] empfohlen. 3.3.1 Komplexität Die Komplexität eines Algorithmus´ ist der Aufwand, den Algorithmus auf einem Rechner oder per Hand auszuführen. Die Komplexität hängt vom Umfang der zu bearbeitenden Daten, der sogenannten Problem- oder Aufgabengröße, ab. Zur Quantifizierung der Komplexität wird ein abstraktes Kostenmaß (keine Geldbeträge!) eingeführt. Diese Kosten können sich einerseits auf die Ausführungszeit einer Elementaroperation (z.B. eines logischen Vergleiches) oder auf den Speicherplatzbedarf beziehen. Bezieht sich die Komplexität auf ein rechenzeitbezogenes Kostenmaß, so wird sie Zeitkomplexität genannt, bezieht sie sich auf ein speicherplatzbezogenes Kostenmaß, so heißt sie Platzkomplexität. Die Komplexität stellt somit einen funktionalen Zusammenhang zwischen dem Umfang der zu bearbeitenden Daten und der Höhe der abstrakten Kosten her. Im folgenden wird vereinfacht angenommen, daß der Umfang der zu bearbeitenden Daten durch eine positive, ganzzahlige Größe n beschrieben wird. Beispiel: (Komplexität von Bubblesort) Der Komplexitätsbegriff soll nun an dem einfachen Sortierverfahren Bubblesort beispielhaft erläutert werden. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen Die Aufgabe ist es, n Zahlen, die in einem eindimensionalen Feld A (engl. array, einer der strukturierten Datentypen) gespeichert sind, aufsteigend zu sortieren. Diese Aufgabe wird von sogenannten Sortieralgorithmen gelöst. Sie werden in Abschnitt 3.4.2.1 näher behandelt. Einer dieser Algorithmen ist Bubblesort: Zunächst stellt man sich das Datenfeld in Spaltenform vor, wobei das erste Element des Feldes oben steht. Das Feld kann man jetzt sortieren, indem man zunächst die kleinste Zahl in das oberste Element transportiert. Dazu wird mit dem untersten Element beginnend jede Zahl mit dem über ihr liegenden Nachbarn verglichen. Wird bei einem solchen Vergleich festgestellt, daß die Zahl im aktuellen Element kleiner als der Nachbar ist, so werden beide Zahlen vertauscht. Daher befindet sich nach diesen Vergleichen die kleinste Zahl des Feldes im ersten Element. Nun muß nur noch das restliche Feld ab dem zweiten Element betrachtet und die in diesem Teilfeld kleinste Zahl in das zweite Element transportiert werden. Ist das restliche Feld auf das unterste Element geschrumpft, so ist das gesamte Feld sortiert und der Algorithmus ist beendet. Das Hochsteigen der kleineren Zahlen in der Spaltendarstellung hat Ähnlichkeit mit dem Aufsteigen von Gasblasen in Flüssigkeiten - daher der Name Bubblesort. Der Algorithmus kann in der Programmiersprache PASCAL wie folgt ausgedrückt werden: CONST VAR n = 10; A : ARRAY [1..n] OF INTEGER; PROCEDURE BubbleSort; VAR i, j, hilf : INTEGER BEGIN FOR i:=2 TO n DO BEGIN FOR j:=n DOWNTO i DO BEGIN IF A[j] < A[j-1] THEN BEGIN hilf:=A[j-1]; A[j-1]:=A[j]; A[j]:=hilf END END END; Vor der Bestimmung der Komplexität des Algorithmus´ muß zunächst das Kostenmaß festgelegt werden, nach dem sie quantifiziert sein soll. In Sortieralgorithmen ist als Kostenmaß der Vergleich zweier Zahlen gebräuchlich. Da während eines Sortierprozesses jede zu sortierende Zahl mindestens einmal angesehen und mit einer anderen Zahl verglichen werden muß, ist die untere Schranke für die Komplexität eines Sortieralgorithmus´ gleich der Anzahl n der zu sortierenden Zahlen. Der Bubblesort-Algorithmus besteht aus zwei geschachtelten FOR-Schleifen. In der inneren Schleife liegt in der IF-Anweisung der als Kostenmaß zu verwendende Vergleich. Anfänglich hat i den Wert 2. Daher wird der Vergleich in der inneren Schleife n-i+1=n-2+1=n-1 mal ausgeführt. Im nächsten Durchlauf der äußeren Schleife hat i den Wert 3. Folglich sind n-i+1=n-3+1=n-2 Vergleiche durchzuführen. Dies setzt sich fort, bis i gleich n ist, in welchem Fall noch ein Vergleich zu realisieren ist. Insgesamt sind demnach n −1 K (n ) = (n − 1) + (n − 2 ) + (n − 3) + ... + 2 + 1 = ∑ m = m =1 n(n − 1) 1 2 1 = n − n 2 2 2 Vergleiche durchgeführt worden. Die Komplexität von Bubblesort ist also Datenverarbeitung in der Konstruktion (DiK) 49 50 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen KBubblesort (n) = 1 2 1 n − n 2 2 Wäre als Kostenmaß die Anzahl der für den Algorithmus zusätzlich benötigten Speicherzellen definiert worden (der Speicherplatz für die Eingabedaten A zählt hier nicht), so ergäbe sich eine Platzkomplexität von 1, da nur die Variable hilf zum Austausch zweier Zahlen eingeführt werden mußte. Diese Platzkomplexität ist also unabhängig von n und somit sehr günstig. Verschiedene Algorithmen für eine Aufgabe können sich für kleine Aufgabegrößen n sehr ähnlich bezüglich ihres Rechenzeitaufwands verhalten. Interessant wird jedoch die Betrachtung der Komplexität für große n. Beispiel: Für die Analyse der Verformung eines mechanischen Bauteils nach der Methode der finiten Elemente (FEM) wird das Bauteil in kleine Würfel oder komplexere Körper (die sogenannten Finiten Elemente) aufgeteilt. Ihr physikalisches Verhalten wird über Gleichungen beschrieben, die von den Zuständen der benachbarten Würfel und allgemeinen Größen wie Werkstoffkenngrößen, Druck, u.a. beeinflußt werden. Da der Aufwand zur Berechnung einer FE-Analyse von der Anzahl der zu lösenden Gleichungen abhängt und die Anzahl der Gleichungen pro Element meist konstant ist, kann die Komplexität der FE-Analyse über die Anzahl der Elemente ausgedrückt werden. Dem Kostenmaß liegt also das Element zugrunde. Ohne Beschränkung der Allgemeinheit sei angenommen, daß das zu betrachtende Bauteil ein Würfel sei. Zur Durchführung der FEM werde jede Kante des Würfels in n Teile geteilt, so daß der Würfel in insgesamt n3 kleine Würfel zerlegt wird. Die Komplexität einer dreidimensionalen FE-Analyse ist somit K3(n)=n3. Für die Werte n=2 und n=3 ergeben sich demnach 23=8 bzw. 33=27 kleine Würfel. Im zweiten Fall sind also 27-8=19 Würfel mehr in die Rechnung einzubeziehen als im ersten Fall. Wird n verdoppelt, also jeder Kantenteil nochmals halbiert, so ergibt sich eine Anzahl von (2n)3=8n3 Würfeln gegenüber n3 Würfeln bei einfachem n. Bei Verdopplung sind somit 8n3-n3=7n3 Würfel mehr zu berechnen. Für n=1000 ergibt eine Verdopplung somit 3 7·1000 =7.000.000.000 zusätzlich zu berechnende Würfel. Wegen dieser riesigen Zuwächse reduziert man die Aufgabe durch eine Verringerung von drei auf zwei Dimensionen, indem man von dem Bauteil nur ein Schnitt betrachtet und es dann in zweidimensionale Elemente zerlegt. Betrachtet man einen zu einer Würfelseite parallelen Schnitt, so erhält man ein Quadrat. Der obigen Vorgehensweise entsprechend ergeben sich bei einer Unterteilung einer Seite des Quadrats in n Teile eine Anzahl von n2 kleinen Quadraten. Die Komplexität einer zweidimensionalen FE-Analyse ist demnach K2(n)=n2. Bei einer Verdoppelung von n erhält man hier (2n)2=4n2 Quadrate, also 4n2-n2=3n2 Quadrate mehr als bei einfachem n. Für n=1000 ergeben sich dann 3·10002=3.000.000 zusätzliche Quadrate. Gegenüber der dreidimensionalen Analyse spart man 4n3/3n2=4/3·n Elemente bei einer Verdopplung von n ein, die Einsparung wächst also linear mit n. Zusammenfassend läßt sich sagen, daß die dreidimensionale FE-Analyse mit einer Komplexität K3(n)=n3 erheblich mehr Aufwand bereitet als die zweidimensionale FE-Analyse mit einer Komplexität von K2(n)=n2. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 51 Trotz der in dem Beispiel offensichtlich schlechteren Komplexität der dreidimensionalen Analyse ist es nicht einfach zu entscheiden, ob ein Algorithmus signifikant besser oder schlechter ist als ein anderer, insbesondere, wenn einer der beiden Algorithmen für jeden auf das Kostenmaß bezogenen Rechenschritt mehr Rechenzeit benötigt als der andere. Beispiel: Angenommen, in dem oben beschriebenen Sortieralgorithmus Bubblesort laufe die innere Schleife nicht von n bis i sondern von n hinunter bis 1, also über das gesamte, auch schon teilweise geordnete Feld. Scheinbar verschlechtert sich der Algorithmus dadurch, denn jetzt sind in jeder Bearbeitung der inneren Schleife n Vergleiche durchzuführen, insgesamt also n2. Die Komplexität KBubblesort'(n) dieses veränderten Algorithmus´ verschlechtert sich demnach von (n2-n)/2 zu n2. Da jedoch KBubblesort (n) = n2 − n 1 1 2 1 = n − − 2 2 2 8 gilt, nimmt die Komplexität des ersten Algorithmus´ gleichfalls parabolisch mit n zu und ist daher nicht signifikant besser als seine Variante. Ein anderer Sortieralgorithmus, der Quicksort (vgl. Abschnitt 3.4.2.1) besitzt eine Komplexität KQuicksort(n)=n·log2 n (vgl. z.B. [Wirt-86, S. 102]). Sie nimmt also fast linear mit n zu. Die Anzahl der Anweisungen, die bei Erfülltsein des als Kostenmaß benutzten Vergleichs bei Bubblesort und bei Quicksort auszuführen sind, schlagen sich in einem Vorfaktor c in der Komplexität nieder (K(n)=c·n·log n). Daher gibt es ein n, ab dem der Quicksort immer weniger Vergleiche benötigt als Bubblesort. Benötigt beispielsweise der Quicksort 2.000 mal mehr Anweisungen, wenn ein Vergleich erfolgreich ist, als der Bubblesort (c=2.000), so gilt (unter Vernachlässigung des Logarithmus´, der selbst für große n sehr klein bleibt) KQuicksort < K Bubblesort’ ⇔ c ⋅n ⋅ log n < n2 ⇒ ⇔ c⋅n < n c<n 2 und mit c=2000 folgt, daß ab n=2001 der Quicksort immer weniger Vergleiche benötigt als der Bubblesort und somit dessen Komplexität signifikant besser ist. Zum Vergleich der Komplexität verschiedener Algorithmen für die gleiche Aufgabe dient ein besonderes Ordnungsschema, die 1894 von Paul Bachmann eingeführte O-Notation. Definition 3-1: Es seien f(n) und g(n) zwei von n abhängige Funktionen. Man schreibt f (n) = O(g(n)) (gesprochen: f ist von der Ordnung g), wenn zwei Konstanten n0 ≥ 0 und c existieren, so daß für alle n ≥ n0 die Ungleichung f (n) ≤ c ⋅g(n) gilt. 1 1Da die Ungleichung für ein festes g für viele f gilt, ist das Gleichheitszeichen in der obigen Glei- chung etwas irreführend. Vorteilhafter wäre das Zeichen ⊆ für unechte Untermenge, jedoch hat sich das Gleichheitszeichen in der Fachliteratur etabliert (siehe hier auch die Diskussion in [GrKP-88, S. 432f]). Datenverarbeitung in der Konstruktion (DiK) 52 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen Beispiele: Die Komplexität der zweiten Variante des Bubblesort, KBubblesort'(n)=n2, ist von der Ordnung O(n2), da für jedes n>0 (n0=1) n 2 ≤ c ⋅ n2 ⇔ 1 ≤ c gilt und somit unendlich viele Konstanten c existieren, für die die Ungleichung gilt. 1 2 3 Jedoch gilt auch n = O(n ) , da n 2 ≤ c ⋅ n3 ⇔ 1≤ c ⋅ n ⇔ ≤ n , wenn für ein bec 1 liebiges c n0 ≥ ist. c 2 Die Komplexität KBubblesort (n) = (n − n) / 2 ist auch von der Ordnung O(n2), da n2 − n 1 1 n2 − n ≤ c ⋅ n2 ⇔ ≤ c ⇔1 − ≤ c ⇔ ≤ n , wenn c>1 ist. Ange2 n n 1− c nommen, der Bubblesort liefe auf einem m mal so schnellen Rechner. Die Komplexität der zweiten Variante ist KBubblesort',m=1/m·KBubblesort'=1/m·n2. Sie ist jedoch noch immer von der gleichen Ordnung O(n2), da 1 2 1 n ≤ c ⋅ n2 ⇔ ≤ c gilt, für jedes m also beliebig viele c existieren, für die die m m Ungleichung erfüllt ist. Die Komplexität der zweiten Variante des Bubblesort ist nicht von der Ordnung n O(n·log n), da n 2 ≤ c ⋅ n⋅ log n ⇔ n ≤ c ⋅log n ⇔ ≤ c ist und für jedes c unlog n endlich viele n existieren, für die die Ungleichung nicht gilt. Aus den Beispielen kann man erkennen, dass • Polynome von der Ordnung der höchsten, in ihnen vorkommenden Potenz sind, • ein konstanter Faktor in der Komplexität an deren Ordnung nichts ändert (ein schlechter Algorithmus wird durch einen schnelleren Rechner nicht signifikant besser!) und • ein Klassensystem existiert, dessen Klassen durch die Ordnungsfunktionen gebildet werden, die nicht kommutativ angewendet werden können, mit anderen Worten: seien f=O(g) und h=O(i). Wenn g=O(i) ist, dann gilt auch f=O(i). Ist jedoch i•O(g), dann gilt nicht für jedes h, für das h=O(i) gilt, auch h=O(g). Beispielsweise ist zwar die Komplexität des Quicksort (n·log n) auch von der Ordnung O(n2), jedoch ist die Komplexität des Bubblesort, wie im obigen Beispiel ausgeführt, nicht von der Ordnung O(n·log n), weil n·log n=O(n2), jedoch n2•O(n·log n) ist. Die gebräuchlichsten Ordnungsklassen sind (nach aufsteigender Wachstumsgeschwindig2n keit) log log n, log n, n, n·log n, n2, n3, ..., 2n, nn, 2 . Zusammenfassend läßt sich sagen, daß Algorithmen, die von der gleichen Ordnung sind, qualitativ gleichwertig bezüglich ihres Aufwands sind. Ziel der Entwicklung eines Algorithmus´ sollte neben der reinen Erfüllung der Aufgaben stets sein, seine Komplexität möglichst günstig zu halten. Dabei ist zu beachten, daß für ein Problem immer eine Mindestkomplexität existiert, die von einem Algorithmus nicht unterschritten werden kann. Zur Vertiefung der O-Notation sei [GrKP-88, Kap. 9] empfohlen. Die Komplexität von verschiedenen Algorithmen wird beispielsweise in [Wirt-86] näher behandelt. Als allgemeine Einführung in die Komplexitätstheorie kann [Paul-78] dienen. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 53 3.3.2 Algorithmenklassen Schon die Begriffsdefinition des Algorithmus´ als allgemeines Lösungsverfahren für eine Aufgabe impliziert, daß zu jedem lösbaren Problem auch mindestens ein Lösungsverfahren existiert. Um nun diese riesige Menge von Algorithmen besser handhaben zu können, ist es sinnvoll, eine Einteilung der Algorithmen nach bestimmten Kriterien vorzunehmen. Ein Kriterium kann direkt aus dem ersten Satz abgeleitet werden: alle Algorithmen, die ein bestimmtes Problem lösen, können zu Klassen zusammengefaßt werden. Solche Klassen sind beispielsweise Sortier- und Suchalgorithmen. Da auch Probleme zu Klassen zusammengefaßt werden können, kann eine entsprechende Einteilung der Algorithmen geschehen. Beispiele hierfür sind geometrische Algorithmen, Graphenalgorithmen, kryptographischen Algorithmen, Kodierungsalgorithmen und numerischen Algorithmen. Zusätzlich können Algorithmen nach den in ihnen verwendeten Methoden zur Problemlösung unterschieden werden. Hier sind z.B. die rekursiven Algorithmen zu nennen. Die aufgeführten Beispiele sollen nur einen groben Eindruck von der Vielfalt der existierenden Algorithmen, Algorithmenklassen und -klassifikationen geben. Sie erheben keinen Anspruch auf Vollständigkeit, stellen jedoch wichtige Vertreter aus der Menge der Algorithmen dar. Sortieren Das Sortieren von Objekten nach bestimmten Kriterien ist eine der weitestverbreiteten Aufgaben, die derzeit mit Computern gelöst werden. Gemeinhin im Zusammenhang mit Zahlen behandelt, können Sortierverfahren jedoch auch beliebige andere Objekte sortieren, auf denen eine sogenannte Ordnungsrelation definiert werden kann. Eine Ordnungsrelation • setzt jeweils zwei Objekte der Menge M der zu sortierenden Objekte in eine Beziehung, für die das Idempotenzgesetz (für alle i aus M gilt: i • i) und das Transitivgesetz (für alle a, b, c aus M gilt: a ≤ b ∧ b ≤ c ⇒ a ≤ c ) gilt. Für die Auswahl eines passenden Sortieralgorithmus ist es wichtig zu wissen, in welcher Datenstruktur die zu sortierende Objektmenge vorliegt. Wird im Arbeitsspeicher des Computers sortiert, so wird meist ein eindimensionales Datenfeld verwendet, das einen direkten Zugriff auf jedes Element der Menge zuläßt. Große Datenmengen, die nicht in den Arbeitsspeicher passen, können in Dateien auf Massenspeichern vorliegend sortiert werden. Dateien bieten meist nur einen sequentiellen Zugriff auf die in ihnen enthaltenen Daten, es ist also nur ein Objekt nach dem anderen zugreifbar. Beim Sortieren in Feldern macht man sich drei Prinzipien zunutze: das Einfügen eines neuen Elements in eine schon geordnete Teilmenge an die durch die Ordnungsrelation vorgegebene Stelle, das Auswählen eines bezüglich der Ordnungsrelation ausgezeichneten Elements der unsortierten Menge (z.B. des kleinsten Elements x) und das Austauschen von Elementen. Das Sortieren durch Einfügen wurde schon in Abschnitt 3.2.6.1 im Zusammenhang in binären Bäumen gezeigt. Dem Sortieren durch Auswahl entspricht ein leicht veränderter Bubblesort, bei dem nicht die benachbarten Feldelemente miteinander verglichen werden, sondern das kleinste Feldelement durch sequentielles Durchwandern des Feldes festgestellt und mit dem ersten Feldelement vertauscht wird. Danach muß nur noch das restliche Feld in Bezug auf die dort kleinste Zahl betrachtet werden. Typisches Beispiel für das Sortieren durch Austauschen ist der normale Bubblesort. Datenverarbeitung in der Konstruktion (DiK) 54 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen Ein sehr bekannter und höchst effizienter Algorithmus ist der Quicksort. Er beruht auf einem der Software-Entwurfsprinzipien für Algorithmen, dem Herrsche und Teile (engl. divide and conquer), also dem Aufteilen des Problems in Teilprobleme und deren separate Lösung. Aus den in einem Datenfeld vorliegenden Objekten wird zufällig ein x, ausgesucht. Nun beginnt man mit dem Feldelement i mit dem kleinsten Index und testet, ob die Ordnungsrelation i • x gilt. Ist dies der Fall so wird zum Element mit dem nächsthöheren Index gegangen. Anderenfalls wird nun von rechts beginnend ein Element gesucht, für das die Relation x • i nicht gilt. Diese beiden Elemente werden miteinander vertauscht. Dieser Prozeß wird solange durchgeführt, bis man sich von links und rechts kommend trifft. Das Feld befindet sich dann in einem Zustand, daß links von dem Element x alle Elemente • x und rechts von x alle Elemente • x angeordnet sind. Diese beiden sogenannten Partitionen sind zwar noch nicht sortiert, wendet man aber den eben beschriebenen Algorithmus auf die beiden Partitionen an, so kann man auch hier wieder Partitionierungen hervorrufen. Die rekursive Anwendung des Algorithmus´ auf die immer kleiner werdenden Partitionen führt zu einer Partitionsgröße von 1, so daß bei deren Erreichen das gesamte Feld sortiert ist. Dem Herrsche von "Herrsche und Teile" ist demnach die Auswahl des Elements x und das optionale Vertauschen von Elementen zuzuordnen, dem Teile das Aufteilen in die beiden Partitionen. Der Quicksort ist ein typisches Beispiel aus der Klasse der rekursiven Algorithmen. Neben den genannten Sortieralgorithmen auf Feldern existieren noch weitere Algorithmen, auf die hier nicht weiter eingegangen werden soll. Die wichtigsten Vertreter sind der Shellsort, benannt nach seinem Entwickler D. L. Shell, und der Heapsort. Das Sortieren auf Dateien beruht auf dem Prinzip des Mischens. Die Datei mit den zu sortierenden Objekte kann beispielsweise in der Mitte geteilt werden. Nun werden die jeweils ersten Elemente der beiden Teile gelesen, über die Ordnungsrelation miteinander verglichen und ggf. vertauscht in eine neue Datei geschrieben. Dies wird für die restlichen Paare aus den beiden Teilen durchgeführt, bis das Ende der ursprünglichen Datei erreicht ist. Auf der neuen Datei wird die Zweiteilung, das Lesen, Vergleichen und Schreiben der Paare erneut ausgeführt, wobei jedoch von der korrekten Reihenfolge der Elemente aus dem ersten Mischen profitiert werden kann. Dadurch erhält man Vierergruppen, in denen eine geordnete Reihenfolge besteht. Der Prozeß wird solange wiederholt, bis die entstehende Gruppe die gesamte Datei umfaßt. Ein umfassendes Kompendium zum Sortieren ist [Knut-73]. Anschauliche Darstellungen von Sortieralgorithmen sind in [Wirt-86] und [Sedg-91] zu finden. Suchen Sortierte Daten können besonders gut weiter verarbeitet werden. Insbesondere bieten sie auch die Grundlage für das Wiederauffinden von Informationen, das die sogenannten Suchalgorithmen übernehmen. Sie benötigen eine Suchbedingung, die entscheidet, ob das zu testende Objekt mit dem gesuchten Objekt übereinstimmt. Datenstrukturen, auf denen meist gesucht wird, sind sequentielle Dateien, Felder und Bäume. Mit dem sequentiellen Suchen kann in sequentiellen Dateien und in Feldern gesucht werden, indem die Suchbedingung vom Beginn der Datei oder Feldes an nacheinander auf alle Objekte angewendet wird, bis sie erfüllt ist. Das gesuchte Objekt ist dann gefunden. Das binäre Suchen auf Feldern macht sich die Ordnung des Feldes zunutze, indem ein Objekt zufällig ausgewählt und über die Suchbedingung entschieden wird, ob das Objekt schon gefunden wurde. Ist dies nicht der Fall, so kann anhand der Ordnungsrelation für das Sortie- Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 55 ren herausgefunden werden, ob das zu suchende Objekt links oder rechts vom aktuellen Objekt liegt. Diese Maßnahme reduziert den noch zu durchsuchenden Feldteil erheblich. Eine solche Intervallschachtelung liegt auch den binären Suchbäumen zugrunde. Das Tabellen-Suchen (engl. hashing) basiert nicht auf dem Durchsuchen des Gesamtdatenbestandes anhand einer Suchbedingung, sondern auf der Aufstellung einer Tabelle vor dem eigentliche Suchprozeß. Dazu ist es wichtig zu wissen, daß die Ordnungsrelation zum Sortieren meist über eine Funktion mit mehreren Eingangsparametern und einem Wahrheitswert als Rückgabeparameter realisiert wird, einem sogenannten Prädikat. Als Eingangsparameter dienen bestimmte Objektattribute, die sogenannten Sortierschlüssel. Eine Suchbedingung testet dann üblicherweise, ob die Schlüssel des zu findenden Objekts, die vor der Suche als Anhaltspunkt vorliegen, mit den Schlüsseln des aktuell zu testenden Objektes übereinstimmen. Stattdessen wird die oben genannte Tabelle aufgebaut, indem mit einer sogenannten Hashfunktion eine eindeutige Abbildung von den Schlüsseln zu einem Eintrag der Tabelle hergestellt wird. Da im allgemeinen weniger Tabelleneinträge als zu sortierende Objekte existieren, fallen auf einen Tabelleneintrag mehrere Objekte, die Hashfunktion ist demnach nicht unbedingt bijektiv (eineindeutig). Daher ist jeder Tabelleneintrag als Liste von Objekten ausgeführt. Beim Suchen eines Objekts anhand eines Schlüssels wird auf diesen die Hashfunktion angewendet, die dann zu einem Tabelleneintrag und der zugehörigen Objektliste führt. Nun können die in der Liste eingetragenen Objekte durch sequentielles Suchen untersucht werden. Existiert das gesuchte Objekt überhaupt, dann muß es sich ja in der Liste des Tabelleneintrags befinden, weil beim Einsortieren des Objekts dieselbe Hashfunktion angewendet wurde wie beim Suchen. Die Tabellensuche ist u.a. in [Wirth-86] beschrieben. Als letzte Gruppe von Suchalgorithmen ist die Mustersuche (engl. pattern matching) anzuführen. Deren Aufgabe ist es, in einem Text den Ort einer Zeichenkette auszumachen. Neben der direkten Mustersuche, bei der jedes Zeichen des Textes mit dem Beginn der Zeichenkette verglichen wird, bei Erfolg das nächste Zeichen an die Reihe kommt, bei Mißerfolg jedoch die Zeichenkette nur um ein Textzeichen weitergeschoben wird, wurden zwei Algorithmen entwickelt, die die Mustersuche im Durchschnitt erheblich beschleunigen können: der Knuth-Morris-Pratt- und der Boyer-Moore-Algorithmus. Beide wurden bis heute mehrfach verbessert und sind so ausgefeilt, daß ihre Besprechung den gesetzten Rahmen sprengen würde. Näheres ist in [Wirt-86], den Originalartikeln [BoMo-77] und [KnMP-77], sowie [HuSu-91] zu finden. Rekursive Algorithmen Benutzt ein Algorithmus während seiner Durchführung sich selbst, um nach dem Prinzip des Herrsche und Teile einen Teil der gestellten Aufgabe zu lösen und dann diese Teillösung zur Lösung der gesamten Aufgabe zu verwenden, so nennt man ihn rekursiv. Damit ein rekursiver Algorithmus korrekt arbeitet, muß er zwei Bedingungen erfüllen: einerseits muß er eine Abbruchbedingung besitzen, die das Selbstaufrufen in bestimmten Fällen unterbindet, andererseits muß das dynamische Verhalten des Algorithmus´ so gestaltet sein, daß die Abbruchbedingung in endlicher Zeit erfüllt werden kann. Beispiel: Die Fakultät n! ist definiert als n! = n ⋅ (n − 1) ⋅ (n − 2 ) ⋅ ... ⋅ 2 ⋅ 1 . Die Definition kann jedoch auch als Rekurrenzbeziehung ausgedrückt werden: ,n = 0 1 n! = n ⋅ (n − 1)! ,sonst In dieser Definition wird von dem Vorhandensein der zu definierenden Funktion Datenverarbeitung in der Konstruktion (DiK) 56 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen ausgegangen. Die rekurrente Fakultätsdefinition kann direkt in einen Algorithmus umgesetzt und in PASCAL wie folgt formuliert werden: FUNCTION Fakultät(n : INTEGER) : INTEGER; BEGIN IF n = 0 THEN Fakultät:=1 ELSE Fakultät:=n*Fakultät(n-1) END; Der Algorithmus weist die beiden, oben genannten Forderungen nach einer Abbruchbedingung (n=0) und dem dynamischen Verhalten in Richtung der Abbruchbedingung (Fakultät(n-1)) auf. Der Algorithmus hat jedoch eine Unzulänglichkeit: da n vom Typ INTEGER ist, kann n auch negativ werden. In diesem Fall besitzt der Algorithmus zwar noch die Abbruchbedingung, das Argument n wird jedoch bei jeder weiteren Rekursion „negativer“ und bewegt sich daher von der Abbruchbedingung weg. Der Algorithmus terminiert nicht. Um diesem Fall zu begegnen, kann entweder vereinbart werden, daß die Funktion nur mit positiven Argumenten aufgerufen wird (es wird eine sogenannte Vorbedingung vereinbart, die erfüllt sein muß, wenn der Algorithmus seine Aufgabe erfüllen soll) oder die IF-Abfrage wird um den Fall n<0 erweitert, dessen Auftreten zu einer Fehlermeldung führt. Gerade im Zusammenhang mit sich selbst wiederholenden Datenstrukturen, wie Graphen, Bäumen oder Listen werden rekursive Algorithmen zur Implementierung der auf den Datenstrukturen anwendbaren Operationen verwendet. Eine übersichtliche Beschreibung von rekursiven Algorithmen kann in [Wirt-86] gefunden werden. Graphenalgorithmen Viele praktische Probleme lassen sich auf Graphen abbilden und somit algorithmisch lösen. Dem gewichteten Graphen (siehe Abschnitt 3.2.2) kommt in vielen Fällen eine besondere Rolle zu, da die Kanten mit Gewichten versehen werden, welche die problemzugehörigen (Kosten, Längen, ...) für den Lösungsalgorithmus verfügbar machen. Die Tiefen- und Breitensuche wurde schon in Abschnitt 3.2.2 behandelt. Häufige Problemstellungen, bei denen gewichtete Graphen zum tragen kommen, sind die Suche nach der kürzesten Verbindung zwischen zwei Punkten (z.B. automatische Fahrplanerstellung) oder die Suche nach der kostengünstigsten Möglichkeit, alle Punkte miteinander zu verbinden (Traveling Salesman Problem). Im ersten Falle ist der kürzeste Pfad, der hier nicht weiter erläutert wird, im zweiten der minimale Spannbaum gesucht. Ein minimaler Spannbaum eines gewichteten Graphen ist eine Menge von Kanten, die alle Knoten des Graphen so verbindet, daß die Summe der Kantengewichte minimal ist. Der minimale Spannbaum muß nicht eindeutig sein. Um den minimalen Spannbaum zu erzeugen, verknüpft man jeden Knoten mit dem benachbarten Knoten, der ihm „am nächsten“ liegt, also dem Knoten mit der am niedrigsten gewichteten Kante, sofern er nicht bereits im minimalen Spannbaum enthalten ist. Sollte es mehrere Knoten mit gleichgewichteter Verbindungskante zum Ausgangsknoten geben, kann ein beliebiger Knoten unter ihnen gewählt werden. Ein anderes Verfahren zur Erzeugung des minimalen Spannbaumes geht auf J. Kruskal zurück. Dem zunächst leeren Baum werden, beginnend mit der am niedrigsten gewichteten Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 57 Kante, nach aufsteigenden Gewichten die Kanten des Graphen hinzugefügt. Die Knoten, die durch eine zum Baum hinzugenommene Kante verbunden werden, werden in Mengen zusammengefaßt. Bei der Hinzunahme einer Kante wird nun in allen schon existierenden Mengen nachgesehen, ob eine der beiden Kanten enthalten ist. Ist eine Menge gefunden, so wird geprüft, ob der zweite zur Kante gehörende Knoten auch Element dieser Menge ist. Ist dies der Fall, so wird die Kante nicht in den Baum aufgenommen, da offenbar schon ein besserer Pfad zu diesem Knoten existiert. Ist der zweite Knoten nicht in der Menge, so wird die Kante in den Baum übernommen und die beiden Knotenmengen werden durch ihre Vereinigungsmenge ersetzt. Beispiel (aus: [AhHU-74, S. 174f]): Gegeben sei der folgende gewichtete Graph: 20 v1 23 v2 1 4 36 v6 9 v7 25 28 15 16 3 17 v5 v3 v4 Zunächst ist der minimale Spannbaum leer, also S={}, und man sortiert alle Kanten nach ihren Kantengewichten aufsteigend, erhält also die Liste (v1,v7)1, (v3,v4)3, (v2,v7)4, (v3,v7)9, (v2,v3)15, (v4,v7)16, (v4,v5)17, (v1,v2)20, (v1,v6)23, (v5,v7)25, (v5,v6)28, (v6,v7)36. Jetzt wird diese Liste der Reihe nach durchsucht, und es werden Kanten zum minimalen Spannbaum S nur dann hinzugefügt, wenn das Ergebnis wieder einen Baum bildet. Nach dem ersten Schritt ist also S={(v1,v7)1}. Im zweiten Schritt wird die nächste Kante der Liste hinzugefügt, es ist also S={(v1,v7)1, (v3,v4)3}. Nach dem dritten Schritt ist S={(v1,v7)1, (v3,v4)3, (v2,v7)4}, nach dem vierten S={(v1,v7)1, (v3,v4)3, (v2,v7)4, (v3,v7)9}. Die dann folgende Kante (v2,v3)15 wird nicht zu S hinzugefügt, da sich sonst ein Kreis in S bilden würde und S damit kein Baum mehr wäre. Aus demselben Grund wird auch die Kante (v4,v7)16 nich hinzugefügt. Die Kante (v4,v5)17 dagegen kann hinzugenommen werden, da das Ergebnis nachwievor ein Baum ist: S={(v1,v7)1, (v3,v4)3, (v2,v7)4, (v3,v7)9, (v4,v5)17,}. Dagegen wird die Kante (v1,v2)20 wieder nicht übernommen, da sich sonst ein Kreis in S bilden würde. Nachdem im letzten Schritt auch die Kante (v1,v6)23 zu S hinzugefügt wurde, erhält man als Ergebnis den minimalen Spannbaum S={(v1,v7)1, (v3,v4)3, (v2,v7)4, (v3,v7)9, (v4,v5)17, (v1,v6)23}. v1 23 v2 1 v6 4 v7 9 v3 v3 3 v5 17 v4 Eine Einführung in die Graphenalgorithmen ist in [Sedg-91] zu finden. Erschöpfend behan- Datenverarbeitung in der Konstruktion (DiK) 58 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen delt [Jung-90] die Graphentheorie. Kryptographische Algorithmen Kommunizieren zwei Kommunikationspartner (Menschen, Tiere oder Maschinen) miteinander, so übermittelt stets einer der Kommunikationspartner, der Sender, Nachrichten über ein Übertragungsmedium an den Empfänger. Nach [BeHW-83, S. 10] kann die Übertragung beeinträchtigt werden durch: • zufällige Störungen, • systematische (physikalisch bedingte) Störungen, • passive Beeinträchtigung und • aktive Beeinträchtigung. Unter passiver Beeinträchtigung wird das Abhören des Übertragungsmediums durch einen Lauscher verstanden, während die aktive Beeinträchtigung durch einen Fälscher zustande kommt, der sich in das Übertragungsmedium einschaltet, Nachrichten mithört und dem Empfänger gefälschte Nachrichten zukommen läßt. Ziel der Kryptographie (griech.: verborgen schreiben) ist es • Nachrichten vor Lauschangriffen durch Verschlüsselung zu schützen (Chiffrierung) und • dem Empfänger zu ermöglichen, daß er feststellen kann, ob die Nachrichten in der Tat von dem erwarteten Sender kommen oder durch einen Fälscher eingespielt wurden (Authentisierung). Die Chiffrierung einer Nachricht ist eine eindeutige Abbildung des Nachrichteninhaltes in eine verschlüsselte Form, dem Chiffre, das mit sogenannten kryptoanalytischen Angriffen eines Fälschers nicht oder nur mit viel Aufwand zu entschlüsseln ist. Viel Aufwand bedeutet hier, daß ein entsprechender Algorithmus für einen kryptanalytischen Angriff mindestens eine polynomiale Komplexität hohen Grades oder eine exponentielle Komplexität hat. Im Unterschied zum Fälscher ist der autorisierte Empfänger der verschlüsselten Nachricht mit einem Schlüssel ausgestattet, von dem er außerhalb des für die Nachricht benutzten Übertragungsmediums Kenntnis erlangt hat. Algorithmen zur Chiffrierung erzeugen also das Chiffre und den Schlüssel, mit dem sich das Chiffre wieder entschlüsseln läßt. Algorithmen zur Dechiffrierung nutzen Chiffre und Schlüssel, um wieder den Klartext zu erhalten. Bei Algorithmen zur Chiffrierung und Dechiffrierung sind drei Klassen verbreitet: • Algorithmen für sequentielle Chiffren mit nichtlinearen PseudoZufallszahlen-Generatoren zerlegen die Nachricht in eine Bitfolge, die mit Bits aus einem Pseudo-Zufallszahlen-Generator (PSG) verknüpft werden. Ein Pseudo-Zufallszahlen-Generator erzeugt Zahlen in einem festgelegten Bereich, deren Reihenfolge von einem Beobachter, der die Rechenvorschrift nicht kennt, als nicht vorhersagbar und daher zufällig eingestuft wird. Da die Zahlen jedoch mittels einer Rechenvorschrift entstanden sind, sind sie nicht wirklich zufällig (daher Pseudo-Zufallszahlen). Die Zufallszahlenfolge, die ein PSG erzeugt, ist wiederholbar, wenn die Initialisierung des PSG bekannt ist. Diese Initialisierung wird dem Empfänger als Schlüssel für das Chiffre mitgeteilt. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen • Algorithmen für Blockchiffren teilen die Nachricht in Blöcke einer festen Größe ein. Die Zeichen eines Blocks werden dann gemeinsam der Chiffrierung unterworfen. Als Beispiel sei hier der Data Encryption Standard (DES) des amerikanischen National Bureau of Standards (NBS) genannt. Dieser Algorithmus teilt die Nachricht in 64-Bit-Blöcke ein, die über einen 56 Bit langen Schlüssel mittels verschiedener boolescher Funktionen verschlüsselt werden. Das Verfahren ist im Detail zu kompliziert, um hier angeführt zu werden. Zudem ist der Export von Programmen, die den DES verwenden, aus den USA nicht erlaubt. • Algorithmen für Chiffren aus Einwegfunktionen benutzen sogenannte Einwegfunktionen (engl. trapdoor functions), deren Funktionswert mit geringem Aufwand zu berechnen ist. Das Finden der zu einem Funktionswert gehörenden Funktionsstelle jedoch ist nur mit sehr viel mehr Aufwand möglich. Allerdings erhält der Empfänger vom Sender über ein anderes Übertragungsmedium eine Zusatzinformation, die ihm die Berechnung der Umkehrfunktion erleichtert. Ein Beispiel für eine Falltürfunktion ist in dem sehr bekannten, von R. Rivest, A. Shamir und L. Adleman entwickelten RSA-Algorithmus zur Berechnung von öffentlichen Schlüsseln (engl. public key system, s.u.) enthalten [RiSA-78]. 59 Die Authentisierung einer Nachricht ist der Nachweis, daß die Nachricht von dem Sender stammt, von dem sie erwartet wurde. Eine Nachricht gilt als authentisch, wenn dieser Nachweis erbracht wurde. Die Authentisierung kann dadurch ermöglicht werden, daß die Nachricht oder Teile von ihr in einer Art verschlüsselt worden sind, die nachweislich nur der erwartete Sender vorgenommen haben kann. Eine Erweiterung der Authentisierung ist die elektronische Unterschrift (elektronische Signatur), mit der der Empfänger nicht nur die Authentizität der Nachricht feststellen kann, sondern auch, ob die Nachricht auf dem Übertragungsmedium verfälscht wurde. Wäre nämlich bei der Authentisierung der Nachricht deren Inhalt nicht miteinbezogen worden, so könnte ein Fälscher die Nachricht in seinem Sinne verändern, den die Authentisierung betreffenden Teil jedoch unverfälscht lassen. Ist der Inhalt einer Nachricht nicht vertraulich, soll sichergestellt sein, daß die Nachricht authentisch ist und nicht verfälscht wurde. In diesem Fall kann eine elektronische Unterschrift verwendet, auf eine weitergehende Chiffrierung der Nachricht aber verzichtet werden. Algorithmen zur Authentisierung berechnen u.a. Schlüsselpaare, mit denen die Nachricht chiffriert und dechiffriert werden kann. Insbesondere kann eine Nachricht nur dann mit dem zweiten Schlüssel eines Schlüsselpaars entschlüsselt werden, wenn sie mit dem ersten Schlüssel verschlüsselt wurde. Dadurch kann die Urheberschaft der Nachricht sichergestellt werden. In den Privaten Schlüsselsystemen (engl.: privat key systems) sind beide Schlüssel gleich. Somit könnte der Besitzer des dekodierenden Schlüssels auch gefälschte Nachrichten versenden. Da jedoch dieser Besitzer üblicherweise mit dem Empfänger übereinstimmt, ist die Gefahr des Mißbrauchs gering. Für die langlebigere Verwendung von Authentisierungsschlüsseln wurden Öffentliche Schlüsselsysteme (engl. public key systems) entwickelt, bei denen sich der chiffrierende von dem dechiffrierenden Schlüssel unterscheidet. Der chiffrierende Schlüssel wird privater Schlüssel genannt, der dechiffrierende öffentlicher Schlüssel. Bei der Herstellung des Schlüsselpaares wird der öffentliche Schlüssel aus dem privaten Schlüssel über eine Einwegfunktion berechnet. Aufgrund der Tatsache, daß die mit dem privaten Schlüssel chiffrierte Nachricht mit dem öffentlichen Schlüssel dekodierbar ist, kann daher die Urheberschaft der Nachricht sichergestellt werden. Das Herstellen authentischer Nachrichten nur mit dem öffentlichen Schlüssel ist jedoch nicht möglich, da dieser nur dekodierende Eigenschaften hat. Datenverarbeitung in der Konstruktion (DiK) 60 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen Beispiel: Der oben genannte RSA-Algorithmus verwendet als Grundlage für den privaten Schlüssel d zwei große Primzahlen p und q und als öffentlichen Schlüssel das Zahlenpaar n=p·q und e, wobei e die multiplikative Inverse zu d bezüglich des Produkts von (p-1) und (q-1) ist: d ⋅e mod((p − 1) ⋅(q − 1)) ≡ 1mod((p − 1) ⋅(q − 1)) ≡ 1 Wird nun die Nachricht m in das Chiffre c = m mod n verschlüsselt, so kann der Empfänger durch Kenntnis von n und e die Nachricht entschlüsseln, denn d c mod n = (m mod n) mod n = m e d e d ⋅e mod n = m mod n = m 1 Den Besitzern des öffentlichen Schlüssels nutzt jedoch die Kenntnis von n und e nichts, da sie zur Berechnung von d die beiden Primzahlen p und q kennen müssen. Diese Primzahlen können zwar aus n durch Primfaktorzerlegung berechnet werden, der derzeit beste Algorithmus zur Primfaktorzerlegung einer n-stelligen Zahl hat aber eine Komplexität der Ordnung (log n )⋅ (log log n) O(e ). Die Primfaktorzerlegung einer 80-stelligen Zahl würde somit etwa e (ln1080 ) ⋅(ln ln10 80 ) ≅e 184⋅ 5 =e 920 ≅ 3,5 ⋅ 10 399 kostenrelevante Einzeloperationen benötigen. Wenn eine solche Operation 1ns dauert, so müssen für die gesamte Primfaktorzerlegung etwa 10380 Jahre Rechenzeit aufgewendet werden. Es ist jedoch auch möglich, daß ein Algorithmus mit einer besseren Komplexitätsordnung gefunden wird ([BeHW-83, S. 36f]). Kodierungsalgorithmen Die Kodierung von Informationen wird verwendet, um • die Informationen kompakter zu speichern oder zu übermitteln (Datenkomprimierungskodierung), • die Informationen gegen Speicher- oder Übertragungsfehler zu sichern (Kodierung zur Fehlererkennung und -korrektur) und • die Struktur der Informationen an die Gegebenheiten des Speicher- oderÜbertragungsmediums anzupassen (Kanalkodierung). Eine Komprimierung von Informationen ist möglich, weil sie meist redundante Anteile enthalten, die weggelassen werden können, ohne den Informationsgehalt der Nachricht zu verändern. Während der Speicherung und Übertragung von Informationen können Fehler auftreten, die durch physikalische Einflüsse auf das Speichermedium oder eine schlechte Qualität des Übertragungsmediums bedingt sind. Um solche Fehler zu erkennen oder sogar zu beheben, läßt man neben den reinen Nutzinformationen weitere, redundante Informationen in die Speicherung oder Übertragung einfließen. Sie werden vom Sender aus den Nutzinformationen berechnet und ermöglichen dem Empfänger eine Überprüfung der übermittelten Informationen und, bei Auftreten eines Fehlers, die Behebung desselben. Oft besitzt das Speicher- oder Übertragungsmedium bestimmte physikalische Eigenschaften, die das reine, bitweise Abspeichern oder Senden verhindern. In diesem Fall wird auf die möglicherweise schon mit einer Komprimierung und Kodierung zur Fehlererkennung und -korrektur versehenen Informationen eine Kanalkodierung angewendet, die die Infor- Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 61 mationen an die Möglichkeiten des Speicher- oder Übertragungsmediums anpasst. Numerische Algorithmen Numerische Algorithmen sind Berechnungsverfahren zur Lösung mathematischer Probleme unter vorwiegender Verwendung der Grundrechenarten und der in Programmiersprachen zur Verfügung stehenden mathematischen Funktionen. Die existierenden numerischen Algorithmen sind so vielfältig, daß hier nur ein grober Überblick in Form einer Klasseneinteilung gegeben werden kann. Die Klasseneinteilung orientiert sich an der Struktur von [EnRe-88]. Numerische Verfahren zur Lösung algebraischer und transzendentaler Gleichungen Mit Hilfe des Newtonschen Verfahrens, des Regula Falsi Verfahrens, des Verfahrens von Steffensen oder des Pegasus-Verfahren kann man transzendentale Gleichungen wie z.B. cos(x) − x = 0 numerisch lösen. Algebraische Gleichungen kann man z.B. mit dem Horner-Schema dem Verfahren von Muller dem Verfahren von Bauhuber oder dem Verfahren von Jenkins und Traub lösen. Numerische Verfahren zur Lösung linearer Gleichungssysteme Es gibt direkte und iterative Methoden, um lineare Gleichungssysteme zu lösen. Der GaußAlgorithmus und das Verfahren von Cholesky sind die bekanntesten Vertreter direkter Verfahren. Sie haben die Eigenschaft, daß sie lineare Gleichungssysteme theoretisch exakt lösen können. In der Praxis spielen jedoch Rundungsfehler eine große Rolle. Daher verwendet man bei großen linearen Gleichungssystemen oft iterative Verfahren, die zwar die Lösung nur annähern, jedoch sehr schnell arbeiten. Das Gauß-Jordan-Verfahren ist ein Beispiel für solche iterativen Verfahren. Insbesondere für spezielle Formen von Gleichungssystemen mit z.B. tridiagonalen Matrizen oder allgemeinen Bandmatrizen gibt es sehr schnelle Algorithmen. Ein kleines Beispiel für ein tridiagonales Gleichungssystem könnte lauten: 2 1 1 2 1 x1 1 1 x 2 2 = 2 1 x 3 3 1 2 x 4 4 Numerische Verfahren zur Lösung von Systemen nichtlinearer Gleichungen Systeme nichtlinearer Gleichungen wie z.B. x 2 + y + sin(z) = 0 x + e y − z +1 = 0 y − z3 −1 = 0 Datenverarbeitung in der Konstruktion (DiK) 62 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen werden meist mit dem Newtonsche Verfahren gelöst. Weitere Verfahren sind Regula Falsi, das Gradientenverfahren und das Verfahren von Brown. Verfahren zur Berechnung von Eigenwerte und Eigenvektoren von Matrizen Ist A eine quadratische Matrix, so nennt man den Vektor x Eigenvektor zum Eigenwert λ , falls die Beziehung A⋅ x = λ ⋅x gilt. Um Eigenwerte und Eigenvektoren von Matrizen näherungsweise zu berechnen, gibt es z.B. das Iterationsverfahren nach von Mises. Direkte Methoden wie z.B. das Verfahren von Krylov oder das Verfahren von Martin, Parlett, Peters, Reinsch und Wilkinson können zwar Eigenwerte theoretisch exkat berechnen, werden aber in der Praxis meist als iterative Methoden benutzt. Numerische Approximation stetiger Funktionen Die Fehlerquadrat-Methode nach Gauß ist wohl das bekannteste Verfahren, um Funktionen zu approximieren, von denen manche Funktionswerte durch Meßreihen bestimmt wurden. Falls die zu approximierenden Funktionen Polynome sind, bieten sich für die Approximation die sogenannten Tschebyscheff-Polynome an. Die Fourier-Transformation benutzt man hingegen eher für die Approximation von (elektronischen) Signalen. Numerische Interpolation Bei der numerischen Interpolation geht es darum, zu gegebenen Funktionswerten Funktionen (meist Polynome oder sogenannte Splines) zu finden, die an den gegebenen Stellen genau die gewünschten Funktionswerte besitzen. Bekannte Verfahren sind die Interpolation nach Lagrange, das Interpolationsschema von Aitken, oder die Interpolation nach Newton. Bei der Spline-Interpolation werden Polynom-Splines dritten Grades, Hermite-Splines fünften Grades oder Bezier-Splines verwendet. Auch rationale Funktionen werden für die Interpolation benutzt, man spricht dann von Rationaler Interpolation. In CAD-Systemen sind Interpolationsverfahren von entscheidenter Wichtigkeit. Zum Beispiel kann man als Benutzer sogenannte Stützpunkte definieren, aus denen dann die CADSoftware mit Hilfe von Interpolationsmethoden Kurven oder Flächen generiert. Numerische Differentiation Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 63 Durch eine Formel gegebene Funktionen lassen sich einfach symbolisch differenzieren. Oft jedoch sind Funktionen nicht in dieser Art vorgegeben und man benötigt numerische Verfahren für die Differentiation. Solche Verfahren sind z.B. die Differentiation mit Hilfe eines Interpolationspolynoms, die Differentiation mit Hilfe interpolierender kubischer Splines oder die Differentiation nach dem Romberg-Verfahren. Numerische Integration Für die numerische Integration gibt es eine Fülle von Verfahren, von denen wir hier einige nur nennen wollen. Bekannt sind z.B. die Newton-Cotes-Formeln (Sehnentrapezformel, Simpsonsche Formel, 3/8-Formel), die Maclaurin-Formeln (Tangententrapezformel, u.a.), die Euler-Maclaurin-Formeln und die Tschebyscheffschen Integrationsformeln. Vergleichsweise sehr exakt arbeiten die Gauß- Integration und das Verfahren von Romberg. Numerische Verfahren für Anfangswertprobleme bei gewöhnlichen Differentialgleichungen erster Ordnung Um Anfangswertprobleme gewöhnlicher Differentialgleichungen numeisch zu lösen gibt es Verfahren, die sich grob aufteilen lassen in Einschrittverfahren, Mehrschrittverfahren und Extrapolationsverfahren. Bei den Einschrittverfahren sind das einfache Polygonzugverfahren von Euler-Cauchy, das Verfahren von Heun oder komplexere Runge-Kutta-Verfahren zu nennen. Bekannte Mehrschrittverfahren wurden z.B. von Adams-Bashforth, AdamsMoulton und von Gear entwickelt. Numerische Verfahren für Anfangswertprobleme bei Systemen von gewöhnlichen Differentialgleichungen erster Ordnung und bei Differentialgleichungen höherer Ordnung Handelt es sich um Systeme von gewöhnlichen Differentialgleichungen erster Ordnung, so können ebenfalls Runge-Kutta-Verfahren oder diverse Mehrschrittverfahren verwendet werden. Typische Anwendungsgebiete im Maschinenbau für solche Verfahren sind z.B. Mehrkörpersysteme wie Radaufhängungen in Fahrzeugen, bei denen das Bewegungsverhalten einzelner Komponenten simuliert wird. Randwertprobleme bei gewöhnlichen Differentialgleichungen Randwertprobleme sind zumindet bei gewöhnlichen Differentialgleichungen numerisch schwieriger zu behandeln als Anfangswertprobleme. Bekannte Verfahren sind zum Beispiel Schießverfahren oder Kollokationsverfahren. Numerische Verfahren zur Lösung partieller Differentialgleichungen Die meisten numerischen Probleme, die in der Praxis entstehen, resultieren daraus, daß partielle Differentialgleichungen zwar bei der Modellierung von technischen Systemen entstehen, jedoch nur mit numerischen Näherungsverfahren gelöst werden können. Im Maschinenbau gehören z.B. die überaus wichtige Methode der Finiten Elemente, aber auch sogenannte Differenzenverfahren, die Linienmethode oder Finite Volumen Verfahren zur Klasse der Verfahren für die Behandlung partieller Differentialgleichungen. Prinzipiell gilt, daß bei der Lösung komplexer partieller Differentialgleichungen auch Verfahren aus den Datenverarbeitung in der Konstruktion (DiK) 64 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen anderen Klassen verwendet werden. So müssen z.B. bei der Methode der Finiten Elemente sehr viele sehr große lineare Gleichungssysteme gelöst werden. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 65 Wesentliches Problem bei der Implementierung numerischer Algorithmen auf Computern ist die Beschränkung des darstellbaren Zahlenbereichs von ganzen und reellen Zahlen, wie sie in Abschnitt 1.2 beschrieben ist. Ganze Zahlen besitzen auf dem Computer eine größten und kleinsten darstellbaren Wert, während reelle Zahlen in der Anzahl der abgespeicherten Nachkommastellen und in der Größe des Exponenten in der Gleitkommadarstellung beschränkt sind. Nach [EnRe-88, S. 6ff] treten bei der Verwendung numerischer Algorithmen immer drei Fehlerquellen in Erscheinung: die Verfahrensfehler, die Eingangsfehler und die Rechnungsfehler. Die Verfahrensfehler entstehen dadurch, daß anstelle der eigentlichen Problemlösung nur eine Näherung an die Lösung möglich ist oder aus technischen Gründen angegangen wird. Beispielsweise werden Funktionen numerisch dadurch integriert, daß an ihren Funktionsgraph Graphen anderer Funktionen (Polynome) oder geometrischer Objekte (Trapeze) angenähert werden, bei denen der Flächeninhalt unter dem Graphen leichter zu berechnen ist. Die Approximation führt zu den Verfahrensfehlern. Die Eingangsfehler kommen zustande, wenn Funktionen, die den numerischen Algorithmen als Eingangsdaten dienen, selbst nur z.B. durch Stützstellen angenähert werden. Dieser Fehler pflanzt sich dann in der Rechnung bis in das Ergebnis fort. Die Rechnungsfehler entstehen durch die oben genannte eingeschränkte Darstellbarkeit der ganzen und reellen Zahlen im Rechner. Bei der Durchführung arithmetischer Operationen kommen Rundungsfehler zustande, die sich je nach Algorithmus akkumulieren oder gegenseitig aufheben können. Zu jeder Verwendung eines numerischen Algorithmus gehört eine Fehlerabschätzung, die bei einem vorgegebenen Eingangsfehler den zu erwartenden Fehler im berechneten Ergebnis angibt. Es ist also unsinnig, ein Ergebnis auf 20 Stellen nach dem Komma ausgeben zu lassen, wenn der Algorithmus bei gewissen Eingangsdaten nur eine Genauigkeit von drei Stellen erwarten läßt. Datenverarbeitung in der Konstruktion (DiK) 66 Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 3.4 Zusammenfassung In Abschnitt 3.1 stellten wir als wichtige Programmbausteine typische Konstrukte wie die Sequenz, die Alternative und die Wiederholung vor. Die Datentypen, die bei der Programmierung verwendet werden, unterteilten wir in einfache, skalare Typen für die Darstellung von Zahlen, Zeichen und Wahrheitswerten und in strukturierte Typen wie Felder, Verbände und Dateien. In Abschnitt 3.2 nutzen wir die Sichtweise aus der objektorientierten Programmierung, um den Begriff des abstrakten Datentyps (ADT) einzuführen. Mit Hilfe des ADT stellten wir dann die wichtigsten Vertreter abstrakter Datentypen, den Graph, die Liste, den Stapel, die Schlange und den Baum, vor. Anhand von Beispielen wurden Eigenschaften dieser abstrakten Datentypen erläutert. Nachdem wir Datenstrukturen als solche untersucht hatten, behandelten wir im Abschnitt 3.3 Algorithmen, die als Methoden auf den Objektdaten operieren. Als Maß für den Aufwand und damit die Qualität eines Algorithmus führten wir in Abschnitt 3.3.1 als abstraktes Kostenmaß die Zeit- und Speicherplatzkomplexität ein. Eine Einteilung der Algorithmen in die wichtigsten Vertreter Sortierverfahren, Suchalgorithmen, rekursive Algorithmen, Graphenalgorithmen und numerische Algorithmen nahmen wir in Abschnitt 3.3.2 vor. Datenverarbeitung in der Konstruktion (DiK) Grundlagen der Datenverarbeitung: Datenstrukturen und Algorithmen 67 3.5 Literatur [AhHU-74] A. V. Aho, J. E. Hopcroft, J. D. Ullman: The Design and Analysis of Computer Algorithms. Addison-Wesley, Reading, Mass., 1974 [BeHW-83] T. Beth, P. Heß, K.Wirl: Kryptographie. Teubner-Verlag, Stuttgart, 1983 [Biae-89] C. Biaesch-Wiebke: CD-Player und R-DAT-Recorder. Vogel, Würzburg, 1989 [BoMo-77] R. S. Boyer, J. S. Moore: A fast string searching algorithm. In: Communcations of the ACM, Bd. 20, Nr. 10, 1977 [DaDH-72] O.-J. Dahl, E. W. Dijkstra, C. A. R. Hoare: Structured Programming. Academic Press, London, New York, 1972 [EnRe-88] G. Engeln-Müllges, F. Reutter: Formelsammlung zur Numerischen Mathematik mit MODULA-2-Programmen. BI-Wissenschaftsverlag, Mannheim, Wien, Zürich, 1988 [Futs-89] G. Futschek: Programmentwicklung und Verifikation. Springer-Verlag, New York, Berlin, Heidelberg, 1989 [Grie-81] D. Gries: The Science of Programming. Springer-Verlag, New York, Berlin, Heidelberg, 1981 [GrKP-88] R. L. Graham, D. E. Knuth, O. Patashnik: Concrete Mathematics. Addison-Wesley, Reading, Mass., 1988 [HoLa-92] J. Hoschek, D. Lasser: Grundlagen der geometrischen Datenverarbeitung. TeubnerVerlag, Stuttgart, 1992 [HuSu-91] A. Hume, D. Sunday: Fast string searching. In: Software - Practice and Experience, Bd. 21, Nr. 11, 1991 [Jung-90] D. Jungnickel: Graphen, Netzwerke und Algorithmen. BI-Wissenschaftsverlag, Mannheim, Wien, Zürich, 1990 [KKUW-83] E. Kaucher, R. Klatte, Chr. Ullrich, J. Wolf v. Gudenberg: Programmiersprachen im Griff, Bd. 4: ADA. BI-Wissenschaftsverlag, Mannheim, Wien, Zürich, 1983 [KnMP-77] D. E. Knuth, J. H. Morris, V. R. Pratt: Fast pattern matching in strings. In: SIAM Journal of Computing, Bd. 6, Nr. 2, 1977 [Knut-73] D. E. Knuth: The Art of Computer Programming, Bd. 1: Fundamental Algorithms. Addison-Wesley, Reading, Mass., 1973 [Loud-93] K.C. Louden: Programming Languages -- Principles and Practice. PWS Publishing Company, Boston, 1993 [Mehl-88] K. Mehlhorn: Datenstrukturen und effiziente Algorithmen, Bd1: Sortieren und Suchen. Teubner, Stuttgart, 1988 [Nguy-99] Thanh Hai, Nguyen. Erkenntnistheoretische und begriffliche Grundlagen der objektorientierten Datenmodellierung, Institut für Informatik, Uni Leipzig, 1999 [Paul-78] W.J. Paul: Komplexitätstheorie. Teubner-Verlag, Stuttgart, 1978 [RBPE-91] J. Rumbaugh, M. Blaha, W. Premerlani, F. Eddy, W. Lorensen: Object-oriented Modeling and Design. Prentice Hall, Englewood Cliffs, New Jersey, 1991 [RiSA-78] R. L. Rivest, A. Shamir, L. Adleman: A method for obtaining digital signatures and public-key cryptosystems. In: Communications of the ACM, Bd. 21, Nr. 2, 1978 [Sedg-91] R. Sedgewick: Algorithmen. Addison-Wesley, Bonn, Paris, Reading, Mass., 1991 [TzHa-91] H. Tzschach, G. Haßlinger: Skript zur Vorlesung Codierungstheorie WS 91/92. Technische Hochschule Darmstadt, FB Informatik, 1991 [Wirt-86] N. Wirth: Algorithmen und Datenstrukturen in Modula-2. Teubner, Stuttgart, 1986 Datenverarbeitung in der Konstruktion (DiK)