Zum Inhalt
wannacode

Trie

Kurz gesagt: Ein Trie ist ein Baum aus Buchstaben, in dem der gemeinsame Anfang der Wörter nur einmal gespeichert wird. Darum kostet eine Präfixsuche (Autovervollständigung) so viel wie die Länge des Präfixes.

Problem

Das Suchfeld

Der Shop hat 200.000 Produkte. Ein Kunde tippt „ko“ ins Suchfeld, und darunter sollen Vorschläge erscheinen: „kohl“, „kokos“, „korb“. Mit jedem neuen Buchstaben ändern sich die Vorschläge.

Die erste Lösung geht alle Namen durch und prüft, welche mit dem Getippten beginnen. Das ist eine lineare Suche: 200.000 Prüfungen bei jedem Tastendruck. Tausend Kunden, die gleichzeitig tippen, machen Hunderte Millionen Prüfungen pro Sekunde.

Und die meisten Namen beginnen gar nicht mit „k“. Warum sie überhaupt ansehen?

(Die Namen stehen im Katalog klein geschrieben. Warum, erklärt die Karte zu Groß- und Kleinschreibung weiter unten.)

Die Idee

Ein Baum aus Buchstaben

Zeichne die Namen als Pfade. Von der Wurzel gehen Äste nach dem ersten Buchstaben ab, von jedem von ihnen nach dem zweiten, und so weiter. „kohl“, „kokos“ und „korb“ beginnen mit „ko“: Diese zwei Buchstaben werden einmal gespeichert, und die Äste trennen sich erst danach.

Dieser Baum ist ein Trie (von retrieval, meist wie „try“ gesprochen). Jeder Knoten hält:

  • seine Kinder nach Buchstaben: eine Sammlung Buchstabe → Knoten;
  • eine Markierung, dass hier ein Name endet. Ein Name kann in einem anderen enden: „kohl“ und „kohlrabi“.

Eine Präfixsuche hat zwei Teile:

  1. Hinabgehen entlang der Buchstaben des Präfixes, ein Schritt pro Buchstabe. Fehlt ein Buchstabe, gibt es keine solchen Produkte.
  2. Einsammeln: den Ast unter dem letzten Knoten ablaufen. Jeder Knoten mit der Markierung „hier endet ein Name“ ist ein Vorschlag.

Das Hinabgehen kostet so viel wie die Buchstaben des Präfixes, nicht so viel wie die Produkte.

Sieh es dir an

Einfügen und Präfixsuche

SortedDictionary hält die Kinder nach Buchstaben sortiert, deshalb kommen die Vorschläge in alphabetischer Reihenfolge heraus.
class Node
{
public readonly SortedDictionary<char, Node> Kids = [];
public bool End;
public int Count;
}
class Trie
{
private readonly Node root = new();
public void Insert(string name, int count)
{
Node at = root;
foreach (char ch in name)
{
if (!at.Kids.TryGetValue(ch, out Node? next))
{
next = new Node();
at.Kids[ch] = next;
}
at = next;
}
at.End = true;
at.Count = count;
}
public List<string> Complete(string prefix)
{
Node? at = root;
foreach (char ch in prefix)
{
if (!at.Kids.TryGetValue(ch, out at)) return [];
}
var found = new List<string>();
Collect(at, prefix, found);
return found;
}
private void Collect(Node at, string word,
List<string> found)
{
if (at.End) found.Add(word);
foreach (var (ch, kid) in at.Kids)
{
Collect(kid, word + ch, found);
}
}
}

Das Einsammeln ist Rekursion, wie in der Lektion „Rekursion“: Jeder Knoten sammelt seine Namen ein und fragt seine Kinder. Der Basisfall ist ein Knoten ohne Kinder.

Vier Produkte und drei Suchen:

