Sie sind auf Seite 1von 64

Praktische Informatik 2 – PI2a

Sommersemester 2020
Session 2:
Suchen und Sortieren in Arrays
Erste Komplexitätsbetrachtungen
Bearbeitungszeitraum: 2020-04-29 — 2020-05-15
Jan Peleska
peleska@uni-bremen.de
Issue 1.3
2020-04-29
Kapitel 1

Vorwort

In diesem Dokument erhalten Sie Material für die zweite Session der Ver-
anstaltung Praktische Informatik 2, PI2a im Sommersemester 2020. Im
Fokus dieser Session stehen die Themen Suchen und Sortieren, sowie eine
Einführung in die Berechnung der Komplexität von Algorithmen.

• In Abschnitt 2 untersuchen wir Code aus dem JDK und diskutie- Übersicht
ren, warum Arrays, die ja eigentlich einen im Vergleich mit den ober-
coolen Java-Sammlungen etwas “altmodisch gewordenen” Datentyp re-
präsentieren, immer noch so wichtig sind.

• In Abschnitt 3 präsentieren wir einen Algorithmus zur binären Suche,


der sicherlich einigen von Ihnen gut bekannt ist. Aber vielleicht kennen
Sie noch nicht den berüchtigten Bug, der in einigen Java-Bibliotheken
immer noch nicht korrigiert wurde, und Sie kennen vielleicht noch nicht
die Komplexitätsberechnung in Abschnitt 4, die zeigt, warum binäre
Suche so effektiv ist.

• In den Abschnitten 3 und 4 wird motiviert, warum es so wichtig ist,


Arrays sortieren zu können. Daher werden in Abschnitt 5 die bei-
den wichtigsten Sortieralgorithmen, Quicksort und Mergesort vor-
gestellt, und ihre Komplexität wird untersucht.

• In Abschnitt 6 wird eine Gesamtbetrachtung über die Worst-Case Lauf-


zeit des Suchens auf Listen oder Mengen angestellt: Wir beantworten
die Frage, wann sich der Aufwand für Übertragung der Liste oder Men-
ge in ein Array, Sortierung desselben und anschließender binärer Suche

1
lohnt, und wann man einfach naiv die Liste oder Menge traversieren
sollte, bis das Element gefunden oder das Ende der Datenstruktur er-
reicht wurde.

• In Abschnitt 7 wird das erste nicht-triviale algorithmische Paradigma


eingeführt – das Teile und Herrsche (engl. Divide and Conquer)
Prinzip. Sowohl die binäre Suche, als auch die Sortieralgorithmen wur-
den nach diesem Paradigma entworfen.

• In Abschnitt 8 werden Fragen und Programmieraufgaben gestellt, die


Sie bitte bis zum nächsten Tutorium bearbeiten.

2
Inhaltsverzeichnis

1 Vorwort 1

2 Verwendung von Arrays im JDK 7

3 Binäre Suche 9
3.1 Der einfache Fall: Suchen nach Zahlen in Arrays . . . . . . . . 9
3.1.1 Berechnung des mittleren Index . . . . . . . . . . . . . 11
3.1.2 Nachstellen des Fehlers . . . . . . . . . . . . . . . . . . 13
3.1.3 Rückgabewerte . . . . . . . . . . . . . . . . . . . . . . 15
3.1.4 Rekursive Varianten der binären Suche . . . . . . . . . 16
3.1.5 Binäre Suche in Arrays von beliebigem primitiven Da-
tentyp . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
3.2 Der allgemeine Fall: Suchen nach vergleichbaren Objekten in
Arrays . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
3.2.1 Binäre Suche auf Objekten, die Comparable implemen-
tieren . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
3.2.2 Binäre Suche auf Objekten mit Komparator . . . . . . 18

4 Erste Komplexitätsbestimmungen 20
4.1 Begriffe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
4.2 Laufzeitkomplexität der binären Suche . . . . . . . . . . . . . 21
4.3 Asymptotische Abschätzung der Worst-Case Laufzeitkomple-
xität . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
4.3.1 Weiterführende Literatur . . . . . . . . . . . . . . . . . 34

5 Sortieren von Arrays 35


5.1 Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
5.1.1 Der Algorithmus . . . . . . . . . . . . . . . . . . . . . 36

3
5.1.2 Komplexitätsaspekte von Quicksort . . . . . . . . . . . 44
5.1.3 Optimierungen von Quicksort im OpenJDK . . . . . . 47
5.2 Stabile Sortierverfahren . . . . . . . . . . . . . . . . . . . . . . 48
5.3 Mergesort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48

6 Laufzeit-Gesamtbetrachtung:
Transformation in Arrays,
Sortieren, Suchen 49

7 Das Divide-and-Conquer Paradigma 53

8 Fragen und Aufgaben 56


8.1 Fragen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
8.1.1 Falsche Implementierungen im Internet . . . . . . . . . 56
8.1.2 Binäre Suche mit Komparator . . . . . . . . . . . . . . 56
8.1.3 Bestimmung der Laufzeit-Komplexität für einen nai-
ven Suchalgorithmus . . . . . . . . . . . . . . . . . . . 57
8.1.4 O(n2 ) 6= O(n3 ) . . . . . . . . . . . . . . . . . . . . . . 58
8.1.5 O(g(n)) ⊆ O(g 0 (n)) . . . . . . . . . . . . . . . . . . . 58
8.1.6 Divide-and-Conquer bei Mergesort . . . . . . . . . . . 58
8.1.7 Asymptotische Abschätzungen . . . . . . . . . . . . . . 58
8.1.8 Varianten von Quicksort . . . . . . . . . . . . . . . . . 59
8.1.9 Worst Case Running Time von Quicksort . . . . . . . . 59
8.2 Aufgaben . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
8.2.1 Quicksort Test . . . . . . . . . . . . . . . . . . . . . . 59
8.2.2 Quicksort auf Elementen mit natürlicher Ordnung . . . 60
8.2.3 Quicksort auf Elementen mit Komparator . . . . . . . 60

9 Anstelle weiterer Literatur-Hinweise 61

4
Abbildungsverzeichnis

4.1 Vergleich der Worst-Case Laufzeit von linearer und binärer


Suche. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
4.2 Vergleich des Wachstums von Polynomen g(n) = 1.5 · n3 und
g 0 (n) = n3 + n2 . . . . . . . . . . . . . . . . . . . . . . . . . . 28
4.3 Vergleich der Polynome g(n) = 1.5 · n3 und g 0 (n) = n3 + n2
im Argumentbereich n ∈ [0, 1.5]. . . . . . . . . . . . . . . . . . 28
4.4 Vergleich des Wachstums der Logarithmen zur Basis 2, e, 10.
Hier bezeichnet log(n) den Euler’schen Logarithmus (Basis e). 31
4.5 Vergleich des Wachstums der Logarithmen zur Basis 2, e, 10,
multipliziert mit unterschiedlichen Faktoren. . . . . . . . . . . 31
4.6 Vergleich der wichtigstens Wachstumsfunktionen. . . . . . . . 33
4.7 Vergleich der wichtigstens Wachstumsfunktionen für größere
n (doppelt logarithmische Skala). . . . . . . . . . . . . . . . . 34

5.1 Worst-Case Running Time O(n2 ) versus Best-Case Running


Time O(n log n) von Quicksort. . . . . . . . . . . . . . . . . . 46

