Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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

- **`IndexedPriorityQueue<TElement, TPriority, THasher>`** in `Celerity.Collections` — an addressable binary min-heap that, unlike the BCL `PriorityQueue<,>`, 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)`. It wins on the priority-relaxation loop of Dijkstra / Prim / A\* and reschedulable event queues, where the BCL's only substitute — lazy deletion — grows the heap by one entry per update. Each element is a key (appears once); a custom `IComparer<TPriority>` gives a max-heap. Not thread-safe. Closes [#282](https://github.com/marius-bughiu/Celerity/issues/282).
- **`DisjointSet<T>`** in `Celerity.Collections` — a disjoint-set / union-find over arbitrary non-null elements, filling a BCL gap (.NET ships no union-find). `Union` / `Find` / `Connected` are near-`O(1)` amortized, so incremental-connectivity and connected-components workloads (equivalence classes, Kruskal's MST, clustering, undirected cycle detection) run in near-linear total time where the idiomatic `Dictionary<T, HashSet<T>>` merge is quadratic. Not thread-safe. Closes [#272](https://github.com/marius-bughiu/Celerity/issues/272).
- **`Deque<T>`** in `Celerity.Collections` — a growable double-ended queue backed by a circular buffer, filling a BCL gap: .NET has no array-backed deque (`Queue<T>` / `Stack<T>` are single-ended, and `LinkedList<T>` allocates a node per element). All four end operations plus a front-relative indexer are `O(1)` amortized, so it wins on allocation and cache locality for both-ended churn — bounded FIFO queues, sliding windows, work/undo buffers. Not thread-safe. Closes [#268](https://github.com/marius-bughiu/Celerity/issues/268).
- **`LruCache<TKey, TValue, THasher>`** in `Celerity.Collections` — a fixed-capacity least-recently-used cache, filling a BCL gap (.NET ships no bounded LRU cache). The steady-state get/put/evict path allocates nothing, unlike the idiomatic `Dictionary` + `LinkedList` hand-roll, so it wins for hot bounded caches under eviction churn. Reads mutate recency, so they invalidate active enumerators; not thread-safe. Closes [#266](https://github.com/marius-bughiu/Celerity/issues/266).
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `

- `DisjointSet<T>` — **union-find** over arbitrary elements: partitions them into disjoint sets with near-`O(1)` amortized `Union` / `Find` / `Connected` via **union by size** + **path halving**. The union-find the BCL lacks — incremental connectivity, connected components, Kruskal MST, and undirected cycle detection in near-linear total time, where the `Dictionary` + `HashSet` set-merge substitute is quadratic. `GetComponents()` materializes the current partition.

**Priority queue**

- `IndexedPriorityQueue<TElement, TPriority, THasher>` — **addressable** binary min-heap: unlike the BCL `PriorityQueue<,>` it can **change a queued element's priority** (`Update` / decrease-key) and **remove an arbitrary element** in `O(log n)`, and answer `Contains` / `TryGetPriority` in `O(1)`. The heap the priority-relaxation loop of Dijkstra / Prim / A\* needs — no lazy-deletion heap growth. Each element is a key (appears once); pass a custom `IComparer<TPriority>` for a max-heap.

**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 @@ -405,6 +409,27 @@ foreach (var component in uf.GetComponents())

</details>

<details>
<summary><b>Addressable priority queue</b> — IndexedPriorityQueue</summary>

`IndexedPriorityQueue<TElement, TPriority, THasher>` is an **addressable** binary min-heap: unlike the BCL `PriorityQueue<,>` it keeps an element→heap-slot index (a dogfooded `CelerityDictionary`) so it can **change a queued element's priority** and **remove an arbitrary element** in `O(log n)`, and look one up in `O(1)`. That is exactly what the priority-relaxation loop of Dijkstra / Prim / A\* needs — the BCL heap forces *lazy deletion* (re-enqueue + skip stale entries), which grows the heap by one entry per update. Each element is a key (it appears once); pass a custom `IComparer<TPriority>` for a max-heap.

