3. Mergesort


[ Zurück zum Inhaltsverzeichnis ]

3.1 Historie/Hintergrund


Mergesort, das "Sortieren durch Verschmelzen", ist eines der ältesten und best-untersuchten Sortierverfahren, welches bereits 1945 von Computerpionier John von Neumann vorgeschlagen und auf dem EDVAC-Computer von ihm implementiert wurde. Im Vergleich zu Quicksort, wo eine Folge rekursiv in 2 Teilfolgen geteilt wurde, findet bei Mergesort ein quasi komplementärer Prozess statt: beim sogenannten Mischen (Merging) werden 2 sortierte Dateien zu einer sortierten Datei zusammen gefügt.
"Teile-und-Herrsche" im Sinne von Quicksort bedeutete: eine Datei wird so umgeordnet, dass sie als sortiert gilt, wenn ihre beiden Teile sortiert sind. Bei Mergesort, welches diesem Prinzip ebenfalls entspricht, bedeutet es hingegen, dass eine Datei in zwei Teile zerlegt wird, die zu sortieren und dann derart zu kombinieren sind, dass sich eine sortierte Gesamtdatei ergibt.
Das Mischen stellt nicht nur einen Konterpart zum Teilen dar, auch ist Mergesort selbst bei rekursiver Implementierung in gewisser Weise ein Komplement zu Quicksort: bei diesem wird zunächst der Teilprozess ausgeführt, gefolgt von den beiden rekursiven Aufrufen für die linke und rechte Teilfolge. Bei Mergesort hingegen werden erst die rekursiven Aufrufe ausgeführt, um anschließend die dadurch entstandenen Teildateien im Mischprozess wieder zu vereinen.
Mergesort bietet einige grundlegende Vorteile gegenüber anderen Sortieralgorithmen: zum einen ist es stabil, d.h. die relative Sortiertheit gleicher Schlüssel in einer Datei bleibt stets erhalten. Zum anderen hat es auch im ungünstigsten Fall eine Laufzeit proportional zu n*log(n), also O(n*log(n)). Weiterer Vorteil: Mergesort kann so implementiert werden, dass es die Daten hauptsächlich sequentiell abarbeitet, was vor allem beim Sortieren auf Externspeicher und von verketteten Listen wichtig ist.
Hauptnachteil: für den Algorithmus wird ein zu n proportionaler zusätzlicher Speicher benötigt - daher wird Mergesort vor allem dort angewandt, wo zusätzlicher Speicherplatz keine Rolle spielt. Zudem ist es oftmals das Verfahren der Wahl, wenn es gilt eine große sortierte Datenmenge zu verwalten und immer wieder Datensätze einzuordnen. Normalerweise würden die neuen Daten angehangen und die komplette Datei neu sortiert werden. Weitaus besser wäre es hingegen, die neuen Daten getrennt zu sortieren und dann mit der bestehenden Datei zu vereinen - ein ideales Szenario für den Mischprozess von Mergesort.

3.2 Der Algorithmus


So wie man bei Quicksort das Prinzip des Teilens verstehen muss, so muss man bei Mergesort den Mischprozess verstehen. Mischen im Sinne von Mergesort bedeutet, dass man zwei sortierte Dateien zu einer einzigen sortierten Datei vereint. Zuerst erfolgt also eine Aufteilung der Gesamtdatei auf Dateien der Länge 1. Nun nimmt man benachbarte Teildateien dieser Länge, die also nur aus einem Element bestehen und trivial als sortiert gelten, und mische (sortiere und kombiniere) sie zu Dateien der Länge 2. Diese nimmt man wiederum und mische sie zu Dateien der Länge 4 usw. bis es nur noch 2 Dateien gibt. Diese werden schlussendlich ebenfalls gemischt, sodass eine einzige Datei entsteht, die der sortierten Ausgangsfolge entspricht.
Diese Art der Kombination zweier Dateien zu einer heißt 2-Wege-Mergesort. Es gibt weiterhin x-Wege-Mergesort-Varianten, welche x Dateien zu einer vereinen - diese sollen hier allerdings nicht weiter betrachtet werden.

Folgendes kleines Beispiel illustriert das grundlegende Prinzip recht anschaulich: durch die Rekursion erfolgt zunächst eine Aufteilung der Datei bis zu einer Länge 1, dann werden die Daten durch den Mischprozess paarweise zusammen gefügt, wobei gleichzeitig die Sortierung stattfindet. Die genaue Implementation dieses Prozesses ist im folgenden Abschnitt erläutert.


3.3 Zur programmiertechnischen Realisierung


