C# : Les collections en pratique
List, Dictionary, HashSet, Queue, Stack, LinkedList et les variantes triées : chaque structure avec ses méthodes et ses coûts.
C# : Les collections en pratique
Le tour des classes concrètes de System.Collections.Generic : celles que tu instancies au quotidien. Pour chacune, l'usage courant et le coût des opérations.
# List<T> - le tableau dynamique
La collection la plus utilisée, de loin. C'est un tableau qui se redimensionne tout seul quand il se remplit.
List<string> villes = new List<string> { "Paris", "Lyon", "Marseille" };
villes.Add("Lille"); // ajoute à la fin
villes.Insert(0, "Bordeaux"); // insère en tête
villes.Remove("Lyon"); // retire par valeur
villes.RemoveAt(1); // retire par index
string premiere = villes[0]; // accès par index
int combien = villes.Count; // nombre d'éléments
bool aParis = villes.Contains("Paris");List<string> villes = new List<string> { "Paris", "Lyon", "Marseille" }; villes.Add("Lille"); // ajoute à la finvilles.Insert(0, "Bordeaux"); // insère en têtevilles.Remove("Lyon"); // retire par valeurvilles.RemoveAt(1); // retire par index string premiere = villes[0]; // accès par indexint combien = villes.Count; // nombre d'élémentsbool aParis = villes.Contains("Paris");Côté performance, c'est un tableau derrière : l'accès par index est instantané (O(1)), mais insérer ou supprimer au milieu décale tous les éléments suivants (O(n)).
| Opération | Coût |
|---|---|
liste[i] (lecture / écriture) | O(1) |
Add (en fin) | O(1) amorti |
Insert / RemoveAt au milieu | O(n) |
Contains / IndexOf | O(n) |
Pourquoi Add est O(1) "amorti", comment le tableau interne grandit, et quand passer la capacité au constructeur : tout est dans List<T> en profondeur.
# Dictionary<TKey, TValue> - la table de hachage
Associe une clé unique à une valeur. La recherche par clé est quasi instantanée (O(1) en moyenne) grâce au hachage.
Dictionary<string, int> ages = new Dictionary<string, int>
{
["Alice"] = 30,
["Bob"] = 25,
};
ages["Charlie"] = 40; // ajoute ou met à jour
int ageAlice = ages["Alice"]; // lecture par clé
bool existe = ages.ContainsKey("Bob");
foreach (KeyValuePair<string, int> paire in ages)
{
Console.WriteLine($"{paire.Key} a {paire.Value} ans");
}Dictionary<string, int> ages = new Dictionary<string, int>{ ["Alice"] = 30, ["Bob"] = 25,}; ages["Charlie"] = 40; // ajoute ou met à jourint ageAlice = ages["Alice"]; // lecture par clébool existe = ages.ContainsKey("Bob"); foreach (KeyValuePair<string, int> paire in ages){ Console.WriteLine($"{paire.Key} a {paire.Value} ans");}Attention à l'accès par clé absente : l'indexeur lève une exception. La bonne pratique, c'est TryGetValue :
// PLANTE : KeyNotFoundException si "Zoe" n'existe pas
int age = ages["Zoe"];
// BIEN : TryGetValue teste et récupère en une seule opération
if (ages.TryGetValue("Zoe", out int ageZoe))
{
Console.WriteLine(ageZoe);
}// PLANTE : KeyNotFoundException si "Zoe" n'existe pasint age = ages["Zoe"]; // BIEN : TryGetValue teste et récupère en une seule opérationif (ages.TryGetValue("Zoe", out int ageZoe)){ Console.WriteLine(ageZoe);}Comment fonctionne le hachage, pourquoi une clé mal choisie casse les performances, et comment utiliser des clés personnalisées : voir Dictionary et le hachage.
# HashSet<T> - l'ensemble d'éléments uniques
Une collection sans doublons où tester l'appartenance est instantané (O(1)). Idéal pour dédupliquer ou vérifier « est-ce que je l'ai déjà vu ? ».
HashSet<string> vus = new HashSet<string>();
bool ajoute1 = vus.Add("Paris"); // true : nouvel élément
bool ajoute2 = vus.Add("Paris"); // false : déjà présent, ignoré
bool contient = vus.Contains("Paris"); // O(1)HashSet<string> vus = new HashSet<string>(); bool ajoute1 = vus.Add("Paris"); // true : nouvel élémentbool ajoute2 = vus.Add("Paris"); // false : déjà présent, ignoré bool contient = vus.Contains("Paris"); // O(1)Il offre aussi les opérations ensemblistes mathématiques :
HashSet<int> a = new HashSet<int> { 1, 2, 3, 4 };
HashSet<int> b = new HashSet<int> { 3, 4, 5, 6 };
a.IntersectWith(b); // a = { 3, 4 } (intersection)
// a.UnionWith(b); // a = { 1..6 } (union)
// a.ExceptWith(b); // a = { 1, 2 } (différence)HashSet<int> a = new HashSet<int> { 1, 2, 3, 4 };HashSet<int> b = new HashSet<int> { 3, 4, 5, 6 }; a.IntersectWith(b); // a = { 3, 4 } (intersection)// a.UnionWith(b); // a = { 1..6 } (union)// a.ExceptWith(b); // a = { 1, 2 } (différence) Attention : un HashSet dédoublonne « par contenu » avec un record, mais « par référence » avec une classe brute. Le détail dans HashSet et l'égalité (classe vs record).
# Queue<T> - premier entré, premier sorti (FIFO)
Une file d'attente. On ajoute d'un côté (Enqueue), on retire de l'autre (Dequeue). Le premier arrivé est le premier servi, comme à la boulangerie.
Queue<string> file = new Queue<string>();
file.Enqueue("Alice"); // Alice entre
file.Enqueue("Bob"); // Bob entre derrière
string premier = file.Peek(); // "Alice" (regarde sans retirer)
string servi = file.Dequeue(); // "Alice" sort
int restants = file.Count; // 1 (Bob)Queue<string> file = new Queue<string>(); file.Enqueue("Alice"); // Alice entrefile.Enqueue("Bob"); // Bob entre derrière string premier = file.Peek(); // "Alice" (regarde sans retirer)string servi = file.Dequeue(); // "Alice" sortint restants = file.Count; // 1 (Bob)Quand l'utiliser : traiter des tâches dans l'ordre d'arrivée, un parcours en largeur (BFS) d'un graphe, un buffer de messages.
# Stack<T> - dernier entré, premier sorti (LIFO)
Une pile. On empile (Push) et on dépile (Pop) toujours par le haut. Le dernier posé est le premier repris, comme une pile d'assiettes.
Stack<string> pile = new Stack<string>();
pile.Push("page 1");
pile.Push("page 2");
pile.Push("page 3");
string sommet = pile.Peek(); // "page 3" (regarde sans retirer)
string retour = pile.Pop(); // "page 3" sort
// il reste "page 1" et "page 2"Stack<string> pile = new Stack<string>(); pile.Push("page 1");pile.Push("page 2");pile.Push("page 3"); string sommet = pile.Peek(); // "page 3" (regarde sans retirer)string retour = pile.Pop(); // "page 3" sort// il reste "page 1" et "page 2"Quand l'utiliser : une fonction annuler / rétablir (undo), l'historique de navigation, un parcours en profondeur (DFS), l'évaluation d'expressions.
La pile d'appels de méthodes (voir la note sur la mémoire) fonctionne exactement comme un Stack : chaque appel empile une frame, chaque return la dépile. Tu peux visualiser une pile et un tas en action dans le playground.
# LinkedList<T> - la liste doublement chaînée
Une suite de nœuds reliés entre eux, chacun connaissant son précédent et son suivant. Insérer ou supprimer quand on tient déjà le nœud est instantané (O(1)), sans décaler le reste.
LinkedList<int> liste = new LinkedList<int>();
LinkedListNode<int> noeud = liste.AddFirst(10);
liste.AddLast(30);
liste.AddAfter(noeud, 20); // insère 20 juste après le 10
// liste = 10 -> 20 -> 30LinkedList<int> liste = new LinkedList<int>(); LinkedListNode<int> noeud = liste.AddFirst(10);liste.AddLast(30);liste.AddAfter(noeud, 20); // insère 20 juste après le 10 // liste = 10 -> 20 -> 30 En pratique, LinkedList<T> est rarement le bon choix. List<T> est presque toujours plus rapide malgré sa complexité théorique, parce que ses éléments sont contigus en mémoire (meilleure utilisation du cache CPU). Ne l'utilise que si tu insères et supprimes beaucoup au milieu et que tu tiens déjà les nœuds.
# Les variantes triées
Trois collections gardent leurs éléments triés en permanence, au prix d'insertions un peu plus coûteuses (O(log n)) :
SortedDictionary<TKey, TValue>: un dictionnaire dont les clés restent ordonnées.SortedSet<T>: unHashSettoujours trié.SortedList<TKey, TValue>: proche duSortedDictionary, plus économe en mémoire mais plus lent à l'insertion.
SortedDictionary<string, int> scores = new SortedDictionary<string, int>
{
["Charlie"] = 3,
["Alice"] = 1,
["Bob"] = 2,
};
// L'itération sort dans l'ordre des clés : Alice, Bob, CharlieSortedDictionary<string, int> scores = new SortedDictionary<string, int>{ ["Charlie"] = 3, ["Alice"] = 1, ["Bob"] = 2,};// L'itération sort dans l'ordre des clés : Alice, Bob, Charlie# Et les interfaces ?
Toutes ces classes implémentent une hiérarchie de contrats (IEnumerable<T>, ICollection<T>, IList<T>...). Les connaître permet d'écrire des méthodes qui acceptent n'importe quelle collection plutôt qu'un type figé.
C'est le sujet de la page voisine : Les interfaces de collection.
# La suite
- List<T> en profondeur - capacité, croissance, coût amorti
- Dictionary et le hachage - buckets, collisions, clés personnalisées
- HashSet et l'égalité - classe vs record, comparateurs
- Les interfaces de collection - la hiérarchie des contrats
- LINQ - filtrer, trier et transformer ces collections
Pour aller plus loin
C# : List<T> en profondeur
Capacity vs Count, croissance du tableau interne, coût amorti de Add, pré-dimensionnement et suppression propre.
C# : Dictionary et le hachage
Comment une table de hachage transforme une clé en emplacement, le rôle de GetHashCode et Equals, et l'usage de clés personnalisées.
C# : HashSet et l'égalité (classe vs record)
Pourquoi un HashSet dédoublonne un record mais pas une classe, comment rendre une classe utilisable dans un set, et le piège de la mutation.
Playground mémoire
Visualise « Tableaux et partage » pas à pas
Exécute ce code et observe la pile et le tas évoluer, ligne par ligne.