```csharp
var pq = new IndexedPriorityQueue<string, int, DefaultHasher<string>>();
pq.Enqueue("a", 10);
pq.Enqueue("b", 30);
pq.Enqueue("c", 20);

pq.Update("b", 5); // decrease-key: 'b' jumps to the front
Console.WriteLine(pq.Peek()); // b

Console.WriteLine(pq.Dequeue()); // b (priority 5)
Console.WriteLine(pq.Dequeue()); // a (priority 10)
Console.WriteLine(pq.Remove("c", out int p)); // True; p == 20
```

</details>

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

Expand Down Expand Up @@ -461,6 +486,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
| **Bounded cache** with automatic eviction — memoize the last `N` results, an admission cache in front of an expensive lookup, any hot key→value store that must not grow without bound | `LruCache<TKey, TValue, THasher>` | Fixed-capacity least-recently-used cache: `O(1)` get/put, and once at capacity every insert evicts the least-recently-used entry. Its recency list is threaded through fixed-size arrays, so after construction the hot get/put/evict path **allocates nothing** — where the idiomatic `Dictionary` + `LinkedList` LRU allocates a `LinkedListNode` per insert. Reads are *uses* (they promote to most-recently-used); use `TryPeek` / `ContainsKey` to inspect without touching recency. Single-threaded — because reads mutate recency, even a read-mostly concurrent workload needs a write lock. |
| **Double-ended queue** — add/remove at both ends (bounded FIFO queue, sliding window, work-stealing / undo buffer) or a queue needing random access by position | `Deque<T>` | Growable double-ended queue backed by a **circular buffer**: `O(1)` amortized `PushFront` / `PushBack` / `PopFront` / `PopBack` / peek and `O(1)` random access by index. The BCL has no deque — `Queue<T>` is FIFO-only, `Stack<T>` LIFO-only, and `LinkedList<T>` (the only O(1)-both-ends type) allocates a node per element. A warm bounded churn reuses the buffer with wrap-around so it **allocates nothing**, and enumeration walks contiguous memory. For a strict FIFO queue that never pushes front / pops back, BCL `Queue<T>` is already a circular buffer and is simpler. |
| **Incremental connectivity / connected components** — union equivalence classes and ask whether two elements are in the same group (Kruskal MST, clustering, image segmentation, undirected cycle detection, "are these accounts linked?") | `DisjointSet<T>` | Union-find with **union by size** + **path halving**: near-`O(1)` amortized `Union` / `Find` / `Connected`, `O(α(n)) ≤ 4`. Runs a stream of merges + connectivity queries in near-linear total time, where the BCL substitutes are super-linear — a `Dictionary<T, HashSet<T>>` set-merge is `O(n²)` to coalesce `n` singletons, and a per-query BFS/DFS is `O(V+E)` every query. Grows only by merging (no un-union); it is not an `ISet<T>` — for element membership with add/remove/set-algebra use `CeleritySet` or `HashSet<T>`. |
| **Priority queue whose priorities change** — a best-so-far frontier you relax (Dijkstra / Prim / A\*), or an event scheduler that reschedules / cancels pending items | `IndexedPriorityQueue<TElement, TPriority, THasher>` | Addressable binary min-heap with an element→slot index: `Update` (decrease-/increase-key) and `Remove` an arbitrary element in `O(log n)`, `Contains` / `TryGetPriority` in `O(1)`. The BCL `PriorityQueue<,>` can do none of these — its only substitute is lazy deletion, which grows the heap by one entry per update. Each element is a key (appears once); custom `IComparer<TPriority>` for a max-heap. For plain enqueue/dequeue with duplicate elements, the BCL `PriorityQueue<,>` is simpler. |
| Need a stable iteration order or multi-threaded access | BCL `Dictionary<,>`, `ConcurrentDictionary<,>` | Celerity is single-threaded and iteration order is unspecified. |

