Skip to content

Commit 90f5095

Browse files
Merge pull request #288 from marius-bughiu/feat/sparse-set
feat(collections): add SparseSet — bounded-universe sparse integer set with O(1) clear & dense iteration
2 parents 528540b + 856ae17 commit 90f5095

15 files changed

Lines changed: 1813 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ All notable changes to Celerity are documented here. This project follows [Keep
77
### Added
88

99
- **`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).
10+
- **`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).
1011

1112
### Fixed
1213

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ Standalone libraries built **on top of** Celerity — each solves a real problem
6262
- `IntSet` / `LongSet``int` / `long`-keyed set specializations.
6363
- `SmallSet<T>` — flat-array, linear-scan set for the very-small (`n <= ~16`) case. No hasher; the default element is stored inline. The set counterpart of `SmallDictionary`.
6464
- `EnumSet<TEnum>` — bit-vector set for enum keys (the .NET `EnumSet`): membership is a single bit test and set algebra is word-wise bitwise ops, with no hashing or boxing. Enumerates in ascending underlying-value order.
65+
- `SparseSet` — bounded-universe integer set (Briggs–Torczon sparse set): `O(1)` `Clear` that leaves the backing arrays untouched, plus dense, cache-friendly iteration — for clear-and-rebuild "visited" sets over ids in `[0, N)` (graph traversal, ECS, sweep-line). Costs `O(Universe)` memory.
6566

66-
The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `IntSet`, `LongSet`, `SmallSet`, `EnumSet`) all implement **`ISet<T>`** — the full `HashSet<T>` set-algebra surface (`UnionWith` / `IntersectWith` / `ExceptWith` / `SymmetricExceptWith` and the `IsSubsetOf` / `IsSupersetOf` / `Overlaps` / `SetEquals` query family, plus `CopyTo`) with BCL semantics — so they drop in wherever a `HashSet<T>` is used.
67+
The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `IntSet`, `LongSet`, `SmallSet`, `EnumSet`, `SparseSet`) all implement **`ISet<T>`** — the full `HashSet<T>` set-algebra surface (`UnionWith` / `IntersectWith` / `ExceptWith` / `SymmetricExceptWith` and the `IsSubsetOf` / `IsSupersetOf` / `Overlaps` / `SetEquals` query family, plus `CopyTo`) with BCL semantics — so they drop in wherever a `HashSet<T>` is used. (The bounded-domain sets, `EnumSet` and `SparseSet`, are the exception to "drop in anywhere": they store only values in their fixed domain, so a mutating op that must add an out-of-domain value throws.)
6768

6869
**Caches**
6970

@@ -222,7 +223,7 @@ Console.WriteLine(queued.ContainsKey(Priority.Normal)); // False — a single bi
222223
</details>
223224

224225
<details>
225-
<summary><b>Sets</b> — IntSet, CeleritySet, SwissSet, RobinHoodSet, HashCachingSet, FrozenCeleritySet, SmallSet, EnumSet</summary>
226+
<summary><b>Sets</b> — IntSet, CeleritySet, SwissSet, RobinHoodSet, HashCachingSet, FrozenCeleritySet, SmallSet, EnumSet, SparseSet</summary>
226227

227228
```csharp
228229
var seen = new IntSet();
@@ -294,6 +295,15 @@ Console.WriteLine(granted.IsSupersetOf(required)); // False — word-wise subset
294295
granted.UnionWith(required); // one bitwise OR
295296
```
296297

298+
`SparseSet` is the bounded-universe integer set — the classic Briggs–Torczon sparse set (a dense value array + a sparse index array). Over a fixed universe `[0, Universe)` chosen at construction, `Add` / `Contains` / `Remove` are `O(1)` with no hashing, but the point of the type is what `HashSet<int>` can't match: `Clear()` is `O(1)` (it resets the count without scanning or clearing the backing arrays, versus zeroing the whole table) and iteration is a dense, contiguous scan over exactly the present elements. That is the winning shape for clear-and-rebuild "visited" sets — graph BFS/DFS, ECS entity membership, sweep-line — where the set is emptied every iteration. The cost is `O(Universe)` memory and non-negative-values-only: a value outside `[0, Universe)` throws on `Add` and reads as absent on `Contains` / `Remove`. It is an opt-in specialized type, not a `HashSet<int>` replacement — for an unbounded or huge-and-sparse key space, reach for `IntSet`.
299+
300+
```csharp
301+
var visited = new SparseSet(nodeCount); // universe = ids in [0, nodeCount)
302+
visited.Add(start);
303+
Console.WriteLine(visited.TryAdd(start)); // False — already seen, unchanged
304+
visited.Clear(); // O(1) — ready for the next traversal
305+
```
306+
297307
</details>
298308

