Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
1ea3aad
feat(collections): add Trie<TValue> — ordered prefix tree with O(pref…
marius-bughiu Jul 20, 2026
6696ae4
refactor(Trie): address review feedback — eager version snapshot, nul…
marius-bughiu Jul 21, 2026
8380dc2
refactor(Trie): address second review pass — avoid Substring alloc, v…
marius-bughiu Jul 22, 2026
cda306a
refactor(Trie): align with the IReadOnlyDictionary<TKey, TValue?> con…
marius-bughiu Jul 22, 2026
0484f5a
docs(Trie): sync API-table and changelog signatures with the TValue? …
marius-bughiu Jul 22, 2026
3765e3c
refactor(Trie): nullable out call sites, guarded README example, skip…
marius-bughiu Jul 22, 2026
5d572a8
perf(Trie): reuse descent child indices in Remove pruning; BCL-accura…
marius-bughiu Jul 22, 2026
a4b1a15
perf(Trie): allocation-free Values enumeration; doc nullability + ord…
marius-bughiu Jul 22, 2026
261b784
perf(Trie): single-search insert, pooled Remove path, doc complexity …
marius-bughiu Jul 22, 2026
b4983d1
perf(Trie): public struct enumerator; pre-size the Add benchmark base…
marius-bughiu Jul 22, 2026
e3fecba
docs(Trie): carry the log(branching-factor) complexity caveat into th…
marius-bughiu Jul 22, 2026
d1a9544
docs(Trie): clarify the _version comment re: the bulk constructor's r…
marius-bughiu Jul 22, 2026
95cbc53
docs(Trie): document GetEnumerator as the struct Enumerator return type
marius-bughiu Jul 22, 2026
f1dd8cd
docs(readme): fix arity of IReadOnlySet reference (single type parame…
marius-bughiu Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to Celerity are documented here. This project follows [Keep

## [Unreleased]

### Added

- **`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 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`, so the trie earns its place on the prefix operations. Implements `IReadOnlyDictionary<string, TValue?>`; not thread-safe. Closes [#285](https://github.com/marius-bughiu/Celerity/issues/285).

## [2.3.0] - 2026-07-19

### Added
Expand Down
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `

- `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.

**Prefix trees**

- `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?>`.

**Probabilistic & bit-level**

- `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.
Expand Down Expand Up @@ -430,6 +434,29 @@ Console.WriteLine(pq.Remove("c", out int p)); // True; p == 20

</details>

<details>
<summary><b>Prefix trees</b> — Trie</summary>

`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).

```csharp
var routes = new Trie<string>();
routes["/"] = "home";
routes["/api"] = "api-root";
routes["/api/v1/users"] = "users-v1";
routes["/api/v1/orders"] = "orders-v1";

// Autocomplete: every entry under a prefix, already sorted.
foreach (var (path, handler) in routes.GetByPrefix("/api/v1/"))
Console.WriteLine($"{path} -> {handler}"); // /api/v1/orders, then /api/v1/users

// Longest-prefix routing: the most specific stored route that prefixes the request.
if (routes.TryGetLongestPrefix("/api/v1/users/42", out string? route, out string? handler))
Console.WriteLine($"{route} -> {handler}"); // /api/v1/users -> users-v1
```

</details>

<details>
<summary><b>Construct from an existing collection</b></summary>

Expand Down Expand Up @@ -487,9 +514,10 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
| **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. |
| **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>`. |
| **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. |
| Need a stable iteration order or multi-threaded access | BCL `Dictionary<,>`, `ConcurrentDictionary<,>` | Celerity is single-threaded and iteration order is unspecified. |
| **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. |
| Need a stable iteration order or multi-threaded access | BCL `Dictionary<,>`, `ConcurrentDictionary<,>` (or `Trie<TValue>` for ordered string keys) | Celerity is single-threaded, and the hash-based collections leave iteration order unspecified. The exception is `Trie<TValue>`, which iterates in ascending ordinal key order by contract. |

**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).
**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 from the **hash-based** collections (the dictionaries and sets expose `IReadOnlyDictionary<,>` / `IReadOnlySet<,>` only and do not promise order across versions). If you need ordered string-keyed iteration, `Trie<TValue>` provides it by contract (ascending ordinal key order).
Comment thread
marius-bughiu marked this conversation as resolved.
Outdated

## Choosing a hasher

Expand Down
99 changes: 99 additions & 0 deletions docs/api/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -3425,3 +3425,102 @@ while (dist.TryDequeue(out int u, out int du))
Console.WriteLine(string.Join(", ", final.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}:{kv.Value}")));
// 0:0, 1:3, 2:1, 3:4, 4:7
```

## Trie&lt;TValue&gt;

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?>`.

```csharp
public sealed class Trie<TValue> : IReadOnlyDictionary<string, TValue?>
```

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.

### How it works

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 order — which 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.

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.

### The documented BCL-beating workload

The **prefix operations**:

- `GetByPrefix` / `GetKeysWithPrefix` yield every entry whose key starts with a prefix in `O(prefix length + matches)` — autocomplete, typeahead, listing a namespace or route table — where a `Dictionary` must scan and `StartsWith`-filter every entry.
- `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.
- Enumeration yields keys in ascending ordinal order for free, where a `Dictionary` is unordered.
Comment thread
marius-bughiu marked this conversation as resolved.

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 faster — the 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.

> The complexities above count each character step as `O(1)`. Strictly, navigating one node's children is a binary search, so a character step is `O(log b)` in that node's branching factor `b`; for the common bounded-alphabet case `b` is a small constant and the length-proportional forms hold, while on a pathologically wide alphabet the character-length terms gain a `log b` factor.

### Constructors

```csharp
public Trie()
public Trie(IEnumerable<KeyValuePair<string, TValue>> entries)
```

- The parameterless constructor starts empty.
- The `entries` overload bulk-loads the pairs; a later duplicate key overwrites the value set by an earlier one (indexer semantics).

**Throws:**

- `ArgumentNullException` if `entries` is `null`, or any key in it is `null`.

### Indexer

```csharp
public TValue this[string key] { get; set; }
```

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`.

### Methods and properties

| Member | Description |
|--------|-------------|
| `int Count` | Number of keys. |
| `void Add(string key, TValue value)` | Adds a key. Throws `ArgumentException` if it already exists. |
| `bool TryAdd(string key, TValue value)` | Adds a key, leaving an existing entry unchanged. Returns `false` if already present. |
| `bool ContainsKey(string key)` | Whether `key` is a stored key (an interior-only prefix returns `false`). |
| `bool TryGetValue(string key, out TValue? value)` | Non-throwing exact lookup. |
| `bool Remove(string key)` | Removes a key, pruning any newly-dead nodes. Returns `false` if absent. |
| `bool Remove(string key, out TValue? value)` | `Remove` returning the removed value (`default` when the key was absent). |
| `void Clear()` | Removes all keys. |
| `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. |
| `IEnumerable<KeyValuePair<string, TValue?>> GetByPrefix(string prefix)` | Every entry whose key starts with `prefix`, in ascending key order (lazy). |
| `IEnumerable<string> GetKeysWithPrefix(string prefix)` | The keys of `GetByPrefix`, in ascending order (lazy). |
| `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). On a miss (`false`), `key` is `null` and `value` is `default`. |
| `IEnumerable<string> Keys` / `IEnumerable<TValue?> Values` | Keys in ascending order and their aligned values. |
| `IEnumerator<KeyValuePair<string, TValue?>> GetEnumerator()` | Entries in ascending key order. Enumeration allocates a small traversal stack. |
Comment thread
marius-bughiu marked this conversation as resolved.
Outdated

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.

### Empty-string and default handling

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.

### Choosing it

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 fit — the trie earns its place only when you use the prefix operations. It is not thread-safe.

### Usage example

```csharp
using Celerity.Collections;

var routes = new Trie<string>();
routes["/"] = "home";
routes["/api"] = "api-root";
routes["/api/v1/users"] = "users-v1";
routes["/api/v1/orders"] = "orders-v1";

// Autocomplete: every route under a prefix, already in sorted order.
foreach (var (path, handler) in routes.GetByPrefix("/api/v1/"))
Console.WriteLine($"{path} -> {handler}"); // /api/v1/orders, then /api/v1/users

// Longest-prefix routing: the most specific stored route that prefixes the request.
if (routes.TryGetLongestPrefix("/api/v1/users/42", out string? route, out string? handler))
Console.WriteLine($"matched {route} -> {handler}"); // matched /api/v1/users -> users-v1
```
1 change: 1 addition & 0 deletions src/Celerity.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ internal class Program
typeof(DequeBenchmark),
typeof(DisjointSetBenchmark),
typeof(IndexedPriorityQueueBenchmark),
typeof(TrieBenchmark),
typeof(StringHasherBenchmark),
typeof(IntegerHasherBenchmark),
};
Expand Down
Loading
Loading