Das C# .Net Framework 3.5 bietet eine Vielzahl an verschiedenen Datenstrukturen um Werte zu speichern – sogenannte Collections. Welche nun die Richtige ist richtet sich vor allem nach der jeweiligen Verwendung und demnach auch nach der Performance der Collection. Ein kleiner Vergleich soll bei der Wahl der richtigen Struktur helfen. Dafür wird jeder Collection dieselbe Anzahl an Objekten hinzugefügt und die Zeit die dafür benötigt wird gemessen. Danach wird in einer Schleife mehrfach ein nicht vorhandenes Objekt gesucht und ebenfalls wieder gemessen. In diesem Artikel soll die Größenbeschränkung auf Grund eines 32bit Systems und der maximalen Größe in Bytes einer Collection keine Rolle spielen.
System.Collections
Der Namespace System.Collections (in der mscorlib) beinhaltet alle nicht generischen Collections.
ArrayList
Die ArrayList ist im Prinzip nichts anderes als ein normales Array welches beliebig wachsen kann. Die Startgröße des Arrays ist 4. Sollte bei einem Einfügen die Größe nicht mehr ausreichen, so verdoppelt sich die mögliche Anzahl an Elementen – sollte dies durch ein AddRange nicht ausreichen so wächst es um die Anzahl der neuen Elemente. Das Verdoppeln (und Einfügen mit AddRange) erfolgt durch ein einfaches Array.Copy.
Ein Lookup mit Contains geht jedes Element durch und prüft mit Hilfe von Equals ob die Bedingung erfüllt ist. Deshalb sollte man tunlichst diese Methode NICHT verwenden. Die ArrayList bietet eine erheblich performantere Variante an: die BinarySearch. Diese beginnt mit einem Mittelwert einer Ober- und Unter-Grenze und vergleicht den gefunden Wert. Je nach dem ob er größer oder kleiner ist wird der Mittelwert dementsprechend angepasst.
Durch das Bereitstellen eines genügend großen Arrays und auf Grund des performanten Array.Copy bietet sich diese Struktur für ein schnelles Einfügen an. Müssen einzelne Werte auf Vorhandensein geprüft oder abgerufen werden so sollte die schnelle BinarySearch verwendet werden. Dies kommt zwar noch lange nicht an einen Hash heran aber ist schon nicht so schlecht.
Hashtable
Die Hashtable speichert beliebige Key-Value Paare ab. Dabei wird ein Hashwert basierend auf dem Key (und der Anzahl an bereits eingefügten Werten) berechnet und einem Array hinzugefügt. Im Grunde unterscheidet sich die Hashtable nicht groß vom Dictionary (mal abgesehen davon, dass sie nicht generisch ist).
Queue
Die Queue ist eine Implementierung einer FIFO Struktur. Das Element welches zuerst hineingepackt wird, kommt auch als erstes wieder heraus. Auch hier wird intern ein Array von Objekten verwendet sowie jeweils einen Zeiger auf das erste und letzte Element. Somit ist das Einfügen ähnlich schnell wie auch bei der ArrayList, jedoch gibt es keine Performante Möglichkeit auf das Vorhandensein eines Objektes zu prüfen. Durch einen geeigneten growFactor Wert (zwischen 1.0 und 10.0 – gibt den Prozentsatz an, um den das Array, basierende auf der derzeitigen Größe, wächst) kann beim Anlegen der Queue die Performance nochmals gesteigert werden.
SortedList
Die SortedList speichert Key-Value Werte in zwei Arrays ab. Dabei wird vor dem Einfügen mit Hilfe der BinarySearch die letzte untere Grenze ermittelt (BinarySearch gibt immer die letzte untere Grenze als negativen Wert zurück). Der LookUp auf einen Key erfolgt wiederum mit einer BinarySearch.
Stack
Der Stack stellt eine typische LIFO Datenstruktur dar. Wie auch die anderen einfachen Listen basiert er auf einem Array von Objekten welches automatisch um das Doppelte anwächst, sollte der Platz nicht mehr ausreichen. Wie erwartet ist dadurch das Hinzufügen von Elementen recht schnell. Ein Contains prüft jedoch jedes einzelne Element in dem Stack wodurch auch die schlechte Performance erklärt wäre.
System.Collection.Generic in mscorlib
Der System.Collection.Generic namespace in der mscorlib enthält lediglich das generische Dictionary und die generische List (neben anderen Hilfsklassen und Interfaces).
Dictionary<TKey, TValue>
Dies ist wohl die am häufigsten verwendete Key-Value Datenstruktur. Dabei wird ein Hashwert des TKeys berechnet und zeigt auf einen Eintrag in einem zweiten Array. Sollte sich ein Hashwert doppeln so entsteht für diesen Eintrag eine einfach verkettete Liste, welche auf den nächsten Wert mit diesem Hash zeigt. In der Art funktioniert auch das ContainsKey: gestartet wird mit dem berechneten Hash und danach wird die Liste durchgegangen, bis das Objekt gefunden wurde oder keine weiteren Objekte mehr vorhanden sind. Auch wenn man sich nun vorstellen könnte, dass durch die Verwendung dieser Liste mehr als Int32 Elemente in dem Dictionary gehalten werden können so täuscht dies: da die eigentlichen Werte wiederum in einem Array von Werten (mit dem Zeiger auf den evtl. vorhanden nächsten Wert) gehalten werden so hat dies zur Folge, dass theoretisch bei 2.147.483.647 Einträgen Schluss ist.
List<T>
Der einzige Unterschied zwischen der generischen List und der ArrayList liegt in dem Typen. Die ArrayList hält intern ein Array von Object und die generische Liste vom Type T.
System.Collection.Generic in System.dll
Die generischen Collections in der System.dll sind zum größten Teil generische Varianten der bereits erwähnten Datenstrukturen. Somit erübrigt sich eine nähere Betrachtung der Queue<T>, SortedList<T> und Stack<T>.
LinkedList<T>
Diese Collection ist eine Implementierung einer ganz normalen doppelt verketteten Liste. Dadurch müssen jedoch für ein Contains oder Find alle Elemente durchsucht werden, welches wie erwartet natürlich einiges an Zeit beansprucht.
SortedDictionary<TKey, TValue>
Das SortedDictionary ist ein Rot-Schwarz Baum welcher die Warte speichert. Damit ist es beim Hinzufügen von Werten natürlich erheblich schneller als die SortedList.
1. Messung
Die erste Messung wurde mit 100.000 Einfüge-Operationen und 1000 Lookups durchgeführt.
| Collection |
Einfügen |
Zeit in ms |
Lookup |
Zeit in ms |
Lookup |
Zeit in ms |
| ArrayList |
Add |
80 |
BinarySearch |
1 |
|
|
| Queue(NumOfItems) |
Enqueue |
84 |
Contains |
3879 |
|
|
| Queue |
Enqueue |
97 |
Contains |
2779 |
|
|
| Queue<String> |
Enqueue |
100 |
Contains |
4819 |
|
|
| Stack<String> |
Push |
102 |
Contains |
4041 |
|
|
| List<String> |
Add |
103 |
BinarySearch |
2 |
|
|
| Stack |
Push |
107 |
Contains |
2843 |
|
|
| LinkedList<String> |
AddFirst |
120 |
Contains |
6316 |
|
|
| HashSet<String> |
Add |
131 |
Contains |
0 |
|
|
| LinkedList<String> |
AddLast |
134 |
Contains |
5805 |
|
|
| ArrayList(NumOfItems) |
Add |
151 |
BinarySearch |
1 |
|
|
| Dictionary<String, String> |
Add |
268 |
ContainsKey |
0 |
ContainsValue |
4577 |
| Hashtable(NumOfItems) |
Add |
285 |
ContainsKey |
0 |
ContainsValue |
8711 |
| Hashtable |
Add |
378 |
ContainsKey |
0 |
ContainsValue |
9602 |
| ArrayList.Sort |
Add & Sort |
383 |
BinarySearch |
1 |
|
|
| SortedDictionary<String, String> |
Add |
838 |
ContainsKey |
3 |
ContainsValue |
28380 |
| ArrayList |
Insert(0) |
10651 |
Contains |
2655 |
|
|
| SortedList<String, String> |
Add |
13602 |
ContainsKey |
2 |
ContainsValue |
6059 |
| SortedList |
Add |
13631 |
ContainsKey |
1 |
ContainsValue |
5906 |
| SortedList(NumOfItems) |
Add |
13734 |
ContainsKey |
1 |
ContainsValue |
5967 |
2. Messung
Die zweite Messung wurde mit 1.000.000 Einfüge-Operationen und 10.000 Lookups durchgeführt.
| Collection |
Einfügen |
Zeit in ms |
Lookup |
Zeit in ms |
Lookup |
Zeit in ms |
| Stack<String> |
Push |
1212 |
Contains |
282.362 |
|
|
| ArrayList |
Add |
1229 |
BinarySearch |
19 |
|
|
| Stack |
Push |
1234 |
Contains |
264.446 |
|
|
| Queue<String> |
Enqueue |
1249 |
Contains |
265.460 |
|
|
| ArrayList(NumOfItems) |
Add |
1318 |
BinarySearch |
20 |
|
|
| Queue(NumOfItems) |
Enqueue |
1380 |
Contains |
254.415 |
|
|
| Queue |
Enqueue |
1385 |
Contains |
258.666 |
|
|
| LinkedList<String> |
AddFirst |
1536 |
Contains |
485.436 |
|
|
| List<String> |
Add |
1587 |
BinarySearch |
24 |
|
|
| LinkedList<String> |
AddLast |
1676 |
Contains |
557.289 |
|
|
| HashSet<String> |
Add |
1858 |
Contains |
0 |
|
|
| Dictionary<String, String> |
Add |
3118 |
ContainsKey |
0 |
ContainsValue |
431.813 |
| Hashtable |
Add |
5267 |
ContainsKey |
0 |
ContainsValue |
917.237 |
| ArrayList.Sort |
Add & Sort |
5404 |
BinarySearch |
20 |
|
|
| Hashtable(NumOfItems) |
Add |
5444 |
ContainsKey |
0 |
ContainsValue |
833.834 |
| SortedDictionary<String, String> |
Add |
14.378 |
ContainsKey |
40 |
ContainsValue |
3.091.111 |
| ArrayList |
Insert(0) |
1.171.009 |
Contains |
242.737 |
|
|
| SortedList |
Add |
1.836.665 |
ContainsKey |
19 |
ContainsValue |
607.117 |
| SortedList(NumOfItems) |
Add |
1.838.737 |
ContainsKey |
19 |
ContainsValue |
622.087 |
| SortedList<String, String> |
Add |
1.845.453 |
ContainsKey |
24 |
ContainsValue |
671.537 |
Fazit
Sucht man eine Datenstruktur um Werte schnell zu speichern so ist die ArrayList die beste Wahl – vorausgesetzt man verwendet die Add Methode. Jedoch sollte unbedingt die BinarySearch Methode verwendet werden um Elemente zu laden oder auf deren Existenz zu prüfen. Steht jedoch die Suche nach Elementen im Vordergrund sollte lieber das HashSet<T> verwendet werden. Auch wenn dies durch die Berechnung des Hashwertes etwas langsamer beim Einfügen ist, so macht es dies bei einem Lookup wieder wett. Will man Key-Value Paare speichern, so ist das generische Dictionary<TKey, TValue>die erste Wahl.
Für sortierte Strukturen – will man keinen B* Baum implementieren – bietet sich das generische ArrayList mit der Sort() Methode an. Dieses ist für Einfügeoperationen am schnellsten und die Suche nach Elementen mit Hilfe der BinarySearch kann braucht sich nicht hinter dem SortedDictionary<TKey, TValue> zu verstecken.