Zum Inhalt springen

Sequenzcontainer: vector, deque, list, array

Erkunde die Sequenzcontainer der STL — vector, deque, list, forward_list und array. Lerne ihre Leistungs-Kompromisse, Speicherlayouts und Regeln zur Iterator-Invalidierung kennen.

Was sind Sequenzcontainer?

Sequenzcontainer speichern Elemente in einer strikten linearen Reihenfolge. Die STL stellt fünf Sequenzcontainer bereit, die jeweils für unterschiedliche Zugriffs- und Änderungsmuster optimiert sind:

- std::vector — dynamisches Array, zusammenhängender Speicher
- std::deque — doppelseitige Warteschlange, schnelles Einfügen an beiden Enden
- std::list — doppelt verkettete Liste
- std::forward_list — einfach verkettete Liste
- std::array — Array fester Größe, Stack-allokiert

Die Wahl des richtigen Containers ist eine der wirkungsvollsten Leistungsentscheidungen, die du in C++ treffen wirst. Die Schlüsselfaktoren sind Zugriffsmuster, Einfüge-/Löschmuster und Cache-Lokalität.

std::vector — Die Standardwahl

std::vector ist der am häufigsten verwendete Container. Er speichert Elemente in einem einzigen zusammenhängenden Speicherblock, was ihm eine hervorragende Cache-Performance und O(1)-Direktzugriff verleiht. Das Anfügen von Elementen mit push_back ist amortisiert O(1), da der Vektor seine Kapazität verdoppelt, wenn er voll ist.

vector_basics.cpp
#include <iostream>
#include <vector>

int main() {
    std::vector<int> nums;          // empty vector
    nums.reserve(100);              // pre-allocate for 100 elements (no reallocation)

    for (int i = 0; i < 10; ++i) {
        nums.push_back(i * i);      // amortized O(1)
    }

    // Size vs capacity
    std::cout << "Size:     " << nums.size()     << '\n';  // 10
    std::cout << "Capacity: " << nums.capacity() << '\n';  // >= 100

    // Random access — O(1)
    std::cout << "Element 3: " << nums[3] << '\n';         // 9
    std::cout << "Element 3: " << nums.at(3) << '\n';      // 9 (bounds-checked)

    // Erase-remove idiom (pre-C++20)
    nums.erase(std::remove_if(nums.begin(), nums.end(),
                              [](int x) { return x > 50; }),
               nums.end());

    // C++20: std::erase_if (cleaner)
    std::erase_if(nums, [](int x) { return x > 30; });

    // Range-based for loop
    for (int n : nums) {
        std::cout << n << ' ';
    }
    std::cout << '\n';
}

deque, list und forward_list

std::deque bietet O(1)-Einfügung und -Löschung an beiden Enden, verwendet aber ein segmentiertes Speicherlayout (Array aus Blöcken fester Größe) anstelle einer einzigen zusammenhängenden Allokation. std::list ist eine doppelt verkettete Liste, die O(1)-Einfügung/-Löschung überall mit einem Iterator ermöglicht, auf Kosten schlechter Cache-Lokalität. std::forward_list ist eine einfach verkettete Liste, die weniger Speicher pro Knoten verbraucht, aber nur Vorwärtstraversierung unterstützt.

deque_list.cpp
#include <deque>
#include <list>
#include <forward_list>
#include <iostream>

int main() {
    // deque — O(1) push_front and push_back
    std::deque<int> dq;
    dq.push_front(1);   // O(1) — vector cannot do this efficiently
    dq.push_back(2);    // O(1)
    dq.push_front(0);   // O(1)
    // dq: {0, 1, 2}

    // list — O(1) insert/erase at any position with iterator
    std::list<std::string> names = {"Alice", "Charlie", "Eve"};
    auto it = std::next(names.begin());  // points to "Charlie"
    names.insert(it, "Bob");             // O(1) insert before "Charlie"
    names.erase(it);                     // O(1) erase "Charlie"
    // names: {"Alice", "Bob", "Eve"}

    // list has splice — O(1) move of entire sublists
    std::list<std::string> more = {"Frank", "Grace"};
    names.splice(names.end(), more);     // moves all of 'more' into 'names'
    // names: {"Alice", "Bob", "Eve", "Frank", "Grace"}
    // more is now empty

    // forward_list — singly linked, no size(), push_front only
    std::forward_list<int> fl = {3, 1, 4, 1, 5};
    fl.push_front(0);
    fl.sort();           // member sort — O(n log n), stable
    fl.unique();         // remove consecutive duplicates
    // fl: {0, 1, 3, 4, 5}

    for (int v : fl) std::cout << v << ' ';
    std::cout << '\n';
}

std::array — Feste Größe, kein Overhead

std::array umschließt ein C-Array in einer richtigen Klasse und bietet .size(), .at(), Iteratoren und Kompatibilität mit allen STL-Algorithmen — ohne Overhead im Vergleich zu einem rohen T[N].

std_array.cpp
#include <array>
#include <algorithm>
#include <iostream>