**Celerity is not the right answer when** you need concurrent access (use `ConcurrentDictionary<,>` or your own lock — Celerity is single-threaded), the mutable `IDictionary<,>` interface, or a guaranteed iteration order (Celerity exposes `IReadOnlyDictionary<,>` only and does not promise order across versions).
Expand Down
114 changes: 114 additions & 0 deletions docs/api/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -3311,3 +3311,117 @@ Console.WriteLine($"{uf.SetCount} connected component(s)"); // 2: {a,b,c} and
foreach (var component in uf.GetComponents())
Console.WriteLine(string.Join(", ", component));
```

## IndexedPriorityQueue&lt;TElement, TPriority, THasher&gt;

An **addressable (indexed) priority queue**: a binary min-heap that maps each element to its position in the heap, so — unlike the BCL `PriorityQueue<TElement, TPriority>` — it can **change a queued element's priority** (`Update` / decrease-key / increase-key) and **remove an arbitrary element** (`Remove`) in `O(log n)`, and answer `Contains` / `TryGetPriority` in `O(1)`. Implements `IReadOnlyCollection<KeyValuePair<TElement, TPriority>>`.

```csharp
public sealed class IndexedPriorityQueue<TElement, TPriority, THasher>
: IReadOnlyCollection<KeyValuePair<TElement, TPriority>>
where THasher : struct, IHashProvider<TElement>
```

Each element is a **key**: it appears in the queue at most once, and equality uses `EqualityComparer<TElement>.Default` through the supplied `THasher`. The `THasher` is a struct implementing `IHashProvider<TElement>`, so the element hashing behind the index devirtualizes and inlines.

The BCL `PriorityQueue<TElement, TPriority>` is a plain binary heap with no handle to an element already inside it: it exposes neither a priority update nor an arbitrary remove. The idiomatic workaround is **lazy deletion** — re-enqueue the element with its new priority and skip stale copies when they surface at the top — which lets the heap grow to `O(operations)` rather than `O(distinct elements)` and still cannot answer *"what is this element's current priority?"*. `IndexedPriorityQueue` keeps the heap at exactly the live elements.

### How it works

Two parallel arrays hold the heap (`_elements[i]` / `_priorities[i]`, a 0-based binary heap: node `i`'s children are `2i+1` and `2i+2`). Beside them, an **element→heap-slot index** — a dogfooded `CelerityDictionary<TElement, int, THasher>` — records where each element currently sits. Every sift/swap updates the index in lockstep, so `Update` and `Remove` locate their element in `O(1)` and then restore the heap invariant in `O(log n)` by sifting the affected slot up or down. Because the index is a `CelerityDictionary`, the out-of-band `default(TElement)` / `null` element is handled for free, exactly as in the rest of the family.

It is a **min-heap** by default (`Comparer<TPriority>.Default`): `Peek` and `Dequeue` return the element with the smallest priority. Pass a custom `IComparer<TPriority>` to invert the order (a max-heap) or to order by any other key.

### The documented BCL-beating workload

The **priority-relaxation loop** at the heart of Dijkstra's shortest paths, Prim's minimum spanning tree, A\*, and discrete-event simulation: seed the frontier, then repeatedly `Update` (decrease-key) an element's priority and `Dequeue` the current minimum. The addressable heap keeps its size at `O(distinct elements)` and updates a priority in `O(log n)`, where the lazy-deletion substitute over a BCL `PriorityQueue` grows the heap by one entry per relaxation and pays to skip the stale ones. It pairs with [`DisjointSet<T>`](#disjointsett) (union-find / Kruskal's MST) to cover the graph-algorithm primitives the BCL omits. See the [priority-queue benchmark](https://marius-bughiu.github.io/Celerity/dev/bench/?collection=IndexedPriorityQueue) on the dashboard.

### Constructors

```csharp
public IndexedPriorityQueue()
public IndexedPriorityQueue(int capacity)
public IndexedPriorityQueue(IComparer<TPriority>? comparer)
public IndexedPriorityQueue(int capacity, IComparer<TPriority>? comparer)
public IndexedPriorityQueue(IEnumerable<KeyValuePair<TElement, TPriority>> items)
public IndexedPriorityQueue(IEnumerable<KeyValuePair<TElement, TPriority>> items, IComparer<TPriority>? comparer)
```

- The `capacity` overloads pre-size the backing storage to hold at least `capacity` elements before the first growth.
- A `null` `comparer` means `Comparer<TPriority>.Default` (a min-heap). Invert it for a max-heap.
- The `IEnumerable` overloads seed the queue with element/priority pairs; a **duplicate element keeps its last-seen priority** (the seeding is an upsert, matching `EnqueueOrUpdate`).

**Throws:**

- `ArgumentOutOfRangeException` if `capacity < 0`.
- `ArgumentNullException` if `items` is `null` (enumerable overloads).

### Methods and properties

| Member | Description |
|--------|-------------|
| `int Count` | Number of elements currently in the queue. |
| `int Capacity` | Elements the backing storage can hold before it must grow. |
| `IComparer<TPriority> Comparer` | The comparer used to order priorities. |
| `void Enqueue(TElement element, TPriority priority)` | Adds `element`. Throws `ArgumentException` if it is already present. |
| `bool TryEnqueue(TElement element, TPriority priority)` | Adds `element`; returns `false` (queue unchanged) if it is already present. |
| `bool EnqueueOrUpdate(TElement element, TPriority priority)` | Adds `element` if absent (returns `true`) or changes its priority if present (returns `false`). |
| `TElement Peek()` | The minimum-priority element. Throws `InvalidOperationException` if empty. |
| `bool TryPeek(out TElement element, out TPriority priority)` | Non-throwing `Peek`. |
| `TElement Dequeue()` | Removes and returns the minimum-priority element. Throws `InvalidOperationException` if empty. |
| `bool TryDequeue(out TElement element, out TPriority priority)` | Non-throwing `Dequeue`. |
| `bool Contains(TElement element)` | Whether `element` is present. `O(1)`. |
| `TPriority GetPriority(TElement element)` | `element`'s current priority. Throws `KeyNotFoundException` if absent. `O(1)`. |
| `bool TryGetPriority(TElement element, out TPriority priority)` | Non-throwing `GetPriority`. |
| `void Update(TElement element, TPriority priority)` | Changes `element`'s priority (decrease- or increase-key) and restores its position. Throws `KeyNotFoundException` if absent. `O(log n)`. |
| `bool TryUpdate(TElement element, TPriority priority)` | Non-throwing `Update`. |
| `bool Remove(TElement element)` | Removes `element` wherever it sits in the heap. Returns `false` if absent. `O(log n)`. |
| `bool Remove(TElement element, out TPriority priority)` | `Remove` that also returns the removed element's priority. |
| `void Clear()` | Removes all elements. The backing storage is retained. |
| `int EnsureCapacity(int capacity)` | Grows the backing storage to hold at least `capacity` elements; returns the resulting capacity. |
| `void TrimExcess()` | Shrinks the backing storage to fit the current count. |
| `Enumerator GetEnumerator()` | A struct enumerator over the element/priority pairs in **heap order** (not priority order). |

Enumeration yields the pairs in heap-array order, which is **not** priority order. To visit elements by priority, `Dequeue` them (which empties the queue) or copy the pairs out and sort them. A pure read (`Peek`, `Contains`, `TryGetPriority`) does not invalidate an in-flight enumerator; every mutation (`Enqueue`, `Dequeue`, `Update`, `Remove`, `Clear`, and a capacity change that reallocates) does.

### Choosing it

Reach for `IndexedPriorityQueue` when you need a priority queue whose elements' priorities **change while they are queued**, or where you must **remove or look up a specific element** by value — the shortest-path / MST / A\* relaxation loop, an event scheduler that can cancel or reschedule a pending event, or any "best-so-far" frontier. If you only ever `Enqueue` and `Dequeue` and never touch an element already inside, the BCL `PriorityQueue<TElement, TPriority>` is simpler and allows duplicate elements; `IndexedPriorityQueue` trades that for the addressable operations and the one-element-per-key constraint. This type is not thread-safe; concurrent callers must synchronize externally.

### Usage example

```csharp
using Celerity.Collections;
using Celerity.Hashing;

