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
#include <iostream>
#include <memory>
#include <string>
#include <functional>
#include <stdexcept>
template <typename KeyT,
typename ValueT,
typename CompareT = std::less<KeyT>
> class map
{
struct TreeNode
{
KeyT key;
ValueT value;
std::shared_ptr<TreeNode> left;
std::shared_ptr<TreeNode> right;
};
std::shared_ptr<TreeNode> root;
CompareT compare;
public:
void insert(KeyT const &key, ValueT const value)
{
std::shared_ptr<TreeNode> curr_pointer(root);
if (!root) {
//Spezialfall "leerer" Baum
root.reset(new TreeNode);
root->key = key;
root->value = value;
}
else {
while (curr_pointer) {
if (compare(key, curr_pointer->key)) {
// key < curr_pointer
if (!curr_pointer->left) {
// Element hat keinen linken Nachfolger -> einfügen
std::shared_ptr<TreeNode> t(new TreeNode);
t->key = key;
t->value = value;
curr_pointer->left = t;
break;
}
curr_pointer = curr_pointer->left;
}
else if (compare(curr_pointer->key, key)) {
// key > curr_pointer
if (!curr_pointer->right) {
// Element hat keinen rechten Nachfolger
std::shared_ptr<TreeNode> t(new TreeNode);
t->key = key;
t->value = value;
curr_pointer->right = t;
break;
}
curr_pointer = curr_pointer->right;
}
else {
// key == curr_pointer -> Wert überschreiben
curr_pointer->value = value;
break;
}
}
}
}
ValueT const &at(KeyT const &key) const
{
std::shared_ptr<TreeNode> curr_pointer(root);
while (curr_pointer) {
if (compare(key, curr_pointer->key)) {
// key < curr_pointer
curr_pointer = curr_pointer->left;
}
else if (compare(curr_pointer->key, key)) {
// key > curr_pointer
curr_pointer = curr_pointer->right;
}
else {
// key == curr_pointer
return curr_pointer->value;
}
}
throw std::out_of_range("Element nicht enthalten");
}
void print(std::ostream &os)
{
rec_printer(os, 0, root);
}
private:
void rec_printer(std::ostream &os, int spaces, std::shared_ptr<TreeNode> node)
{
for (int i = 0; i < spaces; ++i) {
os << " ";
}
os << node->key << " => " << node->value << std::endl;
if (node->left) {
for (int i = 0; i < spaces+1; ++i) {
os << " ";
}
os << "left: \n";
rec_printer(os, spaces+2, node->left);
}
if (node->right) {
for (int i = 0; i < spaces+1; ++i) {
os << " ";
}
os << "right: \n";
rec_printer(os, spaces+2, node->right);
}
}
};
int main()
{
map<std::string, std::string> m;
m.insert("C", "C");
m.insert("E", "E");
m.insert("X", "X");
m.insert("D", "D");
m.insert("A", "A");
std::cerr << m.at("X") << std::endl;
std::cerr << "----\n";
m.print(std::cerr);
}
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.