var trie = new Trie();
trie.Insert("kohl", 120);
trie.Insert("kokos", 15);
trie.Insert("korb", 40);
trie.Insert("tee", 60);
Console.WriteLine("ko: [" + string.Join(", ", trie.Complete("ko")) + "]");
Console.WriteLine("koh: [" + string.Join(", ", trie.Complete("koh")) + "]");
Console.WriteLine("m: [" + string.Join(", ", trie.Complete("m")) + "]");
Ausgabe
ko: [kohl, kokos, korb]
koh: [kohl]
m: []

class Node
{
public readonly SortedDictionary<char, Node> Kids = [];
public bool End;
public int Count;
}
class Trie
{
private readonly Node root = new();
public void Insert(string name, int count)
{
Node at = root;
foreach (char ch in name)
{
if (!at.Kids.TryGetValue(ch, out Node? next))
{
next = new Node();
at.Kids[ch] = next;
}
at = next;
}
at.End = true;
at.Count = count;
}
public List<string> Complete(string prefix)
{
Node? at = root;
foreach (char ch in prefix)
{
if (!at.Kids.TryGetValue(ch, out at)) return [];
}
var found = new List<string>();
Collect(at, prefix, found);
return found;
}
private void Collect(Node at, string word,
List<string> found)
{
if (at.End) found.Add(word);
foreach (var (ch, kid) in at.Kids)
{
Collect(kid, word + ch, found);
}
}
}
Schritt für Schritt, in Worten
  1. Ein leerer Trie: nur die Wurzel. Jeder Schritt nach unten ist ein Buchstabe, darum buchstabiert ein Pfad von der Wurzel einen Namen.
  2. Insert(„kohl“): Buchstaben gibt es noch keine, darum macht jeder Schritt einen neuen Knoten. Der letzte wird grün: Hier endet ein Name.
  3. „kokos“ beginnt wie „kohl“: Die ersten beiden Buchstaben gibt es schon, man geht nur durch sie hindurch. Neue Knoten entstehen nur dort, wo sich die Namen trennen.
  4. „korb“ teilt den Anfang ebenfalls, und „tee“ beginnt einen eigenen Ast an der Wurzel. Vier Namen, ein gemeinsamer Anfang.
  5. Complete(„ko“): zwei Schritte nach unten, einer pro Buchstabe des Präfixes. Das ist die ganze Suche, egal wie viele Produkte es gibt.
  6. Einsammeln: Nur der Ast unter dem Präfix wird abgelaufen, die Kinder in alphabetischer Reihenfolge. Jeder grüne Knoten ist ein Name und kommt in die Liste.
  7. Drei Vervollständigungen: kohl, kokos, korb. „tee“ wurde nie angesehen: Sein Ast liegt nicht unter dem Präfix.

„tee“ wird nie angesehen: Sein Ast liegt nicht unter „ko“.

Probier es aus

Sage die Ausgabe voraus

Drei Produkte. Ein Name ist der Anfang eines anderen:

var trie = new Trie();
trie.Insert("kohl", 1);
trie.Insert("korb", 1);
trie.Insert("kohlrabi", 1);
Console.WriteLine("koh: [" + string.Join(", ", trie.Complete("koh")) + "]");
Antwort

Probier es aus

Ergänze

Was macht beim Einsammeln einen Knoten zu einem Vorschlag?

if (at.) found.Add(word);

Die Idee

Was es kostet

  • Einfügen: ein Schritt pro Buchstabe des Namens: O(m), wobei m seine Länge ist.
  • Ein Präfix hinabgehen: ein Schritt pro Buchstabe des Präfixes: O(m), egal wie viele Produkte es gibt.
  • Einsammeln: so groß wie der Ast unter dem Präfix. Zeigst du nur die ersten 5 Vorschläge, hörst du nach dem fünften auf.