6.1 Vergleich der `-maligen linearen Suche mit der Worst-Case


Laufzeit der `-maligen Suche auf sortiertem Array. . . . . . . . 50
6.2 Schwellwert der Anzahl ` von Suchvorgängen in Abhängigkeit
von Array-Größe n, nach denen die binäre Suche auf sortier-
tem Array weniger akkumulierte Laufzeit benötigt als die li-
neare Suche. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52

9.1 Gary Larson – Lieblings-Cartoonist vieler inzwischen nicht


mehr ganz junger Informatiker*innen. . . . . . . . . . . . . . . 62

5
Listings

3.1 Binäre Suche auf int-Arrays – Implementierung im OpenJDK. 10


3.2 Binäre Suche auf Object-Arrays im OpenJDK. . . . . . . . . 17
3.3 Binäre Suche auf Object-Arrays mit Comparator im OpenJDK. 19
4.1 Lineare naive Suche auf einem Integer-Array. . . . . . . . . . . 23
5.1 Quicksort auf int-Arrays – Implementierung nach Aho et al. [1]. 37
5.2 Quicksort Hilfsfunktionen für die Implementierung in Listing 5.1. 37

6
Kapitel 2

Verwendung von Arrays im


JDK

Wenn Sie im OpenJDK den Source Code von List.java studieren, sehen
Sie folgende Implementierung der Default-Methode sort: List.sort
1 @ S u p p r e s s W a r n i n g s ({ " unchecked " , " rawtypes " }) im
2 default void sort ( Comparator <? super E > c ) { OpenJDK
3 Object [] a = this . toArray () ;
4 Arrays . sort (a , ( Comparator ) c ) ;
5 ListIterator <E > i = this . listIterator () ;
6 for ( Object e : a ) {
7 i . next () ;
8 i . set (( E ) e ) ;
9 }
10 }

Die Liste (egal wie sie implementiert ist) wird zunächst in ein Array vom Arrays.sort
Typ Object[] kopiert (Zeile 3), und dann wird eine Sortiermethode auf
Arrays verwendet (Zeile 4), um die eigentliche Sortierung durchzuführen.
Dann wird der Inhalt des sortierten Arrays in einer Schleife (Ziele 6 — 9)
wieder in die Liste kopiert.
Diese Implementierungstechnik deutet darauf hin, dass Sortiervorgänge
auf Arrays wesentlich effizienter zu realisieren sind als auf komplexeren Da-
tenstrukturen. Das liegt daran, dass die Elemente eines Arrays garantiert
hintereinander im Speicher liegen, während einzelne Listenelemente1 und die
Referenzen darauf häufig im Speicher “verstreut” sind. Infolgedessen kann
man beispielsweise mehrere Array-Elemente hintereinander im CPU-Cache
finden, so dass Zugriffe gar nicht bis in den Hauptspeicher erfolgen müssen,
1
Implementierung von Listen lernen wir in Session 3 kennen.

7
sondern direkt auf dem Cache erledigt werden können. Bei den verstreut im
Speicher liegenden Referenzen, wie sie in Listen vorkommen, ist die Wahr-
scheinlichkeit für solche Cache Hits dagegen viel geringer.
Arrays dagegen schneiden in der Effizienz deutlich schlechter als Listen
ab, wenn sehr viele Einfügeoperationen auf der linearen Datenstruktur durch-
zuführen sind: Sie erinnern sich, bei der MyArrayList-Implementierung aus
dem letzten Semester mussten immer Teile oder das gesamte Array kopiert
werden, wenn ein Element an einer Stelle einzufügen war oder wenn die
Größe nicht mehr ausreichte. Ebenso mussten beim Löschen einzelner Ele-
mente Teile des Arrays kopiert werden (wenn nicht gerade das letzte Element
zu löschen war).
Infolgedessen gelten folgende Entwurfsregeln:

• Für lineare Datenstrukturen, deren Größe sich nach dem Anlegen selten Entwurfsregeln
ändert, sind Arrays gut verwendbar.

• Sortieraufgaben werden am besten auf Arrays ausgeführt.

• Wenn auf einer linearen Datenstruktur häufig Einfüge- oder


Löschoperationen erforderlich sind, sind Listenimplementierungen, wie
wir sie in Session 3 kennenlernen, zu bevorzugen.

8
Kapitel 3

Binäre Suche

3.1 Der einfache Fall:


Suchen nach Zahlen in Arrays
Der Algorithmus für die binäre Suche ist sehr bekannt. Es gibt abzählbar Videos
unendlich viele Videos auf YouTube, die den unterliegenden Grundgedan- und Buch
ken sehr schön erklären. Nur in dem Fall, dass Ihnen die nachfolgend hier
im Skript gelieferte Erläuterung zu kompliziert erscheint, schauen Sie doch
mal beispielsweise https://www.youtube.com/watch?v=P3YID7liBug an,
oder die ausführliche deutschsprachige Vorlesung https://www.youtube.
com/watch?v=8iOykP72b8U1 , oder suchen Sie einfach nach “Binäre Suche”
oder “Binary Search” auf YouTube. Das Buch von Sedgewick [4, 1.1.10, 3.1.5]
gibt auch eine schöne Erläuterung und sie enthält keinen blöden Fehler (kann
ich deutlich mehr empfehlen als die Videos). Bitte studieren Sie die Abschnit-
te in diesem Buch, auch im Zusammenhang mit den nachfolgenden Komple-
xitätsbetrachtungen. Beachten Sie, dass auch im Pseudo-Code in [3, 3.2.2]
das Problem von großen Arrays, das wir unten in Abschnitt 3.1.1 näher ana-
lysieren, sorglos ignoriert wird.
Wir sehen uns zum Studium des Algorithmus lieber die Implementierung OpenJDK
im OpenJDK an. Die Binäre Suche ist dort in mehreren Varianten jeweils als Implemen-
statische Methode der uns bereits bekannten Klasse Arrays implementiert. tierung
Wir betrachten zunächst den einfachsten Fall, wo ganze Zahlen in einem
sortierten int-Array zu suchen sind. Die Implementierung in Arrays.java2
1
Aber Vorsicht, in beiden Videos ist wieder der in Section 3.1.1 beschriebene Bug drin!
2
zu finden unter ./java.base/share/classes/java/util/Arrays.java

9
lautet hierzu wie folgt:

Listing 3.1: Binäre Suche auf int-Arrays – Implementierung im OpenJDK.


1 public static int binarySearch ( int [] a , int key ) {
2 return binarySearch0 (a , 0 , a . length , key ) ;
3 }
4
5 private static int binarySearch0 ( int [] a ,
6 int fromIndex , int toIndex ,
7 int key ) {
8 int low = fromIndex ;
9 int high = toIndex - 1;
10
11 while ( low <= high ) {
12 int mid = ( low + high ) >>> 1;
13 int midVal = a [ mid ];
14
15 if ( midVal < key )
16 low = mid + 1;
17 else if ( midVal > key )
18 high = mid - 1;
19 else
20 return mid ; // key found
21 }
22 return -( low + 1) ; // key not found .
23 }

Das öffentliche Interface binarySearch in Zeile 1 delegiert die eigentliche Erläuterung


Suche sofort an die private Methode binarySearch0 ab Zeile 5. Dort wer- des Algo-
den zunächst Ober- und Untergrenze initialisiert (Zeile 8,9), und zwar mit rithmus
0 für die Untergrenze low und dem höchsten Array-Index a.length − 1 für
Obergrenze high.
Der eigentliche Suchvorgang wird in der while-Schleife zwischen Zei-
le 11 und 21 erledigt. Wird der gesuchte Wert im Array gefunden, wird
direkt aus der Schleife gesprungen und der gefundene Index an den Auf-
rufer zurückgegeben. Andernfalls terminiert die Schleife, weil die Differenz
high − low nach endlich vielen Schritten negativ wird, wie wir der nachfol-
genden Fallunterscheidung entnehmen können. Nach Schleifenterminierung
wird in Zeile 22 ein negativer Wert zurückgegeben, der anzeigt, dass key nicht
in a gefunden wurde. Die Bedeutung dieses Wertes wird in Abschnitt 3.1.3
erläutert.
1. Es wird der Index mid des Array-Elementes, welches in der Mitte zwi-
schen low und high liegt, berechnet. Wenn high − low gerade ist, ist

10
mid exakt gleich weit von low und high entfernt. Wenn die genannte
Differenz ungerade ist, ist mid um 1 näher bei low als bei high.
Dass die Formel in Zeile 12 tatsächlich unter allen Umständen diesen
mittleren Index berechnet, benötigt noch ein bisschen liebevolle Ana-
lyse, diese wir in Abschnitt 3.1.1 durchgeführt. Falls Ihnen Zeile 12
unheimlich vorkommt, sollten Sie diesen Abschnitt erstmal lesen, be-
vor Sie hier weiter machen.

2. Jetzt wird der Wert midVal = a[mid] in einer Fallunterscheidung be-


trachtet:

(a) Ist midVal kleiner als der gesuchte Wert key, ist er entweder gar Fall
nicht in a oder im Array-Bereich a[mid+1], ..., a[high] ent- midVal <
halten. Dies folgt aus der Vorbedingung, dass a aufsteigend sor- key
tiert ist. Also setzt man low auf mid + 1 (Zeile 16) und beginnt
mit dem nächsten Schleifendurchlauf.
Weil bei Schleifeneintritt nur low ≤ high gegolten hat3 , ist mid +
1 unter Umständen um 1 größer als high. Dies bedeutet aber
einfach, dass das gesuchte Element nicht in a enthalten ist, so
dass man die Schleife abbrechen kann. Dies passiert auch, denn
die Schleifenbedingung low ≤ high wertet in diesem Fall zu false
aus.
(b) Ist midVal größer als der gesuchte Wert key, können wir die Su- Fall
che analog im Bereich a[low], ..., a[mid-1] fortsetzen, solan- midVal >
ge low ≤ mid − 1 gilt. Daher setzen wir high = mid −1 in Zeile 18 key
und beginnen mit dem nächsten Schleifendurchlauf. Analog zu
Fall (a) ist der gesuchte Wert nicht im Array, wenn low > mid − 1,
und die Suche in der Schleife kann abgebrochen werden.
(c) Als dritte Möglichkeit kann jetzt nur noch midVal == key gelten; Fall
mid ist also der gesuchte Index, und die Methode kann mit der midVal == key
Rückgabe von mid terminieren (Zeile 20).

3.1.1 Berechnung des mittleren Index


Wie wird die Berechnung des Index mid in Zeile 12 begründet? Betrachten Berechnung
3 von mid
und nicht etwa low < high zugesichert ist

11
wir hierzu das Array und die Grenzen high und low, zwischen denen der
“mittlere Index” zu suchen ist:

a[0] a[1] ... a[low] ... a[mid] ... a[high] ... a[a.length-1]

Um mid zu berechnen, müssen wir um Index low noch die Hälfte der
Distanz zwischen low und high dazu addieren. Die Division in der folgenden
Formel ist die ganzzahlige Division:
high − low
mid = low + (3.1)
2
Jetzt erinnern wir uns daran (lesen Sie hier auch im Java 13 Standard
nach), dass Array mittels int-Werten indiziert werden und das Längenfeld
a.length als int codiert wird. Daher ist die maximale Array Länge4

MAX LENGTH = Integer.MAX VALUE = 231 − 1

und der maximale Index

MAX INDEX = Integer.MAX VALUE − 1 = 231 − 2

Bei der Ausführung des Algorithmus ist der maximale Wert für untere und
obere Schranke, also für den Fall, dass das Array maximale Länge hat und
Unter- und Obergrenze auf den höchsten Index gesetzt sind,

MAX = MAX INDEX = 231 − 2

Aus diesen Fakten können wir folgern, dass mid, wenn es nach Formel 3.1
berechnet wird, immer kleiner oder gleich 231 − 2 ist, denn der Wert liegt
immer zwischen low und high. Die Berechnung ist auch in int-Arithmetik
immer korrekt, denn der Term
high − low
2
subtrahiert eine kleinere Zahl von einer größeren, die kleiner als 231 − 2 ist,
und die Hälfte davon liegt natürlich erst recht im Bereich 0, . . . , 231 − 2.
Während wir jedoch mit ganzzahliger Division in Z einfach Umrechnung
4 in Z gilt
De facto kann man nur Arrays mit maximaler Länge Integer.MAX VALUE − 2 dekla-
rieren. nicht in
int
12
high − low high + low
mid = low + =
2 2
umrechnen dürfen, ist dies für große Werte von low und high in int-
Arithmetik nicht korrekt, denn der int-Ausdruck (high + low)/2 wird ne-
gativ, wenn die Summe (high + low) in Z größer gleich 231 wird: Dann ist
nämlich das Vorzeichen-Bit Nummer 31 gesetzt, und in int-Arithmetik wird
(high + low) als negative Zahl interpretiert. Man erhält also einen Wert für
mid, der kleiner gleich 0 ist, nicht mehr zwischen low und high liegt, und im
Negativ-Fall eine ArrayIndexOutOfBoundsException auslöst.
Jetzt bedenken wir, dass low und high beide kleiner oder gleich 231 − 2
sind. Ihre Summe in Z ist daher kleiner gleich 2 · (231 − 2) = 232 − 4 < 232 .
Das bedeutet, dass es bei der Summe niemals zu einem Overflow kommt:
Das Ergebnis ist immer mit 32 Bit darstellbar. Wenn wir Bit 31 nicht als
Vorzeichen-Bit, sondern als den Wert 231 interpretieren könnten, wäre das
Summenergebnis also korrekt. Nun erinnern wir uns daran, dass der Shift-
Operator >>> den Bit-Ausdruck um eins nach rechts schiebt, und zwar ein-
schließlich des Vorzeichen-Bits 31. Von links wird immer eine Null nachgezo-
gen. Infolgedessen stellt der Wert

mid = (high + low)>>>1 (3.2)

immer das Ergebnis der ganzzahligen Division high+low


2
in Z korrekt dar, wenn
low ≤ high ≤ 231 − 1 erfüllt ist. Diese Berechnung ist sehr viel schneller als
die von Gleichung 3.1, und das begründet die Zuweisung für mid in Zeile 12
des Algorithmus.

3.1.2 Nachstellen des Fehlers


Um das oben beschriebene Problem mit der Berechnung des mittleren
Index mid experimentell nachzuvollziehen, haben wir Ihnen das Archiv
BinsearchBug.zip zur Verfügung gestellt. Dort ist der Algorithmus aus Li-
sting 3.1 in Datei BinSearchFromJdk.java direkt übernommen worden.
Die 2 fehlerfreien und die fehlerhafte Variante, mid zu berechnen, sind Drei
zu Beginn der while-Schleife aufgeführt (zwei sind auskommentiert). In der Varianten,
JUnit-Testdatei BinSearchFromJdkTest.java, Testmethode binarySearch, um mid zu
wird ein int-Array maximaler Größe erzeugt. Jedes Array-Element wird mit berechnen
dem Wert seines Indexes belegt. Dann werden verschiedene Suchoperationen

13
ausgeführt, um die volle Statement Coverage zu erreichen. Mit beiden fehler-
freien Varianten, mid zu berechnen, läuft der Test fehlerfrei. Mit der fehlerhaf-
ten Variante erhält man eine ArrayIndexOutOfBoundsException, denn an
einer Stelle der binären Suche wird mittels Zuweisung mid = (high + low)/2;
der negative Wert −536870914 erzeugt, den man wohl zu Recht als “out of
bounds” bezeichnen kann.
Während der binären Suche werden die Werte von
low high mid (low+high)/2 MAX_VALUE ausgegeben. Man sieht, dass
die Exception bei einem großen Array sehr schnell kommt (2. Iteration),
hätten die Programmierprofis bloß mal ausprobieren müssen . . . .
Wenn eine korrekte Index-Berechnung verwendet wird, sieht man sehr maximal
schön, dass die Suche auch für den Worst Case (gesuchter Wert im letzten 31 Itera-
Element, oder gesuchter Wert größer als alle im Array enthaltenen, oder ge- tionen
suchter Wert ist kleiner als alle im Array enthaltenen) schon nach 31 Iteratio-
nen terminiert. Dies wird in den nachfolgenden Komplexitätsbetrachtungen
näher begründet.
Um diese Experimente durchzuführen, muss der Java VM mitgeteilt wer- Heap
den, dass sie ausreichend Heap-Speicher verwenden darf, um ein so großes Größe für
Array zu allokieren: Man benötigt JVM
setzen
(Länge des Arrays) · (Größe eines int-Wertes)
= Integer.MAX VALUE · 4
= (231 − 1) · 22
= 233 − 4
Bytes, das sind etwa 8 GB. Zusätzlich wird natürlich noch sonstiger Speicher
benötigt. Man konfiguriert hierzu bei IntelliJ-IDEA im Menü Run → Edit
Configurations im Reiter Configuration unter VM Options
-ea -Xmx10g
Parameter -ea bedeutet nur, dass Java-Assertions zur Laufzeit ausgewertet
werden. Parameter -Xmx10g setzt den maximalen Heap auf 10GB.

Hinweis: Bei Rechnern, die nur 8GB Hauptspeicher haben, muss bei Ar- Falls Sie
rays dieser Größe ein sog. Paging5 durchgeführt werden, was häufig in sehr nur 8GB
langen Ausführungszeiten resultiert. In diesem Fall sollten Sie das Beispiel- Haupt-
projekt zur Nachstellung des Fehlers so abändern, dass ein byte-Array an- speicher
stelle eines int-Arrays verwendet wird. haben . . .
5
Das bedeutet, das Hauptspeicherseiten auf die Platte ausgelagert werden müssen.

14
3.1.3 Rückgabewerte
In dem Fall, dass der gesuchte Wert im Array gefunden wird, ist der Rückgabewert
Rückgabewert von binarySearch0 natürlich ein Index idx, so dass a[idx] – Element
den gesuchten Wert enthält. Denken Sie daran, dass das Array mehrere Stel- gefunden
len enthalten kann, in welchen der gesuchte Wert gespeichert ist. Es ist nicht
allgemein vorhersagbar, welcher dieser Indices dann zurückgegeben wird.
In dem Fall, dass der gesuchte Wert nicht im Array a vorhanden ist, wird Rückgabewert
laut Spezifikation der Wert – Element
-(Insertion Point) - 1 nicht
gefunden
zurückgegeben. Der Insertion Point (Einfügepunkt) ist der Index, an welchem
der gesuchte Wert in den sortierten Array einzufügen wäre:
• Ist der gesuchte Wert größer als alle Werte im Array, so ist Insertion
Points
Insertion Point = a.length
denn das Array müsste ja zum Einfügen des neuen Wertes um 1
verlängert werden, und der neue Wert würde an der letzten Stelle ste-
hen.
• Ist der gesuchte Wert kleiner als alle Werte in a, so ist
Insertion Point = 0
denn der Wert müsste am Index 0 in das Array eingefügt werden, und
alle anderen Werte würden einen Platz nach rechts rücken.
• Gilt für den gesuchten Wert x die Bedingung a[i − 1] < x < a[i] mit
i ∈ {1, . . . , a.length − 1}, so ist
Insertion Point = i
Wie man leicht sieht, ist im Fall, dass der gesuchte Wert nicht gefunden wird,
ist der Rückgabewert
-(Insertion Point) - 1
garantiert kleiner als 0: Der kleinste Insertion Point ist 0, dies ergibt
Rückgabewert -1. Alle anderen Insertion Points sind positiv, ihre Nega-
tion also kleiner gleich (−1). Infolgedessen kann man wie gewohnt auf
“Rückgabewert kleiner Null?” abfragen, um festzustellen, dass der Wert nicht
gefunden wurde.

15
3.1.4 Rekursive Varianten der binären Suche
In [3] ist noch eine rekursive Variante der binären Suche dargestellt. Wir Rekursion
überspringen diese Möglichkeit, da Rekursion durch Erzeugung neuer Stack-
frames etwas Overhead im Vergleich zu einer simplen While-Schleife erzeugt,
und wir uns daher Rekursion für Anwendungen aufheben, wo eine Schlei-
fenlösung wirklich extrem schwierig zu programmieren wäre.6 Wir werden
dann auch besprechen, wie man die Stacksize entsprechend setzt, um den
berüchtigten java.lang.StackOverflowError zu vermeiden.

3.1.5 Binäre Suche in Arrays von beliebigem


primitiven Datentyp
Für die binäre Suche auf sortierten Arrays von primitivem Datentyp
(byte[], char[], double[], ...) sind in der Klasse Arrays Varianten
der oben besprochenen statischen binarySearch-Methode vorhanden.

3.2 Der allgemeine Fall: Suchen nach


vergleichbaren Objekten in Arrays
In diesem Abschnitt geht es nur darum, nach Objekten beliebigen Typs zu
suchen. Im letzten Semester haben wir zwei Varianten kennengelernt, wie Objekte
Java-Objekte einer Sammlung oder eines Arrays vergleichbar gemacht werden vergleich-
können: bar
machen:
1. Die Objekte sind Instanzen einer Klasse, welche das Interface zwei
Comparable implementiert, so dass für diese Instanzen eine “natürliche Varianten
Wohlordnung” definiert ist.

2. Es wird ein gesonderter Comparator zur Verfügung gestellt. Dieser wird


benötigt, wenn die Elemente einer Sammlung oder eines Arrays aus
Klassen instanziiert wurden, welche nicht Comparable implementieren,
oder, wenn beim Vergleich bewusst von der “natürliche Wohlordnung”
abgewichen werden soll7 .
6
Vielleicht ist Ihnen aus der theoretischen Informatik bekannt, dass dies bei Problemen
auftritt, die man nur mit Kellerautomaten lösen kann.
7
Wir hatten zum Beispiel Integer-Zahlen nach dem Kriterium sortiert, dass gerade
Zahlen kleiner sind als ungerade.

16
3.2.1 Binäre Suche auf Objekten, die Comparable im-
plementieren
Für die erste Variante stellt die Klasse Arrays eine statische Methode
zur Verfügung, die einfach Objekte vom Typ Object im Array speichert.
Die Verantwortung, dass diese Objekte tatsächlich wechselseitig vergleich-
bar sind und Instanzen von Klassen, welche Comparable implementieren,
liegt beim Nutzer: Sind diese Voraussetzungen nicht erfüllt, wird eine
ClassCastException bei dem Versuch geworfen, einen Objekt-Cast auf ein
vergleichbares Objekt durchzuführen. Der Algorithmus aus dem OpenJDK,
Datei Arrays.java, ist in Listing 3.2 dargestellt.

Listing 3.2: Binäre Suche auf Object-Arrays im OpenJDK.


1 public static int binarySearch ( Object [] a , Object key ) {
2 return binarySearch0 (a , 0 , a . length , key ) ;
3 }
4
5 private static int binarySearch0 ( Object [] a , int fromIndex , int toIndex ,
6 Object key ) {
7 int low = fromIndex ;
8 int high = toIndex - 1;
9
10 while ( low <= high ) {
11 int mid = ( low + high ) >>> 1;
12 @ S u p p r e s s W a r n i n g s ( " rawtypes " )
13 Comparable midVal = ( Comparable ) a [ mid ];
14 @ S u p p r e s s W a r n i n g s ( " unchecked " )
15 int cmp = midVal . compareTo ( key ) ;
16
17 if ( cmp < 0)
18 low = mid + 1;
19 else if ( cmp > 0)
20 high = mid - 1;
21 else
22 return mid ; // key found
23 }
24 return -( low + 1) ; // key not found .
25 }

Im Vergleich zu der Suche auf int-Arrays (Listing. 3.1) is ein Cast


des Objektes a[mid] auf eine Referenz midVal, die Comparable implemen-
tiert erforderlich (Zeile 13). Da dies natürlich nicht zur Übersetzungszeit
abgesichert werden kann, gibt es eine Warnung, dass eine Instanz vom
“Raw Type” Object jetzt plötzlich etwas viel Konkreteres sein soll,

17
nämlich eine Instanz, die Comparable implementiert. Da dies aber nun
unsere Voraussetzung ist, können wir diese Compiler-Warnung getrost
mit @SuppressWarnings("rawtypes") unterdrücken. Analog gibt es dann
in Zeile 15 noch eine “unchecked”-Warnung, denn zur Übersetzungszeit
kann nicht geprüft werden, ob das Objekt wirklich die Vergleichsme-
thode compareTo implementiert. Diese Warnung beseitigen wir mittels
@SuppressWarnings("unchecked").
Der Methodenaufruf midVal.compareTo(key) liefert einen negativen
Wert zurück, wenn midVal in der natürlichen Ordnung kleiner als key ist.
Bei positivem Rückgabewert ist midVal größer als key, und bei Rückgabe 0
gleichen sich die Objekte. Mit dieser Interpretation von compareTo sind die
Fallunterscheidungen in Ziele 17 — 22 völlig analog zu den Zeilen 15 — 20
in Listing 3.1.
Der Rückgabewert wird wieder nach der in Abschnitt 3.1.3 beschriebenen
Formel berechnet.

3.2.2 Binäre Suche auf Objekten mit Komparator


Für die Suche mit Komparator wird eine generische statische Methode im
JDK bereitgestellt. Der Code aus dem OpenJDK ist in Listing 3.3 wieder-
gegeben. Die Objektinstanzen müssen alle zu einem Klassentyp T (oder ei-
ner Ableitung davon) gehören, und der Komparator muss auf T oder einer
Oberklasse davon definiert sein. Sie erinnern sich aus dem letzten Semester:
Das Interface Comparator<T> fordert die Implementierung einer Methode
compare(T o1, T o2), die einen negativen Wert, Null, oder einen positi-
ven Wert zurück gibt, je nachdem, ob o1 als kleiner, gleich, oder größer o2
anzusehen ist. Sie sehen in Listing 3.3, dass der Code nahezu identisch zu Li-
sting 3.2 ist, nur dass die compare-Methode anstelle der compareTo-Methode
aufgerufen wird.
Ein Spezialfall wird in Zeile 7 — 9 behandelt: Es ist erlaubt, eine Null- Spezialfall:
Referenz für den Komparator zu übergeben. In diesem Fall wird in Zeile 8 die Kompara-
binarySearch0-Methode aus Listing 3.2 aufgerufen: Es wird dann erwartet, tor ist
dass T das Interface Comparable implementiert. null

18
Listing 3.3: Binäre Suche auf Object-Arrays mit Comparator im OpenJDK.
1 public static <T > int binarySearch ( T [] a , T key , Comparator <? super T > c ) {
2 return binarySearch0 (a , 0 , a . length , key , c ) ;
3 }
4
5 private static <T > int binarySearch0 ( T [] a , int fromIndex , int toIndex ,
6 T key , Comparator <? super T > c ) {
7 if ( c == null ) {
8 return binarySearch0 (a , fromIndex , toIndex , key ) ;
9 }
10 int low = fromIndex ;
11 int high = toIndex - 1;
12
13 while ( low <= high ) {
14 int mid = ( low + high ) >>> 1;
15 T midVal = a [ mid ];
16 int cmp = c . compare ( midVal , key ) ;
17
18 if ( cmp < 0)
19 low = mid + 1;
20 else if ( cmp > 0)
21 high = mid - 1;
22 else
23 return mid ; // key found
24 }
25 return -( low + 1) ; // key not found .
26 }

19
Kapitel 4

Erste
Komplexitätsbestimmungen

4.1 Begriffe
Komplexitätsbestimmungen bei Algorithmen dienen den Zielen,

• die Laufzeit (engl. Running Time) oder

• den (Speicher-)Platzbedarf (engl. Space) eines Algorithmus

in Abhängigkeit von der Problemgröße zu bestimmen. Wir beschäftigen


uns in diesem Semester nur mit der Laufzeit von Algorithmen.1
Die Laufzeitkomplexität wird natürlich nicht in physikalischen Einhei-
ten (etwa Millisekunden) angegeben, denn unterschiedlich schnelle Compu-
ter führen zu unterschiedlichen absoluten Laufzeiten für dasselbe Problem.
Stattdessen dienen Untersuchungen der Laufzeitkomplexität der Beantwor-
tung folgender Fragen:

1. Wie stark steigt die Laufzeit mindestens mit wachsender Problemgröße


(engl. Best Case Running Time)?

2. Wie stark steigt die Laufzeit höchstens mit wachsender Problemgröße


(engl. Worst Case Running Time)?
1
Für die Platzkomplexität vergleiche etwa https://en.wikipedia.org/wiki/Space_
complexity.

20
Hinweis: Komplementär zur Worst Case Running Time gibt es noch den
Begriff Worst Case Execution Time (WCET). Letzterer bezieht sich
jedoch auf absolute maximale Laufzeit einer konkreten Implementierung ei-
nes Algorithmus (oder allgemeiner eines Programms) auf einer konkreten
Hardware. WCET-Analyse ist für sicherheits-relevante Steuerungssysteme im
Luftfahrtbereich vorgeschrieben: Beispielsweise muss für den Cabin Pressure
Controller in der Passagierkabine eines Flugzeugs angegeben werden, wie lan-
ge es maximal dauert, bei Erkennung von Kabinenunterdruck den Befehl zum
Auswerfen der Sauerstoffmasken zu geben. Die WCET-Analyse ist heute ein
wichtiges Forschungsgebiet und wird mit Hilfe von Modellprüfungsverfahren
auf dem Maschinencode des Programms und einem mathematischen Modell
der Computer-Hardware durchgeführt. In unserer Vorlesung werden wird uns
mit diesem Thema nicht weiter beschäftigen.

4.2 Laufzeitkomplexität der binären Suche


Problemgröße bestimmen: Für die Bestimmung der Problemgröße
schauen wir auf die Eingaben des Algorithmus, dies sind beispielsweise bei
der binären Suche a und key. Die Größe des Problems ist offensichtlich durch
die Länge a.length des Eingabe-Arrays bestimmt, denn diese hat Einfluss
auf die Anzahl der Schleifen, die in Methode binarySearch0 durchlaufen
werden. Die Instruktionen, in denen key vorkommt, werden durch den kon-
kreten Wert des Parameters in ihrer Ausführungszeit nicht beeinflusst, daher
hat key keinen Einfluss auf die Problemgröße.

Anweisungen zählen: Das Grundprinzip von Berechnungen der Laufzeit-


komplexität beruht auf dem Zählen von Instruktionen, die bis zur Terminie-
rung des Algorithmus durchzuführen sind. Wir wenden dies jetzt auf die
binäre Suche in der Implementierung von Listing 3.1 an, und zwar für den
Worst Case. Die schlechteste Laufzeit erzielt man offensichtlich, wenn die
while-Schleife so oft wie möglich durchlaufen wird. Dies ist der Fall, wenn
niemals die return-Anweisung in Zeile 20 ausgeführt wird. Dies wiederum
ist der Fall, wenn sich der gesuchte Wert key nicht im Array a befindet.
Mit diesen Erkenntnissen können wir folgende Anweisungen zählen:
• 1 Methodenaufruf in Zeile 2

• 2 Variableninitialisierungen in Zeile 8 — 9

21
• k+1 Bedingungsauswertungen der Schleifenbedingung in Zeile 11, wenn
die while-Schleife k mal durchlaufen wird

• Pro Schleifendurchlauf

– Maximal 2 if-Bedingungen in der while-Schleife


– Maximal 3 Zuweisungen in der while-Schleife

• 1 Rückgabeanweisung in Zeile 22

Die Maximalzahl Amax der durchzuführenden Anweisungen ist also nach


der Formel
Amax = 4 + (k + 1) + k · 5 (4.1)
zu berechnen.
Was uns jetzt noch fehlt, ist der Wert k für die maximale Anzahl der
Schleifendurchläufe. Hierzu betrachten wir die while-Schleife und stellen fest,
dass sich der Wert der Differenz high − low bei jedem Schleifendurchlauf
mindestens in ganzzahliger Division halbiert, wenn wir nicht aus der Schleife
in Zeile 20 hinausspringen. Wir können also die Anzahl p berechnen, die
angibt, für wie viele Schleifendurchläufen höchstens die Differenz high − low
noch positiv sein kann. Am Anfang ist diese Differenz gleich der Array-Länge
n = a.length. Nach dem ersten Schleifendurchlauf beträgt die Differenz
höchstens n/2, nach dem zweiten Durchlauf (n/2)/2 = n/4 und nach dem
p-ten Durchlauf 2np . Wir stellen also eine Ungleichung auf, die wir nach p
auflösen:
n
1 ≤
2p
p
2 ≤ n
p ≤ log2 n

Das bedeutet, dass die Differenz high−low nach höchstens (1+log2 n) Schlei-
fendurchläufen Null ist. Danach wird die Schleife nur noch einmal durchlau-
fen, denn entweder wird darin low inkrementiert oder high dekrementiert
(wir nehmen ja an, dass key nicht im Array ist), so dass die Auswertung von
low ≤ high am nächsten Schleifenbeginn in Zeile 11 false ergibt.

22
Zusammengefasst bedeutet dies, dass die Schleife nach maximal k = (2 +
log2 n) Durchläufen terminiert. Mit diesem Wert für k lautet Formel (4.1)

Amax (n) = 5 + 6 · k
= 5 + 6 · (2 + log2 n)
= 17 + 6 · log2 n

Vergleich mit einer linearen Suche: Die Laufzeitkomplexität dient vor


allem als Entscheidungshilfe, welcher Algorithmus aus einer Menge von Algo-
rithmen, die alle dasselbe Problem lösen können, in Bezug auf seine Schnel-
ligkeit zu bevorzugen ist. Um dies an einem trivialen Beispiel zu illustrieren,
betrachten wir daher als Alternative zur binären Suche die lineare Suche (der
Array darf hier unsortiert sein), wie sie in Listing 4.1 dargestellt ist.

Listing 4.1: Lineare naive Suche auf einem Integer-Array.


1 public static int linearSearch ( int [] a , int key ) {
2 for ( int i = 0; i < a . length ; i ++ ) {
3 if ( a [ i ] == key ) return i ;
4 }
5 return -1;
6 }

Offensichtlich sind hier im Worst Case folgende Anweisungen auszuführen


(wir wählen wieder die Bezeichnung n = a.length):

• (n + 1) Auswertungen der Schleifenbedingung in Zeile 2

• (n + 1) Zuweisungen auf die Schleifenvariable i in Zeile 2

• n Auswertungen der if-Bedingung in Zeile 3

• 1 return-Anweisung in Zeile 5

In Summe ergibt das


Lmax = 3 · n + 3 (4.2)
Anweisungen im Worst Case. Um die Worst-Case Laufzeiten von binärer und
linearer Suche vergleichen, betrachten wir den Plot in Abb. 4.1. Offensichtlich
schneidet die binäre Suche viel besser ab, sobald n ≥ 12 gilt.

23
300 3n+3

250

200

150

100

17 + 6 log2 (n)
50

n
0 20 40 60 80 100

Abbildung 4.1: Vergleich der Worst-Case Laufzeit von linearer und binärer
Suche.

Andererseits müssen wir auf diesen Vergleich noch einmal zurück kom-
men, sobald wir über die Running Time von effektiven Sortierverfahren ge-
sprochen haben. Die binäre Suche erfordert schließlich noch, das Array zu
sortieren, was bei der linearen Suche nicht erforderlich ist.
Zumindest kann man aber jetzt schon sagen, dass binäre Suche effekti-
ver als lineare Suche ist, wenn man das Array selten ändert (also selten neu
sortieren muss), aber sehr häufig darauf sucht, und das Array groß ist. Bei
kleinen Arrays ist es offensichtlich sinnvoller, einfach die lineare Suche zu ver-
wenden, auch wenn man mit dem zugehörigen Code niemanden beeindrucken
kann.

4.3 Asymptotische Abschätzung der Worst-


Case Laufzeitkomplexität
Das obige Beispiel “lineare versus binäre Suche” zeigt, dass Komple-
xitätsuntersuchungen für sehr kleine Problemgrößen überflüssig sind, weil
man in solchen Fällen den einfachsten verfügbaren Algorithmus nimmt, da
keine relevanten Laufzeitunterschiede zu intelligenteren Lösungen zu beob-

24
achten sind. Wir untersuchen also lieber Abschätzungen der Worst-Case Run-
ning Time für große Problemgrößen n. Die nachfolgende formale Definition
ist durch folgende Grundüberlegungen motiviert:

• Wenn sich die Laufzeit zweier Algorithmen nur um eine Konstante


Zahl von Instruktionen unterscheidet, schätzen wir die Algorithmen als
gleich gut in Bezug auf ihre Laufzeit ein, denn für beliebige Problem-
größe n wird der eine immer nur dieselbe konstante Zahl zusätzlicher
Instruktionen benötigen, und dies fällt für große n nicht mehr ins Ge-
wicht.

• Diese Überlegung dehnen wir sogar auf den Fall aus, wo sich die Anzahl
der benötigten Instruktionen um einen konstanten Faktor unterschei-
den: Sobald ein um diesen Faktor schnellerer Rechner verfügbar ist,
kann ich das Problem genauso gut lösen wie mit dem schnelleren Algo-
rithmus.

Diese Betrachtungen motivieren folgende Definition:

Definition 1 (O-Notation) Zu gegebener Funktion g : N −→ R definieren


wir die O-Notation “Groß O von g von n” als folgende Funktionsmenge:

O(g(n)) = {f : N −→ R | ∃c ∈ R+ , n0 ∈ N : ∀n ≥ n0 : 0 ≤ f(n) ≤ c · g(n)}.

Ist die Obergrenze der Worst-Case Laufzeit durch eine Funktion f(n) ∈
O(g(n)) beschrieben, so wird die Sprechweise “Die asymptotische Worst-Case
Laufzeit des Algorithmus ist O(g(n))” verwendet. Die Schreibweise

f(n) = O(g(n))

mit “=” anstelle von “∈” wird gelesen als “f(n) ist asymptotisch gleich
g(n)”. 

Um uns die Bedeutung dieser Definition klarzumachen, betrachten wir


zunächst, wie sie die obige Überlegung “konstante Laufzeitdifferenz kann
vernachlässigt werden” widerspiegelt. Nehmen wir also an, dass Algorithmus
Alg1 die Worst-Case Laufzeit g(n) hat, wobei n wieder die Problemgröße
beschreibt. Nehmen wir weiter an, dass Alg2 dasselbe Problem wie Alg1
löst, aber Worst-Case Laufzeit f(n) = g(n) + b hat, wobei b eine konstante

25
natürliche Zahl ist. Da g für alle n größer als Null ist, können wir die positive
reellwertige Funktion
g(n) + b
c(n) =
g(n)
definieren, mit der
∀n ∈ N : f(n) = c(n) · g(n)
gilt, denn

g(n) + b
c(n) · g(n) = · g(n) = g(n) + b = f(n).
g(n)

Diese Funktion c(n) ist nach oben durch eine Konstante beschränkt:

g(n) + b b
∀n ∈ N : c(n) = =1+ ≤1+b
g(n) g(n)

Es gilt also

∀n ∈ N : f(n) = c(n) · g(n) ≤ (1 + b) · g(n).

Damit haben wir bewiesen, dass f(n) = O(g(n)), denn (1 + b) ist eine
Konstante; es gilt also der folgende Satz:

Theorem 1 Unterscheidet sich die Worst-Case Laufzeit zweier Algorithmen


nur durch eine konstante Anzahl von Instruktionen, dann haben beide dieselbe
asymptotische Worst-Case Laufzeit. In O-Notation ausgedrückt:

∀g : N −→ R, b ∈ R : O(b + g(n)) = O(g(n))

Eine triviale Konsequenz aus Theorem 1 ist, dass Algorithmen mit kon- Algorithmen
stanter (d.h. von der Problemgröße unabhängiger) Worst-Case Laufzeit alle mit
dieselbe asymptotische Worst-Case Laufzeit besitzen. Deshalb sagt man von konstanter
solchen Algorithmen einfach, “ihre asymptotische Worst-Case Laufzeit ist Worst-
O(1)”. Man verwendet niemals Bezeichnungen wie O(9) oder O(131), da Case
diese Funktionsmengen alle identisch zu O(1) sind. Als ein wichtiges Beispiel Laufzeit

26
für O(1)-Algorithmen nennen wir hier Algorithmen für prioritätsbasiertes
Scheduling2 und Priority Queues mit beschränkten Prioritäten3 .
Direkt aus der obigen Definition folgt das nächste Theorem, welches zeigt, Konstante
dass sie auch die zweite obige Überlegung (“Unterscheidung durch einen kon- Faktoren
stanten Faktor”) widerspiegelt. ändern
nicht die
Theorem 2 Unterscheidet sich die Worst-Case Laufzeit zweier Algorithmen asympto-
nur durch einen konstanten Faktor, dann haben beide dieselbe asymptotische tische
Worst-Case Laufzeit. In O-Notation ausgedrückt: Komple-
xität
∀g : N −→ R, c ∈ R+ : O(c · g(n)) = O(g(n))

Jetzt betrachten wir Worst-Case Laufzeiten, die durch Polynome Asymptotische


Komple-
g(n) = am nm + am−1 nm−1 + · · · + a1 n + a0 xität bei
Polyno-
beschrieben werden können. Der höchste Exponent m bezeichnet den Grad men
des Polynoms. In Abb. 4.2 sieht man am Beispiel der Polynome g(n) =
1.5 · n3 und g 0 (n) = n3 + n2 , dass der Summand n2 keinen so großen Beitrag
zur Differenz zwischen den beiden Funktionen liefert: Es genügt, den n3 -
Summanden mit dem Faktor c = 1.5 zu multiplizieren, um für genügend
große n die Gültigkeit der Abschätzung c · n3 > n3 + n2 zu garantieren. Nur
für kleine n ist n3 + n2 > c · n3 (Abb. 4.3). Bei n = 2 gilt n3 + n2 = c · n3 ,
und von da an c · n3 > n3 + n2 .
2
Ein Scheduler, ist eine Komponente des Betriebssystems, welche Prozessen und
Threads CPU-Cores zuteilt. Prozesse und Threads können unterschiedliche Priorität be-
sitzen. Solche mit hoher Priorität bekommen häufiger und ggf. länger die CPU, wenn sie
rechenbereit sind. In der Vergangenheit gab es Implementierungen von Scheduling Algo-
rithmen, die asymptotische Worst-Case Laufzeit O(n) hatten, wobei n die Anzahl der
rechenbereiten Prozesse ist. Diese Scheduler wurden also immer langsamer, je mehr re-
chenbereite Prozesse sich um die CPU bewarben. Furchtbar! Ich hab das noch mit eigenen
Augen mit ansehen müssen. Seit etwa 20 Jahren sind O(1)-Scheduler bekannt.
3
werden wir in Session 3 kennenlernen

27
1.5 × 1027 1.5 n 3

1.0 × 1027 n3 + n2

5.0 × 1026

n
2 × 108 4 × 108 6 × 108 8 × 108 1 × 109

Abbildung 4.2: Vergleich des Wachstums von Polynomen g(n) = 1.5 · n3 und
g 0 (n) = n3 + n2 .

6
n3 + n2

5 1.5 n 3

n
0.2 0.4 0.6 0.8 1.0 1.2 1.4

Abbildung 4.3: Vergleich der Polynome g(n) = 1.5 · n3 und g 0 (n) = n3 + n2


im Argumentbereich n ∈ [0, 1.5].

28
Wir schließen aus dieser Beobachtung, dass die Polynom-Summanden
a` n` bei asymptotischen Betrachtungen keine Rolle spielen, sobald ` klei-
ner als der Grad des Polynoms ist. Das folgt auch wirklich aus dem nächsten
Lemma, welches allgemein für Polynome P : R −→ R; x 7→ an xn +an−1 xn−1 +
· · · + a1 x + a0 gilt.

