diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index d25f9dd..fb8cc67 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -26,22 +26,28 @@ on: # together on the one shard runner, so the per-benchmark delta still cancels hardware. env: # Keep in sync with the matrix.shard list below. - SHARD_TOTAL: '6' + # + # Raised 6 -> 8: at 6 the heaviest shard measured ~65 min per slice, so head + base + # came to ~130 min and overran the 120 min timeout, while the lightest finished in + # ~86 min. CiConfig.cs is explicit that the job schedule stays as-is and the matrix + # is what scales when the suite grows, so this widens the matrix rather than trading + # away measurement accuracy. + SHARD_TOTAL: '8' jobs: benchmark-shard: name: benchmark (shard ${{ matrix.shard }}) runs-on: ubuntu-latest - # Steady state: head slice (~30 min) + base slice (~30 min) ~= 60 min. The one - # transitional run of THIS PR is slower (see the base step) and may be cancelled - # here — that is no worse than the status quo (the gate is cancelled on every PR - # today) and self-heals the moment `--shard` lands on main. + # Measured at SHARD_TOTAL=6: slices ran 43-65 min, so head + base came to 86-130 min + # and the heaviest shard overran this timeout. At 8 the same work divides further, + # putting the heaviest shard back comfortably inside the limit. Re-measure and widen + # the matrix again if shard durations creep back toward it. timeout-minutes: 120 strategy: fail-fast: false matrix: # Must enumerate 0 .. SHARD_TOTAL-1. - shard: [0, 1, 2, 3, 4, 5] + shard: [0, 1, 2, 3, 4, 5, 6, 7] permissions: contents: read @@ -85,11 +91,11 @@ jobs: # on THIS runner so hardware variance cancels (hosted runners vary 20-50% # run-to-run, so a stored cross-runner baseline would be noise-dominated). # - # TRANSITIONAL: until this PR's `--shard` support lands on main, the base tip's - # `--ci` does not understand `--shard` and runs the FULL ~3h suite here, so these - # base steps will exceed the job timeout and be cancelled on THIS PR only. That - # matches today's behaviour (the gate is already cancelled on every PR) and the - # workflow self-heals once merged — every later PR's base honours `--shard`. + # `--shard` is on main now, so the base tip honours it and measures only this + # shard's slice — the transitional full-suite base run that this comment used to + # warn about no longer happens. A cancellation here is therefore a real signal (the + # slice genuinely exceeded the job timeout) rather than expected behaviour that will + # self-heal, and should be investigated instead of dismissed. if: github.event_name == 'pull_request' working-directory: ${{ github.workspace }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 79cad93..7a32eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Celerity are documented here. This project follows [Keep ### Added +- **`FenwickTree`** in `Celerity.Collections` — a Binary Indexed Tree over a fixed-length numeric sequence (`where T : struct, INumber`) 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). - **`Trie`** 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` 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`; 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` 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`; an opt-in specialized type. Closes [#287](https://github.com/marius-bughiu/Celerity/issues/287). diff --git a/README.md b/README.md index 2f0690f..125410a 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, ` - `Trie` — 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` 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`. +**Prefix sums** + +- `FenwickTree` — a **Binary Indexed Tree** over a fixed-length numeric sequence (`where T : struct, INumber`): **point update** and **prefix / range sum** both in `O(log n)`, in one flat 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` — **probabilistic** membership: bit-array storage, **no false negatives**, tunable false-positive rate, a fraction of a `HashSet`'s memory. Add-and-test only. @@ -467,6 +471,24 @@ if (routes.TryGetLongestPrefix("/api/v1/users/42", out string? route, out string +
+Prefix sums with live updates — FenwickTree + +`FenwickTree` (`where T : struct, INumber`) 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(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 +``` + +
+
Construct from an existing collection @@ -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` | 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>` 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` — for element membership with add/remove/set-algebra use `CeleritySet` or `HashSet`. | | **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` | 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` 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` | 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` 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`; 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` | Binary Indexed Tree (`T : INumber`): **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` for ordered string keys) | Celerity is single-threaded, and the hash-based collections leave iteration order unspecified. The exception is `Trie`, 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` provides it by contract (ascending ordinal key order). diff --git a/docs/api/collections.md b/docs/api/collections.md index 3f6e2de..cfbb58a 100644 --- a/docs/api/collections.md +++ b/docs/api/collections.md @@ -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<T> + +```csharp +public sealed class FenwickTree : IReadOnlyCollection + where T : struct, INumber +``` + +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 flat array of `n + 1` elements (index `0` is unused by the 1-based layout), with no per-node object overhead. It is generic over `System.Numerics.INumber`, 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 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` 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` 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(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 +``` diff --git a/src/Celerity.AotSmokeTest/Program.cs b/src/Celerity.AotSmokeTest/Program.cs index 0c2161f..1e191ea 100644 --- a/src/Celerity.AotSmokeTest/Program.cs +++ b/src/Celerity.AotSmokeTest/Program.cs @@ -568,6 +568,40 @@ void Check(bool condition, string message) Check(reached.Count == 4 && reached.Contains(2), "SparseSet ISet union within universe"); } +// FenwickTree — Binary Indexed Tree over a numeric sequence. This is the one collection +// built on generic math (INumber), 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(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(); + 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(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. diff --git a/src/Celerity.Benchmarks/FenwickTreeBenchmark.cs b/src/Celerity.Benchmarks/FenwickTreeBenchmark.cs new file mode 100644 index 0000000..a9ce728 --- /dev/null +++ b/src/Celerity.Benchmarks/FenwickTreeBenchmark.cs @@ -0,0 +1,132 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using Celerity.Collections; + +// FenwickTree vs the plain-array baseline a developer reaches for without a Binary Indexed Tree. The +// BCL ships no prefix-sum structure, so the honest baseline is a raw long[]: point updates are O(1), but +// every prefix / range sum re-adds the slice (O(n)). That is the losing side of the tradeoff the Fenwick +// tree exists to erase — it keeps BOTH the point update and the prefix/range query at O(log n). +// +// Two categories cover the documented BCL-beating shape. Mixed interleaves point updates with prefix-sum +// queries (the headline workload: running aggregates, rank counters, cumulative-frequency tables) where the +// array is O(n) per query; RangeSum runs a batch of half-open range-sum queries against a pre-built +// structure. The baseline arms are named Array_* so the dashboard classifies them as the BCL reference. +[MemoryDiagnoser] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +public class FenwickTreeBenchmark +{ + private long[] initial = null!; // initial logical values seeding both structures + private int[] updateIndex = null!; // point-update positions for the mixed stream + private long[] updateDelta = null!; + private int[] queryEnd = null!; // prefix-sum query positions for the mixed stream + private int[] rangeStart = null!; // half-open range-sum query bounds for the RangeSum category + private int[] rangeEnd = null!; + + private FenwickTree fenwickFull = null!; + private long[] arrayFull = null!; + + [Params(1000, 100_000)] + public int ItemCount; + + [GlobalSetup] + public void Setup() + { + var rand = new Random(42); + + initial = new long[ItemCount]; + for (int i = 0; i < ItemCount; i++) + initial[i] = rand.Next(-100, 100); + + int ops = Math.Min(ItemCount, 10_000); + updateIndex = new int[ops]; + updateDelta = new long[ops]; + queryEnd = new int[ops]; + rangeStart = new int[ops]; + rangeEnd = new int[ops]; + for (int i = 0; i < ops; i++) + { + updateIndex[i] = rand.Next(ItemCount); + updateDelta[i] = rand.Next(-100, 100); + queryEnd[i] = rand.Next(ItemCount + 1); + + int a = rand.Next(ItemCount + 1); + int b = rand.Next(ItemCount + 1); + if (a > b) + (a, b) = (b, a); + rangeStart[i] = a; + rangeEnd[i] = b; + } + + fenwickFull = new FenwickTree(initial); + arrayFull = (long[])initial.Clone(); + } + + // ---- Mixed: interleave point updates with prefix-sum queries (the headline O(log n) vs O(n) split) ---- + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Mixed")] + public long Array_Mixed() + { + long[] values = (long[])initial.Clone(); + long sink = 0; + for (int i = 0; i < updateIndex.Length; i++) + { + values[updateIndex[i]] += updateDelta[i]; + + // Prefix sum by re-adding the slice — O(n) per query. + long sum = 0; + int end = queryEnd[i]; + for (int j = 0; j < end; j++) + sum += values[j]; + sink += sum; + } + + return sink; + } + + [Benchmark] + [BenchmarkCategory("Mixed")] + public long FenwickTree_Mixed() + { + var tree = new FenwickTree(initial); + long sink = 0; + for (int i = 0; i < updateIndex.Length; i++) + { + tree.Add(updateIndex[i], updateDelta[i]); + sink += tree.PrefixSum(queryEnd[i]); + } + + return sink; + } + + // ---- RangeSum: a batch of half-open range-sum queries against the pre-built structure ---- + + [Benchmark(Baseline = true)] + [BenchmarkCategory("RangeSum")] + public long Array_RangeSum() + { + long sink = 0; + for (int i = 0; i < rangeStart.Length; i++) + { + long sum = 0; + int end = rangeEnd[i]; + for (int j = rangeStart[i]; j < end; j++) + sum += arrayFull[j]; + sink += sum; + } + + return sink; + } + + [Benchmark] + [BenchmarkCategory("RangeSum")] + public long FenwickTree_RangeSum() + { + long sink = 0; + for (int i = 0; i < rangeStart.Length; i++) + sink += fenwickFull.RangeSum(rangeStart[i], rangeEnd[i]); + + return sink; + } +} diff --git a/src/Celerity.Benchmarks/Program.cs b/src/Celerity.Benchmarks/Program.cs index 8884afd..4b60860 100644 --- a/src/Celerity.Benchmarks/Program.cs +++ b/src/Celerity.Benchmarks/Program.cs @@ -47,6 +47,7 @@ internal class Program typeof(DisjointSetBenchmark), typeof(IndexedPriorityQueueBenchmark), typeof(TrieBenchmark), + typeof(FenwickTreeBenchmark), typeof(StringHasherBenchmark), typeof(IntegerHasherBenchmark), }; diff --git a/src/Celerity.Tests/Collections/FenwickTreeDifferentialTests.cs b/src/Celerity.Tests/Collections/FenwickTreeDifferentialTests.cs new file mode 100644 index 0000000..c5b5e16 --- /dev/null +++ b/src/Celerity.Tests/Collections/FenwickTreeDifferentialTests.cs @@ -0,0 +1,103 @@ +using Celerity.Collections; + +namespace Celerity.Tests.Collections; + +/// +/// Deterministic, seeded differential coverage for . Each seed drives the same +/// random stream of point updates, indexer assignments, and clears into the Fenwick tree and into a naive +/// long[] reference model, then asserts after every operation that they agree on every logical value, +/// on at every boundary, on a batch of random +/// queries, and on . This +/// is the strongest guard against a low-bit / lowest-set-bit-walk error that only surfaces after many +/// interleaved updates at specific index shapes. +/// +public class FenwickTreeDifferentialTests +{ + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(42)] + [InlineData(123)] + [InlineData(2026)] + public void FenwickTree_ShouldMatchNaiveArray_UnderRandomOperations(int seed) + { + var rand = new Random(seed); + int n = rand.Next(1, 64); + + // Seed both models from the same random initial values. The long[] goes in through the + // IEnumerable constructor's counted (ICollection) fast path, so this also exercises the + // O(n) linear-time build rather than a sequence of point inserts. + var initial = new long[n]; + for (int i = 0; i < n; i++) + initial[i] = rand.Next(-50, 50); + + var tree = new FenwickTree(initial); + var model = (long[])initial.Clone(); + AssertConsistent(tree, model, rand); + + for (int step = 0; step < 2000; step++) + { + int op = rand.Next(0, 10); + if (op == 0) + { + // Clear: reset both to zero. + tree.Clear(); + Array.Clear(model, 0, model.Length); + } + else if (op <= 5) + { + // Point add. + int idx = rand.Next(0, n); + long delta = rand.Next(-100, 100); + tree.Add(idx, delta); + model[idx] += delta; + } + else + { + // Indexer set. + int idx = rand.Next(0, n); + long value = rand.Next(-100, 100); + tree[idx] = value; + model[idx] = value; + } + + AssertConsistent(tree, model, rand); + } + } + + private static void AssertConsistent(FenwickTree tree, long[] model, Random rand) + { + Assert.Equal(model.Length, tree.Count); + + // Every logical value matches (indexer get and enumeration). + var enumerated = tree.ToArray(); + long runningPrefix = 0; + for (int i = 0; i < model.Length; i++) + { + Assert.Equal(model[i], tree[i]); + Assert.Equal(model[i], enumerated[i]); + + // PrefixSum at every boundary [0, i]. + Assert.Equal(runningPrefix, tree.PrefixSum(i)); + runningPrefix += model[i]; + } + + Assert.Equal(runningPrefix, tree.PrefixSum(model.Length)); + Assert.Equal(runningPrefix, tree.Total); + + // A batch of random half-open range queries. + for (int q = 0; q < 8; q++) + { + int a = rand.Next(0, model.Length + 1); + int b = rand.Next(0, model.Length + 1); + if (a > b) + (a, b) = (b, a); + + long expected = 0; + for (int i = a; i < b; i++) + expected += model[i]; + + Assert.Equal(expected, tree.RangeSum(a, b)); + } + } +} diff --git a/src/Celerity.Tests/Collections/FenwickTreeTests.cs b/src/Celerity.Tests/Collections/FenwickTreeTests.cs new file mode 100644 index 0000000..8d0145d --- /dev/null +++ b/src/Celerity.Tests/Collections/FenwickTreeTests.cs @@ -0,0 +1,476 @@ +using System.Collections; +using Celerity.Collections; + +namespace Celerity.Tests.Collections; + +/// +/// Behavioural coverage for : the point-update / prefix-sum / range-sum core, the +/// indexer get/set, the three constructors, boundary and validation corners, , +/// and the enumeration surface. The randomized reconciliation against a naive array oracle lives in +/// . +/// +public class FenwickTreeTests +{ + [Fact] + public void Constructor_ShouldStartAllZero_WhenGivenLength() + { + var tree = new FenwickTree(8); + + Assert.Equal(8, tree.Count); + Assert.Equal(0, tree.Total); + for (int i = 0; i < 8; i++) + Assert.Equal(0, tree[i]); + Assert.Equal(0, tree.PrefixSum(8)); + } + + [Fact] + public void Constructor_ShouldAllowZeroLength() + { + var tree = new FenwickTree(0); + + Assert.Equal(0, tree.Count); + Assert.Equal(0, tree.Total); + Assert.Equal(0, tree.PrefixSum(0)); + Assert.Empty(tree); + } + + [Fact] + public void Constructor_ShouldThrow_WhenLengthNegative() + { + var ex = Assert.Throws(() => new FenwickTree(-1)); + Assert.Equal("length", ex.ParamName); + } + + [Fact] + public void Constructor_ShouldThrow_WhenLengthExceedsMaxSupported() + { + // The 1-based layout needs length + 1 array slots, so anything above Array.MaxLength - 1 must be + // rejected up front with a clear ArgumentOutOfRangeException rather than overflowing into an + // OverflowException / OutOfMemoryException from the allocation. + var ex = Assert.Throws(() => new FenwickTree(int.MaxValue)); + Assert.Equal("length", ex.ParamName); + + var atCeiling = Assert.Throws(() => new FenwickTree(Array.MaxLength)); + Assert.Equal("length", atCeiling.ParamName); + } + + [Fact] + public void ArrayConstructor_ShouldSeedLogicalValues() + { + long[] values = { 3, 1, 4, 1, 5, 9, 2, 6 }; + var tree = new FenwickTree(values); + + Assert.Equal(8, tree.Count); + long running = 0; + for (int i = 0; i < 8; i++) + { + Assert.Equal(values[i], tree[i]); + running += values[i]; + Assert.Equal(running, tree.PrefixSum(i + 1)); + } + + Assert.Equal(31, tree.Total); + } + + [Fact] + public void ArrayConstructor_ShouldNotAliasSourceArray() + { + long[] source = { 1, 2, 3, 4 }; + var tree = new FenwickTree(source); + + tree.Add(0, 100); + + Assert.Equal(1, source[0]); // mutating the tree must not write back into the caller's array + } + + [Fact] + public void EnumerableConstructor_ShouldSeedLogicalValues_FromCollection() + { + var tree = new FenwickTree(new List { 10, 20, 30, 40 }); + + Assert.Equal(4, tree.Count); + Assert.Equal(new[] { 10, 20, 30, 40 }, tree.ToArray()); + Assert.Equal(100, tree.Total); + Assert.Equal(50, tree.RangeSum(1, 3)); // elements at index 1 and 2: 20 + 30 + } + + [Fact] + public void EnumerableConstructor_ShouldSeedLogicalValues_FromLazySequence() + { + // A non-ICollection source exercises the List materialization fallback. + IEnumerable Lazy() + { + for (int i = 1; i <= 5; i++) + yield return i * i; + } + + var tree = new FenwickTree(Lazy()); + + Assert.Equal(5, tree.Count); + Assert.Equal(new[] { 1, 4, 9, 16, 25 }, tree.ToArray()); + Assert.Equal(55, tree.Total); + } + + [Fact] + public void EnumerableConstructor_ShouldHandleEmptySource() + { + // Boundary for the counted fast path: CopyTo targets index 1 of a length-1 backing array. + var fromCollection = new FenwickTree(Array.Empty()); + Assert.Equal(0, fromCollection.Count); + Assert.Equal(0, fromCollection.Total); + Assert.Empty(fromCollection); + + var fromLazy = new FenwickTree(Enumerable.Empty()); + Assert.Equal(0, fromLazy.Count); + Assert.Equal(0, fromLazy.Total); + Assert.Empty(fromLazy); + } + + [Fact] + public void EnumerableConstructor_ShouldSeedLogicalValues_FromNonListCollection() + { + // A counted source that is neither T[] nor List, so the ICollection.CopyTo path is what runs. + var source = new SortedSet { 4, 1, 3 }; // enumerates ascending: 1, 3, 4 + var tree = new FenwickTree(source); + + Assert.Equal(3, tree.Count); + Assert.Equal(new[] { 1, 3, 4 }, tree.ToArray()); + Assert.Equal(8, tree.Total); + } + + [Fact] + public void EnumerableConstructor_ShouldThrow_WhenSourceNull() + { + Assert.Throws(() => new FenwickTree((IEnumerable)null!)); + } + + [Fact] + public void Add_ShouldAccumulateAtIndex() + { + var tree = new FenwickTree(6); + + tree.Add(2, 5); + tree.Add(2, 3); + tree.Add(4, 10); + + Assert.Equal(8, tree[2]); + Assert.Equal(10, tree[4]); + Assert.Equal(8, tree.PrefixSum(3)); + Assert.Equal(18, tree.PrefixSum(5)); + Assert.Equal(18, tree.Total); + } + + [Fact] + public void Add_ShouldSubtract_WhenDeltaNegative() + { + var tree = new FenwickTree(new[] { 5, 5, 5, 5 }); + + tree.Add(1, -3); + + Assert.Equal(2, tree[1]); + Assert.Equal(17, tree.Total); + } + + [Fact] + public void Add_ShouldThrow_WhenIndexOutOfRange() + { + var tree = new FenwickTree(4); + + Assert.Throws(() => tree.Add(-1, 1)); + Assert.Throws(() => tree.Add(4, 1)); + } + + [Fact] + public void Indexer_Set_ShouldReplaceValue() + { + var tree = new FenwickTree(new[] { 1, 2, 3, 4, 5 }); + + tree[2] = 30; + + Assert.Equal(30, tree[2]); + Assert.Equal(1 + 2 + 30 + 4 + 5, tree.Total); + Assert.Equal(33, tree.PrefixSum(3)); + } + + [Fact] + public void Indexer_Set_ShouldBeIdempotent_WhenAssignedSameValue() + { + var tree = new FenwickTree(new[] { 7, 8, 9 }); + + tree[1] = 8; + + Assert.Equal(new[] { 7, 8, 9 }, tree.ToArray()); + } + + [Fact] + public void Indexer_Set_ShouldNotInvalidateEnumerator_WhenAssignedSameValue() + { + // Assigning the value already stored does not change the observable state, so — like every other + // no-op in the library — it must not bump the version and invalidate active enumerators. + var tree = new FenwickTree(new[] { 7, 8, 9 }); + + var seen = new List(); + foreach (int v in tree) + { + tree[1] = 8; // no-op assignment + seen.Add(v); + } + + Assert.Equal(new[] { 7, 8, 9 }, seen); + } + + [Fact] + public void Add_ShouldNotInvalidateEnumerator_WhenDeltaZero() + { + var tree = new FenwickTree(new[] { 1, 2, 3 }); + + var seen = new List(); + foreach (int v in tree) + { + tree.Add(0, 0); // adding zero changes nothing + seen.Add(v); + } + + Assert.Equal(new[] { 1, 2, 3 }, seen); + } + + [Fact] + public void Add_ShouldStillInvalidateEnumerator_WhenDeltaNonZero() + { + var tree = new FenwickTree(new[] { 1, 2, 3 }); + + Assert.Throws(() => + { + foreach (int _ in tree) + tree.Add(0, 1); + }); + } + + [Fact] + public void Indexer_Get_ShouldThrow_WhenIndexOutOfRange() + { + var tree = new FenwickTree(3); + + Assert.Throws(() => _ = tree[-1]); + Assert.Throws(() => _ = tree[3]); + } + + [Fact] + public void Indexer_Set_ShouldThrow_WhenIndexOutOfRange() + { + var tree = new FenwickTree(3); + + Assert.Throws(() => tree[-1] = 1); + Assert.Throws(() => tree[3] = 1); + } + + [Fact] + public void PrefixSum_ShouldReturnZero_AtStart() + { + var tree = new FenwickTree(new[] { 1, 2, 3 }); + + Assert.Equal(0, tree.PrefixSum(0)); + } + + [Fact] + public void PrefixSum_ShouldReturnTotal_AtFullLength() + { + var tree = new FenwickTree(new[] { 1, 2, 3 }); + + Assert.Equal(6, tree.PrefixSum(3)); + Assert.Equal(tree.Total, tree.PrefixSum(tree.Count)); + } + + [Fact] + public void PrefixSum_ShouldThrow_WhenOutOfRange() + { + var tree = new FenwickTree(3); + + Assert.Throws(() => tree.PrefixSum(-1)); + Assert.Throws(() => tree.PrefixSum(4)); + } + + [Fact] + public void RangeSum_ShouldReturnHalfOpenSum() + { + var tree = new FenwickTree(new[] { 2, 4, 6, 8, 10 }); + + Assert.Equal(4 + 6 + 8, tree.RangeSum(1, 4)); + Assert.Equal(30, tree.RangeSum(0, 5)); + } + + [Fact] + public void RangeSum_ShouldReturnZero_WhenEmptyRange() + { + var tree = new FenwickTree(new[] { 2, 4, 6 }); + + Assert.Equal(0, tree.RangeSum(2, 2)); + Assert.Equal(0, tree.RangeSum(0, 0)); + Assert.Equal(0, tree.RangeSum(3, 3)); + } + + [Fact] + public void RangeSum_ShouldThrow_WhenInvalid() + { + var tree = new FenwickTree(4); + + Assert.Throws(() => tree.RangeSum(-1, 2)); + Assert.Throws(() => tree.RangeSum(0, 5)); + Assert.Throws(() => tree.RangeSum(3, 2)); // end < start + } + + [Fact] + public void Clear_ShouldResetAllToZero() + { + var tree = new FenwickTree(new[] { 1, 2, 3, 4 }); + + tree.Clear(); + + Assert.Equal(0, tree.Total); + for (int i = 0; i < 4; i++) + Assert.Equal(0, tree[i]); + Assert.Equal(4, tree.Count); // length is fixed; only the values reset + } + + [Fact] + public void Clear_ThenReuse_ShouldWork() + { + var tree = new FenwickTree(new[] { 1, 2, 3, 4 }); + + tree.Clear(); + tree.Add(0, 100); + tree.Add(3, 5); + + Assert.Equal(105, tree.Total); + Assert.Equal(100, tree[0]); + Assert.Equal(5, tree[3]); + } + + [Fact] + public void Enumerator_ShouldYieldLogicalValuesInOrder() + { + var tree = new FenwickTree(new[] { 3, 1, 4, 1, 5, 9 }); + + Assert.Equal(new[] { 3, 1, 4, 1, 5, 9 }, tree.ToArray()); + } + + [Fact] + public void Enumerator_ShouldReflectUpdates() + { + var tree = new FenwickTree(new[] { 1, 1, 1, 1 }); + tree[2] = 9; + tree.Add(0, 5); + + Assert.Equal(new[] { 6, 1, 9, 1 }, tree.ToArray()); + } + + [Fact] + public void Enumerator_ShouldThrow_WhenMutatedDuringEnumeration() + { + var tree = new FenwickTree(new[] { 1, 2, 3 }); + + Assert.Throws(() => + { + foreach (int _ in tree) + tree.Add(0, 1); + }); + } + + [Fact] + public void Enumerator_QueryDuringEnumeration_ShouldNotInvalidate() + { + var tree = new FenwickTree(new[] { 1, 2, 3, 4 }); + + int sum = 0; + foreach (int v in tree) + { + _ = tree.PrefixSum(2); // a pure query must not bump the version + sum += v; + } + + Assert.Equal(10, sum); + } + + [Fact] + public void Enumerator_Reset_ShouldRestart() + { + var tree = new FenwickTree(new[] { 5, 6, 7 }); + FenwickTree.Enumerator e = tree.GetEnumerator(); + + Assert.True(e.MoveNext()); + Assert.Equal(5, e.Current); + e.Reset(); + Assert.True(e.MoveNext()); + Assert.Equal(5, e.Current); + } + + [Fact] + public void Enumerator_NonGeneric_ShouldYieldValues() + { + var tree = new FenwickTree(new[] { 2, 4, 6 }); + + var result = new List(); + IEnumerator e = ((IEnumerable)tree).GetEnumerator(); + while (e.MoveNext()) + result.Add(e.Current); + + Assert.Equal(new object?[] { 2, 4, 6 }, result); + } + + // Regression for the index-overflow fix. Both Fenwick ascents advance by adding the lowest set bit, so at + // k == 1 << 30 the next index is 1 << 31 — which overflows a signed int and wraps to int.MinValue, a + // negative value that still passes the `<= _length` bound and then indexes the array out of range. Lengths + // that large are permitted (the ceiling is Array.MaxLength - 1), so this is reachable rather than + // theoretical: the smallest INumber is one byte, making a 2^30-element tree about 1 GiB. + // + // Verified to throw IndexOutOfRangeException against the pre-fix code and pass after the widening, so this + // is the regression guard for that fix. It has to build a real 2^30-element tree: the overflow depends on + // _length, so no smaller instance can reach the failing step, and an arithmetic-only assertion would just + // re-evaluate the expression in the test rather than exercise FenwickTree at all. + // + // The cost is far lower than the 1 GiB figure suggests — the array is committed but never faulted in + // beyond the ~30 cells the ascent touches (the runtime zeroes lazily), so it completes in ~17 ms. Where + // that headroom genuinely is not available (a memory-capped container or runner), MemoryIntensiveFact + // reports the test skipped rather than running it, so it can never turn the build red on resource grounds. + // The Category trait lets CI segregate this if it ever needs to — e.g. `--filter "Category!=MemoryIntensive"` + // to exclude it, or a dedicated serial job to run it away from the parallel suite. + [MemoryIntensiveFact(1024)] + [Trait("Category", "MemoryIntensive")] + public void Add_ShouldNotOverflowIndex_WhenTreeExceedsTwoToThe30() + { + const int length = 1 << 30; // 2^30 one-byte cells + the reserved 1-based slot ≈ 1 GiB + var tree = new FenwickTree(length); + + // index + 1 == 1 << 30, so the ascent lands exactly on the overflowing step. + tree.Add(length - 1, 1); + + Assert.Equal((byte)1, tree[length - 1]); + Assert.Equal((byte)1, tree.Total); + } + + [Fact] + public void FenwickTree_ShouldWorkWithDoubleValues() + { + var tree = new FenwickTree(new[] { 1.5, 2.5, 3.0 }); + + Assert.Equal(7.0, tree.Total); + Assert.Equal(4.0, tree.RangeSum(0, 2)); + tree.Add(2, 1.0); + Assert.Equal(8.0, tree.Total); + } + + [Fact] + public void FenwickTree_ShouldWorkWithLongValues_AtScale() + { + // Long avoids the int-overflow the summed magnitudes would otherwise risk, and exercises a larger tree. + const int n = 1000; + var tree = new FenwickTree(n); + for (int i = 0; i < n; i++) + tree.Add(i, i); + + long expected = (long)(n - 1) * n / 2; + Assert.Equal(expected, tree.Total); + Assert.Equal(expected, tree.PrefixSum(n)); + Assert.Equal(45, tree.PrefixSum(10)); // 0+1+...+9 + } +} diff --git a/src/Celerity.Tests/MemoryIntensiveFactAttribute.cs b/src/Celerity.Tests/MemoryIntensiveFactAttribute.cs new file mode 100644 index 0000000..85bf18b --- /dev/null +++ b/src/Celerity.Tests/MemoryIntensiveFactAttribute.cs @@ -0,0 +1,48 @@ +namespace Celerity.Tests; + +/// +/// A for a test that needs a large allocation to reproduce the behaviour it +/// guards. The test is skipped — reported as skipped, not silently passed — when the environment does not +/// report enough headroom, so a memory-capped container or runner can never turn the build red on resource +/// grounds while every environment with room still runs the check. +/// +/// +/// The decision is made at discovery time from , which +/// reflects the container/cgroup limit where one applies rather than the host's physical memory. A multiple +/// of the bare requirement is demanded so the test never allocates right up against the ceiling. +/// +public sealed class MemoryIntensiveFactAttribute : FactAttribute +{ + // Headroom multiple over the stated requirement before the test is considered safe to run. + private const int RequiredHeadroomFactor = 3; + + /// + /// Marks a test as requiring of allocatable memory. + /// + /// The size of the allocation the test makes, in MiB. Must be positive. + /// is not positive. + public MemoryIntensiveFactAttribute(int requiredMegabytes) + { + // A non-positive requirement would make the threshold meaningless and silently force the test to run + // everywhere — exactly the behaviour this attribute exists to prevent. The argument is a compile-time + // constant, so this fails the first time the test is discovered rather than at run time. + if (requiredMegabytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(requiredMegabytes), requiredMegabytes, + "The required size must be positive."); + } + + // Widening before the multiply already rules out overflow for every permitted argument; `checked` + // states that intent rather than relying on the reader to re-derive it. + long required = checked((long)requiredMegabytes * 1024 * 1024); + long available = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + + // A non-positive reading means "unknown" — run the test rather than skip on missing information. + if (available > 0 && available < checked(required * RequiredHeadroomFactor)) + { + Skip = $"Needs ~{requiredMegabytes} MiB of allocatable memory " + + $"(with {RequiredHeadroomFactor}x headroom); this environment reports " + + $"{available / (1024 * 1024)} MiB available."; + } + } +} diff --git a/src/Celerity/Collections/FenwickTree.cs b/src/Celerity/Collections/FenwickTree.cs new file mode 100644 index 0000000..fbab079 --- /dev/null +++ b/src/Celerity/Collections/FenwickTree.cs @@ -0,0 +1,360 @@ +using System.Collections; +using System.Numerics; + +namespace Celerity.Collections; + +/// +/// A Fenwick tree (Binary Indexed Tree): 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 flat array — n + 1 elements, the slot at index 0 being +/// unused by the 1-based layout — with no per-node object overhead. +/// +/// +/// The numeric element type. Constrained to , so it works for , +/// , , , , , +/// and any other value type that implements 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 / +/// query is O(n) (sum a slice); precompute a running-total array and +/// queries are O(1) but every point is O(n) (fix the whole suffix). +/// A Fenwick tree gives both in O(log n). Each stored cell holds the partial sum of a range of +/// the logical sequence whose length is the lowest set bit of its (1-based) index, so a prefix query +/// accumulates O(log n) cells by repeatedly stripping the lowest set bit, and an update touches the +/// O(log n) cells whose ranges cover the changed index by repeatedly adding it back. +/// +/// +/// The documented BCL-beating workload is 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 +/// or weight accumulators. Against a plain array these are O(n·q); against the Fenwick tree they are +/// O(q·log n). +/// +/// +/// The length is fixed at construction (like ); the tree does not grow. Reads never +/// mutate, so they never invalidate an enumerator; , the indexer setter, and +/// do — except when they are no-ops (a zero delta, or assigning the value already +/// stored), which leave the observable state and any active enumerator untouched. This type is not +/// thread-safe; concurrent callers must synchronize externally. +/// +/// +public sealed class FenwickTree : IReadOnlyCollection + where T : struct, INumber +{ + // 1-based Fenwick storage: _tree[0] is unused, _tree[k] holds the sum of the logical elements in the + // half-open range (k - (k & -k), k] (1-based). _length is the logical element count == _tree.Length - 1. + private readonly T[] _tree; + private readonly int _length; + + // Bumped on every mutation (Add / indexer set / Clear) so active enumerators throw on concurrent + // modification. A pure query (PrefixSum / RangeSum / indexer get) is not a mutation and does not bump it. + private int _version; + + /// + /// The largest logical length a tree can hold. The 1-based Fenwick layout reserves an unused cell at + /// index 0, so the backing array is one longer than the logical length and the ceiling is one + /// below . + /// + private static readonly int MaxLength = Array.MaxLength - 1; + + /// + /// Initializes a new Fenwick tree of logical elements, all zero. + /// + /// + /// The number of logical elements. Must be non-negative and at most minus one + /// (the 1-based layout reserves one array slot). + /// + /// + /// is negative, or exceeds the maximum supported length. + /// + public FenwickTree(int length) + { + if (length < 0) + throw new ArgumentOutOfRangeException(nameof(length), length, "Length must be non-negative."); + if (length > MaxLength) + throw new ArgumentOutOfRangeException(nameof(length), length, + $"Length must be at most {MaxLength} (Array.MaxLength minus the reserved 1-based slot)."); + + _length = length; + _tree = new T[length + 1]; + } + + /// + /// Initializes a new Fenwick tree seeded with , built in O(n). The logical + /// element at index i starts equal to the i-th element of . + /// + /// The initial logical values, in enumeration order. + /// is null. + /// + /// holds more than minus one elements. + /// + public FenwickTree(IEnumerable values) + { + ArgumentNullException.ThrowIfNull(values); + + // A counted source (T[], List, ...) is length-checked *before* anything is allocated and then + // copied straight into the 1-based backing array, so an oversized source reports the documented + // ArgumentException instead of failing the allocation first, and no intermediate array is built. + if (values is ICollection collection) + { + int count = collection.Count; + ThrowIfSourceTooLong(count, nameof(values)); + + _length = count; + _tree = new T[count + 1]; + collection.CopyTo(_tree, 1); + } + else + { + // Unknown length: materialize once, then apply the same ceiling. + T[] seed = values.ToArray(); + ThrowIfSourceTooLong(seed.Length, nameof(values)); + + _length = seed.Length; + _tree = new T[_length + 1]; + Array.Copy(seed, 0, _tree, 1, _length); + } + + // Linear-time build: every cell now holds its own logical value; one ascending pass pushes each into + // its parent, after which each holds its correct range sum — O(n), not O(n log n) point-inserts. + // `parent` is widened to long for the same reason as the ascent in AddCore: at k == 1 << 30 the parent + // index is 1 << 31, which overflows a signed int and wraps negative, passing the `<= _length` guard and + // then indexing out of bounds. The guard keeps the cast back in range. + for (int k = 1; k <= _length; k++) + { + long parent = k + (long)(k & -k); + if (parent <= _length) + _tree[(int)parent] += _tree[k]; + } + } + + /// Gets the number of logical elements in the tree (its fixed length). + public int Count => _length; + + /// + /// Gets the sum of every logical element — equivalent to with the full length. + /// + public T Total => PrefixSumCore(_length); + + /// + /// Gets or sets the logical value at . Both accessors are O(log n): the + /// getter is RangeSum(index, index + 1); the setter applies the delta needed to reach the new value. + /// Assigning the value already stored is a no-op and does not invalidate active enumerators. + /// + /// The zero-based logical index. Must be in [0, Count). + /// The current logical value at . + /// is out of range. + public T this[int index] + { + get + { + if ((uint)index >= (uint)_length) + ThrowIndexOutOfRange(index); + + return RangeSumCore(index, index + 1); + } + set + { + if ((uint)index >= (uint)_length) + ThrowIndexOutOfRange(index); + + T current = RangeSumCore(index, index + 1); + AddCore(index, value - current); + } + } + + /// + /// Adds to the logical value at , in O(log n). + /// A negative subtracts (for signed ). A zero + /// is a no-op and does not invalidate active enumerators. + /// + /// The zero-based logical index. Must be in [0, Count). + /// The amount to add to the current value. + /// is out of range. + public void Add(int index, T delta) + { + if ((uint)index >= (uint)_length) + ThrowIndexOutOfRange(index); + + AddCore(index, delta); + } + + /// + /// Returns the sum of the logical elements in [0, endExclusive), in O(log n). Passing + /// 0 yields zero; passing yields . + /// + /// The exclusive upper bound of the prefix. Must be in [0, Count]. + /// The sum of the first logical elements. + /// is out of range. + public T PrefixSum(int endExclusive) + { + if ((uint)endExclusive > (uint)_length) + throw new ArgumentOutOfRangeException(nameof(endExclusive), endExclusive, + "endExclusive must be in the range [0, Count]."); + + return PrefixSumCore(endExclusive); + } + + /// + /// Returns the sum of the logical elements in [start, endExclusive), in O(log n). An empty + /// range (start == endExclusive) sums to zero. + /// + /// The inclusive lower bound. Must be in [0, endExclusive]. + /// The exclusive upper bound. Must be in [start, Count]. + /// The sum of the logical elements in the half-open range. + /// The range is invalid or out of bounds. + public T RangeSum(int start, int endExclusive) + { + if ((uint)start > (uint)_length) + throw new ArgumentOutOfRangeException(nameof(start), start, + "start must be in the range [0, Count]."); + if ((uint)endExclusive > (uint)_length) + throw new ArgumentOutOfRangeException(nameof(endExclusive), endExclusive, + "endExclusive must be in the range [0, Count]."); + if (endExclusive < start) + throw new ArgumentOutOfRangeException(nameof(endExclusive), endExclusive, + "endExclusive must be greater than or equal to start."); + + return RangeSumCore(start, endExclusive); + } + + /// Resets every logical element to zero. Runs in O(n). + public void Clear() + { + Array.Clear(_tree, 0, _tree.Length); + _version++; + } + + /// + /// Returns an enumerator over the logical values in index order. Enumeration is O(n log n) (each + /// value is recovered by an O(log n) difference of adjacent prefix sums). + /// + /// A struct enumerator over the logical values. + public Enumerator GetEnumerator() => new(this); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + // ---- internals --------------------------------------------------------------------------------- + + // Point update without bounds validation (callers validate). Walks the O(log n) cells whose ranges cover + // `index` by repeatedly adding back the lowest set bit of the 1-based position. + // + // A zero delta is a no-op: it leaves every cell unchanged, so it skips both the walk and the version bump + // (matching the rest of the library, where an operation that does not change the observable state does not + // invalidate active enumerators). This also covers the indexer setter, which reaches here with + // `value - current` — zero exactly when the assigned value is the one already stored. + private void AddCore(int index, T delta) + { + if (T.IsZero(delta)) + return; + + // The cursor is widened to long because the ascent adds the lowest set bit each step: at k == 1 << 30 + // the next index is 1 << 31, which overflows a signed int and wraps to int.MinValue — a negative value + // that would still pass a `<= _length` test and then index the array out of bounds. Lengths that large + // are permitted (the ceiling is Array.MaxLength - 1), so the widening is a correctness fix, not a + // theoretical one. The loop still terminates at _length, so the cast back is always in range. + for (long k = index + 1; k <= _length; k += k & -k) + _tree[(int)k] += delta; + + _version++; + } + + // The prefix walk, without validation — the single place the descending bit-strip is written, shared by + // PrefixSum, RangeSumCore (and through it the indexer), Total, and the enumerator. Every one of those + // callers has already established that the bound is in range, so none of them pay for a second check. + // Unlike the ascending walks this one only ever clears the lowest set bit, so it strictly decreases and + // cannot overflow. + private T PrefixSumCore(int endExclusive) + { + T sum = T.Zero; + for (int k = endExclusive; k > 0; k -= k & -k) + sum += _tree[k]; + + return sum; + } + + // Range sum without validation. + private T RangeSumCore(int start, int endExclusive) => + PrefixSumCore(endExclusive) - PrefixSumCore(start); + + private static void ThrowIfSourceTooLong(int count, string paramName) + { + if (count > MaxLength) + throw new ArgumentException( + $"The source holds more than the maximum supported length of {MaxLength} elements.", + paramName); + } + + private static void ThrowIndexOutOfRange(int index) => + throw new ArgumentOutOfRangeException(nameof(index), index, + "Index must be in the range [0, Count)."); + + /// A struct enumerator over a 's logical values in index order. + public struct Enumerator : IEnumerator + { + private readonly FenwickTree _tree; + private readonly int _version; + private int _index; + private T _prefix; // running PrefixSum(_index): lets each value be one O(log n) query, not two. + private T _current; + + internal Enumerator(FenwickTree tree) + { + _tree = tree; + _version = tree._version; + _index = 0; + _prefix = T.Zero; + _current = default; + } + + /// Gets the logical value at the current position of the enumerator. + public readonly T Current => _current; + + readonly object? IEnumerator.Current => _current; + + /// Advances the enumerator to the next logical value. + /// true if there is a next value; otherwise false. + /// The tree was modified during enumeration. + public bool MoveNext() + { + if (_version != _tree._version) + throw new InvalidOperationException("The Fenwick tree was modified during enumeration."); + + if (_index < _tree._length) + { + // The guard above puts _index + 1 in [1, _length], so the core walk is called directly: + // PrefixSum's range check could never fail here, and skipping it keeps the throw path out + // of the loop body. + T nextPrefix = _tree.PrefixSumCore(_index + 1); + _current = nextPrefix - _prefix; + _prefix = nextPrefix; + _index++; + return true; + } + + _current = default; + return false; + } + + /// Resets the enumerator to before the first value. + /// The tree was modified during enumeration. + public void Reset() + { + if (_version != _tree._version) + throw new InvalidOperationException("The Fenwick tree was modified during enumeration."); + + _index = 0; + _prefix = T.Zero; + _current = default; + } + + /// Releases resources used by the enumerator. This is a no-op. + public readonly void Dispose() + { + } + } +} diff --git a/web/dev/bench/detail.html b/web/dev/bench/detail.html index 3fca044..10cb0f2 100644 --- a/web/dev/bench/detail.html +++ b/web/dev/bench/detail.html @@ -383,9 +383,13 @@ { key: 'Deque', title: 'Deque', vs: 'LinkedList' }, { key: 'DisjointSet', title: 'DisjointSet', vs: 'Dictionary> merge' }, { key: 'IndexedPriorityQueue', title: 'IndexedPriorityQueue', vs: 'PriorityQueue' }, - { key: 'Trie', title: 'Trie', vs: 'Dictionary' } + { key: 'Trie', title: 'Trie', vs: 'Dictionary' }, + { key: 'FenwickTree', title: 'FenwickTree', vs: 'long[] (naive prefix sum)' } ]; - var BCL_TYPES = new Set(['Dictionary', 'HashSet', 'FrozenDictionary', 'FrozenSet', 'BitArray', 'LinkedList', 'PriorityQueue']); + // Baseline (non-Celerity) type names, as they appear in the `_` benchmark method names. + // 'Array' is the plain-array reference the FenwickTree benchmark measures against — the BCL has no + // prefix-sum type, so a raw long[] is the honest baseline there. + var BCL_TYPES = new Set(['Dictionary', 'HashSet', 'FrozenDictionary', 'FrozenSet', 'BitArray', 'LinkedList', 'PriorityQueue', 'Array']); var SUPPORTED_ITEM_COUNTS = [1000, 100000]; // ---- Read URL params ---- diff --git a/web/dev/bench/index.html b/web/dev/bench/index.html index e32d5e7..f8ed7dd 100644 --- a/web/dev/bench/index.html +++ b/web/dev/bench/index.html @@ -459,10 +459,16 @@

Hash function throughput

// IndexedPriorityQueue is an addressable heap benchmarked against the BCL PriorityQueue (lazy-deletion decrease-key). { key: 'IndexedPriorityQueue', title: 'IndexedPriorityQueue', vs: 'PriorityQueue', ops: ['Enqueue', 'DecreaseKey'] }, // Trie is an ordered prefix tree benchmarked against Dictionary; PrefixMatch is the win. - { key: 'Trie', title: 'Trie', vs: 'Dictionary', ops: ['Add', 'Lookup', 'PrefixMatch'] } + { key: 'Trie', title: 'Trie', vs: 'Dictionary', ops: ['Add', 'Lookup', 'PrefixMatch'] }, + // FenwickTree is a Binary Indexed Tree benchmarked against a plain long[] holding the raw values: point + // updates are O(1) there, but every prefix / range sum re-adds the slice (O(n)). The tree keeps both O(log n). + { key: 'FenwickTree', title: 'FenwickTree', vs: 'long[] (naive prefix sum)', ops: ['Mixed', 'RangeSum'] } ]; - var BCL_TYPES = new Set(['Dictionary', 'HashSet', 'FrozenDictionary', 'FrozenSet', 'BitArray', 'LinkedList', 'PriorityQueue']); + // Baseline (non-Celerity) type names, as they appear in the `_` benchmark method names. + // 'Array' is the plain-array reference the FenwickTree benchmark measures against — the BCL has no + // prefix-sum type, so a raw long[] is the honest baseline there. + var BCL_TYPES = new Set(['Dictionary', 'HashSet', 'FrozenDictionary', 'FrozenSet', 'BitArray', 'LinkedList', 'PriorityQueue', 'Array']); var ITEM_COUNT_FOR_HEADLINE = 100000; var data = window.BENCHMARK_DATA; diff --git a/web/index.html b/web/index.html index 8190313..cb8546d 100644 --- a/web/index.html +++ b/web/index.html @@ -305,6 +305,7 @@

What ships in the box

DisjointSet<T>
Union-find over arbitrary elements: partitions them into disjoint sets with near-O(1) amortized Union/Find/Connected via union-by-size and path halving. The union-find the BCL lacks — incremental connectivity, connected components, and Kruskal MST in near-linear time, where a Dictionary+HashSet set-merge is quadratic.
IndexedPriorityQueue<E, P, H>
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.
Trie<TValue>
Ordered prefix tree mapping string keys to values: GetByPrefix lists every entry under a prefix in O(prefix + matches) and TryGetLongestPrefix finds the longest stored prefix of a query in O(query). The trie the BCL lacks — autocomplete, routing, and ordered iteration, where a Dictionary must scan every key and run StartsWith.
+
FenwickTree<T>
Binary Indexed Tree over a fixed-length numeric sequence: point update and prefix / range sum both in O(log n), in one array with no per-node overhead. The prefix-sum structure the BCL lacks — running aggregates, rank counters and cumulative-frequency tables, where a plain array is O(n) per query or O(n) per update.
Celerity.Hashing
Wang, Murmur3, FNV-1a, Guid, default fallback.