3.3.1 Rekursiv:
Zunächst soll hier die rekursive Variante des 2-Wege-Mergesort besprochen werden. Wie schon erwähnt erfolgen im Gegensatz zu Quicksort am Anfang der Funktion die rekursiven Aufrufe, wodurch die aktuelle Datei in der Mitte in 2 Hälften geteilt wird. Dies geschieht solange, bis alle Dateien nur noch die Länge 1 haben, dann erfolgen keine weiteren Rekursionen mehr. Der nächste Schritt ist das Mischen: 2 benachbarte Dateien der Länge x und y werden zu einer Datei der Länge x+y kombiniert, wobei die entstehende Datei sortiert sein soll. Hier macht sich Mergesort die Eigenschaft zunutze, dass Dateien der Länge 1 bereits trivial sortiert sind und somit unter der Eigenschaft kombiniert werden kann, dass die beiden Teildateien bereits sortiert vorliegen. Dies erfordert nun natürlich, dass Mergesort die Dateien beim Vereinen gleich sortiert, was allerdings einfach zu realisieren ist: für die Grundidee benötigt man nur 2 Ganzzahlen auf Arrayindizes. Den ersten Zeiger i initialisiert man mit der Anfangsposition der linken Datei, der zweite Zeiger j erhält den Anfang der rechten Datei. Da man weiß, dass die beiden Dateien schon sortiert sind, kann man wie folgt vorgehen: man durchlaufe mit den beiden Zeigern die Dateien derart, dass die beiden Schlüssel, auf welche i und j zeigen, miteinander verglichen werden. Der kleinere der beiden Schlüssel wird in einen anfangs leeren Zwischenspeicher eingefügt und der mit diesem Schlüssel verbundene Arrayzeiger um eine Position weiter gesetzt. Dieser Vorgang wird solange wiederholt, bis eine der beiden Teildateien erschöpft ist. In diesem Fall kann der Rest der anderen Teildatei an die bisherige Folge angehangen werden - die beiden Dateien sind nun vereint und ergeben eine einzige sortierte Folge.

Zur Realisierung dieses Algorithmus' würde man noch mehrere Marken benötigen, um die Enden der Teildateien abfragen zu können. Es geht aber auch anders: effizienter ist es, die beiden Zeiger derart auszunutzen, dass der eine die Marke für den anderen ergibt. Somit wäre folgende rekursive Realisierung denkbar:

void mergesort(int a[], int l, int r){		//l=linker Rand, r=rechter Rand
    if(r>l){
        int i, j, k, m;				//Variablen deklarieren
        m=(r+l)/2;				//Mitte ermitteln
        mergesort(a, l, m);			//linke Teildatei
        mergesort(a, m+1, r);			//rechte Teildatei
        for(i=m+1; i>l; i--) b[i-1]=a[i-1];	//linke  Teildatei in Hilfsarray
        for(j=m; j<r; j++) b[r+m-j]=a[j+1];	//rechte Teildatei umgedreht in Hilfsarray
        for(k=l; k<=r; k++)
            a[k]=(b[i]<b[j])?b[i++]:b[j--];	//füge sortiert in a ein
    }
}


Zu beachten ist, dass man ein globales Hilfsfeld b[] zur Verfügung stellen muss, welches die gleiche Dimension wie das zu sortierende Feld a[] hat. Ein Sortieren eines Feldes a[] erfolgt dann durch den Aufruf von mergesort(a,0,n-1); wobei n die Elementeanzahl darstellt.
Zur Analyse: besteht die Datei aus mehr als 1 Element (r>l), so folgen zunächst die rekursiven Aufrufe für linke und rechte Teildatei. Dann werden diese beiden Teildateien aus Array a[] in das Hilfsarray b[] übertragen. Die erste for-Schleife ist für die linke Teildatei zuständig und speichert sie in der Reihenfolge in b[] ab, wie sie auch schon in a[] vorkommt. Dabei ist zu beachten, dass i zu Beginn auf das letzte Element der Teildatei gesetzt ist und bis zum linken Rand dekrementiert wird. Dass dies Sinn macht, wird gleich verdeutlicht. Die zweite for-Schleife kümmert sich um die rechte Teildatei. j wird vom Anfang der Teildatei ausgehend solange inkrementiert, bis es den rechten Rand erreicht hat. Die Abspeicherung dieser Teildatei in b[] erfolgt allerdings in umgekehrter Reihenfolge.
Die Zeiger i und j werden jetzt nur noch für das Auslesen aus dem Hilfsfeld verwendet: i steht auf dem linken Rand der Datei und somit auf dem kleinsten Element der linken Teildatei, j zeigt auf den rechten Rand der Datei und auch auf das kleinste Element der rechten Teildatei, da diese in umgekehrter Reihenfolge in das Hilfsfeld eingefügt wurde. Warum das Ganze? Ganz einfach: man benötigt keine weiteren Marken zur Prüfung des Endes der beiden Teildateien. Man muss nur mit der dritten for-Schleife die Elemente vergleichen, auf welche die beiden Zeiger zeigen. Das kleinere von beiden wird zurück in a[] eingefügt und der Zeiger im Falle von i eine Position weiter, im Falle von j eine Position zurück gesetzt. Ist eine der beiden Folgen erschöpft, so steht dessen Zeiger auf dem größten Element der anderen Folge und verharrt dort, bis deren Feldzeiger zum selben Element gelangt. Dieses letzte Element wird noch in a[] eingefügt und die letzte for-Schleife terminiert, da alle Elemente betrachtet wurden - die beiden Teildateien sind miteinander verschmolzen und die entstandene Datei ist sortiert.