299309
<details>
@@ -495,6 +505,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
495505
| Dictionary keyed by a small **enum** — config-by-enum, per-state data, enum→handler tables | `EnumMap<TEnum, TValue>` | Dense array indexed on the enum's underlying value (the .NET `EnumMap`): `this[key]` / `TryGetValue` / `Add` / `Remove` are a single direct array index — no hashing, no probing, no collisions — and a full sweep is a linear array walk. The dictionary counterpart of `EnumSet`; enumerates ascending by value. For enums whose members are small non-negative integers (the default); negative or sparse `[Flags]` enums are unsupported — use `CelerityDictionary<TEnum, TValue, THasher>` there. |
496506
| Tiny set (`n <= ~16`) that stays small — per-scope "seen" sets, small membership guards, deduping a handful of items | `SmallSet<T>` | The set counterpart of `SmallDictionary`: flat-array linear scan beats hashing at small `n`, no hasher to pick, the default element is stored inline. Implements `ISet<T>`. Degrades to `O(n)` for large sets, so only when instances stay small. |
497507
| Set of **enum** values — flag sets, permission sets, state sets over a small enum | `EnumSet<TEnum>` | Bit-vector set indexed on the enum's underlying value (the .NET `EnumSet`): `Add` / `Contains` / `Remove` are a single bit op — no hashing, no boxing — and set algebra between two `EnumSet`s is a word-wise bitwise `OR` / `AND` / `XOR`. Enumerates ascending by value; `All()` builds the full universe. For enums whose members are small non-negative integers (the default); negative or sparse `[Flags]` enums are unsupported — use `CeleritySet<TEnum, THasher>` there. |
508+
| Set of small **non-negative ints** over a bounded range that is **cleared & rebuilt often** — "visited" sets in graph BFS/DFS, ECS entity membership, sweep-line | `SparseSet` | Briggs–Torczon sparse set (dense value array + sparse index array): `O(1)` `Clear` that leaves the backing arrays untouched (vs `HashSet<int>` zeroing its table) and dense, cache-friendly iteration over just the present elements. `Add` / `Contains` / `Remove` are `O(1)`, no hashing. Costs `O(Universe)` memory and stores only values in `[0, Universe)`; for an unbounded or huge-and-sparse key space use `IntSet` / `HashSet<int>`. |
498509
| Set of `int` values | `IntSet` | Same fast path as `IntDictionary`, membership only. |
499510
| Set of `long` values | `LongSet` | 64-bit equivalent of `IntSet`; defaults to `Int64WangNaiveHasher`. |
500511
| Set of any other type | `CeleritySet<T, THasher>` | Same hasher choice as `CelerityDictionary`. |

docs/api/collections.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1971,6 +1971,134 @@ foreach (var p in granted) { /* ascending: Read, Write, Execute */ }
19711971

19721972
---
19731973