int main() {
    std::array<int, 5> arr = {5, 2, 8, 1, 9};

    std::sort(arr.begin(), arr.end());       // works with STL algorithms

    std::cout << "Size: " << arr.size() << '\n';   // constexpr — known at compile time
    std::cout << "Front: " << arr.front() << '\n';  // 1
    std::cout << "Back:  " << arr.back()  << '\n';  // 9

    // Bounds-checked access
    try {
        arr.at(10);  // throws std::out_of_range
    } catch (const std::out_of_range& e) {
        std::cout << "Out of range: " << e.what() << '\n';
    }

    // Compile-time size via std::tuple_size
    constexpr std::size_t n = std::tuple_size_v<decltype(arr)>;  // 5
    static_assert(n == 5);
}

Den richtigen Sequenzcontainer wählen

Verwende diesen Entscheidungsleitfaden:

Verwende std::vector (die Standardwahl), wenn du Direktzugriff, cache-freundliche Iteration oder push/pop am Ende brauchst. Die überwiegende Mehrheit des Codes sollte vector verwenden.

Verwende std::deque, wenn du häufiges Einfügen/Entfernen an beiden Enden brauchst (z.B. ein Gleitfenster, BFS-Warteschlange).

Verwende std::list, wenn du O(1)-Einfügen/-Entfernen in der Mitte mit einem Iterator brauchst, oder wenn du splice() benötigst, um Teillisten zwischen Listen ohne Kopieren zu verschieben.

Verwende std::forward_list, wenn der Speicherverbrauch pro Knoten wichtig ist und du nur Vorwärtstraversierung brauchst.

Verwende std::array, wenn die Größe zur Kompilierzeit bekannt ist und sich nie ändern wird.

Leistungshinweis: Aufgrund von CPU-Cache-Effekten übertrifft std::vector oft std::list, selbst bei Einfügungen in der Mitte, auf moderner Hardware bis zu überraschend großen Größen (Tausende von Elementen). Messe immer, bevor du eine verkettete Liste wählst.

Stolperfalle

Jeder Container hat unterschiedliche Regeln dafür, wann Iteratoren, Zeiger und Referenzen ungültig werden:

vector — Einfügen, das eine Reallokation auslöst, invalidiert alle Iteratoren. Einfügen ohne Reallokation invalidiert Iteratoren an oder nach der Einfügestelle. Löschen invalidiert Iteratoren an oder nach der Löschstelle.

deque — Einfügen am Anfang oder Ende invalidiert alle Iteratoren, aber nicht Referenzen/Zeiger auf bestehende Elemente. Einfügen in der Mitte invalidiert alles. Löschen am Anfang/Ende invalidiert nur den Iterator des gelöschten Elements.

list / forward_list — Einfügen und Löschen invalidieren niemals Iteratoren, Referenzen oder Zeiger auf andere Elemente. Nur der Iterator des gelöschten Elements wird invalidiert.

array — Iteratoren werden nie invalidiert (das Array kann seine Größe nicht ändern).

Ein häufiger Fehler ist das Ändern eines vector innerhalb einer Range-basierten for-Schleife. Wenn ein push_back eine Reallokation auslöst, werden die versteckten Iteratoren der Schleife zu dangling Iteratoren.

Best Practice

Wenn du weißt (oder abschätzen kannst), wie viele Elemente ein vector aufnehmen wird, rufe reserve() auf, bevor du ihn füllst. Dies allokiert Speicher im Voraus und vermeidet kostspielige Reallokationen:

- reserve(n) ändert die Kapazität, aber nicht die Größe — es werden keine Elemente hinzugefügt.
- resize(n) ändert die Größe — es werden tatsächlich Elemente standardkonstruiert.
- shrink_to_fit() ist eine unverbindliche Anfrage, die Kapazität an die Größe anzupassen.

Für heiße Schleifen, die Vektoren aufbauen, kann ein einzelner reserve()-Aufruf den Allokations-Overhead drastisch reduzieren. Messe mit großen Datensätzen, um den Unterschied zu sehen.

Wichtigste Erkenntnisse
  • std::vector ist die Standardwahl — zusammenhängender Speicher bedeutet cache-freundliche Iteration und O(1)-Direktzugriff
  • Verwende reserve(), um Vektorspeicher im Voraus zu allokieren, wenn die ungefähre Größe bekannt ist
  • std::deque bietet O(1) push/pop an beiden Enden, ist aber nicht zusammenhängend
  • std::list bietet O(1)-Einfügen/-Löschen mit einem Iterator und invalidiert niemals andere Iteratoren
  • std::array ist ein Overhead-freier Wrapper um C-Arrays mit voller STL-Kompatibilität
  • Die Regeln zur Iterator-Invalidierung unterscheiden sich je Container — kenne sie, um Dangling-Iterator-Fehler zu vermeiden

Quiz — Teste dein Wissen

(15 XP)

1. Welcher Sequenzcontainer bietet amortisiertes O(1) `push_back` und ein zusammenhängendes Speicherlayout?

2. Was passiert mit allen vector-Iteratoren, wenn ein `push_back` eine Reallokation auslöst?

3. Was ist der Unterschied zwischen `reserve(n)` und `resize(n)` bei einem Vektor?