Skip to content

Commit 4420f1a

Browse files
marius-bughiuclaude
andcommitted
feat(collections): add Trie<TValue> — ordered prefix tree with O(prefix) prefix search & longest-prefix match
The BCL ships no trie. `Dictionary<string, TValue>` answers exact-key lookups but has no efficient prefix operation. `Trie<TValue>` maps string keys to values as a shared-prefix tree: - `GetByPrefix` / `GetKeysWithPrefix` — every entry under a prefix in O(prefix + matches), ascending key order (autocomplete, namespace/route listing). - `TryGetLongestPrefix` — the longest stored key that is a prefix of a query in O(query) (routing tables, tokenizer/dictionary matching). - Ordered iteration for free; implements IReadOnlyDictionary<string, TValue>. Nodes keep child edges in arrays sorted by edge char (binary-search lookup, ordered walk); removal prunes dead paths bottom-up. The empty string is a valid key. Not thread-safe. Full parity rollout in one PR: dedicated tests (Tests/Prefix/Enumeration/Differential vs a SortedDictionary ordinal oracle), TrieBenchmark + Program.cs registration, dashboard wiring (web/index.html + bench index/detail), docs (collections.md + README list/decision-table/ quick-start), and CHANGELOG. The int-keyed hash-family shared suites do not apply (same as Deque/DisjointSet); the roadmap has no Trie item to flip (post-roadmap tier-c enhancement). Closes #285. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent febc3c6 commit 4420f1a

13 files changed

