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).)
-`IndexedPriorityQueue<TElement, TPriority, THasher>` — **addressable** binary min-heap: unlike the BCL `PriorityQueue<,>` it can **change a queued element's priority** (`Update` / decrease-key) and **remove an arbitrary element** in `O(log n)`, and answer `Contains` / `TryGetPriority` in `O(1)`. The heap the priority-relaxation loop of Dijkstra / Prim / A\* needs — no lazy-deletion heap growth. Each element is a key (appears once); pass a custom `IComparer<TPriority>` for a max-heap.
83
83
84
+
**Prefix trees**
85
+
86
+
-`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>`.
87
+
84
88
**Probabilistic & bit-level**
85
89
86
90
-`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.
@@ -430,6 +434,29 @@ Console.WriteLine(pq.Remove("c", out int p)); // True; p == 20
430
434
431
435
</details>
432
436
437
+
<details>
438
+
<summary><b>Prefix trees</b> — Trie</summary>
439
+
440
+
`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).
441
+
442
+
```csharp
443
+
varroutes=newTrie<string>();
444
+
routes["/"] ="home";
445
+
routes["/api"] ="api-root";
446
+
routes["/api/v1/users"] ="users-v1";
447
+
routes["/api/v1/orders"] ="orders-v1";
448
+
449
+
// Autocomplete: every entry under a prefix, already sorted.
<summary><b>Construct from an existing collection</b></summary>
435
462
@@ -487,6 +514,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
487
514
|**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. |
488
515
|**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>`. |
489
516
|**Priority queue whose priorities change** — a best-so-far frontier you relax (Dijkstra / Prim / A\*), or an event scheduler that reschedules / cancels pending items |`IndexedPriorityQueue<TElement, TPriority, THasher>`| Addressable binary min-heap with an element→slot index: `Update` (decrease-/increase-key) and `Remove` an arbitrary element in `O(log n)`, `Contains` / `TryGetPriority` in `O(1)`. The BCL `PriorityQueue<,>` can do none of these — its only substitute is lazy deletion, which grows the heap by one entry per update. Each element is a key (appears once); custom `IComparer<TPriority>` for a max-heap. For plain enqueue/dequeue with duplicate elements, the BCL `PriorityQueue<,>` is simpler. |
517
+
|**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. |
490
518
| Need a stable iteration order or multi-threaded access | BCL `Dictionary<,>`, `ConcurrentDictionary<,>`| Celerity is single-threaded and iteration order is unspecified. |
491
519
492
520
**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).
3464
+
3465
+
**Throws:**
3466
+
3467
+
- `ArgumentNullException` if `entries` is `null`, oranykeyinitis `null`.
3468
+
3469
+
### Indexer
3470
+
3471
+
```csharp
3472
+
publicTValuethis[string key] { get; set; }
3473
+
```
3474
+
3475
+
Thegetterthrows `KeyNotFoundException` if `key` isabsent (aninteriorprefixthatwasneverstoredcountsasabsent). Thesetteraddsthekeyoroverwritesitsexistingvalue. Boththrow `ArgumentNullException` if `key` is `null`.
0 commit comments