Donald Shell stellt 1959 in [📖She59] einen Sortieralgorithmus vor, der später nach ihm benannt wird: Shellsort.
Die in dem Algorithmus verwendete Sortiermethode ist auch bekannt als
Sortieren mit abnehmenden Inkrementen [📖OW17b, p. 88], das Verfahren ist eine Variation von Insertion Sort:
[Shellsort] uses insertion sort on periodic subarrays of the input
to produce a faster sorting algorithm. [📖CL22, p. 48]
In der vorliegenden Implementierung (siehe Listing 1) werden t=log2(n) Inkremente
ht der Form ⌊2in⌋ verwendet, um lg(n)
h-sortierte Folgen zu erzeugen. Im letzten Schritt sortiert der
Algorithmus dann in h1 die Schlüssel mit Abstand=1.
Die Effizienz des Sortierverfahrens ist stark abhängig von h: So zeigt
Knuth, dass O(n23) gilt, wenn für h gilt:
hs=2s+1−1 mit 0≤s<t=⌊lg(n)⌋ (vgl. [📖Knu97c, p. 91]. In unserem Fall können wir von O(n2) ausgehen.
Anzahl der Aufrufe für Feldvergleiche
Gesucht ist die Anzahl der zwischen zwei Feldelementen gemachte Vergleiche für große randomisierte Felder.
Wir vermuten hierfür einen Wert zwischen O(n lg n) und O(n2),
Um die Aufrufe der Feldvergleiche zu zählen, kann wie in Listing 1 eine Zählvariable c3 eingesetzt wird, die
jeden Aufruf über ein increment protokolliert.
Listing 1: Zur Protokollierung der Aufrufe können in dem Code Zählvariablen eingesetzt werden (c1, ... ,c4).
while (distanz > 0) {
c1++;
for (i = distanz; i < feld.length; i++) {
j = i - distanz;
c2++;
while (j >= 0) {
c3++;
if (feld[j] > feld[j + distanz]) {
c4++;
swap(feld, j, j+distanz);
j = j - distanz;
} else {
j =-1;
}
}
}
distanz = distanz / 2;
}
Zur Bestimmung von c3 können randomisierte Felder erzeugt werden, wie
es der Test-Code in Listing 2 demonstriert. Dann wird überprüft, in welchen Bereichen sich c3 bewegt.
Da jeder auf Vergleichsoperationen basierende Sortieralgorithmus zu der Laufzeitklasse Ω(n log n)
gehört, kann im Mittel c3≥n log n angenommen werden:
Jedes allgemeine Sortierverfahren benötigt zum Sortieren von N
verschiedenen Schlüsseln sowohl im schlechtesten Fall als auch im
Mittel wenigstens Ω(N log N) Schlüsselvergleiche.
[📖OW17b, p. 154]
Allerdings wird n1.3 ab n=982 schneller wachsen als n log n
(siehe Abbildung 1), was in der Auswertung berücksichtigt
werden muss. Sonst könnte man fälschlicherweise folgern, das in dem geg.
Fall die Laufzeit im Durchschnitt n log n beträgt.
Listing 2: Code zum Erzeugen randomisierter Felder zum Testen von Shellsort.
int epochs = 2000;
while(epochs-- >= 0) {
int[] tests = new int[]{10, 100, 1000, 100_000, 1_000_000};
java.util.Random r = new java.util.Random();
for (int i = 0; i < tests.length; i++) {
int l = tests[i];
int[] arr = new int[l];
for (int j = l; j > 0; j--) {
arr[l - j] = r.nextInt(l + 1);
}
ShellSort.sort(Arrays.copyOfRange(arr, 0, arr.length));
}
}
Mit einem Wert von 100 für die Epochen und einer Feldgröße von
2.000.000 liefert der Test in Listing 2 folgende Ausgabe:
nlgn<n1.3<x<n2:74
nlgn<x<n1.3:26
Figure 1 Ab n=982 wächst n(4/3) schneller al n log n.
Wegen Ω(n log n) interessieren wir uns für die Intervalle:
Die Ausführung des Tests zeigt, dass die Aufrufanzahl von c3 bei
großen n unterhalb n1.3 oder zwischen n1.3 und n2 liegt:
It appears that the time required to sort n elements is proportional
to n1.226. [📖She59, p. 31]
Laufzeitanalyse
Für die nachfolgenden Betrachtungen sei eine Eingabegröße n=2p
gegeben. Die Inkremente h entsprechen der über die Implementierung
vorgegebenen Folge h mit
ht=2n,ht−1=4n,…,h1=1
Für die Analyse ist die Shellsort-Implementierung in
Listing 3 gegeben.
Listing 3: Implementierung des Shellsort-Algorithmus.
public static int[] sort(int[] arr) {
int n = arr.length;
int delta = n/2;
int min;
int j;
while (delta > 0) {
for (int i = delta; i < arr.length; i++) {
min = arr[i];
j = i;
while (j - delta >= 0 && min < arr[j - delta]) {
arr[j] = arr[j - delta];
j -= delta;
}
arr[j] = min;
}
delta = delta / 2;
}
return arr;
}
Im Folgenden betrachten wir die Anzahl für die im Code durch Kommentare
markierten Stellen c1 in Zeile 8, c2 in Zeile 12 und c3 in Zeile
19.
Für eine erste worst-case-Analyse ist ein Feld der Länge 16 gegeben,
in absteigender Reihenfolge sortiert (s. Abbildung 2):
Figure 2 Die für die Laufzeitabschätzung verwendete Eingabefolge 16..1.
Ganz offensichtlich gilt für c3, dass es lg(n)-mal aufgerufen
wird.
c2 befindet sich im Block der durch die in Zeile 11 definierten
Zählschleife.
Der Startwert für i
ist in jedem Durchgang des Blocks c1 der aktuelle
Wert von delta
, und läuft jeweils bis n−1.
In einem kompletten Durchlauf der Schleife entspricht die Anzahl der
Aufrufe von c2 also
(n−1)−delta+1=n−delta
Hinweis:
Für das Beispiel betrachten wir der Einfachheit halber Feldlängen der
Form 2p.
Für den allgemeinen Fall gilt für die Anzahl der Aufrufe von c2:
∑i=1⌊lg(n)⌋n−⌊2in⌋=n∗lg(n)−∑i=1⌊lg(n)⌋⌊2in⌋
Für die Gesamtzahl der Aufrufe von c2 ergibt sich somit unter
Berücksichtigung von c1
∑i=1lg(n)n−2i−1n
was nach Auflösen
n∗lg(n)−n+1
entspricht, und für unser Beispiel
16∗lg(16)−16+1=49
ist.
Die Aufrufzahlen für c2 für verschiedene n. Schlüsselvergleiche finden erst später statt.
Mit n>2156 wächst O(n34) schneller als c2 und mit n>=982 schneller als
O(n log n).
n | c2 | O(n1.1) | O(n1.3) | O(n log n) | O(n2) |
---|
8 | 17 | 9 | 14 | 24 | 64 |
16 | 49 | 21 | 36 | 64 | 256 |
32 | 129 | 45 | 90 | 160 | 1024 |
64 | 321 | 97 | 222 | 384 | 4096 |
128 | 769 | 207 | 548 | 896 | 16.384 |
256 | 1793 | 445 | 1351 | 2048 | 65.536 |
1024 | 9217 | 2048 | 8192 | 10240 | 1.048.576 |
2156 | 21.565 | 4.645,29 | 21.564,69 | 23.875,84 | 4.648.336 |
2157 | 21.576 | 4.647,66 | 21.577,70 | 23.888,36 | 4.652.649 |
2158 | 21.586 | 4.650,03 | 21.590,70 | 23.900,88 | 4.656.964 |
... | ... | ... | ... | ... | ... |
10.000 | 120.005 | 25.118 | 158.489 | 132.877 | 1.0E8 |
100.000 | 1.500.006 | 316.227 | 3.162.277 | 1.660.964 | 1.0E10 |
200.000 | 3.200.006 | 677.849 | 7.786.440 | 3.521.928 | 4.0E10 |
500.000 | 8.500.007 | 1.857.235 | 2.56..E7 | 9.465.784 | 2.5E11 |
1.000.000 | 18.000.007 | 3.981.071 | 6.30..E7 | 1.99..E7 | 1.0E12 |
In der while
-Schleife in Zeile 17 findet die eigentliche
Arbeit des Algorithmus statt: Es wird überprüft, ob delta-entfernte
Elemente in aufsteigender Reihenfolge sortiert angeordnet sind.
Ist das nicht der Fall, werden die Elemente an den Stellen j und
j−delta ausgetauscht, bis die h-Folge sortiert ist.
Für den ersten Durchgang des Algorithmus an dieser Stelle mit h4=8
ergibt sich somit die in Abbildung 3 dargestellte Reihenfolge der Schlüssel:
Figure 3 Nach dem ersten Durchgang sind die Schlüssel in den Abständen h4 = 8 sortiert, es ergeben sich zwei sortierte Teilfolgen der Länge 8.
Die weiteren Durchgänge des Algorithmus sortieren das Feld entsprechend
der Größe h: Es sind danach jeweils Schlüssel mit den Abständen 4 (Abbildung 4 und 2
(Abbildung 5) sortiert; im letzten Schritt dann vollständig (Abbildung 6):
Figure 4 Die Sortierung für h3 = 4, es sind 4 Folgen deren Schlüssel jeweils den Abstand 4 zueinander haben.
Figure 5 Im vorletzten Sortierschritt sind 8 Folgen der Länge 2 sortiert.
Figure 6 Der letzte Durchgang des Algorithmus vergleicht Schlüssel mit Distanzfolgen der Länge 1, also direkt benachbarte Schlüssel.
Für die Berechnung der Anzahl der Aufrufe von c3 stellt man fest, das
in diesem Fall in jedem Schritt immer 2 Elemente, die eine Distanz von
hs aufweisen, falsch sortiert sind.
Der Algorithmus tauscht daraufhin in jedem Durchgang alle
Schlüssel untereinander aus, die er über min < arr[j-delta]
miteinander vergleicht, was
folglich die maximale Anzahl von Schlüsselvertauschungen in dieser
vergleichsbasierten Implementierung für ein Feld der Größe n ergibt,
nämlich 2n. Insgesamt finden dadurch für c3
lg(n)∗2n
Aufrufe statt.
Mit der Anzahl der berechneten Aufrufe c1,c2,c3 ergibt sich somit
für die Laufzeit T(n) für diesen Fall
lg(n)+n∗lg(n)−n+1+lg(n)∗2n
und zusammengefasst
f(n)=23∗n∗lg(n)+lg(n)−n+1
was nach Einsetzen zu
lg(16)+16∗lg(16)−16+1+lg(16)∗216=85
Aufrufen für unser Beispiel führt.
Nachweis der Komplexitätsklasse
Um O zu ermitteln, werden nun alle Konstanten der
Funktion
f(n)=23∗n∗lg(n)+lg(n)−n+1
eliminiert, und der "dominante" Summand in Abhängigkeit von n betrachtet, der in diesem Fall lg(n)∗n ist.
Wir vermuten ein N−log−N-Wachstum (vgl. [📖OW17a, p. 5]), und wollen
nun zeigen, dass T(n) in O(n log n) liegt.
Hierfür müssen wir ein geeignetes c und ein n0 finden, so dass gilt:
f∈O(n log n):↔∃n0∈N,c∈R,c>0:∀n≥n0:f(n)≤c∗n∗lg(n)
(vgl. [📖GD18a, p. 11]).
Zu zeigen ist
23∗n∗lg(n)+lg(n)−n+1≤c∗n∗lg(n)
Wir wählen für n0=1 und c=23, denn es gilt sicher
∀n≥n0:23∗n∗lg(n)≤23∗n∗lg(n).
Ausserdem gilt stets ∀n∈N:lg(n)<n, woraus
lg(n)−n<0 folgt, und damit auch lg(n)−n+1≤0.
Insgesamt gilt also
n0=1,c=23:∀n≥n0:23∗n∗lg(n)+lg(n)−n+1≤c∗n∗lg(n)
womit f=O(n∗log(n)) gezeigt ist. ◻
Worst-Case-Analyse
Unter der Annahme, dass ein in umgekehrter Reihenfolge sortiertes Feld
zu einer Laufzeit von O(n2) bei dem Shellsort-Algorithmus führt,
konnten wir mit dem in dem vorherigen Abschnitt gewählten Parametern nur
eine Laufzeit von O(n log n) nachweisen.
Tatsächlich stellt der Anwendungsfall nicht den worst-case für den
Algorithmus dar, da ja gerade diese Form von Sortierreihenfolge dem
Algorithmus die Vorsortierung der h-Folgen ermöglicht:
The idea underlying Shellsort is that moving elements of A long
distances at each swap in the early stages, then shorter distances
later, may reduce this O(n2) bound. [📖Pra72, p. 3]
Es gilt also, die Vorsortierung auszuhebeln.
Für Insertion-Sort ist die Laufzeit im worst-case O(n2)
(vgl. [📖OW17b, p. 87]). Da Shellsort mindestens im letzten Schritt diese
Sortiermethode auf Distanzfolgen der Länge 1 anwendet, muss der
Algorithmus eine Folge als Eingabe erhalten, die durch die ersten
lg(n)−1-Durchgänge (mit hs=2s−1,1≤s<lg(n))
keine Änderungen erfährt, um dann
im allerletzten Schritt auf einen Schlag sortiert wird, was
maximal 2n Inversionen bedeutet, und damit ebenso viele Vertauschungen (zuzüglich der benötigten
Verschiebe-Operationen).
Hierzu kann wie im vorherigen Beispiel für n=16 ein Feld mit folgender
Schlüsselanordnung verwendet werden: Felder mit geradem Index enthalten
kleinere Schlüssel als Felder mit ungeradem Index. Hier gilt für alle
Elemente aus dem Feld A:
∀i,j∈N[0,n−1],2∣i,2∤j:A[i]<A[j]∧A[i]<A[i+1]∧A[j]<A[j+1]
Abbildung 7 veranschaulicht die Anordnung.
Figure 7 Eine worst-case Schlüsselfolge für Shellsort. Felder mit geradem Index enthalten kleinere Schlüssel als Felder mit ungeradem Index.
Markiert sind die direkt benachbarten Felder, die eine Inversion
aufweisen, die im letzten Schritt des Algorithmus eine bzw. mehrere
Verschiebungen bedingen.
In den vorherigen Schritten - also bei den Durchgängen mit Distanzfolgen
hs>1 - findet der Algorithmus jeweils Schlüssel in korrekter
Sortierreihenfolge vor.
Mit h4=8 werden die Felder A[0..7] mit den Feldern A[8..15]
verglichen - hier gilt in jedem Fall, dass die Schlüssel
A[i]<A[j](∀i<j,j−i=8) sind.
Auch im darauffolgenden Durchgang (h3=4,j−i=4) finden keine
Verschiebungen statt, da keine Inversion gefunden wird. Erst im letzten
Schritt, in dem direkt benachbarte Elemente miteinander verglichen
werden, werden die Inversionen ermittelt und die Verschiebung der
Elemente findet statt (s. Abbildung 8.
Figure 8 Für die worst-case Schlüsselfolge werden im letzten Schritt 7 Inversionen festgestellt. Jede dieser Fehlstellung bedingt eine Verschiebung um die angegebene Anzahl von Positionen.
In diesem Fall wird c3 insgesamt 32 mal aufgerufen.
Da jeweils zwei Schlüssel bereits korrekt sortiert sind (A[0] und
A[n−1]) existieren noch 2n−1 Inversionen. Jede Inversion
erzwingt eine Verschiebung des größten Elements um 2i
Positionen nach links.
Für die Berechnung der Laufzeitkomplexität ergibt sich somit der Term
∑i=12n−1i=22n(2n−1)=8n2−2n
und unter Berücksichtigung der Terme für c1, c2, c3 die Funktion
f(n)=lg(n)+n∗lg(n)−n+1+8n2−2n
was zu einer Laufzeitabschätzung von O(n2) führt.