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