Folgendes selbst gewählte Beispiel verdeutlicht ausführlich die Vorgehensweise des obigen Algorithmus':
1.) Zunächst werden die beiden schon sortierten Teildateien aus a[] nach b[] verschoben, wobei die rechte Teildatei in umgekehrter Reihenfolge in b[] eingeordnet wird.
2.1) Nun erfolgt die Rückverschiebung nach a[]. i steht zu Beginn auf dem linken, j auf dem rechten Rand der Datei und auf den jeweils kleinsten Elementen der beiden Folgen. Das Element, auf das i zeigt, ist kleiner als das Element von j (1<2), also wird 1 nach a[] verschoben und i inkrementiert.
2.2) 3 ist größer als 2, also wird 2 in a[] eingefügt und j dekrementiert.
2.3) 3<6, also 3 in a[] einfügen und i inkrementieren.
2.4) 5<6, also 5 in a[] einfügen und i inkrementieren.
2.5) 9>6, also 6 in a[] einfügen und j dekrementieren.
2.6) 9>7, also 7 in a[] einfügen und j dekrementieren.
2.7) 9>7, also 7 in a[] einfügen und j dekrementieren.
2.8) j zeigt auf das letzte Element der linken Teilfolge, auf das auch i zeigt. Dieses letzte Element 9 wird nach a[] verschoben - die beiden sortierten Teildateien wurden zu einer sortierten Datei gemischt und der Algorithmus ist beendet.

3.3.2 Iterativ
Im Gegensatz zu Quicksort lässt sich eine iterative Variante von Mergesort recht gut realisieren, vor allem weil die zu sortierende Datenfolge sequentiell abgearbeitet werden kann und kein zusätzlicher Speicherplatz für einen Hilfsstapel benötigt wird, der die Grenzen von Teilfolgen abspeichern muss.
Das sogenannte Bottom-up-Mergesort ist die einfachste Realisierung eines iterativen Mergesort's. Dabei wird die Liste fortwährend durchlaufen, wobei bei jedem Durchlauf die zu kombinierende Folgenlänge verdoppelt wird. So beträgt diese Länge beim ersten Durchlauf 1, sodass benachbarte Folgen der Länge 2 gebildet werden. Beim zweiten Durchlauf werden dann Folgen der Länge 4, beim dritten der Länge 8 usw. erstellt, bis die Datei aus nur noch einer Folge besteht. Wichtig ist anzumerken, dass die Mischoperationen bei der iterativen Variante nicht denen bei der Rekursion gleichen. Da bei der Rekursion stets in 2 Teile zerlegt wird und bei der iterativen Variante sequentiell benachbarte Dateien gleicher Länge kombiniert werden, handelt es sich z.B. bei einer Folge mit 11 Elementen bei der letzten Sortierung mit der rekursiven Version um ein 5-mit-6-Mischen, während bei der Iteration ein 8-mit-3-Mischen ausgeführt wird (die erste Zahl ist hier immer eine Zweierpotenz).
Folgender Code zeigt eine iterative Implementierung des Mergesort-Algorithmus', ein Aufruf der Funktion für ein zu sortierendes Feld a mit n Elementen erfolgt mit mergesort_it(a,n-1); :

