bmssp 0.14.0 → 0.16.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/package.json +4 -3
- package/src/blockList.mjs +248 -0
- package/src/bmssp.mjs +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bmssp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Javascript package implementation of the bmssp algorithm.",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"keywords": [
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --coverage",
|
|
37
|
+
"bench": "node benchmarks/run.mjs",
|
|
37
38
|
"lint": "npm run prettier && npm run eslint",
|
|
38
39
|
"format": "npm run prettier:fix && npm run eslint:fix",
|
|
39
40
|
"eslint": "eslint --max-warnings=0 .",
|
|
@@ -43,12 +44,12 @@
|
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@eslint/js": "^10.0.1",
|
|
46
|
-
"@eslint/markdown": "^
|
|
47
|
+
"@eslint/markdown": "^8.0.1",
|
|
47
48
|
"eslint": "^10.0.0",
|
|
48
49
|
"eslint-config-prettier": "^10.1.8",
|
|
49
50
|
"eslint-plugin-prettier": "^5.5.4",
|
|
50
51
|
"globals": "^17.0.0",
|
|
51
52
|
"jest": "^30.1.1",
|
|
52
|
-
"prettier": "3.
|
|
53
|
+
"prettier": "3.9.5"
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block-based "partial-sort" structure D from Lemma 3.3 of the BMSSP paper
|
|
3
|
+
* ("Breaking the Sorting Barrier for Directed Single-Source Shortest Paths").
|
|
4
|
+
*
|
|
5
|
+
* Holds <key, value> pairs (vertex, distance estimate) semi-sorted: values
|
|
6
|
+
* are ordered BETWEEN blocks but unsorted WITHIN a block. That is enough to
|
|
7
|
+
* repeatedly pull the M smallest values as a batch (pull) and to cheaply
|
|
8
|
+
* add a batch of values known to be smaller than everything present
|
|
9
|
+
* (batchPrepend) — without paying for a full sort.
|
|
10
|
+
*
|
|
11
|
+
* Internal layout:
|
|
12
|
+
* - d1: blocks filled by insert(). Each block carries an upper bound on its
|
|
13
|
+
* values; bounds are non-decreasing across blocks, and the last block
|
|
14
|
+
* always has bound B so every value < B has a home.
|
|
15
|
+
* - d0: blocks filled by batchPrepend() only; they conceptually sit in front
|
|
16
|
+
* of d1 (their values are smaller than everything inserted so far).
|
|
17
|
+
*
|
|
18
|
+
* The block-bound index is a plain array searched with binary search instead
|
|
19
|
+
* of the paper's balanced BST — same behavior, worse constants (issue #167).
|
|
20
|
+
*/
|
|
21
|
+
class BlockList {
|
|
22
|
+
/**
|
|
23
|
+
* Initialize the structure (Lemma 3.3 Initialize).
|
|
24
|
+
* @param {number} M - Block size / pull batch size, >= 1. At recursion level l this is 2^((l-1)·t).
|
|
25
|
+
* @param {number} B - Strict upper bound on every value ever stored (Infinity is allowed)
|
|
26
|
+
* @throws {Error} If M is not a number >= 1
|
|
27
|
+
*/
|
|
28
|
+
constructor(M, B) {
|
|
29
|
+
if (typeof M !== "number" || Number.isNaN(M) || M < 1) {
|
|
30
|
+
throw new Error("M must be a number >= 1");
|
|
31
|
+
}
|
|
32
|
+
this.M = Math.floor(M);
|
|
33
|
+
this.B = B;
|
|
34
|
+
// d1 starts as a single empty block with upper bound B
|
|
35
|
+
this.d1 = [this.makeBlock(B)];
|
|
36
|
+
this.d0 = [];
|
|
37
|
+
// key -> block currently holding that key, for O(1) duplicate handling
|
|
38
|
+
this.locator = new Map();
|
|
39
|
+
this.count = 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Internal: create an empty block with the given value upper bound
|
|
43
|
+
makeBlock(bound) {
|
|
44
|
+
return { bound, entries: new Map() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Number of pairs currently stored
|
|
48
|
+
get size() {
|
|
49
|
+
return this.count;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isEmpty() {
|
|
53
|
+
return this.count === 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Insert a pair (Lemma 3.3 Insert). If the key is already stored, the
|
|
58
|
+
* smallest value wins (the pair is replaced only when value is smaller).
|
|
59
|
+
* @param {*} key - Typically a node ID
|
|
60
|
+
* @param {number} value - Must be < B
|
|
61
|
+
* @throws {Error} If value >= B
|
|
62
|
+
*/
|
|
63
|
+
insert(key, value) {
|
|
64
|
+
if (!(value < this.B)) {
|
|
65
|
+
throw new Error("value must be < B");
|
|
66
|
+
}
|
|
67
|
+
const holder = this.locator.get(key);
|
|
68
|
+
if (holder !== undefined) {
|
|
69
|
+
if (holder.entries.get(key) <= value) return;
|
|
70
|
+
this.removeKey(key, holder);
|
|
71
|
+
}
|
|
72
|
+
// Binary-search d1 for the first block whose bound covers the value
|
|
73
|
+
let lo = 0;
|
|
74
|
+
let hi = this.d1.length - 1;
|
|
75
|
+
while (lo < hi) {
|
|
76
|
+
const mid = (lo + hi) >> 1;
|
|
77
|
+
if (this.d1[mid].bound >= value) {
|
|
78
|
+
hi = mid;
|
|
79
|
+
} else {
|
|
80
|
+
lo = mid + 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const block = this.d1[lo];
|
|
84
|
+
block.entries.set(key, value);
|
|
85
|
+
this.locator.set(key, block);
|
|
86
|
+
this.count += 1;
|
|
87
|
+
if (block.entries.size > this.M) {
|
|
88
|
+
this.splitBlock(lo);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Internal: split an overfull d1 block into two halves around its median
|
|
93
|
+
// value. The lower half gets bound = its own max value; the upper half
|
|
94
|
+
// keeps the original bound, so inter-block ordering is preserved.
|
|
95
|
+
// (The paper uses linear-time median selection; sorting is O(M log M) but
|
|
96
|
+
// simpler — acceptable for this correctness-first implementation.)
|
|
97
|
+
splitBlock(index) {
|
|
98
|
+
const block = this.d1[index];
|
|
99
|
+
const sorted = [...block.entries].sort((a, b) => a[1] - b[1]);
|
|
100
|
+
const half = sorted.length >> 1;
|
|
101
|
+
const lower = this.makeBlock(sorted[half - 1][1]);
|
|
102
|
+
for (let i = 0; i < half; i += 1) {
|
|
103
|
+
const [key, value] = sorted[i];
|
|
104
|
+
block.entries.delete(key);
|
|
105
|
+
lower.entries.set(key, value);
|
|
106
|
+
this.locator.set(key, lower);
|
|
107
|
+
}
|
|
108
|
+
this.d1.splice(index, 0, lower);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Insert a batch of pairs whose values are all smaller than every value
|
|
113
|
+
* currently stored (Lemma 3.3 BatchPrepend). On duplicate keys — within
|
|
114
|
+
* the batch or against the current contents — the smallest value wins.
|
|
115
|
+
* The caller is responsible for the "smaller than everything" contract;
|
|
116
|
+
* only the value < B bound is checked here.
|
|
117
|
+
* @param {Iterable<[*, number]>} pairs - [key, value] pairs, each value < B
|
|
118
|
+
* @throws {Error} If any value >= B
|
|
119
|
+
*/
|
|
120
|
+
batchPrepend(pairs) {
|
|
121
|
+
// Dedupe the batch, keeping the smallest value per key
|
|
122
|
+
const best = new Map();
|
|
123
|
+
for (const [key, value] of pairs) {
|
|
124
|
+
if (!(value < this.B)) {
|
|
125
|
+
throw new Error("value must be < B");
|
|
126
|
+
}
|
|
127
|
+
const seen = best.get(key);
|
|
128
|
+
if (seen === undefined || value < seen) {
|
|
129
|
+
best.set(key, value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Resolve clashes with keys already stored (smallest value wins)
|
|
133
|
+
const fresh = [];
|
|
134
|
+
for (const [key, value] of best) {
|
|
135
|
+
const holder = this.locator.get(key);
|
|
136
|
+
if (holder !== undefined) {
|
|
137
|
+
if (holder.entries.get(key) <= value) continue;
|
|
138
|
+
this.removeKey(key, holder);
|
|
139
|
+
}
|
|
140
|
+
fresh.push([key, value]);
|
|
141
|
+
}
|
|
142
|
+
if (fresh.length === 0) return;
|
|
143
|
+
// One block if the batch fits, otherwise sorted chunks of <= ceil(M/2)
|
|
144
|
+
let chunks;
|
|
145
|
+
if (fresh.length <= this.M) {
|
|
146
|
+
chunks = [fresh];
|
|
147
|
+
} else {
|
|
148
|
+
fresh.sort((a, b) => a[1] - b[1]);
|
|
149
|
+
const chunkSize = Math.ceil(this.M / 2);
|
|
150
|
+
chunks = [];
|
|
151
|
+
for (let i = 0; i < fresh.length; i += chunkSize) {
|
|
152
|
+
chunks.push(fresh.slice(i, i + chunkSize));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Prepend in reverse chunk order so the smallest chunk lands at the front
|
|
156
|
+
for (let c = chunks.length - 1; c >= 0; c -= 1) {
|
|
157
|
+
const chunk = chunks[c];
|
|
158
|
+
const block = this.makeBlock(-Infinity);
|
|
159
|
+
for (const [key, value] of chunk) {
|
|
160
|
+
block.entries.set(key, value);
|
|
161
|
+
this.locator.set(key, block);
|
|
162
|
+
if (value > block.bound) block.bound = value;
|
|
163
|
+
this.count += 1;
|
|
164
|
+
}
|
|
165
|
+
this.d0.unshift(block);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Remove and return the (at most) M smallest-valued keys plus a separating
|
|
171
|
+
* bound (Lemma 3.3 Pull). In Algorithm 3 this is `Bi, Si <- D.Pull()`:
|
|
172
|
+
* `keys` is Si and `bound` is Bi, satisfying
|
|
173
|
+
* max(pulled values) <= bound <= min(remaining values).
|
|
174
|
+
* When the pull drains the structure the bound is B.
|
|
175
|
+
* @returns {{ keys: Set<*>, bound: number }}
|
|
176
|
+
*/
|
|
177
|
+
pull() {
|
|
178
|
+
if (this.count <= this.M) {
|
|
179
|
+
// Everything fits in one batch: drain the structure and reset it
|
|
180
|
+
const keys = new Set(this.locator.keys());
|
|
181
|
+
this.d0 = [];
|
|
182
|
+
this.d1 = [this.makeBlock(this.B)];
|
|
183
|
+
this.locator.clear();
|
|
184
|
+
this.count = 0;
|
|
185
|
+
return { keys, bound: this.B };
|
|
186
|
+
}
|
|
187
|
+
// Collect prefix blocks from each sequence until that side holds >= M
|
|
188
|
+
// candidate elements (or runs out). The M smallest overall are in there.
|
|
189
|
+
const candidates = [];
|
|
190
|
+
for (const seq of [this.d0, this.d1]) {
|
|
191
|
+
let collected = 0;
|
|
192
|
+
for (const block of seq) {
|
|
193
|
+
if (collected >= this.M) break;
|
|
194
|
+
for (const [key, value] of block.entries) {
|
|
195
|
+
candidates.push([key, value, block]);
|
|
196
|
+
}
|
|
197
|
+
collected += block.entries.size;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Take the M smallest candidates out of the structure
|
|
201
|
+
candidates.sort((a, b) => a[1] - b[1]);
|
|
202
|
+
const keys = new Set();
|
|
203
|
+
for (let i = 0; i < this.M; i += 1) {
|
|
204
|
+
const [key, , block] = candidates[i];
|
|
205
|
+
keys.add(key);
|
|
206
|
+
this.removeKey(key, block);
|
|
207
|
+
}
|
|
208
|
+
// Separator = smallest value still stored. Thanks to the inter-block
|
|
209
|
+
// ordering it lives in the first non-empty block of d0 or d1.
|
|
210
|
+
let bound = Infinity;
|
|
211
|
+
for (const seq of [this.d0, this.d1]) {
|
|
212
|
+
const block = seq.find((b) => b.entries.size > 0);
|
|
213
|
+
if (block) {
|
|
214
|
+
for (const value of block.entries.values()) {
|
|
215
|
+
if (value < bound) bound = value;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { keys, bound };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Internal: remove a key from the block that holds it, dropping the block
|
|
223
|
+
// if it becomes empty (deletion cost amortizes into insertion, Lemma 3.3)
|
|
224
|
+
removeKey(key, block) {
|
|
225
|
+
block.entries.delete(key);
|
|
226
|
+
this.locator.delete(key);
|
|
227
|
+
this.count -= 1;
|
|
228
|
+
if (block.entries.size === 0) {
|
|
229
|
+
this.dropIfEmpty(block);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Internal: physically remove an emptied block. The last d1 block (bound B)
|
|
234
|
+
// is kept even when empty so insert always finds a home for any value < B.
|
|
235
|
+
dropIfEmpty(block) {
|
|
236
|
+
const i0 = this.d0.indexOf(block);
|
|
237
|
+
if (i0 !== -1) {
|
|
238
|
+
this.d0.splice(i0, 1);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const i1 = this.d1.indexOf(block);
|
|
242
|
+
if (i1 !== -1 && i1 < this.d1.length - 1) {
|
|
243
|
+
this.d1.splice(i1, 1);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export { BlockList };
|
package/src/bmssp.mjs
CHANGED
|
@@ -8,6 +8,10 @@ class BMSSP {
|
|
|
8
8
|
this.nodeIDs = new Set();
|
|
9
9
|
// Map to store shortest paths
|
|
10
10
|
this.shortestPaths = new Map();
|
|
11
|
+
// Adjacency map: nodeId -> array of [to, weight] outgoing edges.
|
|
12
|
+
// Lets the algorithm fetch a node's edges in O(1) instead of scanning
|
|
13
|
+
// the whole edge list on every lookup.
|
|
14
|
+
this.adjacency = new Map();
|
|
11
15
|
|
|
12
16
|
for (let edge of inputGraph) {
|
|
13
17
|
// Create a deep copy of each edge array
|
|
@@ -18,10 +22,36 @@ class BMSSP {
|
|
|
18
22
|
this.nodeIDs.add(edge[1]);
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
// Build the adjacency map from the copied edges
|
|
26
|
+
this.buildAdjacency();
|
|
27
|
+
|
|
21
28
|
// Initialize shortest paths map
|
|
22
29
|
this.initializeShortestPaths();
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
// Method to (re)build the adjacency map from this.graph.
|
|
33
|
+
// Every node ID gets an entry (an empty array for nodes with no
|
|
34
|
+
// outgoing edges) so callers can rely on .get(node) returning an array.
|
|
35
|
+
buildAdjacency() {
|
|
36
|
+
this.adjacency = new Map();
|
|
37
|
+
|
|
38
|
+
// Ensure every known node has an (initially empty) neighbor list
|
|
39
|
+
for (let nodeId of this.nodeIDs) {
|
|
40
|
+
this.adjacency.set(nodeId, []);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Group outgoing edges by their source node
|
|
44
|
+
for (let [from, to, weight] of this.graph) {
|
|
45
|
+
this.adjacency.get(from).push([to, weight]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Return the outgoing edges of a node as an array of [to, weight].
|
|
50
|
+
// Unknown nodes return an empty array.
|
|
51
|
+
getEdges(nodeId) {
|
|
52
|
+
return this.adjacency.get(nodeId) ?? [];
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
// Method to initialize the shortest paths map
|
|
26
56
|
initializeShortestPaths() {
|
|
27
57
|
for (let nodeId of this.nodeIDs) {
|