Lemma 1 Sei

P(x) = an xn + an−1 xn−1 + · · · + a1 x + a0

ein Polynom n-ten Grades über reellwertigen Koeffizienten ai . Dann existiert


eine Konstante R(1), so dass gilt:

∀x ∈ (R(1), ∞) : |P(x)| ≤ (|an | + 1) · |xn | (4.3)

Beweis: Für x 6= 0 können wir folgende Umrechnung vornehmen:


n
P(x) an x +an−1 xn−1 +···+a1 x+a0
xn = xn
a
a

a0
= an + n−1 x
+ · · · + xn−1
1
+ n
x
≤ an | + | an−1 + · · · + n−1
a1
x x x
+ an0 [Dreiecksungleichung]
≤ |an | + 1 für alle x > R(1)

Die Existenz der Konstanten R(1) folgt aus der Grenzwertbetrachtung


an−1 a1 a0
lim + · · · + n−1 + n = 0.
x→∞ x x x
Daher gibt es für jedes  > 0 ein R() so dass | an−1x
+ ··· + a1
xn−1
+ a0
xn
| ≤
 für alle x > R(). Aus

P(x)
xn ≤ |an | + 1 für alle x > R(1)

folgt direkt
|P(x)| ≤ (|an | + 1) · xn für alle x > R(1),
was zu beweisen war. 
Aus diesem Lemma folgt sofort das nächste Theorem über asymptotische
Worst-Case Abschätzungen der Laufzeit:

