std::map - behind the scenes
Wie funktioniert eigentlich std::map
und welche Garantien gibt der Standard? Der Artikel zeigt einen mögliche
Implementierungsansatz (der garantiert nicht vollständig ist, da die “richtige” std::map
noch viel mehr Funktionalität
unterstützen muss).
Video
Code
|
|
Erklärung
Der Standard ist bzgl. der Container sehr freizügig. Es werden keine Implementierungen vorgegeben, sondern nur Schnittstellen
und Verhalten festgelegt. So gilt für die Operationen insert
und find
von std::map
, dass sie logarithmisch mit der Menge
der gespeicherten Elemente wachsen sollen. Wie der Implementierer das nun erreicht, ist seine Sache. Er könnte mit einem
zugrunde liegenden sortierten Array arbeiten, welches mit binärer Suche beackert wird (wobei das wiederum die Implementierung
des logarithmischen Inserts etwas verkompliziert) oder (was zumindest bei der Implementierung der Standardlibrary des GCC der
Fall ist) auf eine irgendwie geartete Baumstruktur zurückgreifen. Für letzteres habe ich mich hier im Beispiel entschieden.
Binärbäume haben genau die verlangte Eigenschaft: sowohl das Einfügen, als auch das Auffinden von von Elementen ist im Mittel
logarithmisch. Der (ausbalancierte) Baum hat eine Tiefe von log(n) (für n = Anzahl der Elemente im Baum), woraus für jedes
Suche eine maximale Anzahl von log(n) Vergleichen folgt (im schlimmsten Fall muss man sich eben bis zur untersten Ebene
durchhangeln). Einfügen geschieht einfach durch Anhängen an die richtige Stelle im Baum, wobei diese ebenso im schlimmsten
Fall in log(n) Schritten gefunden wird. Ergo: eigentlich die perfekte Datenstruktur für die Anforderungen von std::map
.
Um allerdings einen Baum so implementieren zu können, ist eine strenge schwache Ordnung der Schlüssel notwendig (hier ist mir
im Video ein Fehler unterlaufen: dort habe ich von Halbordnung gesprochen, die aber nicht ausreicht). Sprich: die Schlüssel
müssen “irgendwie” sortiert sein. Grundidee des Baumes lautet: soll ein Element a
unter einem Element b
eigehängt werden
und gilt für eine Relation R
(bspw <
) a R b
, dann wird a als linkes Kind von b
eingehängt. Gilt b R a
, dann wird
a
als rechtes Kind von b eingehängt (wahlweise umgekehrt. Das hat aber die gleiche Wirkung, der Baum wäre dann nur
spiegelverkehrt). Die für die Sortierung notwendige Relation wird als dritter Typparameter an das std::map
-Template
übergeben. Der zu übergebende Parametertyp muss sich mittels des Standardkonstruktors instanziieren lassen und einen
operator()
mit zwei Parametern anbieten. Diese beiden Parameter sind die zu vergleichenden Elemente a
und b
. Der Aufruf
muss true
liefern, wenn a R b
gilt, sonst false. Als Beispiel zur Verdeutlichung kann das Template std::less<T>
aus der
Standardbibliothek dienen, welches der Default-Parameter für die Vergleichsoperation ist. Es vergleicht die Elemente auf
“kleiner” (durch Aufruf der operator<
der betreffenden Klasse). Wenn gilt a<b
, dann liefert std::less(a, b)
true
(und a
wird links von b
einsortiert/gesucht. Beispiel: Zeile 74-77). Wenn gilt a>b
, dann liefert std::less(b, a)
true
(und a
wird rechts von b
einsortiert/gesucht. Beispiel: Zeile 78-81). Liefern beide Aufrufe false
, so nimmt
std::map
an, dass a==b
gilt (und der Schlüssel damit gefunden wurde/überschrieben werden kann. Beispiel: Zeile 82-85).
Auf dieser Basis kann std::map
nun einen Baum aufbauen/durchsuchen.
Die tatsächliche Implementierung von std::map
ist ein klein wenig komplexer, als hier im Beispiel, da es noch andere
Anforderungen an den Container gibt, die hier nicht dargestellt sind (bspw. muss er linear durchlaufen werden können). Die
Elemente werden außerdem in Objekten vom Typ std::pair<Key,Value>
gespeichert (die man beim Durchlaufen raus bekommt). Die
Grundidee bleibt aber in etwa die gleiche.