// Dijkstra's shortest paths over a tiny weighted graph, using decrease-key.
var dist = new IndexedPriorityQueue<int, int, Int32WangHasher>();
foreach (int v in new[] { 0, 1, 2, 3, 4 })
dist.Enqueue(v, v == 0 ? 0 : int.MaxValue); // source at 0, everything else at infinity

// adjacency: node -> (neighbour, weight)
var graph = new Dictionary<int, (int to, int w)[]>
{
[0] = new[] { (1, 4), (2, 1) },
[1] = new[] { (3, 1) },
[2] = new[] { (1, 2), (3, 5) },
[3] = new[] { (4, 3) },
[4] = Array.Empty<(int, int)>(),
};

var final = new Dictionary<int, int>();
while (dist.TryDequeue(out int u, out int du))
{
final[u] = du;
if (du == int.MaxValue) continue; // unreachable
foreach (var (to, w) in graph[u])
{
// relax the edge: decrease-key if we found a shorter path
if (dist.TryGetPriority(to, out int old) && du + w < old)
dist.Update(to, du + w);
}
}

Console.WriteLine(string.Join(", ", final.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}:{kv.Value}")));
// 0:0, 1:3, 2:1, 3:4, 4:7
```
33 changes: 33 additions & 0 deletions src/Celerity.AotSmokeTest/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,39 @@ void Check(bool condition, string message)
Check(order.Count == 3 && order[0] == 7 && order[2] == 9, "DisjointSet insertion-order enumeration");
}

// IndexedPriorityQueue — addressable binary min-heap. Exercise enqueue/peek/dequeue
// min-order, the decrease-key Update, arbitrary Remove, priority lookups, and growth.
{
var pq = new IndexedPriorityQueue<int, int, Int32WangNaiveHasher>();
pq.Enqueue(1, 30);
pq.Enqueue(2, 10);
pq.Enqueue(3, 20);
Check(pq.Count == 3 && pq.Peek() == 2, "IndexedPriorityQueue min at top");
Check(!pq.TryEnqueue(2, 5), "IndexedPriorityQueue rejects duplicate element");

pq.Update(3, 1); // decrease-key
Check(pq.Peek() == 3 && pq.GetPriority(3) == 1, "IndexedPriorityQueue decrease-key");
Check(pq.Remove(1, out int removed) && removed == 30, "IndexedPriorityQueue remove arbitrary out value");
Check(pq.TryGetPriority(2, out int p2) && p2 == 10 && !pq.Contains(1), "IndexedPriorityQueue priority lookup + absence");

Check(pq.Dequeue() == 3 && pq.Dequeue() == 2 && pq.Count == 0, "IndexedPriorityQueue dequeue order");

var grown = new IndexedPriorityQueue<int, int, Int32WangNaiveHasher>(0);
for (int i = 500; i > 0; i--) grown.Enqueue(i, i);
Check(grown.Count == 500 && grown.Peek() == 1, "IndexedPriorityQueue enqueue across growth");
var prev = int.MinValue;
var monotonic = true;
while (grown.TryDequeue(out _, out int pr)) { if (pr < prev) monotonic = false; prev = pr; }
Check(monotonic, "IndexedPriorityQueue drains in ascending priority order");

var maxHeap = new IndexedPriorityQueue<string, int, DefaultHasher<string>>(
Comparer<int>.Create((a, b) => b.CompareTo(a)));
maxHeap.Enqueue("a", 1);
maxHeap.Enqueue("b", 3);
maxHeap.Enqueue("c", 2);
Check(maxHeap.Dequeue() == "b", "IndexedPriorityQueue custom comparer (max-heap)");
}

// 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