1974+
## SparseSet
1975+
1976+
```csharp
1977+
public class SparseSet : ISet<int>
1978+
```
1979+
1980+
A set of **non-negative integers over a bounded universe** `[0, Universe)`, backed by
1981+
the classic **Briggs–Torczon sparse-set representation**: a *dense* array holding the
1982+
present values contiguously, paired with a *sparse* array — indexed by value — that
1983+
points each present value back at its slot in the dense array. Membership is the
1984+
round-trip `sparse[v] < Count && dense[sparse[v]] == v`, which is correct even for a *stale*
1985+
sparse entryone left over from before a `Clear`, or the zero a never-written slot still
1986+
holds. That single fact is what buys the type its two wins over `HashSet<int>`:
1987+
1988+
- **`Clear()` is `O(1)`** — it resets the count *without scanning or clearing the
1989+
backing arrays*. `HashSet<int>.Clear()` is `O(capacity)` (it zeroes the whole entry table).
1990+
This is the headline: per-frame / per-query "visited" sets in graph traversal (BFS/DFS), ECS
1991+
entity membership, register-allocation liveness, and sweep-line algorithms clear on every
1992+
iteration.
1993+
- **Dense, cache-friendly iteration** — present elements live contiguously in `[0, Count)`
1994+
of the dense array, so enumeration is a linear scan over exactly `Count` ints with no
1995+
empty-slot skipping.
1996+
1997+
`Add` / `Contains` / `Remove` are each `O(1)` with **no hashing**, no probe chain, and no
1998+
per-element allocation — a direct array index and the round-trip check. There is **no
1999+
hasher** (and so no `THasher` type parameter).
2000+
2001+
The trade-offs, stated honestly:
2002+
2003+
- The sparse index array is **`O(Universe)` memory**, sized once at construction. The type
2004+
is worth it when the universe is bounded and the set is cleared / rebuilt / iterated
2005+
often — not as a general `HashSet<int>` replacement. For an unbounded or huge-and-sparse
2006+
key space, use [`IntSet`](#intset) / `HashSet<int>`.
2007+
- It stores **only non-negative values below `Universe`**. A value outside `[0, Universe)`
2008+
is rejected by `Add` / `TryAdd` with `ArgumentOutOfRangeException`, and reported as absent
2009+
by `Contains` / `Remove` (the bounded-universe analogue of `EnumSet`).
2010+
- `Remove` moves the last dense element into the vacated slot (an `O(1)` swap), so the
2011+
relative order of the surviving elements is not preserved. Enumeration order is
2012+
unspecified in general.
2013+
2014+
It implements `ISet<int>` (and therefore `ICollection<int>` / `IEnumerable<int>`), ships an
2015+
allocation-free struct enumerator, and accepts an `IEnumerable<int>` source at construction.
2016+
2017+
### Constructors
2018+
2019+
```csharp
2020+
SparseSet(int universe)
2021+
SparseSet(int universe, IEnumerable<int> source)
2022+
```
2023+
2024+
- `universe` is the **exclusive upper bound** of storable values; the set can hold any
2025+
non-negative integer strictly less than it. It sizes the sparse index array once, so
2026+
it is the dominant memory cost — choose it to match the actual value range. `0` creates
2027+
a set that can store nothing.
2028+
- Throws `ArgumentOutOfRangeException` for a negative `universe`. There is **no
2029+
`loadFactor`** parameter.
2030+
- The `source` constructor pre-sizes the dense array from an `ICollection<int>` source,
2031+
silently deduplicates (matching BCL `HashSet<int>(IEnumerable<int>)` semantics), throws
2032+
`ArgumentNullException` if `source` is `null` (the null check beats the universe
2033+
validation), and throws `ArgumentOutOfRangeException` if any source value is outside
2034+
`[0, universe)`.
2035+
2036+
### Methods
2037+
2038+
- `void Add(int item)` — throws `ArgumentException` on duplicate, `ArgumentOutOfRangeException` out of range.
2039+
- `bool TryAdd(int item)` — `true` on success, `false` if already present; throws out of range.
2040+
- `bool Contains(int item)` — `O(1)`; `false` for an out-of-range value.
2041+
- `bool Remove(int item)` — `O(1)` swap-removal; `false` for an absent or out-of-range value.
2042+
- `void Clear()` — **`O(1)`**, touches no memory; the set stays reusable.
2043+
- `int EnsureCapacity(int capacity)` — grow the dense array to hold at least `capacity`
2044+
elements (clamped to `Universe`), returning the resulting dense-array length. Throws
2045+
`ArgumentOutOfRangeException` on a negative capacity.
2046+
- `void TrimExcess()` / `void TrimExcess(int capacity)` — shrink the dense array to exactly
2047+
the current `Count` (or `capacity`). `TrimExcess(capacity)` throws if `capacity < Count`
2048+
or `capacity > Universe`. The sparse index array is unaffected.
2049+
- `int Count { get; }`, `int Universe { get; }`
2050+
- `Enumerator GetEnumerator()` — allocation-free struct enumerator over the dense array.
2051+
- `void CopyTo(int[] array, int arrayIndex)` — matches `HashSet<int>.CopyTo` argument validation.
2052+
2053+
### Set operations (`ISet<int>`)
2054+
2055+
The full BCL `HashSet<int>` set-algebra surface is available and follows `HashSet<int>`
2056+
semantics exactly within the universe (duplicate-tolerant `other`, self-aliasing
2057+
`other == this`):
2058+
2059+
- **Mutating:** `UnionWith`, `IntersectWith`, `ExceptWith`, `SymmetricExceptWith`.
2060+
- **Query:** `IsSubsetOf`, `IsProperSubsetOf`, `IsSupersetOf`, `IsProperSupersetOf`, `Overlaps`, `SetEquals`.
2061+
2062+
Each throws `ArgumentNullException` when `other` is `null`. The one bounded-universe caveat:
2063+
a **mutating** operation that would *add* a value outside `[0, Universe)` (e.g. `UnionWith`
2064+
with such an element) throws `ArgumentOutOfRangeException` rather than silently growing an
2065+
unbounded set. Query operations tolerate out-of-range values in `other` (they simply read
2066+
as absent). As with the other sets, `ISet<int>.Add(int)` returns `bool` (equivalent to
2067+
`TryAdd`), the concrete `public void Add(int)` keeps its throw-on-duplicate behaviour, and
2068+
`ICollection<int>.Add(int)` ignores duplicates.
2069+
2070+
### Usage example
2071+
2072+
```csharp
2073+
using Celerity.Collections;
2074+
2075+
// A BFS "visited" set over a graph whose nodes are ids in [0, nodeCount).
2076+
var visited = new SparseSet(nodeCount);
2077+
2078+
for (int start = 0; start < nodeCount; start++)
2079+
{
2080+
visited.Clear(); // O(1) — no memory touched, ready for the next traversal
2081+
var queue = new Queue<int>();
2082+
queue.Enqueue(start);
2083+
visited.Add(start);
2084+
2085+
while (queue.Count > 0)
2086+
{
2087+
int node = queue.Dequeue();
2088+
foreach (int next in Neighbors(node))
2089+
{
2090+
if (visited.TryAdd(next)) // false if already seen
2091+
queue.Enqueue(next);
2092+
}
2093+
}
2094+
2095+
// Iterate exactly the reached nodes — a dense, contiguous scan.
2096+
Process(visited);
2097+
}
2098+
```
2099+
2100+
---
2101+
19742102
## EnumMap&lt;TEnum, TValue&gt;
19752103

19762104
```csharp

src/Celerity.AotSmokeTest/Program.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,30 @@ void Check(bool condition, string message)
544544
Check(maxHeap.Dequeue() == "b", "IndexedPriorityQueue custom comparer (max-heap)");
545545
}
546546

547+
// SparseSet — bounded-universe sparse integer set (Briggs–Torczon). Exercise add /
548+
// contains / swap-remove, the out-of-range rejection, the O(1) clear-then-reuse path
549+
// (which must reject stale sparse entries), and the dense-array enumerator.
550+
{
551+
var ss = new SparseSet(64);
552+
for (int i = 0; i < 10; i++) ss.Add(i);
553+
Check(ss.Count == 10 && ss.Universe == 64 && ss.Contains(0) && ss.Contains(9), "SparseSet add + contains");
554+
Check(!ss.TryAdd(5), "SparseSet.TryAdd duplicate");
555+
Check(!ss.Contains(64) && !ss.Contains(-1), "SparseSet out-of-range reads absent");
556+
Check(ss.Remove(5) && !ss.Contains(5) && ss.Contains(9), "SparseSet swap-remove keeps survivors");
557+
558+
ss.Clear();
559+
Check(ss.Count == 0 && !ss.Contains(0) && !ss.Contains(9), "SparseSet O(1) clear rejects stale entries");
560+
ss.Add(9); // 9 was present before Clear — must not false-positive until re-added
561+
Check(ss.Count == 1 && ss.Contains(9) && !ss.Contains(0), "SparseSet reusable after clear");
562+
563+
var reached = new SparseSet(128, new[] { 3, 3, 7, 1, 7 }); // dedupes
564+
var seen = new List<int>();
565+
foreach (int x in reached) seen.Add(x);
566+
Check(reached.Count == 3 && seen.Count == 3, "SparseSet source ctor dedupe + enumeration");
567+
((ISet<int>)reached).UnionWith(new[] { 1, 2 });
568+
Check(reached.Count == 4 && reached.Contains(2), "SparseSet ISet<int> union within universe");
569+
}
570+
547571
// SmallDictionary — flat-array, linear-scan dictionary (default key inline, no
548572
// hasher). Exercise the indexer, TryAdd/Add, TryGetValue, Remove, the swap-remove
549573
// path, the inline default/zero key, and the struct enumerator.

src/Celerity.Benchmarks/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ internal class Program
3434
typeof(LongSetBenchmark),
3535
typeof(SmallSetBenchmark),
3636
typeof(EnumSetBenchmark),
37+
typeof(SparseSetBenchmark),
3738
typeof(BloomFilterBenchmark),
3839
typeof(CuckooFilterBenchmark),
3940
typeof(XorFilterBenchmark),

0 commit comments

Comments
 (0)