29
Theorem 3 Wird die Worst-Case Laufzeit zweier Algorithmen durch zwei
verschiedene Polynome desselben Grades beschrieben, dann haben beide die-
selbe asymptotische Worst-Case Laufzeit. In O-Notation ausgedrückt:

∀m ∈ N, g(n), g 0 (n) ∈ Polynome vom Grad m : O(g 0 (n)) = O(g(n))

Aufgrund dieses Theorems schreibt man polynomielle asymptotische


Worst-Case Laufzeiten immer nur in der Form O(nm ), ohne jemals Koef-
fizienten oder Summanden mit niedrigerer Potenz anzugeben.
Zum Schluss werden noch einige Beispiele für Algorithmen mit polyno-
mieller Worst-Case Laufzeitkomplexität genannt:

• Die oben eingeführte naive lineare Suche benötigt O(n).

• Einige Sortieralgorithmen (zum Beispiel das nachher eingeführte Quick-


sort) haben asymptotische Worst-Case Laufzeit O(n2 ).

• Die (naive) Multiplikation von Matrizen benötigt O(n3 ).

Bei der binären Suche haben wir oben die Worst-Case Laufzeit 17 + 6 · Logarithmische
log2 n berechnet. Aus den bisherigen asymptotischen Betrachtungen kann asympto-
man bereits sagen, dass sich dies als O(log2 n) bezeichnen ließe. Es geht tische
jedoch noch einfacher: Für große n unterscheiden sich Logarithmen zu un- Worst-
terschiedlicher Basis gar nicht so sehr, siehe Abb. 4.4. Case
Beispielhaft können wieder Faktoren c gefunden werden, so dass für Laufzeit
genügend große n beispielsweise c · log10 n ≥ log2 n gilt (siehe Beispiel in
Abb. 4.5). Dies lässt sich auch allgemein beweisen; es folgt direkt aus diesem
Lemma, das aus der Analysis bekannt ist:

Lemma 2 Für beliebige a, b ∈ R+ gilt:

∀x > 0 : loga x = loga b · logb x (4.4)

Der Logarithmus zur Basis a lässt sich also durch den Logarithmus zur
Basis b, multipliziert mit der Konstanten loga b ausdrücken.

30
30 log2 (n)

25

log(n)
20

15

10
log10 (n)

2 × 108 4 × 108 6 × 108 8 × 108 1 × 109

Abbildung 4.4: Vergleich des Wachstums der Logarithmen zur Basis 2, e, 10.
Hier bezeichnet log(n) den Euler’schen Logarithmus (Basis e).

2 log(n)
40

4 log10 (n)

30 log2 (n)

20

10

x
2 × 108 4 × 108 6 × 108 8 × 108 1 × 109

Abbildung 4.5: Vergleich des Wachstums der Logarithmen zur Basis 2, e, 10,
multipliziert mit unterschiedlichen Faktoren.

31
Diese Erkenntnis führt zum nächsten Theorem über asymptotische Kom-
plexität:

Theorem 4 Wird die Worst-Case Laufzeit zweier Algorithmen durch Funk-


tionen d + c · loga x und d 0 + c 0 · logb x beschrieben, dann haben beide dieselbe
asymptotische Worst-Case Laufzeit. In O-Notation ausgedrückt:

∀a, b ∈ R+ : O(loga x) = O(logb x)

Man verwendet bei logarithmischen O-Ausdrücken daher niemals eine


Basis, sondern schreibt schlicht O(log n) (“Logarithmus zu beliebiger Ba-
sis”). Typische Suchverfahren wie die binäre Suche haben also asymptotische
Worst-Case Running Time O(log n).
Wenn man beweisen möchte, dass ein neuer Algorithmus in einer “bes- Beweisbeispiel:
seren” Komplexitätsklasse liegt als ein bereits bekannter für dasselbe Pro- O(2n ) 6=
blem, ist häufig nachzuweisen, dass für verschiedene g(n), g 0 (n) auch wirk- O(4n )
lich O(g(n)) verschieden von O(g 0 (n)) ist. Als Beispiel zeigen wir hier, dass
O(2n ) nicht identisch zu O(4n ) ist.
Hierzu nehmen wir das Gegenteil an und leiten einen Widerspruch her:

O(2n ) = O(4n ) [Annahme]


⇒ ∃c ∈ R, n0 ∈ N : ∀n > n0 : c · 2n ≥ 4n
[Definition der O-Notation]
2n · 2n
⇒ ∃c ∈ R, n0 ∈ N : ∀n > n0 : c ≥
2n
n n n n
[4 = (2 · 2) = 2 · 2 ]
⇒ ∃c ∈ R, n0 ∈ N : ∀n > n0 : c ≥ 2n
[Widerspruch: c ist konstant und 2n wächst unbeschränkt]

Folgende weitere Komplexitätsklassen werden wir im Laufe dieser Veran- Weitere


staltung kennenlernen: Komple-
xitätsklassen
• O(n · log n) – superlineare Laufzeit, typisches Beispiel: Sortieralgorith-
men

• O(2n ) – exponentielle Laufzeit, typisches Beispiel: SAT Solver (siehe


Session über DPLL Algorithmus am Ende des Semesters)

32
• O(n!) – faktorielle Laufzeit, typisches Beispiel: naive Lösung des Tra-
velling Salesman Problems4
n
• O(22 ) – doppelt exponentielle Laufzeit, typisches Beispiel: Entschei-
dung des Wahrheitswertes einer Aussage in Presburger Arithmetik5

In Abb. 4.6 sind die Wachstumsfunktionen der o.g. Komplexitätsklassen


zum Vergleich dargestellt. Die stärker wachsenden Funktionen sind nochmal
für einen größeren Wertebereich in Abb. 4.7 dargestellt. Sie sehen, das für
Algorithmen von exponentieller oder noch höherer Komplexität eine geringe
Vergrößerung der Eingabemenge katastrophale Verlängerungen der Worst-
Case Laufzeit zur Folge haben kann. Da hilft nur forschen, unter welchen
Bedingungen der Worst-Case nicht eintritt und hoffen, dass genügend viele
praktisch relevante Fälle diesen Bedingungen genügen!

n
22

n!
1500
4n

2n

1000 n3

n2

n log2 (n)
500
n

log2 (n)

n
2 4 6 8 10 1

Abbildung 4.6: Vergleich der wichtigstens Wachstumsfunktionen.


4
https://en.wikipedia.org/wiki/Travelling_salesman_problem
5
Presburger Arithmetik ist eine Aussagenlogik erster Stufe, in der eingeschränkte arith-
metische Ausdrücke erlaubt sind. Aussagen in dieser Logik sind alle entscheidbar. Siehe
https://en.wikipedia.org/wiki/Presburger_arithmetic

33
n
10300 22

n!

4n

10200 2n

10100

n3

n2
5 10 50 100 500 1000

Abbildung 4.7: Vergleich der wichtigstens Wachstumsfunktionen für größere


n (doppelt logarithmische Skala).

4.3.1 Weiterführende Literatur


Lesen Sie zu diesem Abschnitt die ausführlicheren Beschreibungen der asym-
ptotischen Abschätzungen in [2, 3.1].

34
Kapitel 5

Sortieren von Arrays

In den vorausgegangenen Kapiteln haben wir gesehen, dass Suchen

• sehr effektiv auf Arrays durchgeführt werden kann und

• besonders schnell geht (O(log n)), wenn das Array sortiert ist.

Infolgedessen werden wir uns jetzt mit Sortierverfahren auf Arrays


beschäftigen. Dabei müssen wir auch das Thema Worst-Case Running Ti-
me im Auge behalten: Wir brauchen noch ein Entscheidungskriterium, wann
sich der Aufwand für Transformation einer Menge oder Liste in ein Array,
Sortieren des Arrays und Anwendung der binären Suche lohnt, wo doch die
naive lineare Suche viel einfacher zu programmieren ist und keine Sortierung
benötigt. Diese Frage wird in Abschnitt 6 beantwortet.
Wir werden zwei Sortieralgorithmen kennenlernen, welche die besten Ei-
genschaften für die praktische Anwendung haben:

• Quicksort

• Mergesort

Beide werden in einer etwas neueren Variante im JDK, Klasse Arrays.java


implementiert. Wir nehmen hier die klassischen Implementierungen durch.

35
5.1 Quicksort
5.1.1 Der Algorithmus
Quicksort ist wohl das bekannteste Sortierverfahren, vielleicht auch deshalb,
weil es so beeindruckend elegant ist, auf jeden Fall aber, weil es auch heute
noch als das “in der Regel” schnellste Verfahren bei minimalem Speicher-
verbrauch gilt (siehe die Komplexitätsdiskussion weiter unten). Es wurde
von Sir Tony Hoare erfunden, einem der berühmtesten Informatiker unserer
Zeit.1 Ein entscheidender Vorteil von Quicksort ist, dass die Sortierung auf
dem Eingabe-Array abläuft: Es sind keine internen Kopien des Arrays oder
eines Teils davon erforderlich, und es ist keine Kopie in ein Rückgabe-Array
notwendig. Das Quicksort-Verfahren wird in vielen Varianten beschrieben,
wir stellen hier die von Aho et al. [1, 8.3] vor, die in Listing 5.1 abgebildet
ist.
Wir erläutern im folgenden die Funktionsweise des Algorithmus und be- Videos
gründen seine Korrektheit. Das erfordert intensives Mitdenken, aber am
Schluss sollte sich die Überzeugung einstellen, dass dies ein korrekter und
sehr eleganter Algorithmus ist. Falls Sie eine besonders einfache Einführung
in Quicksort suchen, schauen Sie doch wieder bei den Integer.MAX VALUE
vielen Videos auf YouTube unter dem Stichwort “Quicksort Java” nach, zum
Beispiel ist https://www.youtube.com/watch?v=mN5ib1XasSA gar nicht so
schlecht, weicht aber leicht von unserer Version in Listing 5.1 ab.
1
Ich kenne ihn persönlich, er ist nicht nur ein Genie, sondern auch ungeheuer nett.

36
Listing 5.1: Quicksort auf int-Arrays – Implementierung nach Aho et al. [1].
1 public static void quicksort ( int [] a ) {
2 if ( a == null || a . length ==0) {
3 return ;
4 }
5 quicksort (a , 0 , a . length -1) ;
6 }
7
8 private static void quicksort ( int [] a , int lb , int ub ) {
9 int pIdx = getPivotIdx (a , lb , ub ) ;
10 if ( pIdx == -1 ) return ;
11 int p = a [ pIdx ];
12 int lo = lb ;
13 int hi = ub ;
14
15 while ( lo < hi ) {
16 swap (a , lo , hi ) ;
17 while ( a [ lo ] < p ) lo ++;
18 while ( a [ hi ] >= p ) hi - -;
19 }
20 quicksort (a , lb , lo -1) ;
21 quicksort (a , lo , ub ) ;
22 }

Listing 5.2: Quicksort Hilfsfunktionen für die Implementierung in Listing 5.1.