Lass die drei Wege laufen. Der Katalog besteht aus n Namen mit 4 bis 8 Buchstaben, in alphabetischer Reihenfolge. Der Kunde tippt 20 Präfixe mit je zwei Buchstaben, und für jedes werden die ersten 5 Namen gebraucht. Gezählt werden die Zeichen, die die Suche ansieht: der Vergleich eines Buchstabens des Namens mit einem Buchstaben des Präfixes oder ein Schritt von einem Knoten zu einem Kind. Der Trie wird einmal gebaut, wenn der Katalog geladen wird, und nicht mitgezählt.

  • Alle Namen durchgehen O(n)
  • Binäre Suche im sortierten Katalog O(log n)
  • Trie O(1)
Algorithmusn = 10n = 1.000n = 10.000n = 100.000
Alle Namen durchgehen22311.77893.0711.303.482
Binäre Suche im sortierten Katalog125542676808
Trie46403342266
Der Code läuft in deinem Browser, in JavaScript. Die Zahl der Operationen ist in jeder Sprache gleich, weil der Algorithmus derselbe ist. Die Zeit hängt von der Maschine ab und dient nur zum Vergleich. „≈“ ist eine Schätzung anhand der Kurve: Ein so großes n führen wir nicht aus, damit die Seite nicht einfriert.
class Node
{
public readonly SortedDictionary<char, Node> Kids = [];
public bool End;
public int Count;
}
class Trie
{
private readonly Node root = new();
public void Insert(string name, int count)
{
Node at = root;
foreach (char ch in name)
{
if (!at.Kids.TryGetValue(ch, out Node? next))
{
next = new Node();
at.Kids[ch] = next;
}
at = next;
}
at.End = true;
at.Count = count;
}
public List<string> Complete(string prefix)
{
Node? at = root;
foreach (char ch in prefix)
{
if (!at.Kids.TryGetValue(ch, out at)) return [];
}
var found = new List<string>();
Collect(at, prefix, found);
return found;
}
private void Collect(Node at, string word,
List<string> found)
{
if (at.End) found.Add(word);
foreach (var (ch, kid) in at.Kids)
{
Collect(kid, word + ch, found);
}
}
}

Bei 100.000 Namen sieht das Durchlaufen 1.303.482 Zeichen an, die binäre Suche im sortierten Katalog 808 und der Trie 266. Je mehr Namen es gibt, desto dichter ist der Baum und desto früher sind die fünf eingesammelt: Bei 1000 Namen sieht der Trie 403 Zeichen an, bei 100.000 weniger.

Die binäre Suche ist hier dabei, weil der Katalog sortiert ist: Dann findet sie den ersten Namen mit dem Präfix in log₂ n Vergleichen (binäre Suche). Mehr dazu weiter unten.

Sieh es dir an

Das Meistgekaufte zuerst

Die alphabetische Reihenfolge ist nicht die nützlichste. Wenn ein Kunde „ko“ tippt, will er eher kohl als kokos. Darum hält jeder Knoten, in dem ein Name endet, auch fest, wie oft das Produkt gekauft wurde (count). Die Vorschläge werden danach sortiert:

public int CountOf(string name)
{
Node at = root;
foreach (char ch in name) at = at.Kids[ch];
return at.Count;
}
public List<string> Suggest(string prefix, int k) =>
Complete(prefix).OrderByDescending(CountOf).Take(k).ToList();
var trie = new Trie();
trie.Insert("kohl", 120);
trie.Insert("kokos", 15);
trie.Insert("korb", 40);
trie.Insert("tee", 60);
trie.Insert("kohlrabi", 8);
trie.Insert("kopfsalat", 25);
Console.WriteLine("ko: [" + string.Join(", ", trie.Suggest("ko", 3)) + "]");
Ausgabe
ko: [kohl, korb, kopfsalat]

Die Sortierung ist stabil (siehe Stabilität): Bei gleich vielen Käufen bleibt die alphabetische Reihenfolge erhalten.

Genauer, für Neugierige

Hier sammelt jede Suche alle Namen unter dem Präfix ein und sortiert sie dann. Bei „k“ sind das in einem echten Katalog Zehntausende. Darum halten große Suchmaschinen in jedem Knoten die fertigen ersten 5 bis 10 Vorschläge für den Ast darunter. Die Suche ist dann nur noch das Hinabgehen, und der Preis wird beim Einfügen gezahlt: Jeder Knoten auf dem Pfad eines neuen Namens prüft, ob der Name in seine Bestenliste kommt.

