bmssp 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -13
- package/package.json +2 -1
- package/src/baseCase.mjs +13 -5
- package/src/blockList.mjs +136 -66
- package/src/bmssp.mjs +31 -15
- package/src/boundIndex.mjs +243 -0
- package/src/findPivots.mjs +20 -5
- package/src/select.mjs +144 -0
- package/src/tieBreak.mjs +94 -16
package/README.md
CHANGED
|
@@ -34,15 +34,30 @@ with every building block shipped, tested, and released individually:
|
|
|
34
34
|
| Shortest-path reconstruction ([#169](https://github.com/Sirivasv/bmssp-js/issues/169)) | `BMSSP.reconstructPath()` | ✅ done |
|
|
35
35
|
| Constructor input validation ([#165](https://github.com/Sirivasv/bmssp-js/issues/165)) | `BMSSP` constructor | ✅ done |
|
|
36
36
|
| Opt-in constant-degree transform, in/out-degree ≤ 2 ([#164](https://github.com/Sirivasv/bmssp-js/issues/164)) | `constantDegreeTransform()` | ✅ done |
|
|
37
|
+
| BMSSP-vs-Dijkstra head-to-head in the benchmark harness ([#170](https://github.com/Sirivasv/bmssp-js/issues/170)) | `npm run bench` / `npm run bench:counts` | ✅ done |
|
|
38
|
+
| Performance-cliff investigation; quadratic `BatchPrepend` fixed ([#182](https://github.com/Sirivasv/bmssp-js/issues/182)) | `src/blockList.mjs` | ✅ done — **1.1.1** |
|
|
39
|
+
| Exact Lemma 3.3 asymptotics: balanced-BST bound index + linear-time selection ([#167](https://github.com/Sirivasv/bmssp-js/issues/167)) | `src/boundIndex.mjs` + `src/select.mjs` | ✅ done |
|
|
40
|
+
| Relaxation micro-optimizations: allocation-free hot loops, −13–23% wall-clock ([#168](https://github.com/Sirivasv/bmssp-js/issues/168)) | `src/tieBreak.mjs` + the algorithm modules | ✅ done — **1.2.0** |
|
|
37
41
|
|
|
38
42
|
> **Honest note:** the paper's win is asymptotic, and this repo optimizes for correctness
|
|
39
43
|
> and readability, not raw speed. Measured head-to-head (algorithm time only, graph
|
|
40
|
-
> loading excluded —
|
|
44
|
+
> loading excluded — rerun it yourself with `npm run bench`; methodology and the deep
|
|
45
|
+
> 1.0.0 record in [benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)):
|
|
41
46
|
> Dijkstra still wins on wall-clock at every practical size, though the gap narrows as
|
|
42
47
|
> sparse graphs grow (2.5× at 50k nodes → 1.57× at 2M). But in the paper's own metric —
|
|
43
48
|
> **comparisons between path lengths** — BMSSP does **fewer comparisons than Dijkstra
|
|
44
|
-
> from
|
|
45
|
-
>
|
|
49
|
+
> from under n = 50k on sparse graphs** (`npm run bench:counts` measures 0.95× at 50k →
|
|
50
|
+
> 0.76× at 200k → **0.65× at 1M**), and the advantage grows with size: the "sorting
|
|
51
|
+
> barrier" is measurably broken; what remains is JS constant factors. That crossover
|
|
52
|
+
> used to sit at ~n = 1M until [#167](https://github.com/Sirivasv/bmssp-js/issues/167)
|
|
53
|
+
> replaced the block structure's sort-based internals with the paper's exact machinery
|
|
54
|
+
> (balanced-BST bound index + deterministic linear-time selection).
|
|
55
|
+
> The two performance cliffs found in the 1.0.0 measurements were
|
|
56
|
+
> run down in [#182](https://github.com/Sirivasv/bmssp-js/issues/182): the star-graph
|
|
57
|
+
> blowup was a quadratic in `BatchPrepend`'s bookkeeping — fixed in `1.1.1` (500k-node
|
|
58
|
+
> star: 61 s → ~3 s) — and the recursion-level step is an inherent, measured ~+24% per
|
|
59
|
+
> extra level (see the addendum in
|
|
60
|
+
> [benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)).
|
|
46
61
|
> Where inputs violate the paper's distinct-path-lengths assumption (e.g. zero-weight
|
|
47
62
|
> edges), a principled deterministic tie-break
|
|
48
63
|
> ([#163](https://github.com/Sirivasv/bmssp-js/issues/163)) realizes that assumption in
|
|
@@ -60,8 +75,9 @@ with every building block shipped, tested, and released individually:
|
|
|
60
75
|
closest batch without paying the Θ(log n)-per-vertex "sorting barrier."
|
|
61
76
|
|
|
62
77
|
The wall-clock crossover point is astronomically large, but the asymptotics are real: in
|
|
63
|
-
measured comparison counts this implementation already beats Dijkstra
|
|
64
|
-
sparse graphs
|
|
78
|
+
measured comparison counts this implementation already beats Dijkstra from under 50k
|
|
79
|
+
nodes on sparse graphs, by a third at 1M
|
|
80
|
+
([benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)). This repo
|
|
65
81
|
optimizes for a **correct, readable, well-tested** implementation, validated line-by-line
|
|
66
82
|
against a Dijkstra oracle — not for raw speed.
|
|
67
83
|
|
|
@@ -145,14 +161,17 @@ Other image versions are on [Docker Hub](https://hub.docker.com/r/sirivasv/bmssp
|
|
|
145
161
|
|
|
146
162
|
```bash
|
|
147
163
|
npm install
|
|
148
|
-
npm test
|
|
149
|
-
npm run lint
|
|
150
|
-
npm run bench
|
|
164
|
+
npm test # Jest suite — every graph seeded, every failure reproducible (~7 s)
|
|
165
|
+
npm run lint # Prettier + ESLint
|
|
166
|
+
npm run bench # BMSSP-vs-Dijkstra head-to-head per graph shape, outputs verified
|
|
167
|
+
npm run bench:counts # …plus comparison-count tables (the paper's own cost metric)
|
|
151
168
|
```
|
|
152
169
|
|
|
153
|
-
The measured BMSSP-vs-Dijkstra head-to-head
|
|
154
|
-
|
|
155
|
-
(
|
|
170
|
+
The benchmark harness runs the measured BMSSP-vs-Dijkstra head-to-head on every
|
|
171
|
+
`npm run bench` ([#170](https://github.com/Sirivasv/bmssp-js/issues/170)); methodology,
|
|
172
|
+
the deep 1.0.0 record (up to n = 4M) and the "when to use which" guidance live in
|
|
173
|
+
[benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md) and
|
|
174
|
+
[benchmarks/README.md](benchmarks/README.md).
|
|
156
175
|
|
|
157
176
|
The test suite's core contract: for every node, BMSSP's distances must equal the Dijkstra
|
|
158
177
|
oracle's. A seeded property/fuzz suite (`test/fuzz.test.mjs`) hammers that contract across
|
|
@@ -170,8 +189,8 @@ graphs in a few seconds), and set `FUZZ_XL=1` for an additional 2-million-node r
|
|
|
170
189
|
| --- | --- | --- |
|
|
171
190
|
| [`1.0.0`](https://github.com/Sirivasv/bmssp-js/milestones) | First end-to-end functional BMSSP (issues #40–#45) | ✅ done |
|
|
172
191
|
| `1.1.0` | Correctness hardening — fuzz tests, edge cases, tie-breaking, input validation, constant-degree transform, API docs | ✅ done |
|
|
173
|
-
| `1.2.0` | Performance & ergonomics — exact Lemma 3.3 asymptotics, BMSSP-vs-Dijkstra benchmarks,
|
|
174
|
-
| `2.0.0` | API generalization — public multi-source/bounded entry point, flexible inputs |
|
|
192
|
+
| `1.2.0` | Performance & ergonomics — exact Lemma 3.3 asymptotics, BMSSP-vs-Dijkstra benchmarks, cliff investigation, relaxation micro-optimizations | ✅ done |
|
|
193
|
+
| `2.0.0` | API generalization — public multi-source/bounded entry point, flexible inputs | 🔨 next |
|
|
175
194
|
|
|
176
195
|
## Contributing (humans and AI agents welcome)
|
|
177
196
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bmssp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Javascript package implementation of the bmssp algorithm.",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"keywords": [
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --coverage",
|
|
37
37
|
"bench": "node benchmarks/run.mjs",
|
|
38
|
+
"bench:counts": "node benchmarks/run.mjs --counts",
|
|
38
39
|
"lint": "npm run prettier && npm run eslint",
|
|
39
40
|
"format": "npm run prettier:fix && npm run eslint:fix",
|
|
40
41
|
"eslint": "eslint --max-warnings=0 .",
|
package/src/baseCase.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
makeTies,
|
|
6
6
|
orderKey,
|
|
7
7
|
relaxEdge,
|
|
8
|
+
RELAX_LOST,
|
|
8
9
|
} from "./tieBreak.mjs";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -76,18 +77,25 @@ function baseCase(B, S, dHat, adjacency, k, ties = makeTies()) {
|
|
|
76
77
|
while (!heap.isEmpty() && settled.size < cap + 1) {
|
|
77
78
|
const { key: u } = heap.extractMin();
|
|
78
79
|
settled.add(u);
|
|
79
|
-
|
|
80
|
+
const edges = adjacency.get(u);
|
|
81
|
+
if (edges === undefined) continue;
|
|
82
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
83
|
+
const edge = edges[i];
|
|
84
|
+
const v = edge[0];
|
|
80
85
|
// Canonical relaxation, gated by the bound (the paper's `< B`). An
|
|
81
86
|
// exact-equality result means u is v's recorded label-setter (v was
|
|
82
87
|
// labeled by an earlier phase without being completed) and v must be
|
|
83
88
|
// (re-)enqueued — unless this very call already settled it, which is
|
|
84
89
|
// what keeps zero-weight plateaus finite.
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
90
|
+
const result = relaxEdge(u, v, edge[1], dHat, ties, boundKey);
|
|
91
|
+
if (result === RELAX_LOST || settled.has(v)) continue;
|
|
92
|
+
// Non-lost ⇒ v's stored label IS the candidate; materialize its key
|
|
93
|
+
// only here, on the enqueue path (#168)
|
|
94
|
+
const key = orderKey(v, dHat, ties);
|
|
87
95
|
if (heap.has(v)) {
|
|
88
|
-
heap.decreaseKey(v,
|
|
96
|
+
heap.decreaseKey(v, key);
|
|
89
97
|
} else {
|
|
90
|
-
heap.insert(v,
|
|
98
|
+
heap.insert(v, key);
|
|
91
99
|
}
|
|
92
100
|
}
|
|
93
101
|
}
|
package/src/blockList.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { BoundIndex } from "./boundIndex.mjs";
|
|
2
|
+
import { partitionByRank } from "./select.mjs";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Block-based "partial-sort" structure D from Lemma 3.3 of the BMSSP paper
|
|
3
6
|
* ("Breaking the Sorting Barrier for Directed Single-Source Shortest Paths").
|
|
@@ -11,12 +14,16 @@
|
|
|
11
14
|
* Internal layout:
|
|
12
15
|
* - d1: blocks filled by insert(). Each block carries an upper bound on its
|
|
13
16
|
* values; bounds are non-decreasing across blocks, and the last block
|
|
14
|
-
* always has bound B so every value < B has a home.
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
+
* always has bound B so every value < B has a home. The sequence lives in
|
|
18
|
+
* a self-balancing BST (BoundIndex) searched through the monotone bounds.
|
|
19
|
+
* - d0: blocks filled by batchPrepend() only, kept as a doubly-linked list;
|
|
20
|
+
* they conceptually sit in front of d1 (their values are smaller than
|
|
21
|
+
* everything inserted so far).
|
|
17
22
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
23
|
+
* Since #167 the structure meets Lemma 3.3's exact bounds: the bound index
|
|
24
|
+
* is a balanced BST (O(log #blocks) search/split/drop instead of O(#blocks)
|
|
25
|
+
* array splices) and splits/chunking/pulls use deterministic linear-time
|
|
26
|
+
* selection (partitionByRank) instead of an O(M log M) sort.
|
|
20
27
|
*/
|
|
21
28
|
class BlockList {
|
|
22
29
|
/**
|
|
@@ -35,17 +42,26 @@ class BlockList {
|
|
|
35
42
|
this.M = Math.floor(M);
|
|
36
43
|
this.B = B;
|
|
37
44
|
this.compare = compare ?? ((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
this.
|
|
45
|
+
// Shared [key, value]-pair comparator for splits/chunking/pulls, hoisted
|
|
46
|
+
// so the hot paths don't allocate a closure per call
|
|
47
|
+
this.compareBySecond = (a, b) => this.compare(a[1], b[1]);
|
|
48
|
+
// d1 starts as a single empty block with upper bound B; that block is
|
|
49
|
+
// kept as the sequence's last forever so every value < B has a home
|
|
50
|
+
this.d1 = new BoundIndex();
|
|
51
|
+
this.lastD1Block = this.makeBlock(this.B);
|
|
52
|
+
this.lastD1Block.node = this.d1.append(this.lastD1Block);
|
|
53
|
+
// d0 is a doubly-linked list of blocks; the head holds the smallest values
|
|
54
|
+
this.d0Head = null;
|
|
41
55
|
// key -> block currently holding that key, for O(1) duplicate handling
|
|
42
56
|
this.locator = new Map();
|
|
43
57
|
this.count = 0;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
// Internal: create an empty block with the given value upper bound
|
|
60
|
+
// Internal: create an empty block with the given value upper bound.
|
|
61
|
+
// node is the handle in the d1 BoundIndex (null for d0 blocks);
|
|
62
|
+
// prev/next thread the d0 linked list (null for d1 blocks).
|
|
47
63
|
makeBlock(bound) {
|
|
48
|
-
return { bound, entries: new Map() };
|
|
64
|
+
return { bound, entries: new Map(), node: null, prev: null, next: null };
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
// Number of pairs currently stored
|
|
@@ -73,43 +89,37 @@ class BlockList {
|
|
|
73
89
|
if (this.compare(holder.entries.get(key), value) <= 0) return;
|
|
74
90
|
this.removeKey(key, holder);
|
|
75
91
|
}
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
} else {
|
|
84
|
-
lo = mid + 1;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
const block = this.d1[lo];
|
|
92
|
+
// First d1 block whose bound covers the value: bounds are monotone along
|
|
93
|
+
// the sequence, so this predicate search is the paper's O(log #blocks)
|
|
94
|
+
// BST lookup. It always succeeds — the last block's bound is B > value.
|
|
95
|
+
const node = this.d1.findFirst(
|
|
96
|
+
(candidate) => this.compare(candidate.bound, value) >= 0,
|
|
97
|
+
);
|
|
98
|
+
const block = node.item;
|
|
88
99
|
block.entries.set(key, value);
|
|
89
100
|
this.locator.set(key, block);
|
|
90
101
|
this.count += 1;
|
|
91
102
|
if (block.entries.size > this.M) {
|
|
92
|
-
this.splitBlock(
|
|
103
|
+
this.splitBlock(block);
|
|
93
104
|
}
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
// Internal: split an overfull d1 block into two halves around its median
|
|
97
|
-
// value
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
const lower = this.makeBlock(sorted[half - 1][1]);
|
|
108
|
+
// value (deterministic linear-time selection, as Lemma 3.3 prescribes).
|
|
109
|
+
// The lower half gets bound = the median (its own max value); the upper
|
|
110
|
+
// half keeps the original bound, so inter-block ordering is preserved.
|
|
111
|
+
splitBlock(block) {
|
|
112
|
+
const pairs = [...block.entries];
|
|
113
|
+
const half = pairs.length >> 1;
|
|
114
|
+
partitionByRank(pairs, half - 1, this.compareBySecond);
|
|
115
|
+
const lower = this.makeBlock(pairs[half - 1][1]);
|
|
106
116
|
for (let i = 0; i < half; i += 1) {
|
|
107
|
-
const [key, value] =
|
|
117
|
+
const [key, value] = pairs[i];
|
|
108
118
|
block.entries.delete(key);
|
|
109
119
|
lower.entries.set(key, value);
|
|
110
120
|
this.locator.set(key, lower);
|
|
111
121
|
}
|
|
112
|
-
this.d1.
|
|
122
|
+
lower.node = this.d1.insertBefore(block.node, lower);
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
/**
|
|
@@ -144,21 +154,36 @@ class BlockList {
|
|
|
144
154
|
fresh.push([key, value]);
|
|
145
155
|
}
|
|
146
156
|
if (fresh.length === 0) return;
|
|
147
|
-
// One block if the batch fits
|
|
157
|
+
// One block if the batch fits; otherwise value-ordered chunks of
|
|
158
|
+
// <= ceil(M/2) — O(|L|/M) blocks built with O(|L|·max{1, log(|L|/M)})
|
|
159
|
+
// comparisons, the Lemma 3.3 bound
|
|
148
160
|
let chunks;
|
|
149
161
|
if (fresh.length <= this.M) {
|
|
150
162
|
chunks = [fresh];
|
|
151
163
|
} else {
|
|
152
|
-
fresh.sort((a, b) => this.compare(a[1], b[1]));
|
|
153
164
|
const chunkSize = Math.ceil(this.M / 2);
|
|
154
165
|
chunks = [];
|
|
155
|
-
|
|
156
|
-
chunks
|
|
166
|
+
if (fresh.length >= chunkSize * chunkSize) {
|
|
167
|
+
// Many chunks (|L| >= chunkSize², e.g. the M = 1 star regime, where
|
|
168
|
+
// |L|/M ~ |L|): sorting's O(|L| log |L|) is within 2x of the
|
|
169
|
+
// O(|L| log(|L|/chunkSize)) target — |L| >= c² gives
|
|
170
|
+
// log |L| <= 2 log(|L|/c) — and a single sort is much faster than
|
|
171
|
+
// log(|L|/M) rounds of median selection
|
|
172
|
+
fresh.sort(this.compareBySecond);
|
|
173
|
+
for (let i = 0; i < fresh.length; i += chunkSize) {
|
|
174
|
+
chunks.push(fresh.slice(i, i + chunkSize));
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
// Few chunks (|L| < chunkSize²): repeated median splitting — here
|
|
178
|
+
// the recursion is at most log2(chunkSize) levels deep, and a sort
|
|
179
|
+
// would overshoot the Lemma bound (up to O(|L| log |L|) for
|
|
180
|
+
// O(|L| log(|L|/M)) with |L| close to M)
|
|
181
|
+
this.chunkByMedian(fresh, chunkSize, chunks);
|
|
157
182
|
}
|
|
158
183
|
}
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
184
|
+
// Materialize the chunks as blocks (ascending order)...
|
|
185
|
+
const blocks = [];
|
|
186
|
+
for (const chunk of chunks) {
|
|
162
187
|
// Seed the block bound with the first value, then max-update — avoids
|
|
163
188
|
// needing a -Infinity sentinel that a custom comparator can't order
|
|
164
189
|
const block = this.makeBlock(chunk[0][1]);
|
|
@@ -168,8 +193,30 @@ class BlockList {
|
|
|
168
193
|
if (this.compare(value, block.bound) > 0) block.bound = value;
|
|
169
194
|
this.count += 1;
|
|
170
195
|
}
|
|
171
|
-
|
|
196
|
+
blocks.push(block);
|
|
197
|
+
}
|
|
198
|
+
// ...and link them in front of d0, smallest chunk becoming the new head
|
|
199
|
+
let head = this.d0Head;
|
|
200
|
+
for (let i = blocks.length - 1; i >= 0; i -= 1) {
|
|
201
|
+
const block = blocks[i];
|
|
202
|
+
block.next = head;
|
|
203
|
+
if (head !== null) head.prev = block;
|
|
204
|
+
head = block;
|
|
172
205
|
}
|
|
206
|
+
this.d0Head = head;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Internal: recursively median-split pairs (in place / via slices) into
|
|
210
|
+
// value-ordered chunks of at most maxSize, appended to out ascending
|
|
211
|
+
chunkByMedian(pairs, maxSize, out) {
|
|
212
|
+
if (pairs.length <= maxSize) {
|
|
213
|
+
out.push(pairs);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const half = pairs.length >> 1;
|
|
217
|
+
partitionByRank(pairs, half - 1, this.compareBySecond);
|
|
218
|
+
this.chunkByMedian(pairs.slice(0, half), maxSize, out);
|
|
219
|
+
this.chunkByMedian(pairs.slice(half), maxSize, out);
|
|
173
220
|
}
|
|
174
221
|
|
|
175
222
|
/**
|
|
@@ -184,8 +231,10 @@ class BlockList {
|
|
|
184
231
|
if (this.count <= this.M) {
|
|
185
232
|
// Everything fits in one batch: drain the structure and reset it
|
|
186
233
|
const keys = new Set(this.locator.keys());
|
|
187
|
-
this.
|
|
188
|
-
this.d1
|
|
234
|
+
this.d0Head = null;
|
|
235
|
+
this.d1.clear();
|
|
236
|
+
this.lastD1Block = this.makeBlock(this.B);
|
|
237
|
+
this.lastD1Block.node = this.d1.append(this.lastD1Block);
|
|
189
238
|
this.locator.clear();
|
|
190
239
|
this.count = 0;
|
|
191
240
|
return { keys, bound: this.B };
|
|
@@ -193,18 +242,31 @@ class BlockList {
|
|
|
193
242
|
// Collect prefix blocks from each sequence until that side holds >= M
|
|
194
243
|
// candidate elements (or runs out). The M smallest overall are in there.
|
|
195
244
|
const candidates = [];
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
245
|
+
let collected = 0;
|
|
246
|
+
for (
|
|
247
|
+
let block = this.d0Head;
|
|
248
|
+
block !== null && collected < this.M;
|
|
249
|
+
block = block.next
|
|
250
|
+
) {
|
|
251
|
+
for (const [key, value] of block.entries) {
|
|
252
|
+
candidates.push([key, value, block]);
|
|
204
253
|
}
|
|
254
|
+
collected += block.entries.size;
|
|
205
255
|
}
|
|
206
|
-
|
|
207
|
-
|
|
256
|
+
collected = 0;
|
|
257
|
+
for (
|
|
258
|
+
let node = this.d1.first();
|
|
259
|
+
node !== null && collected < this.M;
|
|
260
|
+
node = this.d1.next(node)
|
|
261
|
+
) {
|
|
262
|
+
for (const [key, value] of node.item.entries) {
|
|
263
|
+
candidates.push([key, value, node.item]);
|
|
264
|
+
}
|
|
265
|
+
collected += node.item.entries.size;
|
|
266
|
+
}
|
|
267
|
+
// Move the M smallest candidates to the front (linear-time selection,
|
|
268
|
+
// the Lemma 3.3 O(M) pull) and take them out of the structure
|
|
269
|
+
partitionByRank(candidates, this.M - 1, this.compareBySecond);
|
|
208
270
|
const keys = new Set();
|
|
209
271
|
for (let i = 0; i < this.M; i += 1) {
|
|
210
272
|
const [key, , block] = candidates[i];
|
|
@@ -217,12 +279,20 @@ class BlockList {
|
|
|
217
279
|
// Under a strict total order (#163's composite keys) this separator is
|
|
218
280
|
// strictly above every pulled value — no boundary ties.
|
|
219
281
|
let bound = null;
|
|
220
|
-
for (
|
|
221
|
-
|
|
222
|
-
if (block) {
|
|
282
|
+
for (let block = this.d0Head; block !== null; block = block.next) {
|
|
283
|
+
if (block.entries.size > 0) {
|
|
223
284
|
for (const value of block.entries.values()) {
|
|
224
285
|
if (bound === null || this.compare(value, bound) < 0) bound = value;
|
|
225
286
|
}
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (let node = this.d1.first(); node !== null; node = this.d1.next(node)) {
|
|
291
|
+
if (node.item.entries.size > 0) {
|
|
292
|
+
for (const value of node.item.entries.values()) {
|
|
293
|
+
if (bound === null || this.compare(value, bound) < 0) bound = value;
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
226
296
|
}
|
|
227
297
|
}
|
|
228
298
|
return { keys, bound };
|
|
@@ -239,18 +309,18 @@ class BlockList {
|
|
|
239
309
|
}
|
|
240
310
|
}
|
|
241
311
|
|
|
242
|
-
// Internal: physically remove an emptied block
|
|
243
|
-
//
|
|
312
|
+
// Internal: physically remove an emptied block from its sequence — an
|
|
313
|
+
// O(log #blocks) BST removal for d1, an O(1) unlink for d0. The last d1
|
|
314
|
+
// block (bound B) is kept even when empty so insert always finds a home.
|
|
244
315
|
dropIfEmpty(block) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
this.
|
|
316
|
+
if (block.node !== null) {
|
|
317
|
+
if (block === this.lastD1Block) return;
|
|
318
|
+
this.d1.remove(block.node);
|
|
248
319
|
return;
|
|
249
320
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
321
|
+
if (block.prev !== null) block.prev.next = block.next;
|
|
322
|
+
else this.d0Head = block.next;
|
|
323
|
+
if (block.next !== null) block.next.prev = block.prev;
|
|
254
324
|
}
|
|
255
325
|
}
|
|
256
326
|
|
package/src/bmssp.mjs
CHANGED
|
@@ -3,10 +3,12 @@ import { findPivots } from "./findPivots.mjs";
|
|
|
3
3
|
import { BlockList } from "./blockList.mjs";
|
|
4
4
|
import {
|
|
5
5
|
compareKeys,
|
|
6
|
+
compareKeyParts,
|
|
6
7
|
toBound,
|
|
7
8
|
makeTies,
|
|
8
9
|
orderKey,
|
|
9
10
|
relaxEdge,
|
|
11
|
+
RELAX_LOST,
|
|
10
12
|
} from "./tieBreak.mjs";
|
|
11
13
|
|
|
12
14
|
class BMSSP {
|
|
@@ -224,19 +226,26 @@ class BMSSP {
|
|
|
224
226
|
// without completing (the paper's `≤` relaxation, deterministic here:
|
|
225
227
|
// only the recorded label-setter triggers it); vertices already
|
|
226
228
|
// completed at this level are filtered so re-enqueues stay finite.
|
|
229
|
+
// A non-lost relaxation leaves v's stored label equal to the
|
|
230
|
+
// candidate, so the band tests compare the unpacked stored components
|
|
231
|
+
// and a key array is built only for the enqueue itself (#168).
|
|
227
232
|
const K = [];
|
|
228
233
|
for (const u of Ui) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
) {
|
|
239
|
-
|
|
234
|
+
const edges = this.adjacency.get(u);
|
|
235
|
+
if (edges === undefined) continue;
|
|
236
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
237
|
+
const edge = edges[i];
|
|
238
|
+
const v = edge[0];
|
|
239
|
+
const result = relaxEdge(u, v, edge[1], dHat, ties);
|
|
240
|
+
if (result === RELAX_LOST || U.has(v)) continue;
|
|
241
|
+
const length = dHat.get(v);
|
|
242
|
+
const hopCount = ties.hops.get(v) ?? 0;
|
|
243
|
+
if (compareKeyParts(length, hopCount, v, BiKey) >= 0) {
|
|
244
|
+
if (compareKeyParts(length, hopCount, v, boundKey) < 0) {
|
|
245
|
+
D.insert(v, [length, hopCount, v]);
|
|
246
|
+
}
|
|
247
|
+
} else if (compareKeyParts(length, hopCount, v, BiPrimeKey) >= 0) {
|
|
248
|
+
K.push([v, [length, hopCount, v]]);
|
|
240
249
|
}
|
|
241
250
|
}
|
|
242
251
|
}
|
|
@@ -244,9 +253,13 @@ class BMSSP {
|
|
|
244
253
|
// go back in front of everything else
|
|
245
254
|
for (const x of Si) {
|
|
246
255
|
if (U.has(x)) continue;
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
256
|
+
const length = dHat.get(x) ?? Infinity;
|
|
257
|
+
const hopCount = ties.hops.get(x) ?? 0;
|
|
258
|
+
if (
|
|
259
|
+
compareKeyParts(length, hopCount, x, BiKey) < 0 &&
|
|
260
|
+
compareKeyParts(length, hopCount, x, BiPrimeKey) >= 0
|
|
261
|
+
) {
|
|
262
|
+
K.push([x, [length, hopCount, x]]);
|
|
250
263
|
}
|
|
251
264
|
}
|
|
252
265
|
if (K.length > 0) D.batchPrepend(K);
|
|
@@ -256,7 +269,10 @@ class BMSSP {
|
|
|
256
269
|
const finalKey =
|
|
257
270
|
compareKeys(lastBoundKey, boundKey) < 0 ? lastBoundKey : boundKey;
|
|
258
271
|
for (const x of W) {
|
|
259
|
-
|
|
272
|
+
const length = dHat.get(x) ?? Infinity;
|
|
273
|
+
if (compareKeyParts(length, ties.hops.get(x) ?? 0, x, finalKey) < 0) {
|
|
274
|
+
U.add(x);
|
|
275
|
+
}
|
|
260
276
|
}
|
|
261
277
|
return finish(finalKey, U);
|
|
262
278
|
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-balancing (AVL) ordered sequence — the "balanced BST over block upper
|
|
3
|
+
* bounds" of Lemma 3.3 (issue #167; replaces BlockList's plain-array bound
|
|
4
|
+
* index, whose splice-based maintenance paid O(#blocks) per split/drop).
|
|
5
|
+
*
|
|
6
|
+
* BlockList keeps its d1 blocks with non-decreasing value bounds. This tree
|
|
7
|
+
* stores that block sequence POSITIONALLY: a node's in-order position is its
|
|
8
|
+
* sequence position, and the tree itself never compares items. Because the
|
|
9
|
+
* bounds are monotone along the sequence, findFirst with a monotone
|
|
10
|
+
* predicate binary-searches the sequence in O(log size) — the paper's bound
|
|
11
|
+
* lookup — while insertBefore / append / remove stay O(log size) via AVL
|
|
12
|
+
* rebalancing.
|
|
13
|
+
*
|
|
14
|
+
* Nodes are plain objects { item, parent, left, right, height } returned as
|
|
15
|
+
* handles; callers keep the handle to later remove or iterate from it.
|
|
16
|
+
*/
|
|
17
|
+
class BoundIndex {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.root = null;
|
|
20
|
+
this.count = 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Number of items currently stored
|
|
24
|
+
get size() {
|
|
25
|
+
return this.count;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Drop every item
|
|
29
|
+
clear() {
|
|
30
|
+
this.root = null;
|
|
31
|
+
this.count = 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// First node in sequence order, or null when empty
|
|
35
|
+
first() {
|
|
36
|
+
let node = this.root;
|
|
37
|
+
if (node === null) return null;
|
|
38
|
+
while (node.left !== null) node = node.left;
|
|
39
|
+
return node;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Last node in sequence order, or null when empty
|
|
43
|
+
last() {
|
|
44
|
+
let node = this.root;
|
|
45
|
+
if (node === null) return null;
|
|
46
|
+
while (node.right !== null) node = node.right;
|
|
47
|
+
return node;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// In-order successor of a node, or null at the end of the sequence
|
|
51
|
+
next(node) {
|
|
52
|
+
if (node.right !== null) {
|
|
53
|
+
let succ = node.right;
|
|
54
|
+
while (succ.left !== null) succ = succ.left;
|
|
55
|
+
return succ;
|
|
56
|
+
}
|
|
57
|
+
let child = node;
|
|
58
|
+
let parent = child.parent;
|
|
59
|
+
while (parent !== null && parent.right === child) {
|
|
60
|
+
child = parent;
|
|
61
|
+
parent = parent.parent;
|
|
62
|
+
}
|
|
63
|
+
return parent;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Leftmost node whose item satisfies the predicate, or null when none
|
|
68
|
+
* does. The predicate must be monotone along the sequence (a prefix of
|
|
69
|
+
* false followed by a suffix of true) — with BlockList's non-decreasing
|
|
70
|
+
* bounds, `(block) => compare(block.bound, value) >= 0` is exactly that.
|
|
71
|
+
* O(log size).
|
|
72
|
+
* @param {(item: *) => boolean} predicate
|
|
73
|
+
* @returns {*} The matching node handle, or null
|
|
74
|
+
*/
|
|
75
|
+
findFirst(predicate) {
|
|
76
|
+
let node = this.root;
|
|
77
|
+
let found = null;
|
|
78
|
+
while (node !== null) {
|
|
79
|
+
if (predicate(node.item)) {
|
|
80
|
+
found = node;
|
|
81
|
+
node = node.left;
|
|
82
|
+
} else {
|
|
83
|
+
node = node.right;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Insert an item at the end of the sequence. O(log size).
|
|
91
|
+
* @returns {*} The new node handle
|
|
92
|
+
*/
|
|
93
|
+
append(item) {
|
|
94
|
+
const node = this.makeNode(item);
|
|
95
|
+
if (this.root === null) {
|
|
96
|
+
this.root = node;
|
|
97
|
+
} else {
|
|
98
|
+
const tail = this.last();
|
|
99
|
+
tail.right = node;
|
|
100
|
+
node.parent = tail;
|
|
101
|
+
this.rebalance(tail);
|
|
102
|
+
}
|
|
103
|
+
this.count += 1;
|
|
104
|
+
return node;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Insert an item immediately before an existing node. O(log size).
|
|
109
|
+
* @param {*} reference - Node handle the new item goes in front of
|
|
110
|
+
* @returns {*} The new node handle
|
|
111
|
+
*/
|
|
112
|
+
insertBefore(reference, item) {
|
|
113
|
+
const node = this.makeNode(item);
|
|
114
|
+
if (reference.left === null) {
|
|
115
|
+
reference.left = node;
|
|
116
|
+
node.parent = reference;
|
|
117
|
+
} else {
|
|
118
|
+
let pred = reference.left;
|
|
119
|
+
while (pred.right !== null) pred = pred.right;
|
|
120
|
+
pred.right = node;
|
|
121
|
+
node.parent = pred;
|
|
122
|
+
}
|
|
123
|
+
this.count += 1;
|
|
124
|
+
this.rebalance(node.parent);
|
|
125
|
+
return node;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove a node from the sequence. O(log size).
|
|
130
|
+
* @param {*} node - Node handle previously returned by append/insertBefore
|
|
131
|
+
*/
|
|
132
|
+
remove(node) {
|
|
133
|
+
let start; // deepest structurally-changed node, where rebalancing begins
|
|
134
|
+
if (node.left === null) {
|
|
135
|
+
start = node.parent;
|
|
136
|
+
this.replaceInParent(node, node.right);
|
|
137
|
+
} else if (node.right === null) {
|
|
138
|
+
start = node.parent;
|
|
139
|
+
this.replaceInParent(node, node.left);
|
|
140
|
+
} else {
|
|
141
|
+
// Two children: splice out the in-order successor (which has no left
|
|
142
|
+
// child) and put it in the removed node's place
|
|
143
|
+
let succ = node.right;
|
|
144
|
+
while (succ.left !== null) succ = succ.left;
|
|
145
|
+
if (succ.parent === node) {
|
|
146
|
+
start = succ;
|
|
147
|
+
} else {
|
|
148
|
+
start = succ.parent;
|
|
149
|
+
this.replaceInParent(succ, succ.right);
|
|
150
|
+
succ.right = node.right;
|
|
151
|
+
succ.right.parent = succ;
|
|
152
|
+
}
|
|
153
|
+
this.replaceInParent(node, succ);
|
|
154
|
+
succ.left = node.left;
|
|
155
|
+
succ.left.parent = succ;
|
|
156
|
+
succ.height = node.height;
|
|
157
|
+
}
|
|
158
|
+
node.parent = node.left = node.right = null;
|
|
159
|
+
this.count -= 1;
|
|
160
|
+
this.rebalance(start);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Internal: fresh leaf node
|
|
164
|
+
makeNode(item) {
|
|
165
|
+
return { item, parent: null, left: null, right: null, height: 1 };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Internal: make replacement take node's place under node's parent
|
|
169
|
+
// (replacement may be null)
|
|
170
|
+
replaceInParent(node, replacement) {
|
|
171
|
+
const parent = node.parent;
|
|
172
|
+
if (parent === null) {
|
|
173
|
+
this.root = replacement;
|
|
174
|
+
} else if (parent.left === node) {
|
|
175
|
+
parent.left = replacement;
|
|
176
|
+
} else {
|
|
177
|
+
parent.right = replacement;
|
|
178
|
+
}
|
|
179
|
+
if (replacement !== null) replacement.parent = parent;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Internal: height of a possibly-null subtree
|
|
183
|
+
heightOf(node) {
|
|
184
|
+
return node === null ? 0 : node.height;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Internal: recompute a node's height from its children
|
|
188
|
+
updateHeight(node) {
|
|
189
|
+
node.height =
|
|
190
|
+
1 + Math.max(this.heightOf(node.left), this.heightOf(node.right));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Internal: left-minus-right height difference
|
|
194
|
+
balanceOf(node) {
|
|
195
|
+
return this.heightOf(node.left) - this.heightOf(node.right);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Internal: walk from node to the root, updating heights and rotating
|
|
199
|
+
// wherever the AVL invariant |balance| <= 1 broke (deletion can require a
|
|
200
|
+
// rotation at every level; insertion at most one — the loop covers both)
|
|
201
|
+
rebalance(node) {
|
|
202
|
+
while (node !== null) {
|
|
203
|
+
this.updateHeight(node);
|
|
204
|
+
const balance = this.balanceOf(node);
|
|
205
|
+
if (balance > 1) {
|
|
206
|
+
if (this.balanceOf(node.left) < 0) this.rotateLeft(node.left);
|
|
207
|
+
node = this.rotateRight(node);
|
|
208
|
+
} else if (balance < -1) {
|
|
209
|
+
if (this.balanceOf(node.right) > 0) this.rotateRight(node.right);
|
|
210
|
+
node = this.rotateLeft(node);
|
|
211
|
+
}
|
|
212
|
+
node = node.parent;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Internal: standard AVL rotation; returns the subtree's new root
|
|
217
|
+
rotateLeft(node) {
|
|
218
|
+
const pivot = node.right;
|
|
219
|
+
node.right = pivot.left;
|
|
220
|
+
if (pivot.left !== null) pivot.left.parent = node;
|
|
221
|
+
this.replaceInParent(node, pivot);
|
|
222
|
+
pivot.left = node;
|
|
223
|
+
node.parent = pivot;
|
|
224
|
+
this.updateHeight(node);
|
|
225
|
+
this.updateHeight(pivot);
|
|
226
|
+
return pivot;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Internal: mirror of rotateLeft
|
|
230
|
+
rotateRight(node) {
|
|
231
|
+
const pivot = node.left;
|
|
232
|
+
node.left = pivot.right;
|
|
233
|
+
if (pivot.right !== null) pivot.right.parent = node;
|
|
234
|
+
this.replaceInParent(node, pivot);
|
|
235
|
+
pivot.right = node;
|
|
236
|
+
node.parent = pivot;
|
|
237
|
+
this.updateHeight(node);
|
|
238
|
+
this.updateHeight(pivot);
|
|
239
|
+
return pivot;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export { BoundIndex };
|
package/src/findPivots.mjs
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
compareKeyParts,
|
|
3
|
+
toBound,
|
|
4
|
+
makeTies,
|
|
5
|
+
relaxEdge,
|
|
6
|
+
RELAX_LOST,
|
|
7
|
+
} from "./tieBreak.mjs";
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* FindPivots(B, S) — Algorithm 1 of "Breaking the Sorting Barrier for Directed
|
|
@@ -75,12 +81,21 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
|
|
|
75
81
|
for (let i = 1; i <= rounds; i += 1) {
|
|
76
82
|
const nextLayer = new Set();
|
|
77
83
|
for (const u of layer) {
|
|
78
|
-
|
|
84
|
+
const edges = adjacency.get(u);
|
|
85
|
+
if (edges === undefined) continue;
|
|
86
|
+
for (let j = 0; j < edges.length; j += 1) {
|
|
87
|
+
const edge = edges[j];
|
|
88
|
+
const v = edge[0];
|
|
79
89
|
// Canonical relaxation: d̂ updates are not gated by B (paper), and
|
|
80
90
|
// both improvements and exact canonical equality admit v to the
|
|
81
|
-
// next layer when its key is below B
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
// next layer when its key is below B. Non-lost ⇒ v's stored label
|
|
92
|
+
// is the candidate, so the W gate compares the unpacked stored
|
|
93
|
+
// components — no key allocation (#168).
|
|
94
|
+
const result = relaxEdge(u, v, edge[1], dHat, ties);
|
|
95
|
+
if (
|
|
96
|
+
result !== RELAX_LOST &&
|
|
97
|
+
compareKeyParts(dHat.get(v), ties.hops.get(v) ?? 0, v, boundKey) < 0
|
|
98
|
+
) {
|
|
84
99
|
nextLayer.add(v);
|
|
85
100
|
}
|
|
86
101
|
}
|
package/src/select.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic worst-case-linear selection — the "linear-time median
|
|
3
|
+
* selection" Lemma 3.3 prescribes for BlockList block splits, batch
|
|
4
|
+
* chunking and pulls (issue #167; replaces the sort-based O(M log M)
|
|
5
|
+
* shortcut).
|
|
6
|
+
*
|
|
7
|
+
* Introselect construction: quickselect with a deterministic median-of-3
|
|
8
|
+
* pivot (~2–3n comparisons on typical inputs — well below a sort's n log n)
|
|
9
|
+
* guarded by a work budget. If pathological pivots exhaust the budget, the
|
|
10
|
+
* pivot switches to median-of-medians (groups of 5), whose guaranteed
|
|
11
|
+
* middle-40% pivot makes the remainder linear — so the whole selection is
|
|
12
|
+
* O(n) worst case. Median-of-medians alone would also be linear but costs
|
|
13
|
+
* ~10–20n comparisons, MORE than sorting at practical sizes; the budgeted
|
|
14
|
+
* hybrid keeps both the bound and the constant. No randomness anywhere:
|
|
15
|
+
* runs stay fully reproducible.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Internal: swap two array slots
|
|
19
|
+
function swap(items, i, j) {
|
|
20
|
+
const tmp = items[i];
|
|
21
|
+
items[i] = items[j];
|
|
22
|
+
items[j] = tmp;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Internal: insertion-sort the closed range [lo, hi] (only ever called on
|
|
26
|
+
// ranges of at most 5 elements)
|
|
27
|
+
function sortRange(items, lo, hi, compare) {
|
|
28
|
+
for (let i = lo + 1; i <= hi; i += 1) {
|
|
29
|
+
const item = items[i];
|
|
30
|
+
let j = i - 1;
|
|
31
|
+
while (j >= lo && compare(items[j], item) > 0) {
|
|
32
|
+
items[j + 1] = items[j];
|
|
33
|
+
j -= 1;
|
|
34
|
+
}
|
|
35
|
+
items[j + 1] = item;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Internal: cheap deterministic pivot — median of the first, middle and
|
|
40
|
+
// last elements of the range
|
|
41
|
+
function medianOfThree(items, lo, hi, compare) {
|
|
42
|
+
const first = items[lo];
|
|
43
|
+
const middle = items[lo + ((hi - lo) >> 1)];
|
|
44
|
+
const last = items[hi];
|
|
45
|
+
if (compare(first, middle) < 0) {
|
|
46
|
+
if (compare(middle, last) < 0) return middle;
|
|
47
|
+
return compare(first, last) < 0 ? last : first;
|
|
48
|
+
}
|
|
49
|
+
if (compare(first, last) < 0) return first;
|
|
50
|
+
return compare(middle, last) < 0 ? last : middle;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Internal: median-of-medians pivot for the closed range [lo, hi] — the
|
|
54
|
+
// budget-exhausted fallback. Sorts each group of 5, gathers the group
|
|
55
|
+
// medians at the front of the range, and recursively selects their median;
|
|
56
|
+
// the result splits the range at worst 30/70.
|
|
57
|
+
function medianOfMedians(items, lo, hi, compare) {
|
|
58
|
+
let write = lo;
|
|
59
|
+
for (let i = lo; i <= hi; i += 5) {
|
|
60
|
+
const groupHi = Math.min(i + 4, hi);
|
|
61
|
+
sortRange(items, i, groupHi, compare);
|
|
62
|
+
swap(items, write, i + ((groupHi - i) >> 1));
|
|
63
|
+
write += 1;
|
|
64
|
+
}
|
|
65
|
+
const mid = lo + ((write - 1 - lo) >> 1);
|
|
66
|
+
selectRange(items, mid, compare, lo, write - 1, 6 * (write - lo));
|
|
67
|
+
return items[mid];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Internal: budgeted quickselect on the closed range [lo, hi]. Elements
|
|
71
|
+
// outside the active range are already on their correct side, so when the
|
|
72
|
+
// range narrows to the rank (or the rank falls inside the pivot-equal band)
|
|
73
|
+
// the whole prefix [0..rank] holds the rank+1 smallest.
|
|
74
|
+
function selectRange(items, rank, compare, lo, hi, budget) {
|
|
75
|
+
while (lo < hi) {
|
|
76
|
+
if (hi - lo < 5) {
|
|
77
|
+
sortRange(items, lo, hi, compare);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const size = hi - lo + 1;
|
|
81
|
+
const pivot =
|
|
82
|
+
budget > 0
|
|
83
|
+
? medianOfThree(items, lo, hi, compare)
|
|
84
|
+
: medianOfMedians(items, lo, hi, compare);
|
|
85
|
+
budget -= size;
|
|
86
|
+
// Three-way partition: [lo, lt) < pivot, [lt, gt] == pivot, (gt, hi] >
|
|
87
|
+
// pivot. The equal band makes duplicate-heavy inputs terminate in one
|
|
88
|
+
// round instead of degrading.
|
|
89
|
+
let lt = lo;
|
|
90
|
+
let i = lo;
|
|
91
|
+
let gt = hi;
|
|
92
|
+
while (i <= gt) {
|
|
93
|
+
const order = compare(items[i], pivot);
|
|
94
|
+
if (order < 0) {
|
|
95
|
+
swap(items, lt, i);
|
|
96
|
+
lt += 1;
|
|
97
|
+
i += 1;
|
|
98
|
+
} else if (order > 0) {
|
|
99
|
+
swap(items, i, gt);
|
|
100
|
+
gt -= 1;
|
|
101
|
+
} else {
|
|
102
|
+
i += 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (rank < lt) {
|
|
106
|
+
hi = lt - 1;
|
|
107
|
+
} else if (rank > gt) {
|
|
108
|
+
lo = gt + 1;
|
|
109
|
+
} else {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Partially reorder items IN PLACE so that items[0..rank] are the rank+1
|
|
117
|
+
* smallest under compare and items[rank] is the largest of them (i.e. the
|
|
118
|
+
* rank-th smallest overall, 0-indexed). Order within the two sides is
|
|
119
|
+
* unspecified — exactly what a semi-sorted block structure needs.
|
|
120
|
+
* @param {Array<*>} items - Reordered in place
|
|
121
|
+
* @param {number} rank - 0-indexed target rank, 0 <= rank < items.length
|
|
122
|
+
* @param {(a: *, b: *) => number} [compare] - Comparator (negative when
|
|
123
|
+
* a < b). Defaults to numeric order.
|
|
124
|
+
* @param {number} [cheapBudget] - Elements the median-of-3 phase may visit
|
|
125
|
+
* before pivots switch to median-of-medians. Defaults to 6·items.length
|
|
126
|
+
* (never reached on non-adversarial inputs); pass 0 to force the
|
|
127
|
+
* median-of-medians path throughout (used by tests).
|
|
128
|
+
* @returns {*} items[rank], the rank-th smallest element
|
|
129
|
+
* @throws {Error} If items is not a non-empty array or rank is out of range
|
|
130
|
+
*/
|
|
131
|
+
function partitionByRank(items, rank, compare, cheapBudget) {
|
|
132
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
133
|
+
throw new Error("items must be a non-empty array");
|
|
134
|
+
}
|
|
135
|
+
if (!Number.isInteger(rank) || rank < 0 || rank >= items.length) {
|
|
136
|
+
throw new Error("rank must be an integer index into items");
|
|
137
|
+
}
|
|
138
|
+
const order = compare ?? ((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
139
|
+
const budget = cheapBudget ?? 6 * items.length;
|
|
140
|
+
selectRange(items, rank, order, 0, items.length - 1, budget);
|
|
141
|
+
return items[rank];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export { partitionByRank };
|
package/src/tieBreak.mjs
CHANGED
|
@@ -42,6 +42,28 @@
|
|
|
42
42
|
// source's own label never loses an equal-(length, hops) comparison.
|
|
43
43
|
const NO_PRED = -Infinity;
|
|
44
44
|
|
|
45
|
+
// Running count of compareKeys calls — the paper's cost metric ("comparisons
|
|
46
|
+
// between path lengths"). Every BMSSP-side comparison funnels through
|
|
47
|
+
// compareKeys (the heap and BlockList receive it as their comparator, and
|
|
48
|
+
// baseCase/findPivots/bmssp call it directly), so this one counter covers the
|
|
49
|
+
// whole algorithm. The benchmark harness (#170) reads it to measure the
|
|
50
|
+
// sorting barrier; one unconditional increment keeps the hot path branch-free.
|
|
51
|
+
let comparisonCount = 0;
|
|
52
|
+
|
|
53
|
+
/** Reset the compareKeys call counter to zero (benchmark instrumentation). */
|
|
54
|
+
function resetComparisonCount() {
|
|
55
|
+
comparisonCount = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Number of compareKeys calls since the last reset (benchmark
|
|
60
|
+
* instrumentation).
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
function getComparisonCount() {
|
|
64
|
+
return comparisonCount;
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
/**
|
|
46
68
|
* Lexicographic comparison of two composite keys.
|
|
47
69
|
* @param {[number, number, *]} a - [length, hops, id]
|
|
@@ -49,12 +71,33 @@ const NO_PRED = -Infinity;
|
|
|
49
71
|
* @returns {number} Negative when a < b, positive when a > b, 0 when equal
|
|
50
72
|
*/
|
|
51
73
|
function compareKeys(a, b) {
|
|
74
|
+
comparisonCount += 1;
|
|
52
75
|
if (a[0] !== b[0]) return a[0] < b[0] ? -1 : 1;
|
|
53
76
|
if (a[1] !== b[1]) return a[1] < b[1] ? -1 : 1;
|
|
54
77
|
if (a[2] !== b[2]) return a[2] < b[2] ? -1 : 1;
|
|
55
78
|
return 0;
|
|
56
79
|
}
|
|
57
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Lexicographic comparison of an UNPACKED key (length, hops, id) against a
|
|
83
|
+
* packed one — identical order to compareKeys without materializing the
|
|
84
|
+
* left-hand array (#168: the hot relax/routing loops call this instead of
|
|
85
|
+
* allocating a throwaway key per test). Counts as one comparison.
|
|
86
|
+
* @param {number} length - Left key's length component
|
|
87
|
+
* @param {number} hops - Left key's hops component
|
|
88
|
+
* @param {*} id - Left key's id component
|
|
89
|
+
* @param {[number, number, *]} key - Right key, packed
|
|
90
|
+
* @returns {number} Negative when (length, hops, id) < key, positive when
|
|
91
|
+
* greater, 0 when equal
|
|
92
|
+
*/
|
|
93
|
+
function compareKeyParts(length, hops, id, key) {
|
|
94
|
+
comparisonCount += 1;
|
|
95
|
+
if (length !== key[0]) return length < key[0] ? -1 : 1;
|
|
96
|
+
if (hops !== key[1]) return hops < key[1] ? -1 : 1;
|
|
97
|
+
if (id !== key[2]) return id < key[2] ? -1 : 1;
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
58
101
|
/**
|
|
59
102
|
* Normalize a bound to a composite key. A scalar B becomes the infimum of
|
|
60
103
|
* all keys of length B, preserving the strict "distance < B" contract;
|
|
@@ -107,6 +150,11 @@ function orderKey(v, dHat, ties) {
|
|
|
107
150
|
* tied alternative parents; callers filter already-completed vertices to
|
|
108
151
|
* keep the re-enqueue finite, exactly like the paper.
|
|
109
152
|
*
|
|
153
|
+
* Allocation-free since #168: the result is one of the three RELAX_* codes,
|
|
154
|
+
* and a caller that needs v's (possibly updated) order key materializes it
|
|
155
|
+
* with orderKey(v, dHat, ties) — only on the rare paths that enqueue v,
|
|
156
|
+
* instead of on every attempt.
|
|
157
|
+
*
|
|
110
158
|
* @param {*} u - Edge tail (its labels are read)
|
|
111
159
|
* @param {*} v - Edge head (its labels may be updated)
|
|
112
160
|
* @param {number} weight - Edge weight, >= 0
|
|
@@ -114,29 +162,59 @@ function orderKey(v, dHat, ties) {
|
|
|
114
162
|
* @param {{ hops: Map<*, number>, preds: Map<*, *> }} ties - Updated in place
|
|
115
163
|
* @param {[number, number, *]} [bound] - Optional gate: skip (no d̂ update)
|
|
116
164
|
* unless the resulting order key would be strictly below this bound
|
|
117
|
-
* @returns {
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
165
|
+
* @returns {number} RELAX_IMPROVED when the labels were updated,
|
|
166
|
+
* RELAX_EQUAL when the candidate exactly matches v's canonical label
|
|
167
|
+
* (the re-enqueue signal), RELAX_LOST when the candidate loses or the
|
|
168
|
+
* bound gates it (labels untouched)
|
|
121
169
|
*/
|
|
122
170
|
function relaxEdge(u, v, weight, dHat, ties, bound) {
|
|
123
171
|
const length = dHat.get(u) + weight;
|
|
124
172
|
const hopCount = (ties.hops.get(u) ?? 0) + 1;
|
|
125
|
-
if (bound !== undefined &&
|
|
126
|
-
return
|
|
173
|
+
if (bound !== undefined && compareKeyParts(length, hopCount, v, bound) >= 0) {
|
|
174
|
+
return RELAX_LOST;
|
|
175
|
+
}
|
|
176
|
+
// Candidate path key [length, hopCount, u] vs. v's current path key
|
|
177
|
+
// [d̂[v], hops[v], preds[v] ?? NO_PRED] — compareKeys inlined on the
|
|
178
|
+
// unpacked components (one counted comparison, no arrays)
|
|
179
|
+
comparisonCount += 1;
|
|
180
|
+
let cmp;
|
|
181
|
+
const currentLength = dHat.get(v) ?? Infinity;
|
|
182
|
+
if (length !== currentLength) {
|
|
183
|
+
cmp = length < currentLength ? -1 : 1;
|
|
184
|
+
} else {
|
|
185
|
+
const currentHops = ties.hops.get(v) ?? 0;
|
|
186
|
+
if (hopCount !== currentHops) {
|
|
187
|
+
cmp = hopCount < currentHops ? -1 : 1;
|
|
188
|
+
} else {
|
|
189
|
+
const currentPred = ties.preds.has(v) ? ties.preds.get(v) : NO_PRED;
|
|
190
|
+
cmp = u !== currentPred ? (u < currentPred ? -1 : 1) : 0;
|
|
191
|
+
}
|
|
127
192
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
ties.hops.get(v) ?? 0,
|
|
131
|
-
ties.preds.has(v) ? ties.preds.get(v) : NO_PRED,
|
|
132
|
-
];
|
|
133
|
-
const cmp = compareKeys([length, hopCount, u], currentPathKey);
|
|
134
|
-
if (cmp > 0) return null;
|
|
135
|
-
if (cmp === 0) return { key: [length, hopCount, v], improved: false };
|
|
193
|
+
if (cmp > 0) return RELAX_LOST;
|
|
194
|
+
if (cmp === 0) return RELAX_EQUAL;
|
|
136
195
|
dHat.set(v, length);
|
|
137
196
|
ties.hops.set(v, hopCount);
|
|
138
197
|
ties.preds.set(v, u);
|
|
139
|
-
return
|
|
198
|
+
return RELAX_IMPROVED;
|
|
140
199
|
}
|
|
141
200
|
|
|
142
|
-
|
|
201
|
+
// relaxEdge result codes (#168): distinct, and only RELAX_LOST is falsy-like
|
|
202
|
+
// in comparisons — callers must compare against the constants, not truthiness
|
|
203
|
+
const RELAX_LOST = -1;
|
|
204
|
+
const RELAX_EQUAL = 0;
|
|
205
|
+
const RELAX_IMPROVED = 1;
|
|
206
|
+
|
|
207
|
+
export {
|
|
208
|
+
compareKeys,
|
|
209
|
+
compareKeyParts,
|
|
210
|
+
toBound,
|
|
211
|
+
makeTies,
|
|
212
|
+
orderKey,
|
|
213
|
+
relaxEdge,
|
|
214
|
+
NO_PRED,
|
|
215
|
+
RELAX_LOST,
|
|
216
|
+
RELAX_EQUAL,
|
|
217
|
+
RELAX_IMPROVED,
|
|
218
|
+
resetComparisonCount,
|
|
219
|
+
getComparisonCount,
|
|
220
|
+
};
|