1 private static int getPivotIdx ( int [] a , int lb , int ub ) {
2 int aux = a [ lb ];
3 for ( int i = lb ; i <= ub ; i ++ ) {
4 if ( aux < a [ i ] )
5 return i ;
6 else if ( a [ i ] < aux )
7 return lb ;
8 }
9 return -1;
10 }
11
12 private static void swap ( int [] a , int i , int j ) {
13 int aux = a [ i ];
14 a [ i ] = a [ j ];
15 a [ j ] = aux ;
16 }

Das Interface für den Quicksort-Algorithmus ist in Listing 5.1, Zeile 1 Interface
abgebildet. Ähnlich wie bei der binären Suche betrachten wir die einfachste für int[]-
Version von zu sortierenden Datentypen: ein Array von int-Werten. Analog Sortierung

37
zur binären Suche lässt sich dies recht einfach auf Arrays von Objekten mit
natürlicher Ordnung und auf Arrays, deren Elemente mit einem Komparator
versehen sind, erweitern. Das sollen Sie bitte selbst in Aufgaben 8.2.2 und
8.2.3 programmieren (unbedingt machen, kommt in der Klausur vor . . . ).
Im Interface wird nur geprüft, ob überhaupt etwas zu tun ist: wenn das
Array null ist oder die Länge 0 hat, können wir schon Feierabend machen
(Zeile 2 — 4). Wenn’s wirklich ‘was zu tun gibt, wird die Arbeit an Metho-
de void quicksort(int[] a, int lb, int ub) delegiert, deren Parame-
ter folgende Bedeutung haben:

• a ist das ursprüngliche Array

• lb ist ein gültiger Array Index im Wertebereich 0..a.length - 1.


“lb” steht für lower index bound.

• ub ist ein gültiger Array Index im Wertebereich -1..a.length - 1.


“ub” steht für upper index bound.

Diese Methode realisiert folgende Nachbedingungen, wie wir im folgenden


schrittweise erläutern werden.

Post-QS-1: Wenn quicksort(a,lb,ub) terminiert, ist das Array a im Nachbedingung 1


Index-Bereich lb..ub aufsteigend sortiert. von
quicksort(a,lb,ub)
Post-QS-2: Wenn quicksort(a,lb,ub) terminiert, ist das Array a ausser- Nachbedingung 2
halb des Index-Bereichs lb..ub unverändert. von
quicksort(a,lb,ub)
Da die Hauptmethode den Aufruf quicksort(a,lb,ub) in Zeile 5 mit
lb = 0 und ub = a.length -1 macht, impliziert Nachbedingung 1, dass
das Array aufsteigend sortiert ist, wenn der Aufruf terminiert. Die zwei-
te Nachbedingung wird erst relevant, wenn wir die rekursiven Aufrufe von
quicksort(int[] a, int lb, int ub) studieren.
Bevor wir gleich den Code von void quicksort(int[] a, int lb, int
ub) im Detail studieren, spezifizieren wir folgende weitere Eigenschaften, die
von der Methode erfüllt werden (das erläutern wir gleich Schritt für Schritt).

Property-QS-1 Wenn alle Array-Elemente im Index-Bereich lb..ub gleich


sind (also a[lb] == a[lb+1] == ...== a[ub]), terminiert die Me-
thode, ohne a zu verändern.

38
Property-QS-2 Wenn die rekursiven Aufrufe von quicksort in Zei-
le 20 und 21 erfolgen, sind alle Elemente aus dem Array-Abschnitt
a[lb]..a[lo-1] kleiner als alle Elemente aus dem Array-Abschnitt
a[lo]..a[ub]. Etwas formaler:
∀x ∈ {a[lb], . . . , a[lo − 1]}, y ∈ {a[lo], . . . , a[ub]} : x < y

Jetzt gehen wir den Methodenkörper (Zeile 9 — 22) stückweise durch: Schrittweise
Erläuterung
1. In Zeile 9 wird eine Hilfsmethode getPivotIdx aufgerufen, deren Co- von
de in Listing 5.2 dargestellt ist. Diese Methode erfüllt folgende Nach- quicksort(a,lb,ub)
bedingungen, wie man aus der Implementierung in Listing 5.2 sofort
entnehmen kann.
Post-PIVOT-1: Wenn alle Elemente a[lb]..a[ub] identisch sind,
dann ist der Rückgabewert (−1).
Post-PIVOT-2: Wenn nicht alle Elemente a[lb]..a[ub] iden-
tisch sind, dann wird ein Index pIdx im Bereich lb..ub
zurückgegeben, so dass a[pIdx] nicht das kleinste Element un-
ter allen a[lb]..a[ub] ist. Etwas formaler:
∃` ∈ lb..ub : a[`] < a[pIdx]

Das Element a[pIdx] heißt Pivotelement, und pIdx heißt Pivotin-


dex des Array-Abschnitts a[lb]..a[ub].
2. Wenn der Rückgabewert von getPivotIdx den Wert (−1) hat, gibt es
offensichtlich nichts zu sortieren, weil alle Werte a[lb]..a[ub] iden-
tisch sind (es wird unten nachgewiesen dass getPivotIdx niemals mit
lb > ub aufgerufen wird). Methode quicksort terminiert also in Zei-
le 10. Dies zeigt die oben spezifizierte Eigenschaft Property-QS-1.
Beachten Sie, dass dies insbesondere in dem trivialen Fall angewendet
wird, wo lb == ub gilt, die Menge a[lb]..a[ub] also nur noch aus
einem Element besteht.
3. Wenn ein Pivotindex gefunden wurde, wird das Pivotelement in Zeile 11
auf lokale Variable p zugewiesen.
4. In Zeile 12 — 13 werden zwei Indices lo = lb und hi = ub initiali-
siert. Diese werden folgendermaßen in der nachfolgenden while-Schleife
verwendet:

39
• lo läuft von lb an “nach rechts”, also in aufsteigender Richtung.
• hi läuft von ub an “nach links”, also in absteigender Richtung.

5. Das Ziel der while-Schleife in Zeile 15 — 19 ist, die oben spezifizierte


Bedingung Property-QS-2 zu realisieren. Dies geschieht folgenderma-
ßen:

(a) Zu Beginn wird in Zeile 16 der Inhalt von a[lo] und a[hi] ver-
tauscht – das wird unten weiter diskutiert.
(b) In der inneren while-Schleife in Zeile 17 wird lo so lange erhöht,
bis a[lo] größer oder gleich dem Pivot-Element ist. Diese Schleife
terminiert immer:
• Beim ersten Durchlauf der äußeren while-Schleife, weil initial
lo == lb gilt und pIdx ≥ lb garantiert ist.
• Bei den folgenden Durchläufen, weil lo < hi aufgrund der
Schleifenbedingung Zeile 15 erfüllt ist und weil nach dem Auf-
ruf von swap in Zeile 16 immer a[lo] < p ∧ p ≤ a[hi] gilt –
das wird gleich weiter vertieft.
Was können wir über die Nachbedingung bei Terminierung der
Schleife sagen? Auf jeden Fall ist a[lo] ≥ p – dies folgt direkt aus
der Terminierungsbedingung. Des Weiteren lässt sich aus der Ter-
minierungsbedingung sofort folgern, dass entweder lo == lb und
a[lb] ≥ p gilt, oder aber lo ist wirklich in der Schleife inkremen-
tiert worden, sodass die vorigen Elemente a[lo-i] noch kleiner
als p sind. lo kann niemals größer als ub werden, denn es wird das
Pivotelement im Indexbereich oberhalb des Anfangswertes von lo
gefunden. Zusammengefasst ergibt dies folgende Nachbedingung:

a[lo] ≥ p ∧ lo ≤ ub ∧ ∀i ∈ lb..(lo − 1) : a[i] < p




6. Als nächstes analysieren wir die while-Schleife in Zeile 18. Hier wird
hi so lange dekrementiert, bis a[hi] < p erfüllt ist. Auch diese Schleife
terminiert immer:

• Beim ersten Durchlauf der äußeren while-Schleife, weil irgendein


Element a[j] existiert, so dass j ∈ lb..ub und a[j] < p gilt.

40
• Bei den folgenden Durchläufen, weil die Schleifenbedingung in Zei-
le 15 lo < hi garantiert und swap in Zeile 16 die Nachbedingung
a[lo] < p realisiert.

Analog zur Nachbedingung der Schleife über lo ergibt sich folgende


Nachbedingung bei Terminierung der Schleife über hi:

a[hi] < p ∧ lb ≤ hi ∧ ∀i ∈ (hi + 1)..ub : a[i] ≥ p




7. Da die Schleife in Zeile 17 nur lo verändert und die in Zeile 18 nur hi,
sind am Ende der äußeren Schleife (Zeile 19) beide Nachbedingungen
erfüllt.

8. Im Fall lo < hi wird ein weiterer Zyklus der äußeren Schleife durch-
geführt. Da die Nachbedingungen der inneren Schleifen

a[lo] ≥ p ∧ a[hi] < p

garantieren, gilt nach der Ausführung von swap in Zeile 16

a[lo] < p ∧ a[hi] ≥ p

Das ist genau die Vorbedingung, welche wir – wie oben diskutiert –
für die Terminierung der beiden inneren while-Schleifen benötigen.
Weiterhin ist wegen dieser Vorbedingung garantiert, dass in den in-
neren Schleifen lo um mindestens 1 inkrementiert und hi um min-
destens 1 dekrementiert wird. Damit ist auch die Terminierung der
äußeren Schleife garantiert.

9. Im Fall lo ≥ hi terminiert die Schleife. Die Nachbedingungen der bei-


den inneren Schleifen implizieren dann

∀i ∈ lb..(lo − 1) : a[i] < p ∧ a[lo] ≥ p ∧




∀i ∈ (hi + 1)..ub : a[i] ≥ p ∧ a[hi] < p ∧




lo ≥ hi

Hieraus können wir folgern, dass lo == hi + 1 gilt. Damit haben wir


das Array a im Abschnitt a[lb]..a[ub] in zwei Teile mit folgenden
Eigenschaften partitioniert:

41
• Die “linke” Partition umfasst den Indexbereich lb..(lo-1) und
hat die Eigenschaft ∀i ∈ lb..(lo − 1) : a[i] < p Da es mindestens
ein Element im Array-Abschnitt a[lb]..a[ub] gibt, das echt klei-
ner als das Pivotelement ist, muss die Partition a[lb]..a[lo-1]
mindestens ein Element enthalten.
• Die “rechte” Partition umfasst den Indexbereich lo..ub und hat
die Eigenschaft ∀i ∈ lo..ub : a[i] ≥ p Auch diese Partition muss
mindestens ein Element enthalten, nämlich das Pivotelement.

Hieraus folgt, dass die oben spezifizierte Property-QS-2 erfüllt ist.

10. Weiterhin ist offensichtlich, dass quicksort(a,lb,ub) die oben spezifi-


zierte Nachbedingung Post-QS-2 erfüllt: Die Methode ändert das Array
nur in der Methode swap, und bei jedem Aufruf ist garantiert, dass die
Indizes der zu vertauschenden Elemente im Bereich lb..ub liegen.

11. Jetzt können wir zusammenfassend schließen:

(a) Die Rekursionen terminieren immer, denn bei jeder Partitionie-


rung werden die rekursiven Aufrufe auf kleinere Array-Abschnitte
angewendet, und es erfolgt spätestens dann kein rekursiver Aufruf
mehr, wenn die Partition nur noch ein Element enthält (Zeile 9 —
10, Eigenschaft von getPivotIdx).
(b) Bei Terminierung einer Rekursion enthält die zuletzt betrachtete
Array-Partition P nur identische Elemente, ist also trivialerweise
sortiert.2
(c) Bei Aufruf von quicksort in Zeile 20 und 21 sind alle Elemen-
te der ersten Array-Partition kleiner als alle Elemente der zwei-
ten Array-Partition (Property-QS-2). Partitionen, die noch weiter
links oder noch weiter rechts stehen, wurden laut Post-QS-2 nicht
verändert. Infolgedessen hat bei der Terminierung einer Rekursi-
on die zuletzt bearbeitete Array-Partition P “links von sich” nur
Partitionen (wenn es solche gibt) mit Elementen, die echt kleiner
als diejenigen von P sind und “rechts von sich” nur Partitionen
2
Wenn also die Elemente des Arrays paarweise verschieden sind, terminieren alle Re-
kursionen immer bei Partitionsgröße |P| = 1.

42
(wenn es solche gibt), deren Elemente größer als die von P sind.
Damit ist die aufsteigende Sortierung des Arrays bewiesen.3

3
Trompeten und Geigen spielen etwas sieghaft-symphonisches, eine Wissenschaftlerin
reitet mit wehendem Staubmantel in den Sonnenuntergang, die Leser*innen wischen sich
Freudentränen aus den Augenwinkeln.