Probier es aus

Finde den Fehler

Ein Kollege hat das Einsammeln geschrieben, aber die Vorschläge sind voller Wortstücke:

private void Collect(Node at, string word, List<string> found)
{
found.Add(word);
foreach (var (ch, kid) in at.Kids)
{
Collect(kid, word + ch, found);
}
}
var trie = new Trie();
trie.Insert("kohl", 120);
trie.Insert("kokos", 15);
trie.Insert("korb", 40);
trie.Insert("tee", 60);
Console.WriteLine("ko: [" + string.Join(", ", trie.Complete("ko")) + "]");
Ausgabe
ko: [ko, koh, kohl, kok, koko, kokos, kor, korb]

In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Groß- und Kleinschreibung und ähnlich aussehende Zeichen

Für den Trie sind „K“ und „k“ verschiedene Buchstaben: verschiedene Kinder des Knotens. Ein Kunde, der „Kohl“ tippt oder „ kohl“ mit einem Leerzeichen davor, findet nichts. Darum gehen sowohl der Name beim Einfügen als auch der Text bei der Suche durch ein und dieselbe Normalisierung: keine Leerzeichen an den Enden, ein Leerzeichen zwischen Wörtern, Kleinbuchstaben. (Deshalb stehen die Namen im Katalog klein geschrieben.)

