bmssp 1.1.1 → 2.0.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 +113 -22
- package/docs/index.html +82 -15
- package/index.mjs +19 -9
- package/package.json +1 -1
- package/src/baseCase.mjs +41 -33
- package/src/blockList.mjs +133 -68
- package/src/bmssp.mjs +370 -66
- package/src/boundIndex.mjs +243 -0
- package/src/findPivots.mjs +40 -26
- package/src/graph.mjs +174 -0
- package/src/select.mjs +144 -0
- package/src/tieBreak.mjs +111 -29
|
@@ -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,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
compareKeyParts,
|
|
3
|
+
toBound,
|
|
4
|
+
relaxEdge,
|
|
5
|
+
RELAX_LOST,
|
|
6
|
+
} from "./tieBreak.mjs";
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* FindPivots(B, S) — Algorithm 1 of "Breaking the Sorting Barrier for Directed
|
|
@@ -9,17 +14,19 @@ import { compareKeys, toBound, makeTies, relaxEdge } from "./tieBreak.mjs";
|
|
|
9
14
|
* the pivots need to be carried into the deeper BMSSP recursion.
|
|
10
15
|
*
|
|
11
16
|
* Preconditions (guaranteed by the caller, Algorithm 3):
|
|
12
|
-
* - Every vertex in S is complete (
|
|
17
|
+
* - Every vertex in S is complete (labels.dist holds its true distance).
|
|
13
18
|
* - Every incomplete vertex v with d(v) < B has a shortest path through some
|
|
14
19
|
* complete vertex in S.
|
|
15
20
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* Since #205 the engine works on dense vertex indices: S holds indices, the
|
|
22
|
+
* graph is the class's CSR bundle, and the labels are the shared typed
|
|
23
|
+
* arrays (see tieBreak's makeLabels) — updated in place, exactly like the
|
|
24
|
+
* paper's global d̂[·], so improvements made here are visible to the levels
|
|
25
|
+
* above. Relaxation is canonical (#163): dist, hops and preds move together
|
|
26
|
+
* under the composite [length, hops, index] order, and an exact-equality
|
|
27
|
+
* result re-admits a vertex to W through its one canonical label-setter (the
|
|
28
|
+
* paper's `≤` made deterministic). The `< B` test only gates membership in
|
|
29
|
+
* W; label updates are not gated.
|
|
23
30
|
*
|
|
24
31
|
* The paper's tight-edge forest falls out of the canonical labels: each
|
|
25
32
|
* vertex of W \ S hangs off its recorded canonical predecessor, which is
|
|
@@ -38,19 +45,20 @@ import { compareKeys, toBound, makeTies, relaxEdge } from "./tieBreak.mjs";
|
|
|
38
45
|
*
|
|
39
46
|
* @param {number|[number, number, *]} B - Strict upper bound gating membership
|
|
40
47
|
* in W: a number (Infinity is allowed) or a composite bound
|
|
41
|
-
* @param {Set
|
|
42
|
-
*
|
|
43
|
-
* @param {
|
|
48
|
+
* @param {Set<number>|Iterable<number>} S - Non-empty set of complete frontier
|
|
49
|
+
* source indices
|
|
50
|
+
* @param {{ dist: Float64Array, hops: Uint32Array, preds: Int32Array }} labels
|
|
51
|
+
* - Engine label state (d̂[·] and tie-break labels), updated in place
|
|
52
|
+
* @param {{ offsets: Uint32Array, targets: Uint32Array, weights: Float64Array }} csr
|
|
53
|
+
* - The graph in CSR layout over dense indices
|
|
44
54
|
* @param {number} k - Relaxation rounds / tree-size threshold, >= 1 (floored);
|
|
45
55
|
* the paper's ⌊log^(1/3) n⌋
|
|
46
|
-
* @
|
|
47
|
-
*
|
|
48
|
-
* @returns {{ pivots: Set<*>, W: Set<*> }} The pivot subset of S and the set W
|
|
49
|
-
* of vertices touched below B (W always contains S)
|
|
56
|
+
* @returns {{ pivots: Set<number>, W: Set<number> }} The pivot subset of S and
|
|
57
|
+
* the set W of vertex indices touched below B (W always contains S)
|
|
50
58
|
* @throws {Error} If k is not a number >= 1, S is empty, or any source has no
|
|
51
59
|
* finite distance estimate
|
|
52
60
|
*/
|
|
53
|
-
function findPivots(B, S,
|
|
61
|
+
function findPivots(B, S, labels, csr, k) {
|
|
54
62
|
if (typeof k !== "number" || Number.isNaN(k) || k < 1) {
|
|
55
63
|
throw new Error("k must be a number >= 1");
|
|
56
64
|
}
|
|
@@ -60,12 +68,12 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
|
|
|
60
68
|
throw new Error("S must contain at least one source node");
|
|
61
69
|
}
|
|
62
70
|
for (const x of sources) {
|
|
63
|
-
|
|
64
|
-
if (typeof distance !== "number" || !Number.isFinite(distance)) {
|
|
71
|
+
if (!Number.isFinite(labels.dist[x])) {
|
|
65
72
|
throw new Error("every source must have a finite distance estimate");
|
|
66
73
|
}
|
|
67
74
|
}
|
|
68
75
|
const boundKey = toBound(B);
|
|
76
|
+
const { offsets, targets, weights } = csr;
|
|
69
77
|
|
|
70
78
|
// W accumulates everything relaxed below B; layer is the paper's W_{i-1}
|
|
71
79
|
const sourceSet = new Set(sources);
|
|
@@ -75,12 +83,18 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
|
|
|
75
83
|
for (let i = 1; i <= rounds; i += 1) {
|
|
76
84
|
const nextLayer = new Set();
|
|
77
85
|
for (const u of layer) {
|
|
78
|
-
for (
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
for (let e = offsets[u]; e < offsets[u + 1]; e += 1) {
|
|
87
|
+
const v = targets[e];
|
|
88
|
+
// Canonical relaxation: label updates are not gated by B (paper),
|
|
89
|
+
// and both improvements and exact canonical equality admit v to the
|
|
90
|
+
// next layer when its key is below B. Non-lost ⇒ v's stored label
|
|
91
|
+
// is the candidate, so the W gate compares the unpacked stored
|
|
92
|
+
// components — no key allocation (#168).
|
|
93
|
+
const result = relaxEdge(u, v, weights[e], labels);
|
|
94
|
+
if (
|
|
95
|
+
result !== RELAX_LOST &&
|
|
96
|
+
compareKeyParts(labels.dist[v], labels.hops[v], v, boundKey) < 0
|
|
97
|
+
) {
|
|
84
98
|
nextLayer.add(v);
|
|
85
99
|
}
|
|
86
100
|
}
|
|
@@ -99,7 +113,7 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
|
|
|
99
113
|
const children = new Map();
|
|
100
114
|
for (const v of W) {
|
|
101
115
|
if (sourceSet.has(v)) continue;
|
|
102
|
-
const parent =
|
|
116
|
+
const parent = labels.preds[v];
|
|
103
117
|
if (!children.has(parent)) children.set(parent, []);
|
|
104
118
|
children.get(parent).push(v);
|
|
105
119
|
}
|
package/src/graph.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// #172 — typed / flexible graph inputs.
|
|
2
|
+
//
|
|
3
|
+
// A small builder (`Graph`) plus a normalizer (`normalizeGraphInput`) that
|
|
4
|
+
// lets the BMSSP constructor accept several input shapes and reduce them to
|
|
5
|
+
// one canonical internal form: `{ edges: [[from, to, weight], ...],
|
|
6
|
+
// vertices: [id, ...] }`.
|
|
7
|
+
//
|
|
8
|
+
// `vertices` carries the EXPLICIT vertex universe — every node the caller
|
|
9
|
+
// declared, including isolated ones with no incident edges. Edge endpoints
|
|
10
|
+
// are still folded in by the BMSSP constructor, so an empty `vertices`
|
|
11
|
+
// (the plain edge-list form) reproduces the pre-#172 "infer nodes from
|
|
12
|
+
// edges" behavior exactly.
|
|
13
|
+
//
|
|
14
|
+
// Node-ID semantics are unchanged from earlier versions: IDs are finite
|
|
15
|
+
// numbers. Value validation (finite IDs, finite non-negative weights) with
|
|
16
|
+
// the "Edge at index N" messages stays in the BMSSP constructor so there is
|
|
17
|
+
// a single source of truth; the pieces here validate only what they must to
|
|
18
|
+
// build a well-formed normalized shape (and the builder validates eagerly at
|
|
19
|
+
// the call site for a friendlier failure).
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A tiny mutable graph builder. Declare vertices (including isolated ones)
|
|
23
|
+
* and directed weighted edges, then hand the instance straight to
|
|
24
|
+
* `new BMSSP(graph)`.
|
|
25
|
+
*
|
|
26
|
+
* All mutators return `this`, so calls chain:
|
|
27
|
+
* `new Graph().addEdge(0, 1, 50).addEdge(1, 2, 75).addVertex(9)`.
|
|
28
|
+
*
|
|
29
|
+
* Node IDs must be finite numbers and weights finite and non-negative — the
|
|
30
|
+
* same contract the edge-array form has always enforced.
|
|
31
|
+
*/
|
|
32
|
+
class Graph {
|
|
33
|
+
constructor() {
|
|
34
|
+
// Declared vertex universe (numbers). addEdge folds in its endpoints too.
|
|
35
|
+
this._vertices = new Set();
|
|
36
|
+
// Declared directed edges as [from, to, weight] triples.
|
|
37
|
+
this._edges = [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Declare a vertex. Safe to call for a node that already exists (idempotent)
|
|
42
|
+
* and the only way to introduce an isolated vertex (one with no edges).
|
|
43
|
+
* @param {number} id finite node ID
|
|
44
|
+
* @returns {Graph} this
|
|
45
|
+
*/
|
|
46
|
+
addVertex(id) {
|
|
47
|
+
if (!Number.isFinite(id)) {
|
|
48
|
+
throw new Error("Graph.addVertex: node id must be a finite number");
|
|
49
|
+
}
|
|
50
|
+
this._vertices.add(id);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Add a directed edge `from -> to` of the given weight. Both endpoints are
|
|
56
|
+
* declared automatically.
|
|
57
|
+
* @param {number} from finite source node ID
|
|
58
|
+
* @param {number} to finite target node ID
|
|
59
|
+
* @param {number} weight finite, non-negative edge weight
|
|
60
|
+
* @returns {Graph} this
|
|
61
|
+
*/
|
|
62
|
+
addEdge(from, to, weight) {
|
|
63
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
|
64
|
+
throw new Error("Graph.addEdge: node ids must be finite numbers");
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isFinite(weight) || weight < 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"Graph.addEdge: weight must be a finite, non-negative number",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
this._vertices.add(from);
|
|
72
|
+
this._vertices.add(to);
|
|
73
|
+
this._edges.push([from, to, weight]);
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {number} id @returns {boolean} whether the vertex is declared */
|
|
78
|
+
hasVertex(id) {
|
|
79
|
+
return this._vertices.has(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @returns {number} number of declared vertices */
|
|
83
|
+
get vertexCount() {
|
|
84
|
+
return this._vertices.size;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @returns {number} number of declared edges */
|
|
88
|
+
get edgeCount() {
|
|
89
|
+
return this._edges.length;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The canonical normalized form the BMSSP constructor consumes. Returns
|
|
94
|
+
* fresh copies so later mutation of this builder cannot affect a graph
|
|
95
|
+
* already constructed from it.
|
|
96
|
+
* @returns {{ edges: number[][], vertices: number[] }}
|
|
97
|
+
*/
|
|
98
|
+
toNormalized() {
|
|
99
|
+
return {
|
|
100
|
+
edges: this._edges.map((edge) => [...edge]),
|
|
101
|
+
vertices: [...this._vertices],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isIterable(value) {
|
|
107
|
+
return value != null && typeof value[Symbol.iterator] === "function";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reduce an adjacency form to `{ edges, vertices }`. Each entry maps a source
|
|
112
|
+
* node to an iterable of `[to, weight]` pairs; the source is always declared
|
|
113
|
+
* (so a node with an empty/absent neighbor list becomes an isolated vertex).
|
|
114
|
+
* When `coerceKeys` is set (plain-object form, whose keys are strings), the
|
|
115
|
+
* source key is converted to a number — finiteness is then checked by the
|
|
116
|
+
* BMSSP constructor's vertex validation.
|
|
117
|
+
*/
|
|
118
|
+
function adjacencyToNormalized(entries, coerceKeys) {
|
|
119
|
+
const edges = [];
|
|
120
|
+
const vertices = [];
|
|
121
|
+
for (const [rawFrom, neighbors] of entries) {
|
|
122
|
+
const from = coerceKeys ? Number(rawFrom) : rawFrom;
|
|
123
|
+
vertices.push(from);
|
|
124
|
+
if (neighbors == null) continue;
|
|
125
|
+
if (!isIterable(neighbors)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Adjacency list for node ${rawFrom} must be an iterable of [to, weight]`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
for (const pair of neighbors) {
|
|
131
|
+
if (!Array.isArray(pair) || pair.length !== 2) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Adjacency entry for node ${rawFrom} must be [to, weight]`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
edges.push([from, pair[0], pair[1]]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { edges, vertices };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Normalize any accepted graph input into `{ edges, vertices }`:
|
|
144
|
+
* - a `Graph` builder instance,
|
|
145
|
+
* - an edge array `[[from, to, weight], ...]` (vertices inferred from edges),
|
|
146
|
+
* - an adjacency `Map<from, Iterable<[to, weight]>>`, or
|
|
147
|
+
* - a plain adjacency object `{ from: [[to, weight], ...] }` (numeric-string
|
|
148
|
+
* keys, coerced to numbers).
|
|
149
|
+
*
|
|
150
|
+
* Only structural checks live here; per-edge value validation stays in the
|
|
151
|
+
* BMSSP constructor so its "Edge at index N" messages remain the single
|
|
152
|
+
* source of truth.
|
|
153
|
+
* @param {*} input
|
|
154
|
+
* @returns {{ edges: number[][], vertices: number[] }}
|
|
155
|
+
*/
|
|
156
|
+
function normalizeGraphInput(input) {
|
|
157
|
+
if (input instanceof Graph) {
|
|
158
|
+
return input.toNormalized();
|
|
159
|
+
}
|
|
160
|
+
if (Array.isArray(input)) {
|
|
161
|
+
return { edges: input, vertices: [] };
|
|
162
|
+
}
|
|
163
|
+
if (input instanceof Map) {
|
|
164
|
+
return adjacencyToNormalized(input.entries(), false);
|
|
165
|
+
}
|
|
166
|
+
if (input !== null && typeof input === "object") {
|
|
167
|
+
return adjacencyToNormalized(Object.entries(input), true);
|
|
168
|
+
}
|
|
169
|
+
throw new Error(
|
|
170
|
+
"Input graph must be an edge array, an adjacency map/object, or a Graph instance",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { Graph, normalizeGraphInput };
|
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 };
|