43
5.1.2 Komplexitätsaspekte von Quicksort
Worst-Case Running Time
Ein Kritikpunkt an Quicksort ist, dass seine Worst-Case O(n2 ) beträgt,
während die von Mergesort nur O(n log n) ist4 . Die Berechnung der Worst-
Case Laufzeit geht wie folgt:

1. Mit ein bisschen Überlegung kommt man darauf, dass der Worst-Case
auftritt, wenn immer so ungünstig partitioniert wird, dass die eine Par-
tition nur die Länge 1 hat. Hat das Array die Länge n, benötigt man
etwa (n − 1) Schritte in der while-Schleife, um die große Partition
zu bestimmen und einen Schritt, um die kleine Partition zu bestim-
men. Die Weiterverarbeitung der kleinen Partition terminiert sofort,
die große Partition hat die Länge n − 1.

2. Setzten wir dieses Schema fort, bekommt man folgende Anzahl von
Verarbeitungsschritten:

Rekursion Nr. Große Partition Kleine Partition


1 n − 1 Schritte 1 Schritt
2 n − 2 Schritte 1 Schritt
3 n − 3 Schritte 1 Schritt
... ... ...
n−1 n − (n − 1) Schritte 0

3. Wenn wir dies aufsummieren, kommen wir auf

X
n−1
!
(n − 1) · n − i + (n − 2)
i=1

Verarbeitungsschritte. Der Summenterm lässt sich vereinfachen, es gilt:


P n−1 1
i=1 i = 2 · (n − 1) · n. Wir rechnen also für die Worst-Case Anzahl
A der Verarbeitungsschritte
4
Die Fans von Quicksort sagen “Der Worst Case tritt ja nie auf in der Praxis, und darum
läuft Quicksort meistens schneller als Mergesort, und außerdem lässt sich Quicksort besser
tanzen . . . ”

44
X
n−1
!
A = (n − 1) · n − i + (n − 2)
i=1
 
1
= (n − 1) · n − · (n − 1) · n + (n − 2)
2
1
= · (n − 1) · n + n − 2
2
1 2 1
= n + n−2
2 2

Hieraus folgt:
Die asymptotische Worst-Case Running Time von Quicksort be-
trägt O(n2 ).

In Frage 8.1.9 sollen Sie sich bitte überlegen, in welcher (sehr einfachen!)
Situation dieser Worst-Case auftritt.

Best-Case Running Time


Die kürzeste Laufzeit für Quicksort ergibt sich, wenn jede Partitionierung
genau in der Mitte des Array-Abschnitts passiert. Im ersten Durchlauf teilen
wir also den Array der Länge n in zwei Partitionen der Länge n2 . Der Aufwand
hierfür ist etwa n. In der ersten Rekursionsstufe läuft quicksort(a,lb,ub)
zwei mal, jedesmal mit einem Array-Abschnitt der Länge n2 . Es werden
insgesamt 4 Partitionen der Länge n4 erzeugt; der Aufwand für die beiden
Ausführungen (ohne Unterrekursionen) ist wieder etwa n. In der k-ten Rekur-
sionsstufe bekommen alle 2k Aufrufe von quicksort(a,lb,ub) einen Array-
Abschnitt der Länge 2nk . Wieder beträgt der Aufwand für alle Aufrufe der
Rekursionsstufe k insgesamt n.
Es wird immer eine neue Rekursionsstufe erzeugt, solange die Partitions-
größe 2nk größer als 1 ist. Daher berechnen wir die Maximalzahl der Rekur-
sionen durch

45
n
= 1
2k

n = 2k

k = log2 n

Auf jeder Rekursionsstufe sind etwa n Anweisungen durchzuführen. Wir


erhalten also
Die asymptotische Best-Case Running Time von Quicksort beträgt
O(n · log n).

In Abb. 5.1 ist noch einmal veranschaulicht, wie viel stärker die Laufzeit
im schlimmsten Fall mit n wächst, und wie sie im besten Fall relativ sanft
mit n steigt.

10 000 n2

8000

6000

4000

2000

n log2 (n)
n
0 20 40 60 80 100

Abbildung 5.1: Worst-Case Running Time O(n2 ) versus Best-Case Running


Time O(n log n) von Quicksort.

46
Mittlere Laufzeit
Studieren Sie bitte den Abschnitt [2, 7.2], dort wird sehr anschaulich
erläutert, dass die Performance von Quicksort im Mittel nahe an der Best-
Case Running Time liegt, und nicht etwa “in der Mitte zwischen Worst-Case
und Best-Case”. Infolgedessen kann man sich “meistens” auf die exzellente
Performance von Quicksort verlassen.

5.1.3 Optimierungen von Quicksort im OpenJDK


Im OpenJDK werden in der Klasse Arrays mehrere Sortiermethoden ange-
boten, die optimierte Versionen von Quicksort und Mergesort implementie-
ren. Die eigentliche Implementierung des optimierten Quicksort ist in Datei
java.base/share/classes/java/util/DualPivotQuicksort.java zu fin-
den. Die entscheidenden Ideen dort sind:

• Der Algorithmus lässt sich parallelisiert auf mehreren CPU-Cores


gleichzeitig ausführen. Wir werden damit in einer separaten Session 2k
experimentieren.

• Der Algorithmus verwendet Varianten von Insertionsort (siehe [2,


2.1]) für kleine Array-Abschnitte, weil dieses sehr einfach implementiert
werden kann und daher für geringe Problemgrößen geeigneter ist als
Quicksort und Mergesort.

• Es wird eine Heuristik verwendet, die erkennen soll, ob das Sortierpro-


blem zu einer Worst-Case O(n2 )-Situation führt. In solch einem Fall
wird auf Mergesort oder Heapsort [3, 2.3] “umgeschaltet”.

• Der entscheidende Gedanke besteht darin, zwei Pivotelemente p1 < p2


anstelle von einem zu verwenden. Zunächst wird dann lo in einer
while-Schleife inkrementiert, bis ein Element größer oder gleich p1 ge-
funden wird. Dann wird hi dekrementiert, bis p2 unterschritten wird.
Danach wird versucht, mit möglichst wenig Aufwand die Bereiche “klei-
ner p1” und “größer p2” durch Swap-Operationen zu vergrößern, so
dass man beim nächsten rekursiven Aufruf eine bessere Vorsortierung
erhält.

• Der Fall mit nur einem Pivotelement wird weiterhin verwendet, wenn
viele gleiche Elemente im Array sind. Das Pivotelement wird aber etwas

47
aufwändiger identifiziert als bei der Variante von Aho et al., indem man
ein Element wählt, welches weder das kleinste noch das größte ist.

5.2 Stabile Sortierverfahren


Ein Sortierverfahren heißt stabil, wenn im Eingabe-Array aufeinanderfolgen-
de gleiche Objekte nicht umsortiert werden, sondern in der ursprünglichen
Reihenfolge stehen bleiben. Quicksort ist kein stabiles Sortierverfahren, Mer-
gesort schon.

5.3 Mergesort
Mergesort ist die Alternative zu Quicksort mit folgenden Vor- und Nachteilen.

Vorteil Der Hauptvorteil besteht darin, dass die Laufzeit auch im Worst-
Case O(n log n) ist, so dass man sich nicht vor seltenen, aber dann sehr
unangenehmen, Ausreißern in der Laufzeit fürchten muss.

Nachteil Der Nachteil gegenüber Quicksort besteht darin, dass die Sortie-
rung nicht “in place” auf dem Eingangsarray gemacht wird, sondern
ein internes Hilfsarray benötigt wird, um Zwischenstadien der Sortie-
rung abzuspeichern. Das sortierte Endergebnis muss dann aus dem Zwi-
schenspeicher wieder in das Eingabearray kopiert werden.

Lesen Sie in [3, 2.4], wie das Mergesort-Verfahren funktioniert. Falls Sie
Interesse daran haben, wie man die Verwendung des Zwischenspeichers ver-
meiden kann, lesen Sie die Diskussion in [4, 2.2.1] – das geht jedoch über den
Vorlesungs- und Prüfungsstoff hinaus.

48
Kapitel 6

Laufzeit-Gesamtbetrachtung:
Transformation in Arrays,
Sortieren, Suchen