void mergesort_it(int a[], int n){		//n+1=Elementezahl, größer 1
    int step, i, j, k, m, l, r;
    for(step=2; step<n*2; step*=2){		//verdopple sequentiell die Dateilänge
        r=-1;					//Anfangswert für rechte Grenze
        while(r<n){				//solange Folge nicht komplett betrachet
            l=r+1;				//linken Rand auf bisher rechte Grenze+1
            r+=step;				//rechten Rand um step erhöhen
            m=(l+r)/2;				//Mitte finden
            if(r>n){				//wenn über das Ziel hinaus geschossen
                r=n;				//dann auf letztes Element setzen
                if(m>r) m=(l+r)/2;		//wenn Mitte hinter r liegt, neu berechnen
            }
            for(i=m+1; i>l; i--) b[i-1]=a[i-1];	//linke  Teildatei in Hilfsarray
            for(j=m; j<r; j++) b[r+m-j]=a[j+1];	//rechte Teildatei umgedreht in Hilfsarray
            for(k=l; k<=r; k++)
                a[k]=(b[i]<b[j])?b[i++]:b[j--];	//füge sortiert in a ein
        }
    }
}


3.4 Ein Beispiel zu rekursivem Mergesort


Das folgende Beispiel zeigt, wie die Teildateien bei rekursivem Mergesort miteinander verbunden und dabei sortiert werden:
1.) Die Ausgangssituation: die Datei wurde durch Rekursion komplett in Teildateien der Länge 1 zerlegt, nun erfolgt paarweises Mischen.
2.1) Die ersten beiden Teildateien werden zu einer Datei der Länge 2 gemischt.
2.2) Die zweiten beiden Teildateien werden ebenso zu einer Datei der Länge 2 kombiniert.
2.3) Die beiden entstandenen Dateien der Länge 2 werden zu einer Datei der Länge 4 gemischt.
2.4) 7 und 9 werden kombiniert...
2.5) ... ebenso 1 und 2.
2.6) Kombination der beiden Teildateien zu einer Datei der Länge 4.
2.7) Die beiden bereits sortierten Länge-4-Dateien werden miteinander gemischt.
2.8) 10 und 15 werden zusammengefügt.
2.9.) 5 und 6 kommen zu einer Datei der Länge 2 zusammen.
2.10) Beide Dateien werden zu einer Länge-4-Datei vereint.
3.) Die beiden übrig bleibenden Dateien werden zu einer einzigen sortierten Datei kombiniert - der Algorithmus ist beendet.

3.5 Analyse der Leistungsfähigkeit


Das umstrittene Genie von Neumann hat mit Mergesort schon in der "Computersteinzeit" ein recht einfaches optimales Sortierverfahren entwickelt, welches leicht stabil implementiert werden kann.
Dabei entspricht der günstigste ungefähr dem ungünstigsten Fall, die Anzahl der zu erledigenden Vergleiche variiert kaum. Die Laufzeitkomplexität kann leicht berechnet werden: pro Rekursionsebene werden n Vergleiche ausgeführt, ein Vergleich für jeden betrachteten Schlüssel. Insgesamt gibt es log(n) Rekursionsebenen, da sich pro Schicht die aktuelle Dateilänge halbiert (mit einem Binärbaum vergleichbar). Daraus ergibt sich eine Komplexität von O(n*log(n)), also das Optimum für ein Sortierverfahren.
Der Grund, warum Mergesort Quicksort nicht allgemein bevorzugt wird, ist der, dass es in praxi bei zufälligen Eingabedaten mehr Zeit zum Sortieren beansprucht als Quicksort mit derselben Datei - dies gilt für das grundlegende Quicksort ebenso wie für ein verbessertes Quicksort mit median-of-three und/oder insertion sort zur Behandlung kleiner Teidateien. Der Wertebereich des Geschwindigkeitsvorteils bewegt sich hier von 10-50%. Da bei Mergesort zusätzlicher Speicher benötigt wird, fällt daher eher die Wahl auf Quicksort. Zwischen iterativem und rekursivem Mergesort gibt es ebenfalls noch einmal Geschwindigkeitsunterschiede. Bei o.a. Implementationen ist die rekursive Variante ca. 15% schneller als die iterative Version, auch dies gilt es zu berücksichtigen.
Da Mergesort gegenüber der ursprünglichen Reihenfolge der Eingabedaten unempfindlich ist, ist es bei zufälligen Permutationen genauso effizient wie etwa bei bereits sortierten Folgen. Demgegenüber steht, dass es zu n proportionalen zusätzlichen Speicher benötigt, was bei immer höheren Speicherkapazitäten allerdings kein Problem mehr darstellen dürfte. Zudem ist es als sequentielles und stabiles Verfahren vor allem für Sortierungen von verketteten Listen und auf Externspeicher unentbehrlich, was seine Praxisrelevanz über die Jahre hin gesichert hat.


[ Zurück zum Inhaltsverzeichnis ]

(c) 2004 by RTC, www.linux-related.de
Dieses Dokument unterliegt der GNU Free Documentation License