Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to Celerity are documented here. This project follows [Keep

### Added

- **`FenwickTree<T>`** in `Celerity.Collections` — a Binary Indexed Tree over a fixed-length numeric sequence (`where T : struct, INumber<T>`) that applies point updates and answers prefix / range sums both in `O(log n)`, filling a BCL gap: .NET ships no prefix-sum structure, and a plain array costs `O(n)` per query or `O(n)` per update. It wins precisely where updates and range-sum queries interleave — running aggregates, rank / order-statistics counters, cumulative-frequency tables. Not thread-safe. Closes [#289](https://github.com/marius-bughiu/Celerity/issues/289).
Comment thread
marius-bughiu marked this conversation as resolved.
- **`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).
- **`SparseSet`** in `Celerity.Collections` — a bounded-universe `[0, Universe)` integer set (the Briggs–Torczon sparse set), filling a BCL gap. Where the set is cleared and rebuilt often — "visited" sets in graph traversal, ECS, sweep-line — it beats `HashSet<int>` with an `O(1)` `Clear` (the backing arrays are left untouched) and dense iteration over just the present elements. Costs `O(Universe)` memory, stores only values in `[0, Universe)`, and implements `ISet<int>`; an opt-in specialized type. Closes [#287](https://github.com/marius-bughiu/Celerity/issues/287).

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `

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

**Prefix sums**

- `FenwickTree<T>` — a **Binary Indexed Tree** over a fixed-length numeric sequence (`where T : struct, INumber<T>`): **point update** and **prefix / range sum** both in `O(log n)`, in one `n`-element array with no per-node overhead. The prefix-sum structure the BCL lacks — running aggregates, rank / order-statistics counters, cumulative-frequency tables — where a plain array is `O(n)` per query (recompute the slice) *or* `O(n)` per update (fix the suffix). Wins precisely when updates and partial-sum queries interleave.

**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 @@ -467,6 +471,24 @@ if (routes.TryGetLongestPrefix("/api/v1/users/42", out string? route, out string

</details>

<details>
<summary><b>Prefix sums with live updates</b> — FenwickTree</summary>

`FenwickTree<T>` (`where T : struct, INumber<T>`) is a **Binary Indexed Tree**: a fixed-length numeric sequence that answers **prefix / range sums** and applies **point updates** both in `O(log n)`, in one array with no per-node overhead. The BCL ships nothing for the interleaved update + prefix-sum-query workload — a plain array is `O(n)` per query or `O(n)` per update. It wins precisely when both interleave (running aggregates, rank counters, cumulative-frequency tables).

```csharp
var tree = new FenwickTree<long>(new long[] { 3, 1, 4, 1, 5, 9 });

Console.WriteLine(tree.PrefixSum(3)); // 8 (3 + 1 + 4)
Console.WriteLine(tree.RangeSum(2, 5)); // 10 (4 + 1 + 5)

tree.Add(0, 10); // point update, O(log n)
Console.WriteLine(tree[0]); // 13
Console.WriteLine(tree.Total); // 33
```

</details>

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

Expand Down Expand Up @@ -526,6 +548,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
| **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. |
| **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. |
| **Prefix / range sums over a sequence you keep mutating** — running aggregates, rank / order-statistics counters (inversions, "how many ≤ x seen"), cumulative-frequency tables | `FenwickTree<T>` | Binary Indexed Tree (`T : INumber<T>`): **point update** and **prefix / range sum** both `O(log n)`, in one array with no per-node overhead. The BCL has no prefix-sum structure; a plain array forces `O(n)` per query (recompute the slice) *or* `O(n)` per update (fix the suffix). Wins precisely when updates and partial-sum queries interleave. If the data is immutable after build, a one-shot precomputed prefix-sum array answers in `O(1)` with less code; if you only update and never query a partial sum, a raw array is simpler. |
| 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 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).
Expand Down
68 changes: 68 additions & 0 deletions docs/api/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -3652,3 +3652,71 @@ foreach (var (path, handler) in routes.GetByPrefix("/api/v1/"))
if (routes.TryGetLongestPrefix("/api/v1/users/42", out string? route, out string? handler))
Console.WriteLine($"matched {route} -> {handler}"); // matched /api/v1/users -> users-v1
```

## FenwickTree&lt;T&gt;

```csharp
public sealed class FenwickTree<T> : IReadOnlyCollection<T>
where T : struct, INumber<T>
```

A **Fenwick tree** (Binary Indexed Tree) is a fixed-length, array-backed sequence of numeric values that answers **prefix sums** — and therefore arbitrary **range sums** — and applies **point updates** in `O(log n)` each, over a single `n`-element array with no per-node object overhead. It is generic over `System.Numerics.INumber<T>`, so it works for `int`, `long`, `uint`, `ulong`, `double`, `decimal`, and any other value type with generic-math addition and subtraction.

The BCL ships nothing for the **interleaved point-update + prefix-sum-query** workload, and a plain `T[]` forces a losing tradeoff: keep the raw values and every prefix / range query is `O(n)` (sum a slice); precompute a running-total array and queries are `O(1)` but every point update is `O(n)` (fix the whole suffix). A Fenwick tree gives **both** in `O(log n)`.

### How it works

Each stored cell holds the partial sum of a contiguous range of the logical sequence whose length is the lowest set bit of its (1-based) index. A prefix query accumulates `O(log n)` cells by repeatedly stripping the lowest set bit (`k -= k & -k`); a point update touches the `O(log n)` cells whose ranges cover the changed index by repeatedly adding it back (`k += k & -k`). A range sum is the difference of two prefix sums. The constructor from a value sequence builds the tree in `O(n)` (one ascending pass that pushes each cell into its parent), not `O(n log n)` point-inserts.

### The documented BCL-beating workload

Any stream that **mixes updates with range-sum queries**: running / rolling aggregates, order-statistics and rank counters (counting inversions, "how many seen values are ≤ x"), cumulative-frequency tables, sliding-window sums over a mutating history, and gradient / weight accumulators. Against a plain array these are `O(n·q)`; against the Fenwick tree they are `O(q·log n)`. See the [Fenwick-tree benchmark](https://marius-bughiu.github.io/Celerity/dev/bench/?collection=FenwickTree) on the dashboard.

### Constructors

```csharp
public FenwickTree(int length) // length logical elements, all zero
public FenwickTree(IEnumerable<T> values) // O(n) build seeded with values, in order
```

`length` must be non-negative and at most `Array.MaxLength - 1` — the 1-based Fenwick layout reserves one array slot (`ArgumentOutOfRangeException` otherwise). The length is **fixed** at construction — the tree does not grow; `Clear` resets the values to zero but keeps the length. The `IEnumerable<T>` overload throws `ArgumentNullException` on a null source and never aliases a caller-supplied array (it copies).

### Methods and properties

| Member | Description |
| --- | --- |
| `int Count { get; }` | The number of logical elements (the fixed length). |
| `T Total { get; }` | The sum of every logical element — `PrefixSum(Count)`. |
| `T this[int index] { get; set; }` | Get/set the logical value at `index`. Both are `O(log n)`; the getter is `RangeSum(index, index + 1)`, the setter applies the delta to reach the new value. Assigning the value already stored is a no-op. |
| `void Add(int index, T delta)` | Add `delta` to the value at `index`, in `O(log n)`. A negative `delta` subtracts (for signed `T`); a zero `delta` is a no-op. |
| `T PrefixSum(int endExclusive)` | Sum of the logical elements in `[0, endExclusive)`, in `O(log n)`. `PrefixSum(0)` is zero; `PrefixSum(Count)` is `Total`. |
| `T RangeSum(int start, int endExclusive)` | Sum of the logical elements in the half-open range `[start, endExclusive)`, in `O(log n)`. An empty range sums to zero. |
| `void Clear()` | Reset every logical element to zero (`O(n)`); the length is unchanged. |
| `Enumerator GetEnumerator()` | Struct enumerator yielding the logical values in index order (`O(n log n)` total). |

Index and range arguments are bounds-checked (`ArgumentOutOfRangeException`): `index` must be in `[0, Count)`, a prefix bound in `[0, Count]`, and a range must satisfy `0 ≤ start ≤ endExclusive ≤ Count`. Reads never mutate, so they never invalidate an enumerator; `Add`, the indexer setter, and `Clear` do — except when they are no-ops (a zero delta, or assigning the value already stored), which leave both the state and any active enumerator untouched. Not thread-safe.

### Choosing it

Reach for `FenwickTree<T>` when you maintain a **mutable numeric sequence** and repeatedly ask for prefix or range sums *while* the values change — running totals, rank / order-statistics counters, cumulative-frequency tables, or windowed aggregates over a history you also edit. If your data is **immutable** after you build it, a one-shot precomputed prefix-sum `T[]` answers queries in `O(1)` with less code; if you **only ever update** and never query a partial sum, a raw array is simpler. The Fenwick tree wins precisely when both happen — updates *and* partial-sum queries interleave. For range **updates** with point queries, apply the tree to the difference array; for range-update + range-query, two Fenwick trees or a segment tree are the next step (not shipped). This type is not thread-safe; concurrent callers must synchronize externally.

### Usage example

```csharp
using Celerity.Collections;

// Count inversions with a rank counter: how many already-seen values exceed the current one.
int[] data = { 5, 2, 6, 1, 3, 4 };
int maxValue = 6;

var seen = new FenwickTree<int>(maxValue + 1); // one counter slot per possible value
long inversions = 0;
foreach (int x in data)
{
// values already seen that are strictly greater than x -> an inversion each
inversions += seen.RangeSum(x + 1, maxValue + 1);
seen.Add(x, 1); // record that we have now seen x
}

Console.WriteLine(inversions); // 8
```
34 changes: 34 additions & 0 deletions src/Celerity.AotSmokeTest/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,40 @@ void Check(bool condition, string message)
Check(reached.Count == 4 && reached.Contains(2), "SparseSet ISet<int> union within universe");
}

// FenwickTree — Binary Indexed Tree over a numeric sequence. This is the one collection
// built on generic math (INumber<T>), so the static abstract interface members resolve
// through constrained calls the AOT compiler must specialize per T — worth pinning here
// over more than one T. Exercise the O(n) seeded build, point update, prefix / range sums,
// the indexer round-trip, the no-op update, clear-then-reuse, and the struct enumerator.
{
var ft = new FenwickTree<long>(new long[] { 3, 1, 4, 1, 5, 9 });
Check(ft.Count == 6 && ft.Total == 23, "FenwickTree seeded build + total");
Check(ft.PrefixSum(0) == 0 && ft.PrefixSum(3) == 8 && ft.PrefixSum(6) == 23, "FenwickTree prefix sums");
Check(ft.RangeSum(2, 5) == 10 && ft.RangeSum(4, 4) == 0, "FenwickTree range sum + empty range");

ft.Add(0, 10);
Check(ft[0] == 13 && ft.Total == 33, "FenwickTree point update");
ft[1] = 100;
Check(ft[1] == 100 && ft.Total == 132, "FenwickTree indexer set");

var before = new List<long>();
foreach (long v in ft) before.Add(v);
Check(before.Count == 6 && before[0] == 13 && before[1] == 100, "FenwickTree enumerates logical values");

ft.Add(2, 0); // no-op: must not invalidate the enumerator below
var during = 0;
foreach (long _ in ft) { ft[3] = ft[3]; during++; } // no-op assignment mid-enumeration
Check(during == 6, "FenwickTree no-op update does not invalidate enumerators");

ft.Clear();
Check(ft.Count == 6 && ft.Total == 0 && ft[0] == 0, "FenwickTree clear resets values, keeps length");

// A second T (and a larger tree) so the generic-math instantiation is exercised twice.
var wide = new FenwickTree<int>(1000);
for (int i = 0; i < 1000; i++) wide.Add(i, i);
Check(wide.Total == 499_500 && wide.PrefixSum(10) == 45, "FenwickTree int instantiation at scale");
}

// SmallDictionary — flat-array, linear-scan dictionary (default key inline, no
// hasher). Exercise the indexer, TryAdd/Add, TryGetValue, Remove, the swap-remove
// path, the inline default/zero key, and the struct enumerator.
Expand Down
Loading
Loading