Wenn wir jetzt die erforderlichen Worst-Case Laufzeiten für das `-malige
Suchen in einer Menge oder Liste mit n Elementen aufsummieren, kommen
wir auf folgende Anzahl von Instruktionen:

1. Liste oder Menge der Länge n in ein Array transformieren: n Anwei-


sungen

2. Array sortieren: etwa n · log n Anweisungen

3. Im Array ` mal suchen: ` mal binäre Suche mit log n Anweisungen

Dies ergibt in Summe

n + n · log n + ` · log n = n + (n + `) · log n Anweisungen

Wenn wir dagegen die naive Suche auf einer Liste oder Menge betreiben,
können wir dies mit n Anweisungen realisieren1 , so dass ` Suchvorgänge ` · n
Anweisungen erfordern. In Abb. 6.1 ist die Worst-Case Laufzeit für beide
Fälle in Abhängigkeit von der Anzahl ` der Suchvorgänge aufgetragen; es
wurde dabei n = 109 angenommen.
1
werden wir uns in Session 3 im Zusammenhang mit Listen klarmachen

49
1 × 1011 109 ell

8 × 1010

6 × 1010

4 × 1010
109 + 109 + ell log2 109 

2 × 1010

ell
20 40 60 80 100

Abbildung 6.1: Vergleich der `-maligen linearen Suche mit der Worst-Case
Laufzeit der `-maligen Suche auf sortiertem Array.

An diesem Beispiel sehen wir, dass sich die binäre Suche auf sortiertem
Array lohnt, wenn wir mehr als etwa 30 Suchvorgänge haben, bevor das Array
aufgrund von Änderungen neu erstellt und sortiert werden muss. Wenn wir
100 mal auf demselben sortierten Array suchen können, bevor ein neues zu
erstellen ist, benötigen wir sogar nur etwa ein Drittel der Zeit, die insgesamt
mit linearer Suche vertrödelt wurde.
Jetzt brennen Sie natürlich darauf zu berechnen, wie das ` von n abhängt.
Das rechnen wir schnell aus; die Frage lautet

“Zu gegebenem n: wie hoch muss die Anzahl ` der Suchvorgänge


auf dem sortierten Array sein, damit die Gesamtzeit der linearen
Suche größer oder gleich der Gesamtzeit der binären Suche auf
sortiertem Array ist?”

50
n · ` ≥ n + n · log n + ` · log n

1
` ≥ 1 + log n + ` · log n
n

1
`−`· log n ≥ 1 + log n
n

1
` · (1 − log n) ≥ 1 + log n
n

1 + log n
` ≥
(1 − n1 log n)

In Abb. 6.2 ist die Funktion ` 7→ (1−1+log


1
n
log n)
für Argumente n bis 231 − 1
n
aufgetragen. Man sieht, dass die Funktion recht langsam wächst, bei der
maximalen Array Größe 231 − 1 genügen also schon ` = 32 Suchvorgänge,
nach denen sich die binäre Suche auf sortiertem Array gegenüber der linearen
Suche lohnt.
Hier sehen wir, wie Komplexitätstheorie zu praktischen und einfach an-
wendbaren Rezepten führen kann:
Anwendungsregel
für binäre
Regel für die Anwendung der binären Suche: Wenn auf einer Suche
Mengen- oder Listenstruktur mindestens 32 mal so häufig gesucht
wie geändert wird, ist es für die akkumulierte Worst-Case Laufzeit
günstiger, die Menge oder Liste in ein Array zu transformieren, dieses
zu sortieren, und dann binärer Suche anzuwenden.
Wenn dagegen häufiger Änderungen an der Menge oder Liste vor-
genommen werden, so dass weniger als 32 Suchvorgänge bis zum
nächsten Update erfolgen, ist die naive lineare Suche besser geeig-
net.

51
32 1 + log2 (n)
log2 (n)
1-
n
31

30

29

28

27

n
5.0 × 108 1.0 × 109 1.5 × 109 2.0 × 109

Abbildung 6.2: Schwellwert der Anzahl ` von Suchvorgängen in Abhängigkeit


von Array-Größe n, nach denen die binäre Suche auf sortiertem Array weniger
akkumulierte Laufzeit benötigt als die lineare Suche.

52
Kapitel 7

Das Divide-and-Conquer
Paradigma

Die in dieser Session studierten Algorithmen folgen alle dem Teile und Abstraktes
Herrsche (engl. Divide-and-Conquer) Paradigma: Die Lösung des Pro- Verfahren
blems wird auf folgende Weise erreicht:
Teile (Divide) die Eingabemenge in Teilmengen, die zu kleineren Sub-
Problemen führen.
Beherrsche (Conquer) die Sub-Probleme, indem nach Bedarf iterativ
oder rekursiv eine weitere Aufteilung in Unterprobleme vorgenommen
wird.
Identifikation von Basisfällen (Base Cases): “Passt” ein Unterpro-
blem auf einen Basisfall, muss keine weitere Teilung der Eingabe-
menge mehr vorgenommen werden, weil für diesen Fall ein einfaches
Lösungsverfahren existiert. Es kann auch mehrere Basisfälle geben, wie
die Beispiele unten zeigen werden.
Kombiniere (Combine) die Teillösungen zu einer Gesamtlösung
Diese abstrakte Verfahrensbeschreibung wurde bei der binären Suche (sie- Umsetzung
he Listing 3.1) folgendermaßen umgesetzt: bei der
binären
1. Die Eingabemenge ist ein aufsteigend sortiertes Array
Suche
2. Die Teilung der Eingabemenge erfolgt immer in der Mitte, wodurch
sich die Problemgröße halbiert.

53
3. Bei der Beherrschung des Problems nutzt man aus, dass die Lösung
nur in einer Hälfte des geteilten Arrays sein kann. Dies bedeutet, dass
man die andere Hälfte nicht mehr untersuchen muss. Hierdurch ist die
rekursive Bearbeitung des verbleibenden Unterproblems nicht unbe-
dingt erforderlich – die iterative Lösung ist nicht weniger elegant.

4. Es gibt zwei Basisfälle, welcher keine weitere Teilung erfordern:

• Wenn midVal == key gilt, wurde der gesuchte Wert gefunden,


und man muss nur den mittleren Index mid zurück geben (Zei-
le 20).
• Wenn low > high gilt, ist der gesuchte Wert im Array nicht vor-
handen, und man muss nur noch den negierten, dekrementierten
Insertion Point zurück geben (Zeile 22).

5. Die Combine-Phase entfällt, denn bei jeder Teilung des Eingaberaums


bleibt nur ein einziger relevanter Teil übrig.

Bei Quicksort wurde das Divide-and-Conquer Paradigma folgendermaßen Umsetzung


umgesetzt: bei
Quicksort
1. Die Eingabemenge ist ein unsortiertes Array, in dem alle Elemente
paarweise vergleichbar sind.

2. Die Teilung der Eingabemenge erfolgt für das Problem und alle Sub-
Probleme durch die Vorgabe, dass im “linken” Teil nur Elemente ein-
geordnet werden, die kleiner als das vorher ermittelte Pivot-Element
sind, und im “rechten” Teil nur Elemente, die größer oder gleich dem
Pivot-Element sind.

3. Beherrschung: Jedes einzelne Sub-Problem muss durch Anwendung


des unter 2 beschriebenen Teilungsverfahrens beherrscht werden, bis
der Basisfall erreicht wird.

4. Der (einzige) Basisfall bei Quicksort besteht darin, dass die Eingabe-
menge des (Unter-)problems lauter identische Elemente enthält. Dies
wird daran erkannt, dass kein Pivot-Element gefunden wird. Beachten
Sie, dass die mit Basisfällen assoziierten Unterprobleme verschieden
groß sein können.

54
5. Das sortierte Array setzt sich aus den sortierten Unter-Arrays zusam-
men. Für diese Kombination ist nichts mehr zu tun, denn es wird
auf dem Original-Array sortiert, der auch das Ausgabe-Array ist. Da
jede Unterproblemlösung kleinere Elemente enthält als die rechts be-
nachbarte Problemlösung, sind die sortierten Teillösungen auch in der
richtigen Reihenfolge im Array angeordnet.

55
Kapitel 8

Fragen und Aufgaben

8.1 Fragen
8.1.1 Falsche Implementierungen im Internet
Suchen Sie mindestens 3 Stellen im Internet und geben die URLs an, wo
ein Java-Algorithmus für die binäre Suche angegeben wird, der den oben
beschriebenen berüchtigten Fehler noch enthält1 . Erschrecken Sie darüber so
sehr, dass Sie bis ans Ende Ihres Lebens keinen einzigen Algorithmus aus dem
Netz mehr übernehmen, ohne seine Korrektheit im Detail zu analysieren.
Sie könnten den Personen, welche die falschen Implementierungen
veröffentlicht haben, noch freundlich (!) schreiben, welche Korrektur anzu-
bringen ist – das würde die Welt zu einem besseren Ort machen.

8.1.2 Binäre Suche mit Komparator


Gegeben sei das Integer-Array
1 Integer [] a = { 10 , 2 , 5 , 9 , 11 };

Was ist der Rückgabewert des folgenden Aufrufs der Methode aus Li-
sting 3.3 ?
1 Arrays . binarySearch (a , 13 , ( o1 , o2 ) -> ( o1 %2 - o2 %2) ) ;

Führen Sie den Aufruf mit Hilfe von Listing 3.3 als “Schreibtischtest” aus.
Das heißt, Sie inspizieren das Listing und schreiben sich für jeden Schritt die
1
Ich habe innerhalb von 120 Sekunden 3 falsche Implementierungen gefunden. Finden
Sie mehr in dieser Zeit?

56
Variablenbelegung auf, bis der Rückgabewert bestimmt ist und die Funktion
terminiert.

8.1.3 Bestimmung der Laufzeit-Komplexität für einen


naiven Suchalgorithmus
Betrachten Sie folgenden Suchalgorithmus, der genau wie binäre Suche auf
seinem sortierten Array arbeitet.
1 private static final int stepSize = 10;
2
3 /**
4 * Check whether x is contained in sorted array a
5 * @param a A sorted int - array
6 * @param x The value we are looking for
7 * @return An index of a , so that a [ x ] == x , or -1 if a does not contain
x
8 */
9 static int stepSearch ( int [] a , int x ) {
10
11 int currentIdx = ( stepSize < a . length ) ? stepSize : ( a . length - 1) ;
12 int lowerIndex = -1;
13
14 while ( true ) {
15
16 // Case 1: we have found the value x in a
17 if ( a [ currentIdx ] == x ) return currentIdx ;
18
19 // Case 2: If currentIdx is the last index of a ,
20 // x is not an element of a
21 if ( currentIdx == a . length - 1 ) return -1;
22
23 // Case 3: increase currentIdx by stepSize ,
24 // because x is greater than the current array value
25 if ( a [ currentIdx ] < x ) {
26 lowerIndex = currentIdx ;
27 int aux = currentIdx + stepSize ;
28 // Consider overflow problem
29 if ( aux <= currentIdx ) aux = a . length - 1;
30 currentIdx = ( aux < a . length ) ? aux : ( a . length - 1) ;
31 }
32 else {
33 // Case 4: currentIdx is too big , go down until we
34 // have found x or can decide that x is not in a
35 for ( int i = currentIdx - 1; i > lowerIndex ; i - - ) {
36 if ( a [ i ] == x ) return i ;
37 }
38 // If we reach this , x is not contained in a
39 return -1;
40 }
41 }
42 }

57
1. Berechnen Sie die exakte Worst-Case Laufzeit in Abhängigkeit von der
Array-Größe n = a.length und der Konstanten stepSize. Hierbei
dürfen Sie annehmen, dass n ein Vielfaches von stepSize ist.

2. Berechnen Sie die asymptotische Worst-Case Laufzeit.

8.1.4 O(n2 ) 6= O(n3 )


Beweisen Sie, dass O(n2 ) und O(n3 ) nicht dieselben Komplexitätsklassen
sind. Verfahren Sie dabei ähnlich wie bei dem Beweisbeispiel O(2n ) 6= O(4n ).

8.1.5 O(g(n)) ⊆ O(g 0 (n))


Beweisen Sie folgende Inklusionsbeziehungen:

1. O(n2 ) ⊆ O(n3 )

2. O(n) ⊆ O(n · log n)

3. O(2n ) ⊆ O(4n )
n
4. O(2n ) ⊆ O(22 )

8.1.6 Divide-and-Conquer bei Mergesort


Beschreiben Sie analog zu Quicksort in Abschnitt 7, wie das Divide-and-
Conquer Paradigma beim Mergesort Algorithmus umgesetzt wurde.

8.1.7 Asymptotische Abschätzungen


Die oben dargestellten asymptotischen Worst Case Abschätzungen (O-
Notation) sind ausreichend für Komplexitätsuntersuchungen im Rahmen
des Stoffes von PI2a. In [2, 3.1] werden noch weitere asymptotischen
Abschätzungen eingeführt. Wie heißen diese, und wie unterscheiden sie sich
von der O-Notation?

58
8.1.8 Varianten von Quicksort
1. Lesen Sie die Beschreibung von Quicksort in [4, Abschnitt 2.3] und
erklären Sie, wie sich diese algorithmische Variante von unserer auf [1]
basierenden Version unterscheidet.

2. Es ist bei Informatiker*innen sehr beliebt, den Quicksort-Algorithmus


durch Volkstänze zu veranschaulichen2 . Leider sind die Tänzer*innen
häufig etwas schlampig in ihrer Implementierung. Suchen Sie auf You-
Tube (einfach ein Stichwort wie “Quicksort dance” eingeben) nach einer
tänzerischen Implementierung, die konsistent zu einer der Versionen aus
[1, 4] ist.

8.1.9 Worst Case Running Time von Quicksort


Geben Sie ein möglichst einfaches Beispiel, bei welchem die Worst-Case Lauf-
zeit von Quicksort auftritt.

8.2 Aufgaben
8.2.1 Quicksort Test
Erstellen Sie ein IntelliJ-Projekt für den Algorithmus aus Listing 5.1 und
erstellen Sie JUnit Tests, die 100% Statement Coverage erzielen.
Schreiben Sie wieder Requirements für die Methoden quicksort(int[]
a, int lb, int ub), swap und getPivotIdx auf und verweisen von den
Testfällen auf die Anforderungen.
Weisen Sie die Teststärke nach, indem Sie wieder Mutationen in die Me-
thoden “injizieren”3 und prüfen, dass ihre Testfälle diese Fehler aufdecken.

Hinweis: Die LATEX-Dokumentation der Mutanten ist recht umständlich,


ausserdem muss man immer nachbessern, wenn sich die Zeilennummern im
Code ändern. Beschreiben Sie daher Ihre Mutation als Kommentare direkt
im Code, so dass man die mutierten Versionen durch Aus-/Einkommentieren
herstellen kann.
2
Ja echt, Informatiker*innen sind so . . .
3
Im Englischen nennt man diesen Prozess Fault Injection.

59
8.2.2 Quicksort auf Elementen mit natürlicher
Ordnung
Programmieren Sie die oben eingeführte auf [1] beruhende Variante des
Quicksort-Algorithmus auf Object-Arrays, deren Elemente das Interface
Comparable implementieren. Das Interface soll genauso sein, wie bei der Java
Klasse Arrays spezifiziert:
1 public static void sort ( Object [] a )

Klausuraufgabe: Dies wäre eine typische Klausuraufgabe. Der Quicksort Klausuraufgabe


Code ist kurz und, wenn man ihn verstanden hat, einfach. Wir würden von
Ihnen erwarten, dass Sie diesen Algorithmus vollständig entwickeln können.

8.2.3 Quicksort auf Elementen mit Komparator


Programmieren Sie die oben eingeführte Variante des Quicksort-Algorithmus
auf Arrays, deren Elemente vom Typ T eine Oberklasse besitzen, welche einen
Comparator implementiert. Das Interface soll genauso sein, wie bei der Java
Klasse Arrays spezifiziert:
1 public static <T > void sort ( T [] a , Comparator <? super T > c )

Klausuraufgabe: Dies wäre eine typische Klausuraufgabe. Der Quicksort Klausuraufgabe


Code ist kurz und, wenn man ihn verstanden hat, einfach. Wir würden von
Ihnen erwarten, dass Sie diesen Algorithmus vollständig entwickeln können.

60
Kapitel 9

Anstelle weiterer
Literatur-Hinweise

Lange Jahre, aber vor Ihrer Zeit, war der Cartoonist Gary Larson1 ein Favorit
von Informatikern und Informatikerinnen. Sie sollten seinen Namen zumin-
dest kennen (siehe Abb. 9.1), auch wenn seine Cartoons heute nicht mehr
zum Prüfungsstoff gehören.
1
https://de.wikipedia.org/wiki/Gary_Larson

61
Abbildung 9.1: Gary Larson – Lieblings-Cartoonist vieler inzwischen nicht
mehr ganz junger Informatiker*innen.

62
Literaturverzeichnis

[1] Alfred V. Aho, John E. Hopcroft, and Jeffrey D. Ullman. The Design and
Analysis of Computer Algorithms. Addison-Weley Publishing Company,
2009.

[2] Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford


Stein. Introduction to Algorithms, Third Edition. The MIT Press, 3rd
edition, 1973.

[3] T. Ottmann and P. Widmayer. Algorithmen und Datenstrukturen. Spek-


trum, 2017.

[4] R. Sedgewick and K. Wayne. Algorithmen: Algorithmen und Datenstruk-


turen. Pearson Studium, 2014.

63