static string Normalize(string text) =>
string.Join(' ', text.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.ToLowerInvariant();
var trie = new Trie();
trie.Insert("kohl", 120);
trie.Insert("kokos", 15);
trie.Insert("korb", 40);
trie.Insert("tee", 60);
trie.Insert("kohlrabi", 8);
trie.Insert("kopfsalat", 25);
trie.Insert("müsli", 30);
Console.WriteLine("KoHl: [" + string.Join(", ", trie.Complete(Normalize(" KoHl "))) + "]");
Console.WriteLine("musli: [" + string.Join(", ", trie.Complete(Normalize("musli"))) + "]");
Ausgabe
KoHl: [kohl, kohlrabi]
musli: []

Die zweite Suche findet nichts, obwohl der Katalog „müsli“ hat. „u“ und „ü“ sind verschiedene Zeichen, verschiedene Kinder im Baum. Dasselbe passiert bei Buchstaben, die genau gleich aussehen: das lateinische „a“ und der kyrillische Buchstabe, der genauso aussieht, in einem Shop, der in zwei Alphabeten verkauft. Die Normalisierung behebt das nicht von allein: Du brauchst einen ausdrücklichen Ersatz (zum Beispiel das Entfernen der Umlautpunkte), wenn du es verzeihen willst.

Genauer, für Neugierige
ToLowerInvariant() gibt auf jedem Rechner dieselbe Regel. ToLower() benutzt die Sprache des Rechners, und auf einem türkischen wird „I“ zu „ı“ (i ohne Punkt). Noch etwas: char ist in C# 16 Bit groß. Lateinische und kyrillische Buchstaben passen in ein char, ein Emoji braucht aber zwei, und ein Trie nach char zerschneidet es in Hälften.

Wie es in den anderen Sprachen ist: Java und C# laufen nach 16-Bit-char, Python und for…of in JavaScript nach Zeichen. Für lateinische und kyrillische Buchstaben gibt es keinen Unterschied.

Die Idee

Speicher, und wann ein sortiertes Array reicht

Jeder Knoten hält seine Kinder. Dafür gibt es zwei Wege:

  • Eine Sammlung Buchstabe → Knoten, wie hier: Der Knoten hält nur die Kinder, die er hat.
  • Ein Array aus Plätzen, einer pro Buchstabe: 26 für das lateinische Alphabet, 30 für das deutsche mit ä, ö, ü und ß, 32 mit Leerzeichen und Bindestrich. Das Kind findet man sofort über die Nummer des Buchstabens, aber jeder Knoten hält alle Plätze, auch die leeren.

Wie leer sind sie? Der Trie im Bench für 100.000 Namen (631.833 Buchstaben) hat 210.642 Knoten, im Mittel ein Kind pro Knoten: Der gemeinsame Anfang ist kurz, danach geht jeder Name für sich weiter. Ein Array mit 30 Plätzen pro Knoten hält mehr als 6,3 Millionen Verweise, von denen 210.641 belegt sind. Die Sammlung ist kleiner, und das Array ist nur in den oberen Ebenen schneller, wo es viele Kinder gibt.

Wenn sich die Namen selten ändern und du nur „alle, die mit … beginnen“ brauchst, reicht oft ein sortiertes Array. Die binäre Suche findet den ersten Namen, der nicht vor dem Präfix steht, und die Treffer liegen danach nebeneinander:

List<string> names = ["kohl", "kohlrabi", "kokos", "kopfsalat", "korb", "tee"];
string prefix = "ko";
int at = names.BinarySearch(prefix, StringComparer.Ordinal);
if (at < 0) at = ~at;
var found = new List<string>();
while (at < names.Count && names[at].StartsWith(prefix, StringComparison.Ordinal))
{
found.Add(names[at++]);
}
Console.WriteLine($"{prefix}: [{string.Join(", ", found)}]");
Ausgabe
ko: [kohl, kohlrabi, kokos, kopfsalat, korb]
Vorsicht: Der Standardvergleich von Strings in C# hängt von der Sprache des Rechners ab. Das Sortieren und BinarySearch müssen ein und dieselbe Regel benutzen, hier StringComparer.Ordinal (nach Zeichencode). Ein negatives Ergebnis ist ~ der Einfügestelle.

Ein Trie gewinnt, wenn ständig Namen hinzukommen und verschwinden, wenn du Zähler und das Meistgekaufte für jedes Präfix brauchst oder wenn du Buchstabe für Buchstabe suchst, während der Kunde tippt: Jeder neue Buchstabe ist ein Schritt von dem Knoten, den du schon erreicht hast.

Übung

Wie viele Produkte beginnen mit …

Löse die Aufgabe in deinem Editor.

Die Anzahl unter einem Präfix. Neben jedem Vorschlag zeigt das Feld, wie viele Produkte mit dem Getippten beginnen. Füge dem Knoten einen Zähler hinzu, wie viele Namen durch ihn gehen, und eine Methode CountPrefix, die das Präfix hinabgeht und den Zähler zurückgibt, ohne Namen einzusammeln. Füge den ganzen Katalog aus der Karte „Das Meistgekaufte zuerst“ ein und prüfe die Präfixe „ko“, „koh“, „t“ und „m“:

Ausgabe
ko: 5
koh: 2
t: 1
m: 0

Weitere Aufgaben

  • Löschen. Ein Produkt wird nicht mehr verkauft. Entferne den Namen aus dem Trie, ohne die Namen zu zerstören, die durch dieselben Knoten gehen.
  • Anzahl der Knoten. Zähle die Knoten nach dem Einfügen des Katalogs und vergleiche mit der Gesamtzahl der Buchstaben in den Namen.
  • Buchstabe für Buchstabe. Baue eine Suche, die den erreichten Knoten behält und für jeden neuen Buchstaben nur einen Schritt macht.

Test

Teste dich

1. Der Katalog hat 200.000 Produkte. Wie viele Schritte braucht der Trie, um den Knoten des Präfixes „kohl“ zu erreichen?
2. Warum hat ein Knoten eine eigene Markierung „hier endet ein Name“?
3. Die Produktnamen ändern sich einmal am Tag, und du brauchst nur „alle, die mit … beginnen“. Was reicht?
4. Der Kunde tippt „Kohl“, und der Katalog hat „kohl“. Was tust du?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·