\( \newcommand{\data}{\textnormal{data}} \newcommand{\lower}{\textnormal{lower}} \newcommand{\upper}{\textnormal{upper}} \newcommand{\array}{\textnormal{array}} \newcommand{\depth}{\textnormal{depth}} \newcommand{\height}{\textnormal{height}} \newcommand{\BST}{\textnormal{BST}} \newcommand{\floor}[1]{\left\lfloor #1 \right\rfloor} \newcommand{\ceil}[1]{\left\lceil #1 \right\rceil} \newcommand{\lognpone}{\ceil{\log_2(n+1)}} \newcommand{\bitsize}{\textnormal{bitSize}} \newcommand{\mult}{\textnormal{mult}} \newcommand{\inneighbors}{\textnormal{inNeighbors}} \newcommand{\outneighbors}{\textnormal{outNeighbors}} \newcommand{\neighbors}{\textnormal{neighbors}} \newcommand{\indeg}{\textnormal{indeg}} \newcommand{\outdeg}{\textnormal{outdeg}} \newcommand{\scc}{\textnormal{scc}} \newcommand{\R}{\mathbb{R}} \newcommand{\val}{\textnormal{val}} \newcommand{\dist}{\textnormal{dist}} \newcommand{\flows}{\textnormal{flows}} \)

2. Suchen und Sortieren

2.2 Naive Sortieralgorithmen

Der erste Sortieralgorithmus, den wir kennenlernen werden, ist SelectionSort. Dieser sucht in einem Array das kleinste Element und verschiebt es an den Anfang (also auf Position 0). Dann wiederholen wir es auf dem Teil-Array mit Indizes 1 bis \(n-1\). Hier ist eine Animation, die SelectionSort auf einem Array der Größe 7 illustriert (aber nicht zu Ende führt):

Am Anfang der \(k\)-ten Phase befinden sich in den ersten \(k\) Positionen des Arrays die \(k\) kleinsten, aufsteigend sortiert. In der \(k\)-ten Phase müssen wir das kleinste Element unter \(A[k], A[k+1],\dots,A[n-1]\) finden und mit dem an Position \(k\) vertauschen. In Python schaut das so aus:

def selectionSort(array):
array = array[0:] # wir fertigen hier eine Kopie an, um das ursprüngliche Array unangetastet zu lassen
    n = len(array) 
    for k in range(n):
        argmin = findArgmin(array, k, n-1) 
        swap (array, argmin, k)
    return array

Die Funktion argmin(array, a, b) sucht den Index des kleinsten Elements in array[a], array[a+1], ..., array[b]. Die Funktion swap vertauscht die Elemente an den beiden Indizes. Hier ist der Code:

def swap(array, i, j):
    temp = array[i]
    array[i] = array[j]
    array[j] = temp

def findArgmin (array, lower, upper):
    i = lower 
    argmin = lower 
    for i in range(lower, upper+1):
        if array[i] < array[argmin]:
            argmin = i 
    return argmin 
Demo. Wir testen SelectionSort mit der Datei englishVocabulary.py. Da die zu groß für unser ineffizientes selectionSort ist, testen wir es an den ersten \(k\) Elementen unseres Arrays:
~/AuK/code/sorting $ python3 -i selectionSort.py
wordlist_unsorted[:10]
['joblessness', 'presets', 'yeaned', 'birefringence', 'splutter', 'destabilisation', 'brags', 'supplies', 'enthroned', 'crests']

Mit array[a:b] können Sie bequem das Unterarray [array[a], array[a+1], ..., array[b-1]] konstruieren. Sortieren wir nun:

selectionSort(wordlist_unsorted[:10])

Die Laufzeit schwankt von mal zu mal (je nachdem, ob ich währenddessen noch etwas Anderes am Computer mache), liegt aber ungefähr zwischen 20 und 40 Sekunden.

Übungsaufgabe Testen Sie selectionSort per Hand, indem Sie es für die ersten \(n\) Wörter in der unsortierten Liste aufrufen, also

measure_alg(selectionSort, wordlist_unsorted[0:1000])

Wie verändert sich die Laufzeit, wenn Sie die Größe des Arrays verdoppeln?

Theoretische Analyse von Sortieralgorithmen

Für Sortieralgorithmen haben wir ein Kriterium für deren Effizienz, das nicht von der Tageslaune des Computers abhängt: die Anzahl der Vergleiche; wie oft Sie also compareTo aufgerufen haben. Wenn also Ihre Vergleichsoperation an sich recht teuer ist, dann ist dies ein sehr guter Maßstab. Oft sind die Vergleichsoperationen aber relativ billig (wenn es sich bei den Daten zum Beispiel um int oder float handelt), und dann spielen andere Phänomene eine Rolle; dann kann es vorkommen, dass ein Algorithmus, der etwas mehr Vergleiche tätigt, denoch schneller ist. Die Anzahl der Vergleiche ist aber auch in diesem Fall ein zumindest anständiger Maßstab.

Übungsaufgabe Wieviele Vergleiche benötigt ein Aufruf von argmin(array, lower, upper)?

Leiten Sie eine Formel für die Gesamtzahl der Vergleichsoperationen her, die selectionSort auf einem Array mit \(n\) Elementen tätigt.

InsertionSort

Der zweite Algorithmus, den wir untersuchen, ist InsertionSort. Er ist dem SelectionSort sehr ähnlich, allerdings versucht er pro Phase nicht, das Minimum in array[k..n-1] zu finden, sondern nimmt das Element array[k] und verschiebt es nach links, bis es seinen korrekten Platz gefunden hat und array[0..k] wieder sortiert ist. Hier sehen Sie eine Animation:

Ähnlich wie SelectionSort gilt auch bei InsertionSort, dass am Anfang von Phase \(k\) die ersten \(k\) Elemente des Arrays sortiert sind. Allerdings sind diese nicht notwenidgerweise die kleinsten \(k\) Elemente des gesamten Arrays, sondern sind identisch mit den ersten \(k\) Elementen des unsortierten Arrays.

Übungsaufgabe Implementieren Sie insertionSort in Python. Testen Sie es und vergleichen Sie es experimentell mit selectionSort. Welches ist schneller?

Übungsaufgabe Wieviele Vergleichsoperationen führt InsertionSort aus auf einem Array der Größe \(n\)? Hinweis. Die Antwort ist nicht für jedes Input-Array gleich. Finden Sie ein bestes und ein schlechtestes Array und geben Sie für beide Fälle die Anzahl der Vergleichsoperationen als Funktion in \(n\) an.

Laufzeitanalyse

Sie haben vielleicht bemerkt, dass die Anzahl der Vergleichsoperationen, die SelectionSort ausführt, in jedem Fall $$ 1 + 2 + \cdots + n-1 = {n \choose 2} = \frac{n(n-1)}{2} $$ ist. Für InsertionSort ist sie im besten Fall \(n-1\) (falls das Array bereits aufsteigend sortiert ist), im schlechtesten Fall aber auch \({n \choose 2}\), also genau wie bei SelectionSort. Falls Ihnen dieser Ausdruck nicht so geläufig ist, können wir ihn so abschätzen: \begin{align} {n \choose 2} & = \frac{(n(n-1))}{2} = \frac{n^2}{2} - \frac{n}{2} \\ & = \frac{n^2}{2} - \textnormal{etwas, das deutlich kleiner ist }\\ & \approx \frac{n^2}{2} \\ & = n^2 \times \textnormal{ etwas}. \end{align}

Sowohl SelectionSort als auch InsertionSort machen also ungefähr \(n^2\) Vergleichsoperationen, und ihre Laufzeit ist daher auch ungefähr \(n^2\). In dieser Vorlesung werden Sie auch lernen, wie man auf mathematisch rigorose Weise über solche unpräzisen Größen sprechen kann. Der Sinn dahinter ist: ob ein Algorithmus \(\frac{n^2}{2} - \frac{n}{2}\) oder \(\frac{n^2}{2}\) Vergleichsoperationen ausführt, ist nicht so relevant; es gibt eh andere Dinge, Implementierungsdetails, Memory-Operationen etc., die den Unterschied zwischen den beiden übertönen. Und auch ob es \(\frac{n^2}{2}\) oder \(n^2\) sind, ist oft nicht weltbewegend; schließlich können Sie den Unterschied eliminieren, indem Sie einen doppelt so schnellen Rechner kaufen (oder zwei, falls das Problem sich parallelisieren lässt). Der Unterschied zwischen einer Laufzeit, die ungefähr \(n^2\) ist und einer, die ungefähr \(n^3\) ist, wird sich nicht mit dem Kauf besserer Hardware oder einer besseren Implementierung übertünchen lassen. Sie werden in diesem Kurs also auch lernen, worauf man bei der Laufzeitanalyse von Algorithmen achten sollte.

Einen ähnlichen, vielleicht intuitiveren Blickwinkel auf die Laufzeit eröffnet folgende Frage: Wenn ich die Größe meines Arrays verdopple, wie ändert sich dann die Laufzeit? Wenn wir dies für die Anzahl der Vergleichsoperationen, also\({n \choose 2}\), gegenüberstellen, bekommen wir folgende Rechnung:

\begin{align*} \frac{2n \choose 2}{n \choose 2} & = \frac{\frac{2n (2n-1)}{2}}{\frac{n (n-1)}{2}} = \frac{2n (2n-1)}{n (n-1)} = 4 \cdot \frac{n - \frac{1}{2}}{n-1} \\ & = 4 \cdot \frac{n - 1 + \frac{1}{2}}{n-1} \\ & = 4 \cdot \left(1 + \frac{1}{2n-2}\right) \ . \end{align*}

Für großes \(n\) konvergiert der Wert in der Klammer gegen \(1\). Wir können also sagen: Für ein doppelt so großes Array brauchen SelectionSort und InsertionSort ungefähr viermal so lange. Dies ist natürlich nicht zufriedenstellend. Doppelt so lange klänge vernünftig, da könnten wir uns nicht beschweren. Aber viermal so lange hieße ja, dass es bei 100.000 Wörtern auf meinem Rechner eine Minute, bei 200.000 Wörtern bereits vier Minuten dauern würde, und bei 1.000.000 Wörtern sogar 200 Minuten, also über drei Stunden. Und das, wo 1.000.000 nach gar nicht so viel klingt für heutige Rechner.

Übungsaufgabe Überprüfen Sie die Aussage Für doppelt so große Arryas brauchen InsertionSort und SelectionSort viermal so lange empirisch, indem Sie Ihre Implementierung testen.
Übungsaufgabe Für welches Array der Größe \(n\) führt InsertionSort
  1. die meisten
  2. die wenigsten
Vergleiche aus? Welcher Input ist also der "worst case" bzw. der "best case"?
Übungsaufgabe Eigentlich reden wir an dieser Stelle noch nicht über Average Case Analysis. Können Sie trotzdem schätzen, wie viele Vergleiche InsertionSort ausführt, wenn die Elemente von array zufällig geordnet sind?

Gerne dürfen Sie auch Experimente machen, also zum Beispiel in Python 100 mal

random.shuffle(array)
insertionSort(array)
                        
aufrufen und messen, wieviele Vergleiche im Schnitt gemacht werden.

Übungsaufgabe Kombinieren Sie Insertion-Sort mit binärer Suche! Lassen Sie das neue Element \(\array[k]\) nicht "runterblubbern" sondern suchen Sie mittels binärer Suche den richtigen Ort.
  • Schreiben Sie Pseudocode für Ihren verbesserten InsertionSort. Schreiben Sie ihn in Python, wenn Sie Lust haben.
  • Wieviele Vergleiche führen Sie im Worst-Case aus?
  • Wieivele Schritte führt Ihr Algorithmus im Worst-Case aus?