You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+10Lines changed: 10 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -4,6 +4,16 @@ All notable changes to Celerity are documented here. This project follows [Keep
4
4
5
5
## [Unreleased]
6
6
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).)
-`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.
79
79
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
+
80
84
**Probabilistic & bit-level**
81
85
82
86
-`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())
405
409
406
410
</details>
407
411
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
+
varroutes=newTrie<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.
<summary><b>Construct from an existing collection</b></summary>
410
437
@@ -461,6 +488,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
461
488
|**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. |
462
489
|**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. |
463
490
|**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. |
464
492
| Need a stable iteration order or multi-threaded access | BCL `Dictionary<,>`, `ConcurrentDictionary<,>`| Celerity is single-threaded and iteration order is unspecified. |
465
493
466
494
**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).
- The `entries` overloadbulk-loadsthepairs; alaterduplicatekeyoverwritesthevaluesetbyanearlierone (indexersemantics).
3350
+
3351
+
**Throws:**
3352
+
3353
+
- `ArgumentNullException` if `entries` is `null`, oranykeyinitis `null`.
3354
+
3355
+
### Indexer
3356
+
3357
+
```csharp
3358
+
publicTValuethis[string key] { get; set; }
3359
+
```
3360
+
3361
+
Thegetterthrows `KeyNotFoundException` if `key` isabsent (aninteriorprefixthatwasneverstoredcountsasabsent). Thesetteraddsthekeyoroverwritesitsexistingvalue. Boththrow `ArgumentNullException` if `key` is `null`.
0 commit comments