Lines changed: 1451 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to Celerity are documented here. This project follows [Keep
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- **`Trie<TValue>`** in `Celerity.Collections` — an ordered prefix tree mapping `string` keys to values, filling a BCL gap (.NET ships no trie). `GetByPrefix` lists every entry whose key starts with a prefix in `O(prefix + matches)` and in ascending key order, and `TryGetLongestPrefix` finds the longest stored key that is a prefix of a query in `O(query)` — the prefix, autocomplete, longest-prefix-routing, and ordered-iteration workloads a `Dictionary<string, TValue>` can only answer with an `O(n)` scan plus a `StartsWith` per key. Exact `Add` / `TryGetValue` favour a `Dictionary` (one hash vs a per-character walk), so the trie earns its place on the prefix operations. Implements `IReadOnlyDictionary<string, TValue>`; the empty string is a valid key; not thread-safe. Closes [#285](https://github.com/marius-bughiu/Celerity/issues/285).
10+
- `TrieTests`, `TriePrefixTests`, `TrieEnumerationTests`, and `TrieDifferentialTests` (`Celerity.Tests/Collections`) — dedicated coverage mirroring the `Deque*` / `DisjointSet*` four-file layout: the core surface (`Add` / `TryAdd` / indexer / `ContainsKey` / `TryGetValue` / `Remove` with bottom-up node pruning and shared-prefix retention / `Clear` / the empty-string key / duplicate-`Add` and not-found throws / the bulk constructor / null-argument contracts / per-node child-array growth and tail-shifting removal); the prefix surface (`ContainsPrefix`, `GetByPrefix` / `GetKeysWithPrefix` ordering and prefix-equals-key inclusion and the empty/missing prefix, and `TryGetLongestPrefix` including the root/empty-key match and the no-match case); the enumeration surface (ascending ordinal order, `Keys` / `Values` alignment, the `IReadOnlyDictionary` and non-generic paths, and modification-mid-enumeration detection on `Add` / `Remove` / `Clear` and a `GetByPrefix` stream); and a 4,000-step randomized differential battery per seed reconciling insert / overwrite / remove / lookup / prefix / longest-prefix against a `SortedDictionary<string, int>` (ordinal) oracle.
11+
- `TrieBenchmark` in `Celerity.Benchmarks`, registered in `Program.cs`'s `CoreBenchmarks` array (so it joins the per-PR core run and the gh-pages dashboard, mirroring `DequeBenchmark`) — `[MemoryDiagnoser]` `Trie<int>` vs `Dictionary<string, int>` across `Add` / `Lookup` / **`PrefixMatch`** at `[Params(1000, 100_000)]`, with keys sharing 16 prefix buckets so each prefix query returns `ItemCount/16` matches. `PrefixMatch` is the headline win (the trie answers from the structure; the dictionary scans every key and runs `StartsWith`); `Add` / `Lookup` are the honest exact-key arms where the dictionary leads.
12+
- Dashboard wiring for `Trie`: the "What ships in the box" ship card in [`web/index.html`](web/index.html) and the `COLLECTIONS` arrays in [`web/dev/bench/index.html`](web/dev/bench/index.html) (key / title / vs / ops `Add` + `Lookup` + `PrefixMatch`) and [`web/dev/bench/detail.html`](web/dev/bench/detail.html) (key / title / vs), so the benchmark data the CI job publishes to gh-pages is surfaced on the dashboard rather than silently ignored.
13+
- Documentation for `Trie`: a full API section in [`docs/api/collections.md`](docs/api/collections.md#trietvalue) (the mechanism, the documented BCL-beating prefix workloads, constructors, indexer, the method table, empty-string/default handling, a "choosing it" guide, and a runnable routing example) mirroring the `DisjointSet` / `Deque` sections, plus README entries — a new "Prefix trees" Collections list entry, a quick-start `<details>` block with a runnable example, and a new prefix/autocomplete row in the "Choosing a collection" decision table.
14+
15+
(Parity notes: `Trie` is a string-keyed, ordered, prefix collection — not part of the int-keyed open-addressed set/dictionary family — so, exactly like `Deque` / `DisjointSet`, the cross-collection *shared* suites (`AddAndTryAddTests`, `SetConstructorValidationTests`, `TryAddProbeCountTests`, the `IEnumerableConstructor*` set, etc.) do **not** gain a row: those drive the hash-table `Add` / `TryAdd` / capacity / load-factor / probe-count contract, none of which a trie has. `ROADMAP.md` carries no `Trie` item to flip — the roadmap is `done` through 2.1.0; this is a post-roadmap BCL-gap-filling enhancement, the same tier-(c) pattern as `Deque` (#268) and `DisjointSet` (#272).)
16+
717
## [2.3.0] - 2026-07-19
818

919
### Added

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `
7777

7878
- `DisjointSet<T>`**union-find** over arbitrary elements: partitions them into disjoint sets with near-`O(1)` amortized `Union` / `Find` / `Connected` via **union by size** + **path halving**. The union-find the BCL lacks — incremental connectivity, connected components, Kruskal MST, and undirected cycle detection in near-linear total time, where the `Dictionary` + `HashSet` set-merge substitute is quadratic. `GetComponents()` materializes the current partition.
7979

80+
**Prefix trees**
81+
82+
- `Trie<TValue>` — ordered **prefix tree** mapping string keys to values. `GetByPrefix` lists every entry whose key starts with a prefix in `O(prefix + matches)`, and `TryGetLongestPrefix` finds the longest stored key that is a prefix of a query in `O(query)`. The trie the BCL lacks — autocomplete, longest-prefix routing, and ordered (ascending-ordinal) iteration, where a `Dictionary<string, TValue>` has no prefix index and must scan every key and run `StartsWith`. Exact `Add` / `TryGetValue` favour a `Dictionary` (one hash vs a character walk); the trie earns its place on the prefix operations. Implements `IReadOnlyDictionary<string, TValue>`.
83+
8084
**Probabilistic & bit-level**
8185

8286
- `BloomFilter<T, THasher>`**probabilistic** membership: bit-array storage, **no false negatives**, tunable false-positive rate, a fraction of a `HashSet<T>`'s memory. Add-and-test only.
@@ -405,6 +409,29 @@ foreach (var component in uf.GetComponents())
405409

406410
</details>
407411

412+
<details>
413+
<summary><b>Prefix trees</b> — Trie</summary>
414+
415+
`Trie<TValue>` is the ordered **prefix tree** the BCL lacks: it maps string keys to values and answers the prefix queries a `Dictionary<string, TValue>` can't do without an `O(n)` scan. `GetByPrefix` lists every entry under a prefix in `O(prefix + matches)` and in ascending key order; `TryGetLongestPrefix` finds the most specific stored key that prefixes a query. Reach for it for autocomplete, longest-prefix routing, or ordered iteration — not for pure exact-key lookups, where a `Dictionary` (one hash vs a character walk) wins. See [the API reference](docs/api/collections.md#trietvalue).
416+
417+
```csharp
418+
var routes = new Trie<string>();
419+
routes["/"] = "home";
420+
routes["/api"] = "api-root";
421+
routes["/api/v1/users"] = "users-v1";
422+
routes["/api/v1/orders"] = "orders-v1";
423+
424+
// Autocomplete: every entry under a prefix, already sorted.
425+
foreach (var (path, handler) in routes.GetByPrefix("/api/v1/"))
426+
Console.WriteLine($"{path} -> {handler}"); // /api/v1/orders, then /api/v1/users
427+
428+
// Longest-prefix routing: the most specific stored route that prefixes the request.
429+
routes.TryGetLongestPrefix("/api/v1/users/42", out string route, out string h);
430+
Console.WriteLine($"{route} -> {h}"); // /api/v1/users -> users-v1
431+
```
432+
433+
</details>
434+
408435
<details>
409436
<summary><b>Construct from an existing collection</b></summary>
410437

@@ -461,6 +488,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
461488
| **Bounded cache** with automatic eviction — memoize the last `N` results, an admission cache in front of an expensive lookup, any hot key→value store that must not grow without bound | `LruCache<TKey, TValue, THasher>` | Fixed-capacity least-recently-used cache: `O(1)` get/put, and once at capacity every insert evicts the least-recently-used entry. Its recency list is threaded through fixed-size arrays, so after construction the hot get/put/evict path **allocates nothing** — where the idiomatic `Dictionary` + `LinkedList` LRU allocates a `LinkedListNode` per insert. Reads are *uses* (they promote to most-recently-used); use `TryPeek` / `ContainsKey` to inspect without touching recency. Single-threaded — because reads mutate recency, even a read-mostly concurrent workload needs a write lock. |
462489
| **Double-ended queue** — add/remove at both ends (bounded FIFO queue, sliding window, work-stealing / undo buffer) or a queue needing random access by position | `Deque<T>` | Growable double-ended queue backed by a **circular buffer**: `O(1)` amortized `PushFront` / `PushBack` / `PopFront` / `PopBack` / peek and `O(1)` random access by index. The BCL has no deque — `Queue<T>` is FIFO-only, `Stack<T>` LIFO-only, and `LinkedList<T>` (the only O(1)-both-ends type) allocates a node per element. A warm bounded churn reuses the buffer with wrap-around so it **allocates nothing**, and enumeration walks contiguous memory. For a strict FIFO queue that never pushes front / pops back, BCL `Queue<T>` is already a circular buffer and is simpler. |
463490
| **Incremental connectivity / connected components** — union equivalence classes and ask whether two elements are in the same group (Kruskal MST, clustering, image segmentation, undirected cycle detection, "are these accounts linked?") | `DisjointSet<T>` | Union-find with **union by size** + **path halving**: near-`O(1)` amortized `Union` / `Find` / `Connected`, `O(α(n)) ≤ 4`. Runs a stream of merges + connectivity queries in near-linear total time, where the BCL substitutes are super-linear — a `Dictionary<T, HashSet<T>>` set-merge is `O(n²)` to coalesce `n` singletons, and a per-query BFS/DFS is `O(V+E)` every query. Grows only by merging (no un-union); it is not an `ISet<T>` — for element membership with add/remove/set-algebra use `CeleritySet` or `HashSet<T>`. |
491+
| **Prefix / autocomplete / longest-prefix** over string keys — list everything under a prefix, find the most specific stored key that prefixes a query, or iterate keys in order (typeahead, route/dispatch tables, tokenizer / dictionary matching, namespace listing) | `Trie<TValue>` | Ordered prefix tree: `GetByPrefix` yields every entry under a prefix in `O(prefix + matches)` and in ascending key order, `TryGetLongestPrefix` finds the longest stored prefix of a query in `O(query)`, and enumeration is sorted for free — none of which a `Dictionary<string, TValue>` can do without an `O(n)` scan + `StartsWith`. For **pure exact-key** `Add` / `TryGetValue` / `Remove` a `Dictionary` (one hash vs a per-character walk) is faster; the trie earns its place only when you use the prefix operations. Implements `IReadOnlyDictionary<string, TValue>`; not thread-safe. |
464492
| Need a stable iteration order or multi-threaded access | BCL `Dictionary<,>`, `ConcurrentDictionary<,>` | Celerity is single-threaded and iteration order is unspecified. |
465493

466494
**Celerity is not the right answer when** you need concurrent access (use `ConcurrentDictionary<,>` or your own lock — Celerity is single-threaded), the mutable `IDictionary<,>` interface, or a guaranteed iteration order (Celerity exposes `IReadOnlyDictionary<,>` only and does not promise order across versions).

docs/api/collections.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3311,3 +3311,100 @@ Console.WriteLine($"{uf.SetCount} connected component(s)"); // 2: {a,b,c} and
33113311
foreach (var component in uf.GetComponents())
33123312
Console.WriteLine(string.Join(", ", component));
33133313
```
3314+
3315+
## Trie&lt;TValue&gt;
3316+
3317+
An ordered **prefix tree** (trie) mapping `string` keys to values. Every key is stored as a path of characters from a shared root, so keys sharing a prefix share that prefix's nodes. Implements `IReadOnlyDictionary<string, TValue>`.
3318+
3319+
```csharp
3320+
public sealed class Trie<TValue> : IReadOnlyDictionary<string, TValue>
3321+
```
3322+
3323+
The BCL ships no trie. `Dictionary<string, TValue>` answers an exact-key lookup in `O(1)` but has **no efficient prefix operation**: listing every key that starts with a prefix, or finding the longest stored key that is a prefix of a query, both force an `O(n)` scan of the whole dictionary plus a `StartsWith` per key. A trie answers those directly from its structure.
3324+
3325+
### How it works
3326+
3327+
Each node holds its child edges in two parallel arrays kept sorted ascending by edge character, so a child lookup is a binary search and a pre-order walk visits children in ordinal orderwhich is why enumeration is sorted for free. A key terminates at the node reached by walking its characters from the root; the empty string is a valid key (it terminates at the root). Removal prunes bottom-up any node that no longer leads to a key, so the structure never retains dead paths, and the `Count` / `ContainsPrefix` invariants hold.
3328+
3329+
Keys are compared and ordered by their UTF-16 code units (ordinal) — the same comparison `Dictionary<string, TValue>` uses with the ordinal comparer. Culture-aware comparison is not applied.
3330+
3331+
### The documented BCL-beating workload
3332+
3333+
The **prefix operations**:
3334+
3335+
- `GetByPrefix` / `GetKeysWithPrefix` yield every entry whose key starts with a prefix in `O(prefix length + matches)` — autocomplete, typeahead, listing a namespace or route tablewhere a `Dictionary` must scan and `StartsWith`-filter every entry.
3336+
- `TryGetLongestPrefix` finds the longest stored key that is a prefix of a query in `O(query length)` — routing tables, tokenizer / dictionary matching, filesystem-style longest-match.
3337+
- Enumeration yields keys in ascending ordinal order for free, where a `Dictionary` is unordered.
3338+
3339+
An exact `Add` or `TryGetValue` walks the key character by character rather than hashing it once, so for **pure exact-key** workloads a `Dictionary` is competitive or fasterthe trie's value is the prefix and ordering operations, not raw exact-lookup speed. See the [trie benchmark](https://marius-bughiu.github.io/Celerity/dev/bench/?collection=Trie) on the dashboard.
3340+
3341+
### Constructors
3342+
3343+
```csharp
3344+
public Trie()
3345+
public Trie(IEnumerable<KeyValuePair<string, TValue>> entries)
3346+
```
3347+
3348+
- The parameterless constructor starts empty.
3349+
- The `entries` overload bulk-loads the pairs; a later duplicate key overwrites the value set by an earlier one (indexer semantics).
3350+
3351+
**Throws:**
3352+
3353+
- `ArgumentNullException` if `entries` is `null`, or any key in it is `null`.
3354+
3355+
### Indexer
3356+
3357+
```csharp
3358+
public TValue this[string key] { get; set; }
3359+
```
3360+
3361+
The getter throws `KeyNotFoundException` if `key` is absent (an interior prefix that was never stored counts as absent). The setter adds the key or overwrites its existing value. Both throw `ArgumentNullException` if `key` is `null`.
3362+
3363+
### Methods and properties
3364+
3365+
| Member | Description |
3366+
|--------|-------------|
3367+
| `int Count` | Number of keys. |
3368+
| `void Add(string key, TValue value)` | Adds a key. Throws `ArgumentException` if it already exists. |
3369+
| `bool TryAdd(string key, TValue value)` | Adds a key, leaving an existing entry unchanged. Returns `false` if already present. |
3370+
| `bool ContainsKey(string key)` | Whether `key` is a stored key (an interior-only prefix returns `false`). |
3371+
| `bool TryGetValue(string key, out TValue value)` | Non-throwing exact lookup. |
3372+
| `bool Remove(string key)` | Removes a key, pruning any newly-dead nodes. Returns `false` if absent. |
3373+
| `bool Remove(string key, out TValue value)` | `Remove` returning the removed value. |
3374+
| `void Clear()` | Removes all keys. |
3375+
| `bool ContainsPrefix(string prefix)` | Whether any stored key starts with `prefix` (a key equal to the prefix counts). The empty prefix matches iff the trie is non-empty. |
3376+
| `IEnumerable<KeyValuePair<string, TValue>> GetByPrefix(string prefix)` | Every entry whose key starts with `prefix`, in ascending key order (lazy). |
3377+
| `IEnumerable<string> GetKeysWithPrefix(string prefix)` | The keys of `GetByPrefix`, in ascending order (lazy). |
3378+
| `bool TryGetLongestPrefix(string query, out string key, out TValue value)` | The longest stored key that is a prefix of `query` (an exact match qualifies and is longest). |
3379+
| `IEnumerable<string> Keys` / `IEnumerable<TValue> Values` | Keys in ascending order and their aligned values. |
3380+
| `IEnumerator<KeyValuePair<string, TValue>> GetEnumerator()` | Entries in ascending key order. Enumeration allocates a small traversal stack. |
3381+
3382+
Every key-taking member throws `ArgumentNullException` on a `null` argument. `Add`, `TryAdd` (when it adds), the setter, `Remove` (when it removes), and `Clear` are structural changes that invalidate an in-flight enumerator (including a `GetByPrefix` stream); a pure lookup does not.
3383+
3384+
### Empty-string and default handling
3385+
3386+
The empty string is an ordinary key. The trie stores no `TValue` out-of-band, so any `TValue` — including `default`/`null` — is a valid value. `null` keys are rejected.
3387+
3388+
### Choosing it
3389+
3390+
Reach for `Trie<TValue>` when the workload needs **prefix or ordered** access: autocomplete / typeahead, longest-prefix routing, ordered key iteration, or listing everything under a namespace. If you only ever do exact-key `Add` / `TryGetValue` / `Remove`, a `Dictionary<string, TValue>` (or `CelerityDictionary`) is the better fitthe trie earns its place only when you use the prefix operations. It is not thread-safe.
3391+
3392+
### Usage example
3393+
3394+
```csharp
3395+
using Celerity.Collections;
3396+
3397+
var routes = new Trie<string>();
3398+
routes["/"] = "home";
3399+
routes["/api"] = "api-root";
3400+
routes["/api/v1/users"] = "users-v1";
3401+
routes["/api/v1/orders"] = "orders-v1";
3402+
3403+
// Autocomplete: every route under a prefix, already in sorted order.
3404+
foreach (var (path, handler) in routes.GetByPrefix("/api/v1/"))
3405+
Console.WriteLine($"{path} -> {handler}"); // /api/v1/orders, then /api/v1/users
3406+
3407+
// Longest-prefix routing: the most specific stored route that prefixes the request.
3408+
if (routes.TryGetLongestPrefix("/api/v1/users/42", out string route, out string handler))
3409+
Console.WriteLine($"matched {route} -> {handler}"); // matched /api/v1/users -> users-v1
3410+
```

src/Celerity.Benchmarks/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ internal class Program
4444
typeof(LruCacheBenchmark),
4545
typeof(DequeBenchmark),
4646
typeof(DisjointSetBenchmark),
47+
typeof(TrieBenchmark),
4748
typeof(StringHasherBenchmark),
4849
typeof(IntegerHasherBenchmark),
4950
};

0 commit comments

Comments
 (0)